Skip to main content

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.

5 min read
View MarkdownEdit on GitHub

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

FieldWhat it bounds
agent_idThe one agent this allowance authorizes. A spend by another agent is refused.
purposeThe signed purpose. The verifier binds every spend to it; it is not a caller field.
merchant_allowlistThe 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_allowlistSeparate, 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_minorTotal 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.
slotsThe per-currency legs of a multi-slot mandate (for example BRL over Pix plus USDC on-chain), minted from one signature.
expires_atUNIX seconds. Checked on every spend.
statusactive, 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/mandates and is what codespar_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 decrements remaining_amount under 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 deployment POST /v1/orgs/{orgId}/mandates answers 501 not_implemented before 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.

↻ mandate lifecycle
Who does what, from consent to receipt
  1. your backend
    Mint the consent
    • POST /v1/consents/init
    • agent, purpose, caps, payees, TTL
    • returns a one-shot token
  2. the consumer
    Sign at the hosted page
    • /consent/{token}
    • picks the rail, reviews the terms
    • submits once (replay-guarded)
  3. CodeSpar
    Sign the mandate
    • per-consumer secret in the vault
    • HMAC over the canonical fields
    • returns { mandate, signature }
  4. your agent
    Spend under it
    • codespar_pay, or POST .../spend
    • verify, hold, debit, seal receipt
    • revoke or pause at any time
You never hold the signing secret. Every spend path re-verifies the stored mandate server-side.

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

REST
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.
SDK
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.
CLI
codespar mandate create drives the same two calls; the operator supplies the provider token.
Dashboard
/dashboard/consumers, + New consent, opens the hosted page in a popup.
Meta-tool
Not exposed. No tool creates a mandate; codespar_pay spends under one that already exists.

Create an org-side mandate

REST
POST /v1/orgs/{orgId}/mandates (published). 501 not_implemented unless the deployment enables org-side issuance.
SDK
cs.api.post("/v1/orgs/{orgId}/mandates", ...)
CLI
Not exposed.
Dashboard
The sandbox cookbook at /dashboard/sandbox/agent-mandate-payment issues a demo one against the demo backend.
Meta-tool
Not exposed.

Read and list

REST
GET /v1/mandates, GET /v1/mandates/{id} (published). Org-side: GET /v1/orgs/{orgId}/mandates, GET /v1/orgs/{orgId}/mandates/{id}.
SDK
cs.api.get("/v1/mandates"), cs.api.get("/v1/mandates/{id}", ...)
CLI
codespar wallet reads the per-currency rollup (GET /v1/consumers/{id}/wallet), not the mandate rows.
Dashboard
/dashboard/consumers lists funding sources and the consent log.
Meta-tool
codespar_wallet with action: "balance" reads the rollup, not the mandate rows.

Pause, resume, revoke

REST
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.
SDK
cs.api.post("/v1/mandates/{id}/pause", ...) and its two siblings.
CLI
Not exposed.
Dashboard
Not exposed. The consumers page shows the consent log and has no transition button.
Meta-tool
Not exposed.

Spend under a mandate

REST
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.
SDK
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
/dashboard/consumers, Execute, streams the lifecycle step by step. /dashboard/wallets/{id}, Execute, takes an org-side mandate id.
Meta-tool
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

REST
GET /v1/consumers/receipts/{id}, GET /v1/consumers/{consumerId}/receipts (published). GET /v1/consumers/mandates/{id}/receipts (published), a ledger feed per mandate with a different shape (ent#978). Nothing per wallet: decision 3 in ent#1186.
SDK
cs.api.get("/v1/consumers/receipts/{id}", ...)
CLI
Not exposed.
Dashboard
Not exposed as a receipt view.
Meta-tool
codespar_ledger with action: "receipt" or "receipts".

Decide a held approval

REST
POST /v1/orgs/{orgId}/approvals/{id}/decide (published), and it refuses API keys by design (see below). Poll GET /v1/approvals/{id}/status (published).
SDK
cs.api.get("/v1/approvals/{id}/status", ...) to poll. Deciding is not exposed.
CLI
Not exposed.
Dashboard
/dashboard/approvals, admin or owner.
Meta-tool
Not exposed. The tool result carries 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
RouteTransition
POST /v1/mandates/{id}/pauseactive to paused. Spends are refused while paused.
POST /v1/mandates/{id}/resumepaused to active.
POST /v1/mandates/{id}/revokeactive 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/execute and its stream variant answer receipt_id alongside the payment; the by-id spend route returns the same lifecycle response.
  • codespar_pay returns a receipt object (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/consents and its alias POST /v1/consents/init entered the published document on 2026-09-11 (ent#1176). The public GET and 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}/spend and POST /v1/consumer-payments/execute entered the published document on 2026-09-11 (ent#1176), and cs.api reaches both from @codespar/sdk 0.13.0. They carry ted for an outbound bank transfer under the mandate's signed withdrawal allowlist. POST /v1/consumers/mandates/{id}/card is still in the code and not in the published document (ent#1186).

Next

Mandates, receipts and approvals | CodeSpar