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/salesanswersactivity_outcome:commission_createdwhen money followed, orsale_recordedwhen we accepted the sale and paid nobody. A sale that paid nobody carrieswarningswith 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-Keyfollows 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 withRetry-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.