Transactions
Contract §4. Section numbers are contract-wide and never change; the contract overview maps every section to its page.
Conventions — authentication, the response envelope, idempotency, retry safety, pagination, rate limits — are defined in the Partner Integration Guide and apply to every endpoint here.
Read-only, and the one resource the service does not store. That single fact explains every difference between this section and the rest of the contract.
The transaction object — a discriminated union
Switch on type before reading anything else: eight kinds share this ledger and carry
different fields entirely — seven are the issuer's, and OTHER is this API's own.
The Filter column is which values ?type= accepts. It is six, not eight, and the two
missing ones are refused rather than ignored — see the endpoints below.
type | Filter | What it is | Carries |
|---|---|---|---|
SPEND | ✔ | A card authorization or purchase | cardId, status, amount, authorizedAmount, localAmount, declineReason, merchant, authorizationMethod, authorizedAt, cardType |
VERIFICATION | ✔ | A zero-value merchant card check | cardId, merchant, authorizedAt, cardType — never an amount |
COLLATERAL | ✔ | On-chain funding in | amount, chainId, walletAddress, transactionHash, memo (rows also carry status: null — collateral has no lifecycle here) |
PAYMENT | ✔ | On-chain payment against the balance | the same, plus status |
FEE | ✔ | A charge levied by the programme | amount, description |
ADJUSTMENT | — | A correction posted by the programme | amount, description |
TRANSFER | ✔ | A funds movement, own lifecycle | status, source, destination, exchangeRate, quoteId, depositAddress, expiresAt, createdAt, updatedAt |
OTHER | — | A kind this API version does not model | base fields only |
Every kind carries id, type, clientId, cardholderId and postedAt. Two of those are
nullable and routinely null: cardholderId on a row that belongs to the client rather
than to a person, and postedAt on any row that is still authorized or pending. Sorting or
windowing a reconciliation on postedAt therefore drops every unsettled row — use
authorized_* for a live feed and posted_* for reconciliation, which is why the endpoint
publishes both pairs.
Handle OTHER rather than failing on it. The issuer added two kinds in four months, so
unknown kinds arrive as OTHER with the base fields and the page is still served — a client
that throws on an unrecognised type would lose a whole page because one row was unfamiliar.
{
"id": "7c1c0e2c-2f2f-4f0a-9a1e-0f2f7b8f4b21",
"type": "SPEND",
"clientId": "cl_01M0…",
"cardholderId": "chd_01M0…",
"cardId": "card_01M0…",
"status": "POSTED",
"amount": { "value": "42.90", "currency": "USD" },
"localAmount": { "value": "39.50", "currency": "EUR" },
"merchant": {
"name": "ACME COFFEE",
"city": "Berlin",
"country": "DE",
"category": "Eating Places, Restaurants",
"categoryCode": "5812"
},
"authorizedAt": "…",
"postedAt": "…"
}
localAmount.currency is the merchant's, not yours — do not assume USD there even though
v1 settles in USD only.
Lifecycles are per kind and deliberately not merged:
SPEND → AUTHORIZED → POSTED | REVERSED, or DECLINED (with declineReason).
PAYMENT → PENDING → COMPLETED.
TRANSFER → PENDING | AWAITING_TRANSFER | PROCESSING | SETTLED | FAILED | CANCELLED | EXPIRED | REFUNDED.
Transaction ids
Everywhere else in this contract an id is an service public id. Here it is not, for one reason:
The service stores no transactions, so there is no record to mint a stable id from. clientId,
cardholderId and cardId on these rows are Service ids — the exception is one field wide.
Treat it as opaque. Ownership is enforced by comparing the fetched row's owner to yours, never
by the id being hard to guess: another partner's transaction answers 404, identically to one
that does not exist.
Transaction endpoints
GET /transactions
Query parameters: client_id (required), and optional cardholder_id, card_id, type,
authorized_after, authorized_before, posted_after, posted_before, cursor, _limit.
200 — client_id required (422 without it): the issuer exposes no cross-client
transaction read. Keyset paginated: { items, nextCursor }, no page number, no total.
nextCursor is the last row's id, so it is an issuer identifier for the same reason id is.
GET /transactions/:id
200 — one row. Another partner's id answers 404, never 403. A row of type OTHER answers
404 here even though it appeared in your list — see below.
type accepts SIX values, while responses carry eight. ADJUSTMENT comes back in a page
but the issuer's list does not filter on it, and OTHER is this API's own label with no
issuer spelling — both are rejected at validation rather than silently widening your query to
every type.
A row of type OTHER can be listed but not fetched by id. Not a contradiction of
clientId above: the published row always carries one, but the issuer's payload for a kind
this version does not model carries no company reference, and that is what the by-id read
compares against yours. With nothing to compare it fails closed, refusing rather than serving
a row it cannot prove is yours. In a list the owner is known already, from your client_id.
This bites only once the issuer ships a kind this version does not model. When reconciling,
read a 404 on a row you just listed as "not yet supported", not "deleted".
card_id may only be combined with SPEND or VERIFICATION. No other kind is attached to
a card, so another pairing could only return an empty page — which reads as "this card had no
activity". It answers 422 FILTER_NOT_APPLICABLE instead.
Query parameters that were removed
_page/_limit→cursor/_limit. The issuer is keyset-only. Measured 2026-08-21: it acceptsoffset=andpage=, answers200, and ignores them — an offset-shaped passthrough would have served page one forever with no error anywhere.statusremoved. The issuer's transaction list takes no status parameter. Filtering after the fetch returns short pages whose cursor skips the rows it dropped, which is the same reasonGET /cardholderspublishes no status filter.from/to→ four explicit bounds. The issuer filters authorization time and posting time separately, and on a settled card transaction they differ by days. Onefromwould have had to silently pick one: a reconciliation window wantsposted_*, a live activity feed wantsauthorized_*.