# 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`:

```json
{
  "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.

```json
{ "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:

```text
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.
