Skip to main content

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.

typeFilterWhat it isCarries
SPENDA card authorization or purchasecardId, status, amount, authorizedAmount, localAmount, declineReason, merchant, authorizationMethod, authorizedAt, cardType
VERIFICATIONA zero-value merchant card checkcardId, merchant, authorizedAt, cardTypenever an amount
COLLATERALOn-chain funding inamount, chainId, walletAddress, transactionHash, memo (rows also carry status: null — collateral has no lifecycle here)
PAYMENTOn-chain payment against the balancethe same, plus status
FEEA charge levied by the programmeamount, description
ADJUSTMENTA correction posted by the programmeamount, description
TRANSFERA funds movement, own lifecyclestatus, source, destination, exchangeRate, quoteId, depositAddress, expiresAt, createdAt, updatedAt
OTHERA kind this API version does not modelbase 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: SPENDAUTHORIZED → POSTED | REVERSED, or DECLINED (with declineReason). PAYMENTPENDING → COMPLETED. TRANSFERPENDING | 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.

200client_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/_limitcursor/_limit. The issuer is keyset-only. Measured 2026-08-21: it accepts offset= and page=, answers 200, and ignores them — an offset-shaped passthrough would have served page one forever with no error anywhere.
  • status removed. 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 reason GET /cardholders publishes 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. One from would have had to silently pick one: a reconciliation window wants posted_*, a live activity feed wants authorized_*.