Mandates, receipts and approvals
What a mandate is, where one is born per channel (REST, SDK, CLI, dashboard, meta-tool), who signs it and why you never do, how it is paused and revoked, what the receipt of a spend is, and why an API key cannot decide a held approval.
A mandate is the consumer's signed authorization for one agent to spend: which agent, for what purpose, to which payees, up to which cap per currency and per transaction, until when. On the governed spend paths (a boleto the agent pays with codespar_pay, the consumer-payments routes and the mandate spend route POST /v1/consumers/mandates/{id}/spend) it is re-verified server-side before money moves, and a settled spend seals a signed receipt that names the mandate it ran under. A direct Pix the agent sends with codespar_pay in a live project (to a key, a dynamic QR code or bank details) is not checked against a mandate: the runtime records it and does not refuse it. When a spend is held for a human, that hold is an approval, and it is decided in the dashboard by an admin, never by an API key. A policy rule can hold a spend; so can the per-transaction cap, on two paths only: a boleto the agent pays with codespar_pay, and a Pix on the consumer-payments routes. A boleto on the consumer-payments routes above the per-transaction cap, any other rail above it, and any spend over the periodic (monthly) or total cap are refused, and an approval does not lift them.
This page is the entry point. It says what exists today, per channel, with the routes that are in the published API document and the ones that exist in the code but are not in it yet. The consent flow in depth is on Directed-pay; the policy layer that opens approvals is on Guardrails.
You do not sign anything
The mandate is signed by CodeSpar, with a per-consumer secret that lives in CodeSpar's vault and never leaves it. Your backend starts the consent, the consumer signs at a hosted page, and you get back the mandate and its signature to store. Spending by mandate id (codespar_pay, POST /v1/consumers/mandates/{id}/spend) reconstructs and re-verifies the stored row, so most integrations never handle the signature at all.
What a mandate carries
| Field | What it bounds |
|---|---|
agent_id | The one agent this allowance authorizes. A spend by another agent is refused. |
purpose | The signed purpose. The verifier binds every spend to it; it is not a caller field. |
merchant_allowlist | The payees the agent may pay, pinned by merchant_pin_kind (pix-key, merchant-id or mcc). Never empty; a single "*" is an explicit wildcard. |
withdrawal_allowlist, dda_allowlist | Separate, signed lists for cash-out destinations (ted:<ispb>:<branch>:<account>) and for the documents a DDA registration may expose. A merchant wildcard never authorizes either. The withdrawal rule is mid-rollout: until its flag is on, a cash-out destination named explicitly in the signed merchant_allowlist is also accepted, and the stricter refusal is recorded rather than applied. Sign cash-out destinations into withdrawal_allowlist. |
cap_minor, per_tx_cap_minor | Total and per-transaction ceilings in minor units, per currency slot. There is no FX inside a mandate: a BRL cap does not authorize a USDC spend. |
slots | The per-currency legs of a multi-slot mandate (for example BRL over Pix plus USDC on-chain), minted from one signature. |
expires_at | UNIX seconds. Checked on every spend. |
status | active, paused, revoked or expired. Only active spends. |
The signed material (nonce, secret_version, hmac_signature) is never projected by the reads; GET /v1/mandates/{id} returns the fields above and nothing that could be replayed.
Two tables, one word
The word "mandate" names two different rows in the API, and the routes do not mix them.
- Consumer mandate, the canonical one. The allowance a consumer signed at the consent page. Lives under
/v1/mandatesand is whatcodespar_pay,POST /v1/consumers/mandates/{id}/spend, the card authorizer and the wallet withdrawal route verify. Signed with the consumer's HMAC secret. This page is about this one unless it says otherwise. - Org-side mandate. An allowance the organization issues to its own agent for an account wallet, charged by
POST /v1/wallets/{id}/execute, which decrementsremaining_amountunder a guard before the payment is attempted. Lives under/v1/orgs/{orgId}/mandates. Signed over a canonical string (12 fields on format version 2, 14 on version 3, 15 on version 4) with the org's HMAC key, and for a registered agent also with the agent's and the platform issuer's Ed25519 keys. That dual signature is what Agent trust lets a counterparty verify offline. Issuance is off by default: on a default deploymentPOST /v1/orgs/{orgId}/mandatesanswers501 not_implementedbefore any row is written.
A card bound to a consumer mandate (GET /v1/consumers/mandates/{id}/card) spends under that mandate's caps and allowlist; the {id} there is a consumer mandate id.
- your backendMint the consent
- POST /v1/consents/init
- agent, purpose, caps, payees, TTL
- returns a one-shot token
- the consumerSign at the hosted page
- /consent/{token}
- picks the rail, reviews the terms
- submits once (replay-guarded)
- CodeSparSign the mandate
- per-consumer secret in the vault
- HMAC over the canonical fields
- returns { mandate, signature }
- your agentSpend under it
- codespar_pay, or POST .../spend
- verify, hold, debit, seal receipt
- revoke or pause at any time
Where a mandate is born, per channel
Every cell below was read from the code, not from a roadmap. "Not exposed" means no surface exists on that channel today. Routes marked "not in the published document" exist in the API and are missing from openapi.json; the tracking issue is linked. cs.api is the typed client the SDK generates from the published document, so it covers exactly what that document covers.
Create a consumer mandate
POST /v1/consents mints a one-shot token (POST /v1/consents/init is the deprecated alias); both entered the published document on 2026-09-11. The consumer then signs at the hosted page. POST /v1/consents/{token}/submit is what that page calls, and it entered the published document on 2026-09-21, with GET /v1/consents/{token} (published) next to it.cs.api.post("/v1/consents", ...) mints the token. The submit is in the document since 2026-09-21, so a released client will type it; the one on npm today was generated before that, and it is the consumer's browser that calls it anyway, not your backend.codespar mandate create drives the same two calls; the operator supplies the provider token./dashboard/consumers, + New consent, opens the hosted page in a popup.codespar_pay spends under one that already exists.Create an org-side mandate
POST /v1/orgs/{orgId}/mandates (published). 501 not_implemented unless the deployment enables org-side issuance.cs.api.post("/v1/orgs/{orgId}/mandates", ...)/dashboard/sandbox/agent-mandate-payment issues a demo one against the demo backend.Read and list
GET /v1/mandates, GET /v1/mandates/{id} (published). Org-side: GET /v1/orgs/{orgId}/mandates, GET /v1/orgs/{orgId}/mandates/{id}.cs.api.get("/v1/mandates"), cs.api.get("/v1/mandates/{id}", ...)codespar wallet reads the per-currency rollup (GET /v1/consumers/{id}/wallet), not the mandate rows./dashboard/consumers lists funding sources and the consent log.codespar_wallet with action: "balance" reads the rollup, not the mandate rows.Pause, resume, revoke
POST /v1/mandates/{id}/pause, POST /v1/mandates/{id}/resume, POST /v1/mandates/{id}/revoke (published). Org-side: POST /v1/orgs/{orgId}/mandates/{id}/revoke; there is no org-side pause.cs.api.post("/v1/mandates/{id}/pause", ...) and its two siblings.Spend under a mandate
POST /v1/consumers/mandates/{id}/spend by id, and POST /v1/consumer-payments/execute with { mandate, signature }. Both are in the published document since 2026-09-11 (ent#1176); ent#978 still proposes renaming the second. Both carry ted for an outbound bank transfer, under the mandate's signed withdrawal allowlist. POST /v1/wallets/{id}/transfer (published) is a cash-out and needs mandate plus signature. POST /v1/wallets/{id}/execute (published) charges an org-side mandate_id on an account wallet.cs.api.post("/v1/wallets/{id}/transfer", ...), cs.api.post("/v1/wallets/{id}/execute", ...), and since 0.13.0 also cs.api.post("/v1/consumers/mandates/{id}/spend", ...) and cs.api.post("/v1/consumer-payments/execute", ...)./dashboard/consumers, Execute, streams the lifecycle step by step. /dashboard/wallets/{id}, Execute, takes an org-side mandate id.codespar_pay resolves the consumer's active mandate server-side; mandateId is optional and, when given, is tested on its own terms.Read a receipt
cs.api.get("/v1/consumers/receipts/{id}", ...)Decide a held approval
POST /v1/orgs/{orgId}/approvals/{id}/decide (published), and it refuses API keys by design (see below). Poll GET /v1/approvals/{id}/status (published).cs.api.get("/v1/approvals/{id}/status", ...) to poll. Deciding is not exposed./dashboard/approvals, admin or owner.approval_required and the approval_id to poll.Who signs, and why you never do
At consent submit the API provisions a per-consumer HMAC secret (or resolves the active version), builds the canonical mandate from the intent your backend declared plus the rail the consumer chose, signs it, and returns { mandate, signature } to your callback. The signature is a 64-character hex HMAC-SHA256. You store the pair, or just the mandate id: nothing on your side ever holds the key, so nothing on your side can forge, extend or re-sign an allowance.
On every spend the verifier checks the signature against the secret version stamped on the row (a row edited in the database fails here), the expiry, that the funding source is still active, the cumulative cap in the mandate's own accounting unit, the per-transaction cap, the agent, the purpose, and membership of the payee in the signed allowlist. The refusal names the gate that tripped.
Two entry points differ only in who reconstructs the signed material:
- By id (
codespar_pay,POST /v1/consumers/mandates/{id}/spend, the CLI): the server loads the stored row and its signature. The caller never sees the HMAC. - By proof (
POST /v1/consumer-payments/execute,POST /v1/wallets/{id}/transfer, the public payment-link gateway): the caller presents{ mandate, signature }it stored at consent time. Possession of the signature is what locates the row, and the row's stored signature is re-checked anyway.
Org-side mandates follow the same idea with a different key: the org HMAC covers the canonical string, and a registered agent adds two Ed25519 signatures over the same bytes. @codespar/sdk/mandate and codespar mandate verify verify that token offline, with the public keys from the agent's DID document.
Lifecycle
pause revoke
active ─────────────► paused ───────────────► revoked (terminal)
▲ │ ▲
└───────────────────────┘ │
resume │
└──────────────────────────────────────────────────┘
revoke| Route | Transition |
|---|---|
POST /v1/mandates/{id}/pause | active to paused. Spends are refused while paused. |
POST /v1/mandates/{id}/resume | paused to active. |
POST /v1/mandates/{id}/revoke | active or paused to revoked. Terminal. |
The body is optional ({ "reason": "..." }, up to 280 characters). Each applied transition flips the status and appends a consent evidence row in the same transaction, so the LGPD trail stays append-only. Repeating a transition already at its target answers 200 with changed: false; an illegal one answers 409 invalid_transition; a concurrent change answers 409 transition_conflict.
Expiry is enforced at spend time from expires_at: a mandate past it is refused on every path whatever its stored status. No sweep flips a consumer mandate's row to expired today; the expiry sweep that writes that status exists for org-side mandates. Revoking an agent key (see Agent trust) stops new org-side mandates from being minted under it and does not revoke consumer mandates, which never read the agent key table.
Receipts
A receipt is the signed record of one spend, the Control Record: four linked records, mandate (authority), quote (the offer the agent accepted), payment (settlement) and delivery (proof, when it lands), chained by a SHA-256 over their canonical JSON and then sealed twice. Its state is paid, delivered, exception or voided, and exceptions carries any settle-time mismatch (for example a payment that hit a payee the quote did not name).
Two seals, because one of them proves nothing to an outsider. receipt_sig is an HMAC under the same consumer secret that signed the mandate. It proves the receipt to whoever holds that secret, and nobody else: verifying it means being handed the key that also mints it, so to a buyer's accountant or a marketplace reconciling a payout it has been a claim rather than evidence. receipt_sig_ed25519 is the other half — an Ed25519 signature over codespar-receipt:v1:<receipt_id>:<chain>, made with CodeSpar's platform issuer key, with receipt_sig_kid naming which published key made it. The public keys are served with no credential at /.well-known/codespar-receipt-keys.json, so anyone with the receipt JSON and a stock Ed25519 library can check it without asking us for anything. The signer is deliberately CodeSpar and not the organization being attested: a key held by the party under scrutiny would prove exactly what the HMAC already proves.
The Ed25519 signature is additional. The HMAC seal did not change and still means what it always meant. And what the asymmetric half attests is that CodeSpar sealed this receipt for a settlement CodeSpar executed — it is not a bank's confirmation that money moved.
Two limits on the Ed25519 seal
It is only on receipts sealed since the API gained it. There is no backfill and there will not be one: signing an older receipt today would attest to what the database says now, not to what happened then. Those receipts read null in receipt_sig_ed25519 and receipt_sig_kid, which is an absent signature and not a failed one — their HMAC seal is untouched.
Check it against the key document of the deployment that issued the receipt. Production and the sandbox both publish a key under the same kid (did:web:id.codespar.dev#1) with the same issuer and different key material, so a sandbox receipt checked against the production keys fails as though somebody had tampered with it. Nothing in either document tells them apart yet; naming the deployment in the kid or the issuer is tracked in ent#1641.
Where the id comes from. The spend that sealed the receipt returns it:
POST /v1/consumer-payments/executeand its stream variant answerreceipt_idalongside the payment; the by-id spend route returns the same lifecycle response.codespar_payreturns areceiptobject (id,state,chain) on the result.- The x402 facilitator returns
receipt: { id, state, chain }on settle. POST /v1/wallets/{id}/execute(account wallets, org-side mandate) does not seal a receipt; its evidence is the audit trail it returns.
How to read it. GET /v1/consumers/receipts/{id} for one, GET /v1/consumers/{consumerId}/receipts for a consumer's list, both project-scoped (a receipt in another project answers 404). From an agent, codespar_ledger with action: "receipt" and a receipt_id, or action: "receipts" for the consumer. The Receipts reference has the field list.
Two more, and both are in the published document since ent#1406. POST /v1/consumers/receipts/{id}/delivery seals the delivery proof onto a paid receipt and re-anchors the chain (an order confirmation, an NF-e access key, a tracking code); the proof is ranked, and a weaker or equal one is an idempotent no-op rather than a clobber. GET /v1/consumers/mandates/{id}/receipts lists the settled debits of one mandate straight from the wallet ledger, a different shape from the Control Record: it shows money that moved, not attempts. Both are still on the rename list in ent#978.
What does not exist. A receipt read keyed by wallet (/v1/wallets/{id}/receipts or a wallet_id filter). Whether to add one is decision 3 in the ent#1186 exposure comment.
Approvals
The mandate is the standing authorization. An approval is a one-off hold placed in front of a single call by a rule the operator wrote (an approval-required or budget rule in /dashboard/policies) or by a guard the runtime does not let a rule override (the per-transaction cap, for a boleto the agent pays with codespar_pay or a Pix on the consumer-payments routes). The runtime writes a pending_approvals row with an expiry (24 hours by default) and answers the caller with approval_required and an approval_id. Nothing dispatched. The caller polls GET /v1/approvals/{id}/status until it leaves pending; approved means the held call was re-executed server-side and execution_result carries the upstream outcome.
Who decides. An admin or owner, at /dashboard/approvals. The dashboard proxies to POST /v1/orgs/{orgId}/approvals/{id}/decide, the older spelling of the canonical POST /v1/approvals/{id}/decide — one handler, two paths, both in the published document since ent#1406.
Why an API key cannot decide. The decide route answers 403 bearer_token_cannot_decide to any project API key, on purpose. The key that asked for a spend must not be the key that approves it; otherwise a leaked csk_ key approves its own transfer and the approval is theatre. The route accepts service-key callers and expects the approver's identity as a user token that the backend verifies, whose subject must hold admin or owner. This is dual control, and it is a design decision, not a missing endpoint. The ent#1186 exposure comment records it as decision 2: if programmatic approval is ever wanted, it is a different design (a narrow mandate minted in advance), not this route opened to keys.
What is enforced today
Refusing a caller that sends no user token is gated on APPROVAL_DECIDE_ENFORCE_USER_TOKEN, which is off by default. While it is off, a service-key caller with no token still decides, and the approver recorded is the x-codespar-user header the caller wrote; the audit chain marks those decisions approval.decide_unverified with decided_by_source: "header_asserted". A caller that does send a token is verified regardless of the flag. The API-key refusal is not gated. Guardrails tracks the rollout.
Confirming a one-off spend outside the dashboard. If your product confirms a purchase with the consumer on your own channel (a message, a push, a call), that confirmation is your logic and it runs before you dispatch: ask, get the yes, then call codespar_pay or the spend route. Do not model it as an approval you then try to decide by API; the route will refuse you, by design. When the risk is bounded rather than per-call, mint a narrow mandate instead: one payee in the allowlist, a per-transaction cap equal to the price, a short expires_at. The consent flow signs it, the spend runs under it, and the receipt records exactly that authority.
What is not exposed yet
- Creating a consumer mandate from an API key alone. Not in the API. Today a mandate is born only through the consent flow (the consumer signs), the CLI that drives it, the dashboard popup that opens it, or the x402 facilitator translating an x402 authorization. Decision 1 in the ent#1186 exposure comment.
- The consent routes.
POST /v1/consentsand its aliasPOST /v1/consents/initentered the published document on 2026-09-11 (ent#1176). The publicGETand submit under/v1/consents/{token}stay out of it on purpose: the consumer's browser calls them, not a partner backend. Class (c) in ent#1186. - Deciding an approval by API key. By design, see above. Decision 2.
- A receipt read per wallet. Decision 3.
- The by-id spend and the signed-envelope spend.
POST /v1/consumers/mandates/{id}/spendandPOST /v1/consumer-payments/executeentered the published document on 2026-09-11 (ent#1176), andcs.apireaches both from@codespar/sdk0.13.0. They carrytedfor an outbound bank transfer under the mandate's signed withdrawal allowlist.POST /v1/consumers/mandates/{id}/cardis still in the code and not in the published document (ent#1186).
Next
Shopper Identity
Connect-once + vaulted shopper identity — let a shopper connect a login-walled store once, or vault a guest-checkout profile, so an agent can buy on their behalf without re-asking. Per-consumer, inbound, credentials never exposed.
Guardrails
The policy layer that gates every agent tool call before it dispatches, spend budgets, rate limits, and human approval, enforced server-side.