AffiliateRail docs
API

Money in the API

Integer minor units with a currency in hand, everywhere, enforced by a test that walks every endpoint.

Every amount the API returns is an integer in minor units (4999 is 49.99, and 1000 Japanese yen is 1000), in a field ending _minor, with a currency beside it or on the object that carries it. Rates and percentage changes are basis points, also integers: 2000 is 20%. There is no float and no decimal string anywhere in any response.

This is a contract, not a habit. A test in our build walks every documented endpoint, every schema and real seeded responses, and fails the build if any amount serialises as a float, a string, or without a currency a consumer can reach. If you build a parser on these rules, no future release breaks it quietly.

Two practical consequences:

  • Parse amounts as integers and divide only at the display edge, using the currency's own minor unit count (JPY has none, BHD has three, nearly everything else has two).
  • Never infer a currency. It is always in the response; read it from there.

Comparison periods, in the same response

GET /v1/reports returns the previous window beside the current one, so a chart with a "versus last period" line needs one request, not two. The previous window is the adjacent one of the same length, stated explicitly, and the movement between the two comes as delta:

{
  "totals": { "revenue_minor": 1450000, "...": "..." },
  "previous_period": {
    "from": "2026-07-01T00:00:00.000Z",
    "to": "2026-07-31T23:59:59.999Z",
    "rows": ["..."],
    "totals": { "revenue_minor": 1200000, "...": "..." }
  },
  "delta": {
    "revenue": { "absolute_minor": 250000, "change_bps": 2083 },
    "customers": { "absolute": 12, "change_bps": 1500 }
  }
}

change_bps is the percentage change in basis points: 2083 means +20.83%. It is an integer like every other number here, and null when the previous period was zero, because dividing by nothing is not a number either. Pass compare=none to skip the second window when you do not want it.

Drill-down metadata

Report responses carry drill_down_config: which row property to read, which query parameter it becomes, and which property holds the value.

{ "field": "key", "filter_type": "partner_id", "value_prop": "key" }

A chart segment click becomes a filter with no lookup table in your code: take the row's key, pass it as the named parameter to the same report or to the matching list endpoint, and the numbers drill down. The filter vocabulary is shared between reports and lists on purpose.

When am I paid: the maturity query

GET /v1/commissions/maturing?program_id=...&from=...&to=... returns the commissions whose mature_at falls inside the range, oldest first, cursor paginated. approved commissions become payable at their mature_at; pending ones still need approval first, so pass status=approved when you only want the certain money. Every commission is written with its mature_at at creation, so this answer never shifts underneath you.

Writes tell you what happened

Three behaviours worth knowing before you integrate a billing system:

  • POST /v1/sales answers activity_outcome: commission_created when money followed, or sale_recorded when we accepted the sale and paid nobody. A sale that paid nobody carries warnings with machine-readable codes (no_partner_attributed, no_commission_from_flows) instead of failing the call, so revenue is never dropped just because attribution was incomplete.
  • A duplicate create answers 409 with the existing record. Creating a partner whose email or handle is taken returns the conflict AND the partner you collided with under existing, so you can link your records without a second request.
  • Idempotency-Key follows the IETF draft. The same key replays the first response for 24 hours; the same key with a different body is a 422; the same key while the first request is still running is a 409 with Retry-After, so a nervous retry cannot run the work twice.

Signed requests to us, if you want them

Our webhooks to you are signed with a timestamped HMAC. You can sign your requests to us the same way: send Rail-Signature: t=<unix seconds>,v1=<hex hmac-sha256(secret, "{t}.{raw body}")>, where the secret is your full API key. A signature is verified whenever you send one. To make it required for a key, call POST /v1/inbound_signing with {"required": true} using that key; from then on an unsigned request with that key is refused, and a captured request is only replayable inside the five-minute tolerance instead of for the key's whole life. Opt-in per key, and switching it on breaks nothing else.