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.

6 min read
View MarkdownEdit on GitHub

Mandates, receipts and approvals

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. Every spend path on the buy side re-verifies it server-side before money moves, and every settled spend seals a signed receipt that names the mandate it ran under. When a policy rule holds a spend for a human, that hold is an approval, and it is decided in the dashboard by an admin, never by an API key.

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.
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/init mints a one-shot token; the consumer signs at the hosted page, which calls POST /v1/consents/{token}/submit. Both are in the code and not in the published document (ent#1186). No route creates a signed mandate from an API key alone; whether one should is decision 1 in ent#1186.
SDK
Not exposed. The consent routes are not in the published document, so cs.api has no operation for them.
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, not in the published document (ent#1186). POST /v1/consumer-payments/execute with { mandate, signature }, not in the published document (ent#978 renames it). 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", ...). The by-id spend is not in the published document, so not in cs.api.
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, a ledger feed per mandate with a different shape, not in the published document (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, not in the published document (ent#1186), 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 signed with the same consumer secret that signed the mandate. 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).

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.

What exists in the code and is not in the published document. 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). 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. Both are on the rename list in ent#978 and are counted in ent#1186.

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 (a boleto settlement over the mandate cap). 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 (same handler as the canonical POST /v1/approvals/{id}/decide; both not in the published document, ent#1186).

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 in the published document. POST /v1/consents/init (alias of the canonical POST /v1/consents) and the public GET/submit under /v1/consents/{token} are live and not in the published document (openapi.json). Class (c) in ent#1186.
  • Deciding an approval by API key. By design, see above. Decision 2.
  • A receipt read per wallet. Decision 3.
  • POST /v1/consumers/mandates/{id}/spend, POST /v1/consumers/mandates/{id}/card and POST /v1/consumer-payments/execute in the published document. Live, described on the Wallets reference by hand, and not in the published document until ent#978 and ent#1186 land them.

Next

Mandates, receipts and approvals | CodeSpar