Skip to main content
Cookbooks

Card Bound to a Mandate

Issue a card and pin it to the allowance a consumer signed. The order inside the mint handler, what already exists when each refusal arrives, which refusal a retry fixes, and the one call that separates an issued card from a governed one.

1 min read
View MarkdownEdit on GitHub
TIME
~25 min
STACK
HTTP / SDK RESTno agent, no CLI
SERVERS
issuer: pomeloissuer: bridge

Four calls take a consumer from nothing to a card whose every authorization is checked against the allowance they signed: mint a consent token, let the consumer sign at the hosted page, mint the card against the mandate id, then prove the binding landed.

CARD UNDER MANDATE
Consent token → consumer signs → mint → verified binding
1 · CONSENT
Mint the token
POST /v1/consents → 201 · ctk_ token, 24h
2 · SIGN
Consumer at the hosted page
mandate, secret and funding source written server-side
3 · MINT
Issue against the mandate
POST /v1/cards → 201, 202 or 200
4 · VERIFY
Prove it is governed
read the binding · card must not be null
STEP 1
parse, then corridor
400 invalid_body, then 400 issuer_corridor_unsupported
STEP 2
environment guard
403 before any flow work, replays included
STEP 3
idempotency read
409 on a changed body, 200 on a finished flow
STEP 4
mandate gates
404, 409 and 400, on fresh attempts only
STEP 5
driveMintFlow
the issuer is called last, and the binding after that
the card_mandates row is written after the issuer returns, outside the transaction, and its failure does not change the status

Prerequisites

npm install @codespar/sdk

An API key with consents:write for the consent token, cards:issue for the mint, cards:read for the card reads, and mandates:read plus mandates:write for the binding read and the unbind. A key carrying the * wildcard takes a fast path in scope enforcement and passes regardless. See Authentication.

One corridor issues today, and it is Pomelo BR x BRL

resolveIssuer maps Pomelo to BR x BRL and Bridge to AR, CO, EC, MX, PE and CL x USD. The Bridge lane then refuses every mint anyway: projectMandateControls throws IssuerControlsUnsupportedError whenever the mandate carries an expiry, and consumer_mandates.expires_at is NOT NULL, so every consumer mandate carries one. A Bridge mint answers 400 issuer_controls_unsupported, always. The served document says only that the country and currency pair has no corridor.

Issuing is off until an operator turns it on

ISSUER_POMELO_ENABLED and ISSUER_BRIDGE_ENABLED are off by default, and the service refuses to boot with a flag on and its vault secret missing. A corridor that does not exist and a corridor that is disabled answer with the same 400 issuer_corridor_unsupported, on purpose: the refusal does not tell an outsider which corridors this deployment runs.

The whole journey in one file

card-on-mandate.ts
import { CodeSpar } from "@codespar/sdk";
import { randomUUID } from "node:crypto";

const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY! });

// 1. Mint the consent token. Needs consents:write.
//    merchant_pin_kind decides whether the allowlist means anything on a
//    card. The card authorizer reads "mcc" and "merchant-id" only.
const consent = await cs.api.post("/v1/consents", {
  body: {
    agent_id: "agt_0a1b2c3d4e5f6071",
    consumer_email_hint: "person@example.com",
    callback_url: "https://app.example.com/mandates/signed",
    intent: {
      purpose: "Weekly groceries",
      cap_minor: 50000,
      per_tx_cap_minor: 12000,
      currency: "BRL",
      mandate_ttl_seconds: 2592000,
      merchant_pin_kind: "mcc",
      merchant_allowlist: ["5411", "5499"],
      display_name: "Grocery run",
    },
  },
});

// 2. Compose the hosted consent page URL from the token and hand it to the
//    consumer. One shot, and it expires 24 hours after this call.
console.log(consent.token, consent.expires_at);

// 3. The consumer signs there. Read the mandate id back from callback_url,
//    or list the active allowances for that consumer.
const listed = await cs.api.get("/v1/mandates", {
  query: { consumer_id: "csm_0a1b2c3d4e5f6071", status: "active", limit: 50 },
});
const mandateId = listed.mandates[0].id;

// 4. Mint the card. Needs cards:issue. Keep the key: it is the only handle
//    on a flow that parks for KYC, and the only safe way to retry.
const idempotencyKey = randomUUID();

const minted = await cs.api.response("post", "/v1/cards", {
  header: { "Idempotency-Key": idempotencyKey },
  body: {
    country: "BR",
    currency: "BRL",
    mandate_id: mandateId,
    holder: {
      full_name: "Ana Souza",
      email: "ana@example.com",
      birthdate: "1990-04-17",
      document: { type: "CPF", value: "39053344705", issuing_country: "BR" },
      address: {
        line1: "Rua Haddock Lobo",
        street_number: "595",
        neighborhood: "Cerqueira Cesar",
        city: "Sao Paulo",
        state: "SP",
        postal_code: "01414001",
        country: "BR",
      },
      phone: "+5511987654321",
    },
  },
});

// 5. Read the outcome from the status, never from the body shape.
let flowId: string;
if (minted.status === 201 || minted.status === 200) {
  flowId = minted.data.flow_id;
} else if (minted.status === 202) {
  flowId = minted.data.flow_id;
  console.log("send the holder to", minted.data.kyc_url);
} else {
  throw new Error(`mint refused: ${minted.status}`);
}

// 6. Poll the flow until it leaves pending. The card key exists only when
//    state is "issued" AND the flow points at a card row. Otherwise the key
//    is absent, not null.
let flow = await cs.api.get("/v1/cards/{id}", { path: { id: flowId } });
while (flow.state === "pending") {
  await new Promise((r) => setTimeout(r, 5000));
  flow = await cs.api.get("/v1/cards/{id}", { path: { id: flowId } });
}

// 7. Verify the binding. This is the step that separates an issued card from
//    a governed one, and nothing in the 201 tells you which one you got.
const bound = await cs.api.get("/v1/consumers/mandates/{id}/card", {
  path: { id: mandateId },
});
if (bound.card === null) {
  throw new Error("card exists and is NOT governed: no active binding");
}
console.log(bound.card.card_id, bound.card.last_four, bound.card.status);

// 8. Later: read the decisions the authorizer recorded.
const recent = await cs.api.get("/v1/consumers/mandates/{id}/card", {
  path: { id: mandateId },
  query: { limit: 100 },
});
for (const a of recent.authorizations) {
  // amount_minor and remaining_minor are decimal STRINGS. Adding them
  // as numbers concatenates.
  console.log(a.at, a.merchant_name, a.amount_minor, a.decision);
}

// 9. Kill switch, first half. Closes the binding; the card still exists at
//    the issuer and has to be canceled there too.
await cs.api.delete("/v1/consumers/mandates/{id}/card", {
  path: { id: mandateId },
});

Required: agent_id, and inside intent the fields purpose, cap_minor, per_tx_cap_minor, currency and mandate_ttl_seconds. The 201 carries token, prefixed ctk_, and expires_at.

curl -X POST https://api.codespar.dev/v1/consents \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": "agt_0a1b2c3d4e5f6071",
    "consumer_email_hint": "person@example.com",
    "callback_url": "https://app.example.com/mandates/signed",
    "intent": {
      "purpose": "Weekly groceries",
      "cap_minor": 50000,
      "per_tx_cap_minor": 12000,
      "currency": "BRL",
      "mandate_ttl_seconds": 2592000,
      "merchant_pin_kind": "mcc",
      "merchant_allowlist": ["5411", "5499"]
    }
  }'

The 24 hours belong to the token, not to the allowance. The mandate's own clock is intent.mandate_ttl_seconds, counted from the moment the consumer signs.

POST /v1/consents/init is the deprecated alias of this call, same handler and same responses, kept for two releases.

The default pin kind makes the allowlist inert on a card

merchant_pin_kind defaults to pix-key, and the card authorizer skips that kind outright: it applies the allowlist only when the kind is mcc, comparing the transaction MCC, or merchant-id, comparing the merchant id. A mandate signed with the default has an allowlist the consumer read and the card rail never consults. What is left governing the card is the total cap, the per-transaction cap, the currency, the expiry and the status. Pass mcc or merchant-id when the mandate is going to back a card.

Step 2. The consumer signs, and only the consumer

Compose the hosted page URL from the token and hand it to the consumer. At submit, the mandate, the consumer's secret, the funding source and the consent record are written server-side in one transaction. Nothing a partner can call with an API key produces a signed mandate. See Mandates.

The mandate id comes back through callback_url when you supplied one. Otherwise list it:

curl -H "Authorization: Bearer $CODESPAR_API_KEY" \
  "https://api.codespar.dev/v1/mandates?consumer_id=csm_0a1b2c3d4e5f6071&status=active&limit=50"

limit runs 1 to 200 and defaults to 50, and there is no cursor: past the limit the page truncates silently. An unknown status answers 400 rather than an empty list. GET /v1/consumers/mandates is the deprecated alias of this list.

Step 3. Mint the card

curl -X POST https://api.codespar.dev/v1/cards \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Idempotency-Key: 9a1d4f3c-6b20-4d88-9f07-5c2e1a7b3d40" \
  -H "Content-Type: application/json" \
  -d '{
    "country": "BR",
    "currency": "BRL",
    "mandate_id": "mandate_0a1b2c3d4e5f6071",
    "holder": {
      "full_name": "Ana Souza",
      "email": "ana@example.com",
      "birthdate": "1990-04-17",
      "document": { "type": "CPF", "value": "39053344705", "issuing_country": "BR" },
      "address": {
        "line1": "Rua Haddock Lobo",
        "street_number": "595",
        "neighborhood": "Cerqueira Cesar",
        "city": "Sao Paulo",
        "state": "SP",
        "postal_code": "01414001",
        "country": "BR"
      },
      "phone": "+5511987654321"
    }
  }'

mandate_id is required on every lane, with no exception per issuer. country is ISO 3166-1 alpha-2 uppercase and decides the corridor; currency is ISO 4217 uppercase. The Idempotency-Key header is optional, and leaving it out has a cost: the server mints one, and the call stops being repeatable because you hold no key to repeat with.

There is no controls field in the body. The spending controls are projected out of the mandate.

The handler runs in this order, and the order is what tells you what exists when a refusal arrives:

  1. Zod parse of the body. 400 invalid_body.
  2. resolveIssuer(country, currency). 400 issuer_corridor_unsupported.
  3. The bidirectional environment guard, read off the issuer base URL. 403, before any flow work at all, replays included.
  4. The HMAC fingerprint of the request. 503 vault_unavailable when VAULT_MASTER_KEY is missing.
  5. The pre-read of the flow by Idempotency-Key. A changed body is 409 idempotency_key_conflict; state='issued' answers 200 with the card; state='revoked' answers 200 with the flow alone.
  6. The mandate gates, on a fresh attempt only, meaning no row yet or a row in failed. 404 mandate_not_found, 409 mandate_not_active, 400 issuer_controls_unsupported for a currency mismatch, 409 mandate_already_bound for an occupied slot.
  7. createMintFlow, which can still hit the unique index from migration 0123 and answer 409 mandate_already_bound.
  8. Only now is the issuer adapter called.

Step 6 running only on a fresh attempt is why a replay cannot answer 409 mandate_not_active because the mandate expired while the holder was doing KYC.

Step 4. Read the outcome from the status

2xxWorked
201Issued now. The body is state: "issued", flow_id and card.
202The issuer asked for holder KYC first. The body is flow_id, state: "pending", issuer, reason, kyc_url and created_at. kyc_url is where the person goes.
200A replay: the same Idempotency-Key landing again on a flow that already finished, returning what it produced instead of minting a second card.

The three bodies differ, so switch on the status and not on the shape.

The mint surface never returns card-number material. last4 and token_id are all that exist here, and token_id stays null until the ingest into the cardholder data environment lands, which is its own step after the card exists. A freshly issued card can be perfectly readable with no token yet.

Step 5. The 202 path

A parked flow moves forward through two independent doors. Pomelo's endorsement.approved webhook resumes every parked flow for that customer through the same driveMintFlow, and a fresh POST /v1/cards carrying the same Idempotency-Key pushes the flow too, because the adapters are re-entrant.

Poll the flow by its id:

curl -H "Authorization: Bearer $CODESPAR_API_KEY" \
  https://api.codespar.dev/v1/cards/imf_0a1b2c3d4e5f6071

GET /v1/cards/{id} is one path serving two resources, chosen by the id prefix. An id starting imf_ is a mint flow and the response is the flow; anything else is read as a card row. A flow in state='issued' that already points at a row returns the flow plus a card key. In every other case that key is absent, not null.

There is no event to subscribe to

The trigger catalog carries commerce.payment.*, commerce.charge.*, commerce.ted_in.* and commerce.ted_out.*. There is no card.*, no issuance.* and no authorization.*. A flow parked at KYC is observable only by polling this route, and an authorization decision only by reading the binding. See Triggers.

Step 6. There is no bind step

The card_mandates row is written inside the mint, by driveMintFlow calling bindCardToMandate, with cardId taken from the issuer's own card id and mandateId read out of the flow metadata.

The binding is best effort, and 201 does not mean governed

bindCardToMandate runs outside the transaction, deliberately, inside a try/catch that only logs. mandate_missing, already_bound and cross_tenant_card all become log lines, and a thrown exception becomes the log line card exists but is NOT governed (manual bind required). The mint still answers 201, and the 201 carries no signal about it. The only proof of governance is the next step.

Step 7. Verify the binding

curl -H "Authorization: Bearer $CODESPAR_API_KEY" \
  "https://api.codespar.dev/v1/consumers/mandates/mandate_0a1b2c3d4e5f6071/card?limit=50"

A 200 with card not null proves the binding exists. A 200 with card: null and authorizations: [] means the mandate is visible and nothing is bound to it, which is exactly the shape an ungoverned card leaves behind. 404 mandate_not_found means the mandate is not visible to this caller at all.

The card projection carries card_id, consumer_id, affinity_group_id, cardholder_id, last_four, brand, status and bound_at.

limit here is clamped, never validated

Default 20, clamped into 1 to 100. A limit of 1000 becomes 100, 0 becomes 1, and anything that does not parse as a number becomes 20. No input is refused, so a client bug that sends garbage gets a 200 and never a 400.

Step 8. Spend

Pomelo calls POST /v1/webhooks/pomelo/authorize on every transaction. The core resolves the card with SELECT ... FROM card_mandates WHERE card_id = <the issuer's card id> AND status = 'active'. With no active binding the reason is card_not_bound and nothing is persisted: no card_authorizations row, no receipt seal. There is no way to audit afterwards how many purchases an ungoverned card attempted.

With a binding, the order is: idempotency on the pair of org and transaction, re-read of the mandate, verification of the mandate's stored HMAC, the decision, an advisory lock per mandate to redo the cap arithmetic, the write into card_authorizations and the seal into the audit chain, and only then the agentic receipt seal, outside the critical path. See Audit chain.

Observe mode approves everything, and records the decision it did not send

enforcing() is process.env.POMELO_AUTHORIZER_ENFORCE === "true", read straight off the process environment. Until that is true the authorizer computes, persists and logs the real decision, and answers APPROVED to the network every time. The file header gives the reason: the exact payload shape and the amount units, major decimal against minor integer, are not yet confirmed with the issuer.

The consequence reaches the reads. card_authorizations.decision stores the shadow decision; the effective one goes only into the audit chain payload and is exposed by no REST route. So authorizations[].decision can say REJECTED on a purchase the merchant watched go through. No route reports which mode a deployment is running in.

Step 9. Read the decisions

The same read returns authorizations, newest first. Each entry carries transaction_id, amount_minor, currency, merchant_name, mcc, operation, decision, status_detail, remaining_minor and at.

amount_minor and remaining_minor are decimal strings in the JSON. The columns are cast to text on the way out so no precision is lost, so adding them as numbers concatenates them.

Step 10. Unbind

curl -X DELETE https://api.codespar.dev/v1/consumers/mandates/mandate_0a1b2c3d4e5f6071/card \
  -H "Authorization: Bearer $CODESPAR_API_KEY"

The binding moves to status='closed' and the response is mandate_id, card_id and revoked: true. Only active bindings govern, so the authorizer now fails closed for that card.

This call is not idempotent. The second one answers 404 no_active_card, which is a different code from the 404 mandate_not_found you get when the mandate is not visible.

It also does not cancel the card at the issuer. For the full kill switch, cancel there as well, through codespar_issue with action set to a card control of cancel, or through the issuer's own console.

Refusals, and which one a retry fixes

CodeStatusWhat already happenedDoes a retry fix it?
invalid_body400Nothing. The parse runs first.No. details.issues names the field. A corrected body needs a new key: the old one is bound to the old fingerprint.
issuer_corridor_unsupported400Nothing.No. An operator decision: the corridor flag plus the vault secret. Nonexistent and disabled answer alike.
invalid_holder400The issuer refused the holder. A flow that had not parked vendor-side is now failed.No, not with the same holder data.
issuer_controls_unsupported400Nothing was minted.No. Three causes wear this code: the mandate currency differs from the card currency, checked on fresh attempts only; the control projection failed on the Bridge lane; or the adapter returned a permanent rejection.
stage_issuer_live_key / live_issuer_test_key403Nothing. Resolved before any flow work, replays included.No. A key in the other mode fixes it. The lane's base URL decides, fail closed: a base URL that is missing or unparseable classifies as real, so a test key is refused.
mandate_not_found404Nothing.No. The lookup is scoped to this organization, and a mandate held by another org answers identically.
mandate_not_active409Nothing. Fresh attempts only.No. The message names the status. A new mandate fixes it.
mandate_already_bound409Nothing new.Never. details carries mandate_id. Read the slot note below before treating this as transient.
idempotency_key_conflict409Nothing.No with the same key, yes with a new one. The fingerprint is an HMAC over country, currency, mandate id, name, email, birthdate, document, address, phone and sorted metadata, and it survives the redaction of holder data, so the mismatch is caught long afterwards. The same code also covers a key belonging to a finished flow whose holder was already discarded.
holder_already_registered409Nothing. Refused before any card is created; the flow is marked failed.No. The issuer deduplicated this holder onto a customer another organization already owns.
issuer_card_unknown500An invariant broke: the flow reads as issued and no card row is readable behind it.No.
issuer_mint_failed502The issuer refused or failed. details carries issuer, issuer_status and issuer_code.Yes with the same key, if the cause was transient.
issuer_ingest_failed502The card exists. Only the ingest into the cardholder data environment failed, and details.card already carries the issued card.Yes, and here it is mandatory. The same key reuses the token id, because allocation is first write wins, and completes the ingest. A new key mints a second card.
vault_unavailable503Nothing.Yes, once the vault is readable again.

Two of those 409 codes are missing from the served document, whose 409 enum lists only idempotency_key_conflict and mandate_not_active. Both mandate_already_bound and holder_already_registered are on the errors index. The gap is in the OpenAPI document, not in the docs.

On the read side, GET /v1/cards/{id} answers 404 issuer_card_unknown for two different situations: an id that does not exist, and a card belonging to a sibling project of the same organization. The messages differ, mint flow not found against issuer card not found, and the code does not, so no client can tell a wrong id from another project's id by reading the code.

Gotchas the field tables do not show

The unbind frees the bind slot, not the mint slot

Closing the binding does free the partial index card_mandates_one_active_per_mandate, so a different card can be bound afterwards. The mint gate counts something else. getMandateForMint sums active bindings plus every mint flow whose state is not failed, and migration 0123 enforces UNIQUE (org_id, metadata->>'mandate_id') WHERE state <> 'failed'. A flow that reached issued never becomes failed. So after a DELETE you can bind another card, and a second POST /v1/cards against that mandate answers 409 mandate_already_bound forever. One mandate backs at most one mint.

An abandoned flow holds the mandate with no way out

Counting live flows is what makes the slot honest, and it has a price the code states plainly: a holder who never finishes KYC holds the mandate's slot until someone cleans it up, and there is no cancel route and no expiry sweeper. Nothing in the API releases it.

There are two card identifiers and they are not interchangeable

GET /v1/cards/{id} resolves card.id, CodeSpar's own row id. The authorizer resolves card.card_id, the issuer's id, and that is what bindCardToMandate writes. GET /v1/consumers/mandates/{id}/card returns card_id, the issuer's. Writing a row keyed by the CodeSpar row id produces a binding the authorizer never finds.

Comparing the two card listings is the ungoverned-card detector

GET /v1/cards?consumer_id=X joins card_mandates, while the unfiltered list reads issuer_cards directly. A card whose binding failed has no card_mandates row, so it disappears from the filtered list and shows up in the unfiltered one. The document describes the parameter only as a filter by consumer.

The listing returns cards, not flows. A 202 parked at KYC appears in neither.

bound_at is the first binding, not the current one

The handler returns created_at from card_mandates, and the upsert on conflict updates updated_at and leaves created_at alone. Point a card at a second mandate and bound_at still reads the first binding.

The authorizations belong to the card, not to the mandate

The query filters on the organization and card_id, with no mandate_id anywhere. A card that once lived under another mandate carries its whole history into the current one.

The read and the unbind are scoped by organization only

Both reads of card_mandates filter on org_id alone. A sibling project of the same organization can see the binding and can close it.

Blocking a card at the issuer does not close the binding

The card.status_changed webhook closes the card_mandates row when the status becomes canceled, and does nothing when it becomes blocked, because a block is reversible at the issuer. A blocked card still holds an active binding here.

The error envelope is not one shape

Handler refusals use an object envelope with error.code, error.message, optional error.details, and request_id. The auth layer answers with a flat error string: an unauthenticated POST /v1/cards returns 401 with a string. Parsing one as the other reads undefined where a string was expected.

What this does not do

  • No bind call in the published API. POST /v1/consumers/mandates/{id}/card is not in the served document, and the exclusion is deliberate and dated: the route is internal, a dashboard backend for frontend, and the API matrix classifies it as never a key surface. GET and DELETE on that same path are in the document. The route is served and the scope map does grant it mandates:write, so the boundary is a product decision rather than a technical wall. Bind by minting, and verify with the GET.
  • No SDK method for the bind. The typed REST client is generated from the served document, so the operation has no typed entry. The SDK page for this group lists two operations, the read and the unbind.
  • codespar_issue does not put a card under a mandate. Its arguments are action, cardholder_id, program_id, card_id, control, reason, shipping_address and metadata. There is no mandate_id, and the dispatch goes through the catalog straight to the issuer's own create-card endpoint. The result is a card with no card_mandates row, which the authorizer reads as card_not_bound. The only minting path that binds is POST /v1/cards. See codespar_issue.
  • No CLI command for the mint. The published tree has 103 commands in 34 groups and no cards group and no issuer group. codespar issue is the meta-tool path described above. For the binding the CLI carries only codespar consumers list-mandates-card and codespar consumers delete-mandates-card, both documented under the consumers reference.
  • No typed SDK wrapper for the mint. session.issue() wraps the meta-tool, not this lane. The mandate lane is reached through the generic REST client, cs.api.response("post", "/v1/cards", ...).
  • No route to cancel or expire a mint flow. It is an open follow-up in the code, and a flow abandoned at KYC holds its mandate with no remedy through the API.
  • No documentation page for the authorizer itself. POST /v1/webhooks/pomelo/authorize is a provider webhook and sits outside the client route surface. Its refusal reasons, among them card_not_bound, mandate_expired, currency_mismatch, exceeds_per_tx_cap, exceeds_total_cap, mcc_not_allowed, merchant_not_allowed, mandate_sig_invalid and replay, are named nowhere in these docs.
  • No PAN, CVV or expiry on this surface. last4 and token_id are the whole of it.

Next steps

Card Bound to a Mandate | CodeSpar