Cardholders & cards
Contract §2 (cardholders) and §3 (cards). 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. Endpoints marked 🔑 require an
Idempotency-Keyheader.
A cardholder is the person a card belongs to; a card is issued to a cardholder. The two sections were split from the client application because they share one lifecycle: a cardholder must clear KYC before any card under it can be issued.
Cardholders
A cardholder is an issuing-user under a client, created with
POST /clients/:id/cardholders under an approved client. Every cardholder you issue
cards against is one you created through this API — the account owner submitted in the
§1 company body is provisioned at the issuer as part of company KYB, but is not exposed
as a cardholder here: it has no chd_ id, does not appear in the cardholder list, and
cannot be issued a card. If the account owner should hold a card, create them as a
cardholder too. Individuals under kind: INDIVIDUAL clients are their own single
cardholder (ships with INDIVIDUAL).
The cardholder object
{
"id": "chd_01M0…",
"clientId": "cl_01M0…",
"firstName": "…",
"lastName": "…",
"email": "…",
"phone": null,
"status": "PENDING",
"verificationUrl": null,
"externalVerificationUrl": null,
"createdAt": "…",
"updatedAt": "…"
}
status: same lifecycle vocabulary as clients (all eleven values). KYC is a
person-driven hosted flow: the create returns verificationUrl /
externalVerificationUrl — two separately signed sessions, same semantics and same
secrecy rules as the client links in §1 — and the cardholder completes identity
verification there (in production this includes a liveness check; the sandbox
auto-approves within seconds). phone is relayed live from the issuer when it holds one.
updatedAt reflects the service's record of the cardholder, not issuer-side changes — read
status live rather than watching updatedAt.
POST /clients/:id/cardholders 🔑
201. Body: firstName, lastName, email, termsAccepted: true — nothing else; the rest of the
person's KYC is collected by the issuer through the hosted verification flow. The create
normally returns status: "PENDING" (the status is the issuer's own answer and is relayed,
not asserted). Only an APPROVED cardholder can be issued a card.
GET /cardholders?client_id= · GET /cardholders/:id
The client_id query parameter is required (422 without it) and there is no status
filter — both
follow from the issuer, not from preference. It exposes no cross-client cardholder list, so
a partner-wide view would be one call per client behind a cursor encoding N positions; and
its cardholder list takes no status parameter, so the only way to offer one would be to
filter a page after fetching it — which returns a short page whose nextCursor skips every
row the filter dropped. A filter that silently loses rows is worse than no filter.
Pagination is keyset: pass cursor from the previous page's nextCursor, stop when it
is null. There is no page number and no total, because the issuer publishes neither.
Lists return only resources created through this API — issuer-side objects that did not
originate here (including the §1 account owner's issuing-user) are not listed.
{ "success": true, "data": { "items": [ … ], "nextCursor": "chd_01M0…" }, "_metadata": { … } }
nextCursor is always an service public id — never an issuer identifier — and is only
meaningful against the same filters.
Cards
The card object
{
"id": "card_01M0…",
"cardholderId": "chd_01M0…",
"clientId": "cl_01M0…",
"type": "VIRTUAL",
"last4": "4242",
"expiryMonth": "08",
"expiryYear": "2030",
"status": "ACTIVE",
"limit": { "amount": "500.00", "currency": "USD", "frequency": "PER_30_DAYS" },
"createdAt": "…",
"updatedAt": "…"
}
status: PENDING_ACTIVATION → ACTIVE ⇄ FROZEN → CANCELED (terminal). Virtual cards are
normally returned ACTIVE on creation — no activation step; the status is the issuer's
own answer and is relayed, not asserted. Physical-card activation (confirming the
delivered card) is published with the physical-card fields; do not assume a generic activate
call. last4, expiryMonth, expiryYear and limit are null when the issuer has not
(yet) produced them — a card created without a cap reads limit: null. updatedAt
reflects the service's record of the card, not issuer-side changes — read status live.
limit.frequency: PER_TRANSACTION | PER_DAY | PER_7_DAYS | PER_30_DAYS | PER_YEAR | ALL_TIME.
The limit is relayed to the provider as supplied — its authorization-time behavior
follows the provider and is not specified in this version; do not rely on the service to
enforce it. One limit per card. v1 is USD-only — currency is always "USD".
The card object carries no display name. You may still send displayName on create and
it is relayed onward, but no read anywhere returns it, so keep your own label against the
card id you get back.
Card endpoints
POST /cardholders/:id/cards 🔑
201 — body: type: VIRTUAL|PHYSICAL, displayName (1–26 chars), limit?, shipping?.
shipping is required when type is PHYSICAL (omitting it is a validation error, 422 E_CPX_VAL_4201, naming shipping in details.validationErrors) and rejected when type is
VIRTUAL (422 SHIPPING_NOT_APPLICABLE) — a virtual card delivers nothing, so accepting an address
for one would promise a parcel that is never posted. Fields: line1, city, postalCode,
countryCode, phoneNumber (E.164, e.g. +4915112345678 — the carrier's delivery contact)
required; line2, region, firstName, lastName, method optional. region is optional here
even though it is required on the client KYB address. Names are the RECIPIENT's, not the
cardholder's. method: STANDARD|EXPRESS|INTERNATIONAL; omit it to take the program default rather
than have a carrier tier — a delivery time and a cost — chosen for you. Address text is Latin-only.
No read returns shipping — keep your own copy against the card id. limit takes the same
object as PUT /cards/:id/limit (amount, currency, frequency) and is in force from issuance,
so the card is never live and uncapped. It is part of the request body, therefore part of what
Idempotency-Key fingerprints: replaying a create with a changed cap is 422 IDEMPOTENCY_KEY_REUSED, not a cap change — use PUT /cards/:id/limit for that.
GET /cards/:id
Reads one card. The response is the card object above; the API Reference carries its field-by-field schema.
GET /cards?client_id=&cardholder_id=&status=&cursor=&_limit=
200 — client_id query parameter required (422 without it): the issuer exposes no
cross-client card list. Keyset paginated, same shape as the cardholder list: { items, nextCursor }, no page
number, no total. status IS supported here (the issuer's card list takes one, so filtering happens
at the source and a filtered page stays a full page). nextCursor is an service public id, never an
issuer identifier.
POST /cards/:id/freeze · /unfreeze
200 — reversible; bodyless
POST /cards/:id/cancel
200 — terminal, no successor. For offboarding a card. Bodyless. Repeating it is a no-op that
answers 200, not a conflict — the route carries no Idempotency-Key, so your network-level
retry is expected to land twice. Freeze/unfreeze on a canceled card answer 409 CARD_TERMINAL;
cancel on one does not.
POST /cards/:id/replace 🔑
201 new card — atomic cancel-and-reissue; the old card → CANCELED. VIRTUAL ONLY: a
physical card cannot be replaced (422 REPLACE_NOT_APPLICABLE) — cancel it and issue a new one. The
new card inherits the old card's spending limit. Replacing an already-canceled card is 409 CARD_TERMINAL. Concurrent replaces of the same card are serialized — exactly one succeeds, the
other gets 409 OPERATION_IN_FLIGHT. No lineage field: the response is the new card and nothing
links it to the old one, so record the pairing yourself. The Idempotency-Key is bound to this card
— reusing it on a different card is 422 IDEMPOTENCY_KEY_REUSED, not a replay.
PUT /cards/:id/limit
200 — set or change the velocity cap. Body: amount (decimal string, ≤2dp), currency
(alpha-3, USD-only in v1), frequency. Maximum amount is 21474836.47 (the issuer's hard
cap); larger values are rejected. The body carries the whole cap, so repeating it converges.
Clearing to unlimited is not supported in v1 — every field is required, and a body omitting amount
is rejected rather than silently leaving the previous cap in force.
A 502 on POST /cards/:id/replace is terminal for that card, not a retry. If the issuer
times out, or if the replacement is created but the service cannot record it, the operation is parked
in an unresolved state and that card cannot be replaced again — every further attempt
answers 409 OPERATION_IN_FLIGHT, with any Idempotency-Key, indefinitely. This is
deliberate: re-sending would mint a second PAN against a card that may already have been
replaced, and there is no safe way to tell from the outside. Resolution is manual in v1 —
contact support with the request id from _metadata and do not poll the 409, which will not
clear on its own. The 409's "wait, then retry" wording is advice for the ordinary concurrent
case, not for this one. Automated reconciliation of parked operations is on the roadmap and is
tracked in the completeness ledger; the card itself is unaffected in the meantime — it keeps
working, or, if the issuer already replaced it, it is canceled and its successor exists at the
issuer but is not yet visible through this API.