Consumer Mandates
7 operations under /v1/consumers/mandates/{id} (GET DELETE POST): parameters, status codes, refusal bodies, and the same request in curl, HTTP, Python and TypeScript.
Base URL: https://api.codespar.dev
Every operation below requires a Bearer token. See Authentication.
The {id} here is a consumer mandate id (the rows GET /v1/mandates lists). These two operations read and remove the card binding of a mandate: a card minted for an agent spends under the same caps, allowlist and expiry the mandate carries. Binding a card (POST on the same path) exists in the code and is not in this document; see the concept page, Mandates, receipts and approvals, for where each operation is exposed today and the Wallets reference for spending under a mandate by id.
Every operation, from the spec
Generated from the published OpenAPI document, so it never drifts from what the API actually serves. The section above is written by hand and carries what a schema cannot: the object model, field rules, and the order to call things in.
GET /v1/consumers/mandates/{id}/card
https://api.codespar.dev/v1/consumers/mandates/{id}/cardThe card bound to a mandate, and its recent authorizations
The active card binding for this allowance plus the card's recent authorization decisions, newest first. A mandate with no active binding answers 200 with card: null and an empty authorizations array, which is the shape to branch on; 404 is reserved for the mandate itself not being visible to this caller.
limit bounds the authorizations, not the card. It is clamped rather than validated: a value above 100 becomes 100, a value below 1 becomes 1, and anything that does not parse as a number falls back to 20. No input to it is rejected.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | The mandate id. |
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | no | Authorizations to return. Default 20, clamped to the range 1 to 100. |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
404 | object | No such mandate for this organization. A mandate another organization holds answers the same way. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
authorizations | array of object | yes | Newest first. Empty when no card is bound. |
card | object,null | yes | Null when the mandate exists and has no active card binding. |
mandate_id | string | yes | — |
curl -X GET https://api.codespar.dev/v1/consumers/mandates/{id}/card \
-H "Authorization: Bearer $CODESPAR_API_KEY"GET /v1/consumers/mandates/{id}/card HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.get(
"https://api.codespar.dev/v1/consumers/mandates/{id}/card",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/consumers/mandates/{id}/card", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const result = await cs.api.get("/v1/consumers/mandates/{id}/card", {
path: {
id: "mandate_0000000000000000"
}
});{
"mandate_id": "mandate_0000000000000000",
"card": {
"card_id": "card_0000000000000000",
"consumer_id": "csm_0000000000000000",
"affinity_group_id": "affinitygroup_0000000000000000",
"cardholder_id": "cardholder_0000000000000000",
"last_four": "string",
"brand": "string",
"status": "string",
"bound_at": "2026-01-15T12:00:00.000Z"
},
"authorizations": [
{
"transaction_id": "transaction_0000000000000000",
"amount_minor": "1000",
"currency": "BRL",
"merchant_name": "Example",
"mcc": "string",
"operation": "string",
"decision": "string",
"status_detail": "string",
"remaining_minor": "1000",
"at": "2026-01-15T12:00:00.000Z"
}
]
}DELETE /v1/consumers/mandates/{id}/card
https://api.codespar.dev/v1/consumers/mandates/{id}/cardUnbind the card from a mandate
Closes the binding, which frees the slot so a different card can be bound and makes the card authorizer fail closed for that card, since it only governs ACTIVE bindings.
This does NOT cancel the card at the issuer. It unbinds the card from the allowance and nothing more. For a full kill switch, cancel the card at the issuer as well.
Not idempotent in the way a delete usually is: the second call answers 404 no_active_card, because after the first there is no active binding left to revoke. The two 404 codes are worth telling apart, and are: mandate_not_found means this caller cannot see the mandate at all, no_active_card means the mandate is there and carries nothing to unbind.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | The mandate id. |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
404 | object | mandate_not_found: no such mandate for this organization, which is also the answer for another organization's mandate. no_active_card: the mandate exists and has no active card binding to revoke. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
card_id | string | yes | — |
mandate_id | string | yes | — |
revoked | true | yes | — |
curl -X DELETE https://api.codespar.dev/v1/consumers/mandates/{id}/card \
-H "Authorization: Bearer $CODESPAR_API_KEY"DELETE /v1/consumers/mandates/{id}/card HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.delete(
"https://api.codespar.dev/v1/consumers/mandates/{id}/card",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/consumers/mandates/{id}/card", {
method: "DELETE",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const result = await cs.api.delete("/v1/consumers/mandates/{id}/card", {
path: {
id: "mandate_0000000000000000"
}
});{
"mandate_id": "mandate_0000000000000000",
"card_id": "card_0000000000000000",
"revoked": true
}POST /v1/consumers/mandates/{id}/pause
https://api.codespar.dev/v1/consumers/mandates/{id}/pausePause an allowance (legacy path)
DEPRECATED alias of POST /v1/mandates/{id}/pause (ent#979), kept for two releases. Same handler, same mandates:write scope. active is still the only legal source, an already-paused allowance still answers 200 with changed: false, and the status flip and its evidence row are still one transaction.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | — |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
reason | string | no | Recorded in the evidence row's metadata, alongside the from/to pair. Not echoed in the response. |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
400 | object | A body was sent and did not match the schema. details.issues carries the Zod issues. |
403 | object | Service auth (the dashboard) did not establish an admin. user_token_required: no x-codespar-user-token was forwarded, and a service key alone never moves a mandate. user_token_invalid: the token did not verify; mint a fresh one. user_token_identity_mismatch: x-codespar-user names somebody else. insufficient_role: that person is not an admin or owner of the organization. A project API key without mandates:write gets the scope refusal instead. Nothing was written. Bare { error, remediation } body. |
404 | object | No such allowance for this credential's organization. An allowance belonging to another organization answers exactly the same way: the org predicate sits in the SELECT, so the handler cannot tell absent from someone else's, and must not. |
409 | object | invalid_transition when the allowance's current status is not a legal source for this verb. transition_conflict when another request moved the allowance between this one's read and its write: the UPDATE is guarded on the status that was read, so a concurrent transition loses rather than double-applying, and the evidence row is written in the same transaction as the status change or not at all. |
503 | object | user_token_verification_unconfigured: a deployment fault on the dashboard path, not a caller fault; retrying the same request will not help. Bare body. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
changed | boolean | yes | False when the allowance was already in this state. Nothing was written and no evidence row was appended. |
mandate | object | yes | — |
curl -X POST https://api.codespar.dev/v1/consumers/mandates/{id}/pause \
-H "Authorization: Bearer $CODESPAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "string"
}'POST /v1/consumers/mandates/{id}/pause HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json
{
"reason": "string"
}import os
import requests
res = requests.post(
"https://api.codespar.dev/v1/consumers/mandates/{id}/pause",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
json={
"reason": "string"
},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/consumers/mandates/{id}/pause", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"reason": "string"
}),
});
const data = await res.json();const r = await cs.api.response("post", "/v1/consumers/mandates/{id}/pause", {
path: {
id: "mandate_0000000000000000"
},
body: {
reason: "string"
}
});
// r.status is one of the documented statuses (200, 403),
// each with its own body shape in r.data; nothing here throws on 403.
if (r.ok) {
console.log(r.data);
}{
"mandate": {
"id": "obj_0000000000000000",
"status": "paused"
},
"changed": true
}GET /v1/consumers/mandates/{id}/receipts
https://api.codespar.dev/v1/consumers/mandates/{id}/receiptsThe spend feed for one allowance
Every SETTLED debit against this mandate, newest first. This is the consumer-facing answer to "what did my agent spend my allowance on", and it is the read to show a person rather than the tool-call log.
IT READS THE LEDGER, NOT THE ATTEMPTS. The rows come from the wallet ledger, so an authorization that never settled is not here and neither is a failed payment. That also means a spend appears only once the money moved, which can be after the agent reported success.
AMOUNTS ARE POSITIVE. The ledger stores a debit as a negative number and this route negates it, so amount_minor reads as a spend without a sign to interpret. It is a STRING because ledger amounts are exact and a large one does not survive a round trip through a double.
THE OPTIONAL FIELDS ARE THE MERCHANT'S, NOT OURS. payee and purpose come out of the ledger row's metadata, which the paying rail fills in as best it can; both are null when whatever moved the money did not say. psp_tx_id is the provider's own reference for the settlement, useful for reconciling against a statement.
SCOPE IS THE ORG, AND THE 404 IS DELIBERATE. A mandate belonging to another organization answers mandate_not_found, the same as one that never existed, so the route cannot be used to test which mandate ids are real.
PAGING IS A CEILING, NOT A CURSOR. limit defaults to 50 and caps at 200, and there is no cursor: a mandate with more settled debits than the limit shows the newest ones and the rest are not reachable through this route today.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | The consumer mandate. |
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | no | — |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
400 | object | The query did not match the schema — a limit below 1, above 200, or not a number. Zod's issues are in details.issues. |
404 | object | No such mandate for this organization. The same answer for a mandate that does not exist and one that belongs to someone else. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
mandate_id | string | yes | — |
receipts | array of object | yes | — |
curl -X GET https://api.codespar.dev/v1/consumers/mandates/{id}/receipts \
-H "Authorization: Bearer $CODESPAR_API_KEY"GET /v1/consumers/mandates/{id}/receipts HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.get(
"https://api.codespar.dev/v1/consumers/mandates/{id}/receipts",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/consumers/mandates/{id}/receipts", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const result = await cs.api.get("/v1/consumers/mandates/{id}/receipts", {
path: {
id: "mandate_0000000000000000"
}
});{
"mandate_id": "mandate_0000000000000000",
"receipts": [
{
"ledger_id": "ledger_0000000000000000",
"amount_minor": "1000",
"currency": "BRL",
"psp_tx_id": "psptx_0000000000000000",
"payee": "string",
"purpose": "string",
"settled_at": "2026-01-15T12:00:00.000Z"
}
]
}POST /v1/consumers/mandates/{id}/resume
https://api.codespar.dev/v1/consumers/mandates/{id}/resumeResume a paused allowance (legacy path)
DEPRECATED alias of POST /v1/mandates/{id}/resume (ent#979), kept for two releases. Same handler, same mandates:write scope. paused is still the only legal source, and resuming still leaves caps, expiry and spend-to-date exactly as they were.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | — |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
reason | string | no | Recorded in the evidence row's metadata, alongside the from/to pair. Not echoed in the response. |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
400 | object | A body was sent and did not match the schema. details.issues carries the Zod issues. |
403 | object | Service auth (the dashboard) did not establish an admin. user_token_required: no x-codespar-user-token was forwarded, and a service key alone never moves a mandate. user_token_invalid: the token did not verify; mint a fresh one. user_token_identity_mismatch: x-codespar-user names somebody else. insufficient_role: that person is not an admin or owner of the organization. A project API key without mandates:write gets the scope refusal instead. Nothing was written. Bare { error, remediation } body. |
404 | object | No such allowance for this credential's organization. An allowance belonging to another organization answers exactly the same way: the org predicate sits in the SELECT, so the handler cannot tell absent from someone else's, and must not. |
409 | object | invalid_transition when the allowance's current status is not a legal source for this verb. transition_conflict when another request moved the allowance between this one's read and its write: the UPDATE is guarded on the status that was read, so a concurrent transition loses rather than double-applying, and the evidence row is written in the same transaction as the status change or not at all. |
503 | object | user_token_verification_unconfigured: a deployment fault on the dashboard path, not a caller fault; retrying the same request will not help. Bare body. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
changed | boolean | yes | False when the allowance was already in this state. Nothing was written and no evidence row was appended. |
mandate | object | yes | — |
curl -X POST https://api.codespar.dev/v1/consumers/mandates/{id}/resume \
-H "Authorization: Bearer $CODESPAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "string"
}'POST /v1/consumers/mandates/{id}/resume HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json
{
"reason": "string"
}import os
import requests
res = requests.post(
"https://api.codespar.dev/v1/consumers/mandates/{id}/resume",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
json={
"reason": "string"
},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/consumers/mandates/{id}/resume", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"reason": "string"
}),
});
const data = await res.json();const r = await cs.api.response("post", "/v1/consumers/mandates/{id}/resume", {
path: {
id: "mandate_0000000000000000"
},
body: {
reason: "string"
}
});
// r.status is one of the documented statuses (200, 403),
// each with its own body shape in r.data; nothing here throws on 403.
if (r.ok) {
console.log(r.data);
}{
"mandate": {
"id": "obj_0000000000000000",
"status": "active"
},
"changed": true
}POST /v1/consumers/mandates/{id}/revoke
https://api.codespar.dev/v1/consumers/mandates/{id}/revokeRevoke an allowance (legacy path)
DEPRECATED alias of POST /v1/mandates/{id}/revoke (ent#979), kept for two releases. Same handler, same mandates:write scope. Still terminal, and still no verb anywhere that brings a revoked allowance back.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | — |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
reason | string | no | Recorded in the evidence row's metadata, alongside the from/to pair. Not echoed in the response. |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
400 | object | A body was sent and did not match the schema. details.issues carries the Zod issues. |
403 | object | Service auth (the dashboard) did not establish an admin. user_token_required: no x-codespar-user-token was forwarded, and a service key alone never moves a mandate. user_token_invalid: the token did not verify; mint a fresh one. user_token_identity_mismatch: x-codespar-user names somebody else. insufficient_role: that person is not an admin or owner of the organization. A project API key without mandates:write gets the scope refusal instead. Nothing was written. Bare { error, remediation } body. |
404 | object | No such allowance for this credential's organization. An allowance belonging to another organization answers exactly the same way: the org predicate sits in the SELECT, so the handler cannot tell absent from someone else's, and must not. |
409 | object | invalid_transition when the allowance's current status is not a legal source for this verb. transition_conflict when another request moved the allowance between this one's read and its write: the UPDATE is guarded on the status that was read, so a concurrent transition loses rather than double-applying, and the evidence row is written in the same transaction as the status change or not at all. |
503 | object | user_token_verification_unconfigured: a deployment fault on the dashboard path, not a caller fault; retrying the same request will not help. Bare body. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
changed | boolean | yes | False when the allowance was already in this state. Nothing was written and no evidence row was appended. |
mandate | object | yes | — |
curl -X POST https://api.codespar.dev/v1/consumers/mandates/{id}/revoke \
-H "Authorization: Bearer $CODESPAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "string"
}'POST /v1/consumers/mandates/{id}/revoke HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json
{
"reason": "string"
}import os
import requests
res = requests.post(
"https://api.codespar.dev/v1/consumers/mandates/{id}/revoke",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
json={
"reason": "string"
},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/consumers/mandates/{id}/revoke", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"reason": "string"
}),
});
const data = await res.json();const r = await cs.api.response("post", "/v1/consumers/mandates/{id}/revoke", {
path: {
id: "mandate_0000000000000000"
},
body: {
reason: "string"
}
});
// r.status is one of the documented statuses (200, 403),
// each with its own body shape in r.data; nothing here throws on 403.
if (r.ok) {
console.log(r.data);
}{
"mandate": {
"id": "obj_0000000000000000",
"status": "revoked"
},
"changed": true
}POST /v1/consumers/mandates/{id}/spend
https://api.codespar.dev/v1/consumers/mandates/{id}/spendMoves money out under a mandate already projected into this organization
Spend under a mandate already projected here
Moves money out under a mandate already projected into this organization, addressed by id. This is the path the codespar_pay meta-tool takes internally, so the behaviour here is the behaviour an agent gets; the route exists so a caller who does not use the SDK reaches the same place. One difference: this route runs the organization's policy rules under consumer_payment:execute, for the mandate's agent, before the payment starts, exactly like /v1/consumer-payments/execute.
With ted present the provider leg is a real bank transfer, debited from the consumer's own sub-account. Without ted, payee is a Pix key or a copy-and-paste string.
TED carries three requirements the other payment methods do not, and all three bind BEFORE any money step:
attempt_idis MANDATORY. A TED has no copy-and-paste string and no nonce to derive correlation from, and two TEDs under the same mandate must never collide on a derived id.payeemust EQUAL the destination's canonical string,ted:<ispb>:<branch>:<account>, alphanumerics only and upper-cased. The comparison normalizes whitespace and nothing else, so an entry carrying a dot or a hyphen does NOT match. The rule is what makes the signed-allowlist pin and the wire name the SAME destination: without it, an approved payee fronts for a different bank account.- The destination must be NAMED in a signed list of the mandate, and a merchant-list wildcard
"*"NEVER authorizes money out. Which list is accepted is mid-rollout, so read this one carefully: the target rule iswithdrawal_allowlistalone, and while the rollout flag is off a destination named explicitly in the signedmerchant_allowlistis ALSO accepted, with the stricter rule's refusal recorded rather than applied. Tolerance never widens what passes: a wildcard is refused on both paths, and a destination in neither list is refused with its own code. Build againstwithdrawal_allowlist: it is the list that keeps working when the flag turns on.
The cumulative cap is checked and CONSUMED under an advisory lock, and the wallet hold is taken inside that same lock. The lock is released before the provider call, so a slow provider does not serialize everyone else.
There is no read route for the outcome. An attempt_id that answered psp_dispatch_uncertain has no endpoint to ask about afterwards. Keep the attempt_id and present it again: the lifecycle is idempotent on it and answers the state that attempt stopped in, instead of firing a second one. Do NOT restart the same intent under a fresh attempt_id. That is how a payment gets made twice. A retry of an attempt that already holds money is not evaluated against the organization's policy rules again, so a budget or a rate limit cannot refuse the retry that finds out what happened.
A settled attempt answers its recorded response. Presenting the attempt_id of an attempt that already settled, with the same amount, payee, mandate, rail, quote and approval, returns the ORIGINAL 200 body verbatim (same transactionId, same receipt) with idempotent_replay: true, and nothing is dispatched, held, sealed or published. This holds on every rail and in test mode alike, and while the organization is paused, since the answer moves no money. Presenting it with any of those parameters changed is attempt_id_conflict. Idempotency is opt-in: a request without an attempt_id is its own payment and is never replayed, so two spends under one mandate are two payments. Send an attempt_id to make a retry safe.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | The mandate's id. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
actor | PaymentActor | no | WHO triggered this spend, recorded on the receipt and read back from it. Optional, and absence is a real answer: a spend that sends no actor records null, and nothing is ever inferred from agent_id — that field names the agent the mandate was SIGNED for, which is an authority, not an event. A person acting through WhatsApp under an agent's mandate and the agent acting unattended are the same row without this field. |
agent_id | string | no | — |
amount_minor | integer | yes | Amount in minor units. |
approval | SpendApproval | no | The hash of what the caller says a person approved: items_hash for this spend's lines, and for a batch batch_hash over the whole presented list. When present it is SEALED into the receipt's signed chain as its own link (chain version 4), so it cannot be stripped or altered afterwards without breaking both receipt_sig and receipt_sig_ed25519, and the receipt reads return it. It is a SEALED CLAIM by the caller, NOT a server-side approval check. The server only validates the shape; it never interprets the value, never compares it with anything and never refuses a spend because of it. It proves that this payment was sealed together with this hash, so whoever holds the approval artifact can recompute the hash and hold it against the receipt. It does not prove that anybody approved anything, or who. A malformed value, or a key other than these two, is refused with invalid_approval_hash before anything is read, held or sent. Part of what makes two requests with one attempt_id the same payment: a repeat with a different approval is attempt_id_conflict. |
attempt_id | string | no | Mandatory when ted is present. Present the same one again on every retry. |
payee | string | yes | A Pix key, a copy-and-paste string, or, for TED, the destination's canonical string. The 1024 limit is there to fit a copy-and-paste string. |
quote | SpendQuote | no | The offer the agent approved. When present it is signed into the receipt, and at close the price and the payee are compared against what actually settled. A divergence is RECORDED on the receipt; it does not abort settlement. |
session_id | string | no | The session this payment is made in: stamped on its hold and debit, so the session shows the amount it paid. Must name a session of this project, or 422 session_not_found before any money step. Not the quote's session_id, which is a store checkout session. |
ted | TedDestination | no | The destination of a TED, the Brazilian same-day bank wire. Present this object on a spend to send a wire instead of a Pix: its presence is what switches the rail, and what makes the three TED requirements on those operations bind. Money leaves the consumer's own sub-account, never a pooled one. |
Responses
| Status | Body | Description |
|---|---|---|
200 | SpendOutcome | OK |
400 | object | The body did not match the schema (invalid_body), approval is malformed (invalid_approval_hash), quote.at is not an RFC 3339 timestamp with an offset in UTC years 0001-9999 (invalid_receipt_timestamp), or actor.on_behalf_of names a consumer other than the one this mandate belongs to (actor_consumer_mismatch). Nothing was read, held or sent in any of these cases. attempt_id_required: the body names no attempt_id and the deployment enforces one (ATTEMPT_ID_REQUIRED_ENFORCE); add a stable id and reuse it on every retry of that payment. It is checked AFTER the body is valid, so a request that is both malformed and id-less is answered invalid_approval_hash, invalid_receipt_timestamp or invalid_body first. |
403 | object | Either a policy rule refused the spend (policy_denied, with reason, ruleType and ruleId, plus approval_id and expires_at when the rule opened an approval; since ent#1807 an approval also carries payment_state: awaiting_approval, attempt_id and hold_ref, the wallet hold that reserves the amount until the decision, released on deny or expiry; a payment that could not be reserved is answered with the refusal instead, e.g. insufficient_funds, and its approval is closed), or the destination is not on the mandate's signed withdrawal allowlist (withdrawal_pin, carrying the next consent step), or a Pix is above the mandate's per-transaction cap and waits for a person (approval_required, flat body like policy_denied: reason: per_tx_cap_exceeded, approval_id, expires_at, payment_state: awaiting_approval, attempt_id, amount_minor, per_tx_cap_minor, hold_ref). Approving it settles exactly that amount once, debiting the held amount; the cumulative cap still binds. A retry of a waiting attempt_id answers the same approval. Nothing was sent to the provider in any case. |
404 | object | No mandate with that id exists in this organization. |
409 | object | Local state refuses. psp_attempt_in_flight: the same attempt is already in flight; retry the SAME attempt_id once it has answered, never a new one. psp_attempt_uncertain: an earlier attempt ended with an unknown outcome and this intent is PINNED to it, so present the same attempt_id again, never a new one. psp_attempt_conflict: the earlier attempt was already compensated. attempt_id_conflict: this attempt_id was already used for a different payment (mismatched_fields names what differs); nothing was held or sent. attempt_id_unavailable: another project of this organization holds this attempt_id; use a new one. |
422 | object | The mandate refuses. mandate_verify: signature, agent, purpose or payee do not check out. mandate_projection_mismatch: the signature presented differs from the stored one, or the mandate is not active. no_funding_source: the consumer has no funding source that serves this rail; TED requires a registered bank account. carrier_crc_invalid, carrier_malformed, carrier_format_unsupported: the payee is a Pix copia-e-cola that fails its own check (CRC, structure, or no usable Pix template); nothing was held or sent. per_tx_cap_exceeded: the amount is above the mandate's per-transaction cap and cannot wait for a person (not a Pix, or asked by a lane that does not park); details carries amount_minor and per_tx_cap_minor (integer strings in the mandate's own unit: minor for a v1 mandate, currency-native for unit_version: 2) and currency, beside the message "<amount> > <cap>". psp_refused: the provider answered and declined (in test mode, the sandbox's reserved refusal key); the hold is released and nothing moved. |
500 | object | The verifier answered without resolving the cumulative cap, so the re-check under the lock would have no limit to compare against. A deliberate refusal: nothing is debited. |
502 | object | The provider refused or did not answer. psp_dispatch_failed: a known refusal, nothing left. psp_dispatch_uncertain: it is NOT known whether anything left, so the hold stands and the attempt stays pinned. |
503 | object | The policy engine could not evaluate the spend. Nothing was evaluated and nothing was sent to the provider; retry. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
attempt_id | string | yes | The attempt this payment settled under: the attempt_id sent, or the fresh one minted when none was. Presenting it again reads this outcome instead of paying again. |
audit | array of — | yes | The lifecycle steps, in order, each with a timestamp and an outcome. |
idempotent_replay | boolean | yes | True when this attempt_id had already settled and this is its recorded outcome, answered again: every other field is the ORIGINAL response, verbatim, and nothing was sent, held, sealed or published by this call. False on the call that settled it. |
mandate | object | yes | — |
payment | object | yes | — |
receipt | object,null | yes | The sealed Control Record. Null when there was no consumer secret to sign it with. |
requestId | string | yes | — |
status | "completed" | yes | — |
wallet | object | yes | — |
curl -X POST https://api.codespar.dev/v1/consumers/mandates/{id}/spend \
-H "Authorization: Bearer $CODESPAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount_minor": 1000,
"payee": "string",
"agent_id": "agt_0000000000000000",
"attempt_id": "attempt_0000000000000000",
"ted": {
"bank": "string",
"branch": "string",
"account": "string",
"tax_id": "tax_0000000000000000",
"name": "Example",
"account_type": "CC",
"person_type": "F",
"client_finality": "string"
},
"quote": {
"seller": "string",
"resource": "string",
"price_minor": 1,
"payee": "string",
"session_id": "ses_0000000000000000",
"at": "2026-01-15T12:00:00.000Z"
},
"actor": {
"type": "agent",
"id": "paymentactor_0000000000000000",
"on_behalf_of": "string"
},
"approval": {
"items_hash": "string",
"batch_hash": "string"
},
"session_id": "ses_0000000000000000"
}'POST /v1/consumers/mandates/{id}/spend HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json
{
"amount_minor": 1000,
"payee": "string",
"agent_id": "agt_0000000000000000",
"attempt_id": "attempt_0000000000000000",
"ted": {
"bank": "string",
"branch": "string",
"account": "string",
"tax_id": "tax_0000000000000000",
"name": "Example",
"account_type": "CC",
"person_type": "F",
"client_finality": "string"
},
"quote": {
"seller": "string",
"resource": "string",
"price_minor": 1,
"payee": "string",
"session_id": "ses_0000000000000000",
"at": "2026-01-15T12:00:00.000Z"
},
"actor": {
"type": "agent",
"id": "paymentactor_0000000000000000",
"on_behalf_of": "string"
},
"approval": {
"items_hash": "string",
"batch_hash": "string"
},
"session_id": "ses_0000000000000000"
}import os
import requests
res = requests.post(
"https://api.codespar.dev/v1/consumers/mandates/{id}/spend",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
json={
"amount_minor": 1000,
"payee": "string",
"agent_id": "agt_0000000000000000",
"attempt_id": "attempt_0000000000000000",
"ted": {
"bank": "string",
"branch": "string",
"account": "string",
"tax_id": "tax_0000000000000000",
"name": "Example",
"account_type": "CC",
"person_type": "F",
"client_finality": "string"
},
"quote": {
"seller": "string",
"resource": "string",
"price_minor": 1,
"payee": "string",
"session_id": "ses_0000000000000000",
"at": "2026-01-15T12:00:00.000Z"
},
"actor": {
"type": "agent",
"id": "paymentactor_0000000000000000",
"on_behalf_of": "string"
},
"approval": {
"items_hash": "string",
"batch_hash": "string"
},
"session_id": "ses_0000000000000000"
},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/consumers/mandates/{id}/spend", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"amount_minor": 1000,
"payee": "string",
"agent_id": "agt_0000000000000000",
"attempt_id": "attempt_0000000000000000",
"ted": {
"bank": "string",
"branch": "string",
"account": "string",
"tax_id": "tax_0000000000000000",
"name": "Example",
"account_type": "CC",
"person_type": "F",
"client_finality": "string"
},
"quote": {
"seller": "string",
"resource": "string",
"price_minor": 1,
"payee": "string",
"session_id": "ses_0000000000000000",
"at": "2026-01-15T12:00:00.000Z"
},
"actor": {
"type": "agent",
"id": "paymentactor_0000000000000000",
"on_behalf_of": "string"
},
"approval": {
"items_hash": "string",
"batch_hash": "string"
},
"session_id": "ses_0000000000000000"
}),
});
const data = await res.json();const r = await cs.api.response("post", "/v1/consumers/mandates/{id}/spend", {
path: {
id: "mandate_0000000000000000"
},
body: {
amount_minor: 1000,
payee: "string",
agent_id: "agt_0000000000000000",
attempt_id: "attempt_0000000000000000",
ted: {
bank: "string",
branch: "string",
account: "string",
tax_id: "tax_0000000000000000",
name: "Example",
account_type: "CC",
person_type: "F",
client_finality: "string"
},
quote: {
seller: "string",
resource: "string",
price_minor: 1,
payee: "string",
session_id: "ses_0000000000000000",
at: "2026-01-15T12:00:00.000Z"
},
actor: {
type: "agent",
id: "paymentactor_0000000000000000",
on_behalf_of: "string"
},
approval: {
items_hash: "string",
batch_hash: "string"
},
session_id: "ses_0000000000000000"
}
});
// r.status is one of the documented statuses (200, 403, 422),
// each with its own body shape in r.data; nothing here throws on 403.
if (r.ok) {
console.log(r.data);
}{
"status": "completed",
"requestId": "request_0000000000000000",
"mandate": {
"consumer_id": "csm_0000000000000000",
"purpose": "string",
"currency": "BRL"
},
"payment": {
"transactionId": "transaction_0000000000000000",
"endToEndId": "endtoend_0000000000000000",
"amountMinor": 1000,
"rail": "string",
"provider": "string",
"moneyMoved": true,
"adapter": "string"
},
"wallet": {
"walletId": "wlt_0000000000000000",
"holdEntryId": "holdentry_0000000000000000",
"fundEntryId": "fundentry_0000000000000000",
"debitEntryId": "debitentry_0000000000000000"
},
"receipt": {
"id": "spendoutcome_0000000000000000",
"state": "paid",
"chain": "string",
"exceptions": []
},
"audit": [],
"attempt_id": "attempt_0000000000000000",
"idempotent_replay": true
}Mandates
5 operations under /v1/mandates (GET POST): parameters, status codes, refusal bodies, and the same request in curl, HTTP, Python and TypeScript.
Approvals
5 operations under /v1/approvals (GET POST): parameters, status codes, refusal bodies, and the same request in curl, HTTP, Python and TypeScript.