Skip to main content

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-Key header.

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-onlycurrency 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=

200client_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

200terminal, 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.