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 a whole number in minor units, in a field ending _minor, with a currency beside it or on the object that carries it. A minor unit is the smallest unit a currency has, like the cent. So 4999 is 49.99. Japanese yen has no smaller unit, so 1000 yen is 1000.

Rates and percentage changes are basis points, also whole numbers. A basis point is a hundredth of a percent, so 2000 is 20%. No response has a float or a decimal string anywhere.

AffiliateRail uses the same number of decimal places when it shows those amounts, so 1000 minor units appears as ¥1,000 for JPY and KWD 1.000 for KWD.

A test in our build enforces this. It walks every documented endpoint, every schema and real seeded responses, and fails the build if any amount comes out as a float, a string, or without a currency you can reach. So a parser built on these rules won't break quietly in a future release.

What that means for your code:

  • Parse amounts as integers. Divide only at the display edge, using the currency's own count of minor units. JPY has none, BHD has three, and nearly everything else has two.
  • Never guess a currency. It's always in the response, so 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. The previous window is the one right before, with the same length, and its dates are stated in the response. 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's a whole number like every other number here. It's null when the previous period was zero, because you can't divide by zero. Pass compare=none to skip the second window when you don't want it.

Drill-down metadata

Report responses carry drill_down_config. It names three things: 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" }

With it, a click on a chart segment becomes a filter, with no lookup table in your code:

  1. Take the row's key.
  2. Pass it as the named parameter, to the same report or to the matching list endpoint.
  3. The numbers drill down to that slice.

Reports and lists share the same filter names on purpose.

When am I paid: the maturity query

A commission's mature_at is the date it can be paid, once it's approved. GET /v1/commissions/maturing?program_id=...&from=...&to=... returns the commissions whose mature_at falls inside the range. They come oldest first, with cursor pagination.

  • approved commissions become payable at their mature_at.
  • pending ones still need approval first. Pass status=approved when you only want the certain money.

Every commission is written with its mature_at when it's created. So this answer never shifts underneath you.

Writes tell you what happened

Know these three behaviours before you connect a billing system:

  • POST /v1/sales answers activity_outcome. It's commission_created when money followed. It's 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_rules, fixed_rate_currency_mismatch). That last one means a fixed-amount rule met a sale in another currency. A fixed amount is in your program's currency, so we don't guess a conversion. Use a percentage rate for those sales. The call still succeeds, so revenue is never dropped when attribution was incomplete.
  • A duplicate create answers 409 with the existing record. Let's say you create a partner whose email or handle is taken. You get 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 IETF is the body that writes internet standards. 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 can't run the work twice.

Signed requests to us, if you want them

We sign our webhooks to you with a timestamped HMAC. An HMAC is a code built from the message and a shared secret, so the receiver can tell the message wasn't changed. You can sign your requests to us the same way. Send this header, where the secret is your full API key:

Rail-Signature: t=<unix seconds>,v1=<hex hmac-sha256(secret, "{t}.{raw body}")>

We verify a signature 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. A captured request is then only replayable inside the five-minute tolerance, instead of for the key's whole life. It's opt-in per key, and switching it on breaks nothing else.