---
title: "Card Bound to a Mandate"
description: "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."
---

<MetaStrip items={[
  { label: "TIME", value: "~25 min" },
  { label: "STACK", value: (<><ServerChip name="HTTP / SDK REST" accent /><span style={{ color: "var(--color-fd-muted-foreground)", fontSize: 12 }}>no agent, no CLI</span></>) },
  { label: "SERVERS", value: (<><ServerChip name="issuer: pomelo" /><ServerChip name="issuer: 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.

<EventPipeline
  title="CARD UNDER MANDATE"
  subtitle="Consent token → consumer signs → mint → verified binding"
  nodes={[
    { tag: "1 · CONSENT", name: "Mint the token", meta: "POST /v1/consents → 201 · ctk_ token, 24h", tone: "event" },
    { tag: "2 · SIGN", name: "Consumer at the hosted page", meta: "mandate, secret and funding source written server-side", tone: "validation" },
    { tag: "3 · MINT", name: "Issue against the mandate", meta: "POST /v1/cards → 201, 202 or 200", tone: "loop" },
    { tag: "4 · VERIFY", name: "Prove it is governed", meta: "read the binding · card must not be null", tone: "event" },
  ]}
  loopSteps={[
    { tool: "parse, then corridor", description: "400 invalid_body, then 400 issuer_corridor_unsupported" },
    { tool: "environment guard", description: "403 before any flow work, replays included" },
    { tool: "idempotency read", description: "409 on a changed body, 200 on a finished flow" },
    { tool: "mandate gates", description: "404, 409 and 400, on fresh attempts only" },
    { tool: "driveMintFlow", description: "the issuer is called last, and the binding after that" },
  ]}
  note="the card_mandates row is written after the issuer returns, outside the transaction, and its failure does not change the status"
/>

## Prerequisites

```bash
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](/docs/concepts/authentication).

<Callout type="warn" title="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.
</Callout>

<Callout type="warn" title="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.
</Callout>

## The whole journey in one file

```typescript title="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 },
});
```

## Step 1. Mint the consent token

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`.

```bash
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.

<Callout type="warn" title="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.
</Callout>

## 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](/docs/concepts/mandates).

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

```bash
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

```bash
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

<StatusTable rows={[
  { code: "201", meaning: "Issued now. The body is `state: \"issued\"`, `flow_id` and `card`." },
  { code: "202", meaning: "The 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." },
  { code: "200", meaning: "A **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:

```bash
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.

<Callout type="warn" title="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](/docs/concepts/triggers).
</Callout>

## 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.

<Callout type="warn" title="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.
</Callout>

## Step 7. Verify the binding

```bash
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`.

<Callout type="warn" title="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.
</Callout>

## 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](/docs/concepts/audit-chain).

<Callout type="warn" title="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.
</Callout>

## 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

```bash
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

| Code | Status | What already happened | Does a retry fix it? |
|---|---|---|---|
| `invalid_body` | 400 | Nothing. 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_unsupported` | 400 | Nothing. | No. An operator decision: the corridor flag plus the vault secret. Nonexistent and disabled answer alike. |
| `invalid_holder` | 400 | The issuer refused the holder. A flow that had not parked vendor-side is now `failed`. | No, not with the same holder data. |
| `issuer_controls_unsupported` | 400 | Nothing 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_key` | 403 | Nothing. 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_found` | 404 | Nothing. | No. The lookup is scoped to this organization, and a mandate held by another org answers identically. |
| `mandate_not_active` | 409 | Nothing. Fresh attempts only. | No. The message names the status. A new mandate fixes it. |
| `mandate_already_bound` | 409 | Nothing new. | Never. `details` carries `mandate_id`. Read the slot note below before treating this as transient. |
| `idempotency_key_conflict` | 409 | Nothing. | 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_registered` | 409 | Nothing. 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_unknown` | 500 | An invariant broke: the flow reads as issued and no card row is readable behind it. | No. |
| `issuer_mint_failed` | 502 | The issuer refused or failed. `details` carries `issuer`, `issuer_status` and `issuer_code`. | Yes with the **same** key, if the cause was transient. |
| `issuer_ingest_failed` | 502 | **The 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_unavailable` | 503 | Nothing. | 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](/docs/errors). 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](/docs/api/sdk/rest-client/consumer-mandates) 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](/docs/concepts/meta-tools/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](/docs/cli/reference/consumers).
- **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

<NextStepsGrid items={[
  { label: "REFERENCE", title: "Cards", description: "Every parameter, body and documented response for the three card operations.", href: "/docs/api/reference/cards" },
  { label: "REFERENCE", title: "Consumer mandates", description: "The binding read, the unbind, and the rest of the consumer mandate group.", href: "/docs/api/reference/consumer-mandates" },
  { label: "CONCEPT", title: "Mandates", description: "Who signs, what is signed, and why two different rows share the word.", href: "/docs/concepts/mandates" },
]} />
