What an Allowance Spent
Read what an agent spent under a consumer mandate, and tell an attempt apart from money that actually moved. The order each handler runs in, what the ledger feed leaves out, and which refusal a retry fixes.
Four calls answer "what did this agent spend": the ledger feed for one mandate, the receipt list for the consumer, one receipt in full, and the audit chain for everything the feed's ceiling cut off.
Prerequisites
npm install @codespar/sdkCODESPAR_API_KEY=csk_live_...A key of 41 characters whose prefix picks the environment: csk_live_ or csk_test_, on the same host either way. An OAuth 2.1 token works too. The three reads need receipts:read; the delivery seal needs receipts:write. See Authentication.
The feed is scoped to the org. The three receipt routes are scoped to the project.
A test key and a live key of the same organisation see the same mandate feed and different receipts. The project is the environment boundary for receipts, so a test key cannot read or reseal a production receipt, and it answers receipt_not_found rather than a refusal that names the boundary. Nothing on the mandate feed marks which project a line came from. See Projects and Test mode.
The whole journey in one file
import { CodeSpar } from "@codespar/sdk";
const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY! });
const mandateId = "cm_0a1b2c3d4e5f6071";
const consumerId = "csm_0a1b2c3d4e5f6071";
// 1. The ledger feed for one mandate. Org-scoped. Only kind='debit' rows.
const feed = await cs.api.get("/v1/consumers/mandates/{id}/receipts", {
path: { id: mandateId },
query: { limit: 200 },
});
for (const line of feed.receipts) {
// amount_minor is a string, and for USDC/USDT it is NOT cents. See below.
console.log(line.settled_at, line.currency, line.amount_minor, line.psp_tx_id);
}
// 2. The feed cannot say whether money moved. The receipts can.
// Project-scoped. No query parameters exist on this route: the ceiling is 50.
const list = await cs.api.get("/v1/consumers/{consumerId}/receipts", {
path: { consumerId },
});
const moved = list.receipts.filter(
(r: any) => r.payment?.money_moved === true && r.payment?.sandbox !== true,
);
// 3. One receipt in full, by id.
const one = await cs.api.get("/v1/consumers/receipts/{id}", {
path: { id: moved[0].receipt_id },
});
// 4. Seal the delivery proof when it arrives.
const sealed = await cs.api.post("/v1/consumers/receipts/{id}/delivery", {
path: { id: one.receipt_id },
body: {
result: "confirmed",
proof: "35260912345678000199550010000012341000012348",
kind: "nfe",
nfe_chave: "35260912345678000199550010000012341000012348",
},
});
// The 200 does not say whether your proof won. Compare it yourself.
const landed = sealed.delivery?.kind === "nfe";Step 1. Read the feed for one mandate
curl -H "Authorization: Bearer $CODESPAR_API_KEY" \
"https://api.codespar.dev/v1/consumers/mandates/cm_0a1b2c3d4e5f6071/receipts?limit=200"limit is an integer, 1 to 200, default 50. The response is an envelope with mandate_id and a receipts array. Every item carries ledger_id, amount_minor as a string, currency, psp_tx_id, payee, purpose and settled_at, and all seven are required in the served document. psp_tx_id, payee and purpose are nullable.
Order inside the handler: parse the query, then assert the mandate belongs to your org with SELECT 1 FROM consumer_mandates WHERE id = $id AND org_id = $orgId, then read wallet_ledger filtered by mandate_id and kind='debit', ordered by posted_at DESC, limited.
The query is validated before ownership, so the codes cross
A bad limit against a mandate that belongs to another organisation answers 400 invalid_query, not 404 mandate_not_found. The parse runs at line 1251 of the handler and the ownership check at 1259. Nobody can read a foreign mandate this way, but a client that branches on 400 versus 404 to decide "does this mandate exist" will branch wrong.
Summing this feed does not give you what left the wallet
Only kind='debit' rows carry a mandate_id. The reversal row and the fee row are both inserted with mandate_id NULL in the wallet runtime. So a refund never appears in the feed and never reduces the original debit, which stays there at full value, and the platform fee never appears at all. This feed is a list of charge attempts that reached the ledger, not a balance. See Refunds and Wallets.
amount_minor here is not cents when the currency is crypto
The ledger stores native units. USDC and USDT carry 6 decimals; BRL and USD carry 2. The payment lifecycle converts with minorToNative(currency, amountMinor) before writing. So US$1.50 in USDC reads "1500000" on this feed, with currency "USDC". On the receipt the field with the same name, payment.amount_minor, is cents, floored, and the exact figure lives in payment.amount_atomic. In USDC the two homonyms differ by a factor of 10,000. Never compare them without converting.
payee and purpose are not the merchant's words. This lane writes purpose from the mandate itself, which means it repeats identically on every line of the feed, and payee from what the caller sent in the spend body. Neither was confirmed with an acquirer.
Step 2. Ask the receipt whether money moved
The feed has no field for it. There is no money_moved, no sandbox, no state on a feed item. The debit is posted whether or not the PSP actually moved funds, so in a test project a simulated spend looks exactly like a real one on the feed.
curl -H "Authorization: Bearer $CODESPAR_API_KEY" \
https://api.codespar.dev/v1/consumers/csm_0a1b2c3d4e5f6071/receiptsThe receipt answers it with two fields. payment.money_moved is a boolean. payment.sandbox is present and always true only when settlement was simulated, and it is sealed inside the signed chain, so it cannot be edited after the fact. See the audit chain.
This route takes no parameters at all
No limit, no cursor, no filter by state or by mandate. The handler calls the list function without an explicit limit, so the default of 50 applies, ordered by created_at DESC. Fifty is the ceiling and there is no way to raise it or to page past it on this route.
There is no refusal on this route beyond 401 and 403. A consumer with no receipts and a consumer this project cannot see both answer 200 with an empty array.
Step 3. Open one receipt
curl -H "Authorization: Bearer $CODESPAR_API_KEY" \
https://api.codespar.dev/v1/consumers/receipts/rcp_8f2a41c9Same shape as a list item. The lookup is scoped to org and project. The id is a free string, so there is no 400 here: an id that matches nothing answers 404 receipt_not_found, and so does a receipt that exists in a sibling project of the same organisation.
Step 4. Seal the delivery proof
curl -X POST https://api.codespar.dev/v1/consumers/receipts/rcp_8f2a41c9/delivery \
-H "Authorization: Bearer $CODESPAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"result": "confirmed",
"proof": "35260912345678000199550010000012341000012348",
"kind": "nfe"
}'result is one of confirmed, pending, failed. proof is 1 to 500 characters. kind is one of order_confirmation, nfe, nfse, tracking, resource, merchant_ref, pix_e2e. Optional: nfe_chave (1 to 60 characters) and at (1 to 40 characters). Needs receipts:write.
The handler runs in this order, and knowing it tells you what already happened when a refusal arrives:
- Zod parses the body. A failure here is
400 invalid_bodyand nothing was read. - The receipt is loaded by org and project. Miss is
404 receipt_not_found. - If the receipt state is
voided, the call answers 200 with the receipt untouched. That is not an error and not a write. - If the state is already
delivered, the new proof is compared against the current one. A proof of equal or lower class answers 200 and changes nothing. - The hash chain is rebuilt with the new delivery.
- The consumer secret version is resolved from the vault. A miss is
409 consumer_secret_unavailable, and nothing was written. - The chain is signed.
- One transaction appends the
receipt_deliveredaudit entry and updates the row. Any failure there is500 receipt_reseal_failedand neither half survives.
The 200 does not tell you whether your proof won
sealReceiptDelivery returns an idempotent flag and the route handler drops it before responding. A no-op answers 200 with the receipt byte for byte as it already was. The only way to know your proof landed is to compare delivery.kind and delivery.nfe_chave on the response against what you sent.
Proof classes, and the one that jumps the queue
| Class | Kinds | Beats |
|---|---|---|
| 1 rail_evidence | pix_e2e, order_confirmation, tracking, resource | nothing |
| 2 merchant_document_ref | merchant_ref, and anything carrying a non-empty nfe_chave | class 1 |
| 3 fiscal_document | nfe, nfse | classes 1 and 2 |
The classifier tests kind.startsWith("merchant_") or a non-empty nfe_chave under any kind. So kind: "tracking" with nfe_chave filled is classified as class 2 and overwrites a rail proof that was already sealed. Send nfe_chave only when the proof really is a fiscal key.
The first seal accepts anything, however weak
The strength comparison only runs when the row is already delivered. A receipt sitting at paid or exception accepts whatever you send, including the weakest class. In practice most receipts are already delivered: when the PSP moved money, the lifecycle seals the rail's own native proof right after settlement, so the receipt arrives at class 1 on its own.
The at field is validated as a string, and the column is a timestamp
The schema for at is a string of 1 to 40 characters with no date validation, and the column is timestamptz. A 40-character string that is not a timestamp passes Zod, blows up on the UPDATE, and comes back as 500 receipt_reseal_failed with the whole transaction reverted. Omit at and the row is stamped with now.
There is no way to undo this. The seal only climbs classes. No DELETE, no downgrade, no correction.
Step 5. Reach what the ceiling cut off
Past 200 lines, the mandate feed cannot show you anything older: it has LIMIT with no offset and no cursor. The audit chain is the only paginated surface in this journey.
curl -H "Authorization: Bearer $CODESPAR_API_KEY" \
"https://api.codespar.dev/v1/audit-events?event_type=mandate_consumed&from=2026-01-01T00:00:00Z&limit=200"import { CodeSpar } from "@codespar/sdk";
const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY! });
let before: number | undefined = undefined;
const forThisMandate: unknown[] = [];
for (;;) {
const page: any = await cs.api.get("/v1/audit-events", {
query: {
event_type: "mandate_consumed",
from: "2026-01-01T00:00:00Z",
limit: 200,
...(before === undefined ? {} : { before_sequence: before }),
},
});
// There is no mandate_id filter. mandate_id lives inside payload,
// so the filtering is yours to do, client-side.
for (const e of page.events) {
if (e.payload?.mandate_id === "cm_0a1b2c3d4e5f6071") forThisMandate.push(e);
}
if (!page.next_before_sequence) break;
before = page.next_before_sequence;
}event_type matches exactly, or as a prefix when the value ends in a dot. from and to are ISO timestamps, and the defaults are seven days ago and now. limit is clamped to 1 to 200, unlike the mandate feed, which refuses instead of clamping. Each event carries sequence_number, event_type, happened_at, payload, prev_hash and entry_hash. The refusals are invalid_before_sequence, invalid_iso_8601 and from_after_to, all 400.
The four event types this journey writes: mandate_call_attempted for the attempt, mandate_consumed for the draw against the allowance, receipt_sealed when the receipt is written, and receipt_delivered when a proof is bound.
Without an explicit from you never reach the past
The default window is the last seven days. Paging before_sequence inside that window walks to the start of the window and stops. Set from to the date you actually mean before you page.
Step 6. Why the feed and the receipt disagree about timing
The spend route, POST /v1/consumers/mandates/{id}/spend, runs the lifecycle in this order: a hold on the wallet with kind='hold', the dispatch to the PSP, a fund, then the debit with kind='debit', external_ref set to psp_tx_id and metadata carrying purpose and payee. Then one transaction writes the mandate_consumed, mandate_call_attempted and receipt_sealed audit entries together with the receipt row. Only after all of that, best effort, it seals the rail's native delivery proof.
So in this lane the ledger line is written before the HTTP response and before the receipt exists. The gap people describe as "the agent said it worked before the money showed up" does not come from here.
Two rails never write a ledger line at all
The card authorizer seals an agentic receipt and writes nothing to wallet_ledger; on a PURCHASE authorisation its moneyMoved is false. The x402 facilitator also seals a receipt and records the mandate_id in foreign_authorizations, not in the ledger. A mandate bound to a card has an empty feed forever, and the only evidence is the receipt. Do not read an empty feed as "nothing was spent". See Directed pay.
Refusals, and which one a retry fixes
| Code | Status | What already happened | Does a retry fix it? |
|---|---|---|---|
invalid_query | 400 | Nothing. The parse runs before the ownership check. | No. limit below 1, above 200 or not numeric. details.issues names it. limit=abc coerces to NaN and fails; limit=0 fails rather than clamping. |
mandate_not_found | 404 | Nothing. | No. No mandate with that id in this organisation. A mandate that never existed and one owned by someone else answer identically, on purpose, so the route cannot be used to probe ids. |
receipt_not_found | 404 | Nothing. | Not with the same key. No receipt with that id in this project. A receipt in a sibling project of the same org answers the same. A key for the right project does fix it. |
invalid_body | 400 | Nothing. Zod runs first. | No. Almost always kind outside the seven values or proof over 500 characters. details.issues names it. |
consumer_secret_unavailable | 409 | Nothing. The chain could not be re-signed, so the receipt is exactly as it was. | Yes, once the secret resolves again. This is an operator problem, not a caller problem. |
receipt_reseal_failed | 500 | Nothing. The audit append and the row update share one sql.begin, and it rolled back. | Safe to repeat. Check at first: a non-timestamp string is the common cause. |
unauthorized | 401 | Nothing. | No. Key missing, malformed or revoked. |
forbidden | 403 | Nothing. | No. The key lacks receipts:read or receipts:write. |
401 and 403 do not carry error.code
A 401 answers the bare body {"error": "unauthorized"}: no code, no message, no request_id. A 403 answers a flat {"error": "forbidden", "message": "...", "status": 403}, also with no code. Every other refusal on this journey does carry error.code. A client that reads error.code gets undefined on 401 and 403 and a real value everywhere else. These two are transversal, so they are not listed per operation on the reference pages.
The scope-infrastructure refusals route_scope_unresolved, scopes_unresolved and route_scope_unmapped are alarms about our mapping, not about your call. All four routes here are mapped, so they do not take that path. route_scope_unmapped only refuses when ROUTE_SCOPE_DENY_UNMAPPED=true, and the default is off, which logs and lets the request through.
From the CLI and the meta-tool
Two of the four have a CLI command:
codespar consumers list-receipts csm_0a1b2c3d4e5f6071
codespar consumers get-receipts rcp_8f2a41c9Both accept only -q/--query and --timeout. See the consumers CLI reference.
codespar ledger does not reach the receipt actions. The CLI validates client-side and refuses with ledger.action must be one of: entry, balance, account. The generic escape hatch works:
codespar tool codespar_ledger -i '{"action":"receipts","consumer_id":"csm_0a1b2c3d4e5f6071"}'codespar_ledger with action: "receipt" needs receipt_id and scopes the read to org, consumer and project, falling back to the session user when consumer_id is omitted; a missing receipt answers { found: false } rather than an error. action: "receipts" needs consumer_id, accepts limit clamped to 1 to 200, and answers { receipts, count }. See the ledger meta-tool.
session.ledger() type-checks and then lies about the shape
The SDK's meta-tool wrapper declares its return as LedgerResult, which is id, status, account_id, alias, balances, raw. None of those fields exist in the output of receipt or receipts, which is { found, receipt } or { receipts, count }. The call compiles and hands you an object the type does not describe. Cast it.
What this does not do
- No cursor on the mandate feed. Only
limit, ceiling 200, no offset and no date filter. Past 200 debits the older ones are unreachable on that route. Use audit events. - No parameters at all on the consumer receipt list. No limit, no cursor, no filter by state or by mandate. The ceiling is 50, fixed by the default of the list function because the route passes no limit.
- No money-moved signal on the feed. There is no
money_moved, nosandboxand nostateon a feed item. Those exist only on the receipt. - No refund signal on the feed. No kind, no negative sign, no
amount_refunded. The receipt haspayment.amount_refunded, but it is optional and absent from the required list, so it only shows up on a receipt that was actually metered. - No webhook for any of this. No
receipt.*and nomandate.*event type exists in the emitted catalogue, which carries onlycommerce.*andwallet.created. You cannot be told that a spend landed or that a receipt was sealed. Polling is the only option. See Webhooks. - No canonical
/v1/mandates/{id}/receipts. Canonical aliases exist for pause, resume and revoke. The feed lives only under the consumer path. - No way to delete or correct a sealed delivery. The seal only climbs classes.
- No way to void a receipt. The
voidedstate exists in the enum and the seal respects it, but no public operation produces it. - No documented event names for the audit filter.
mandate_consumed,mandate_call_attemptedandreceipt_sealedappear nowhere in the docs outside this page. Onlyreceipt_deliveredis published, inside the text of the receipts reference. - Two codes are not on the errors index.
consumer_secret_unavailableandreceipt_reseal_failedare absent from the errors page and appear only on the receipts reference. - No CLI for the feed or the seal. There is no command for
GET /v1/consumers/mandates/{id}/receiptsand none forPOST /v1/consumers/receipts/{id}/delivery. - No meta-tool action that reads the ledger feed by mandate.
codespar_ledgerhas five actions;receiptandreceiptshit the agentic receipts table, whileentry,balanceandaccountroute to the tenant's own ledger, which is a different book. Thewallet_ledgerfeed per mandate exists over HTTP only. - No named SDK method. There is no
cs.receipts.list(). What exists is the typed REST client with the literal path, as on the SDK page for receipts and for consumer mandates.
Next steps
Card Bound to a Mandate
Issue a card and pin it to the allowance a consumer signed. The order inside the mint handler, what already exists when each refusal arrives, which refusal a retry fixes, and the one call that separates an issued card from a governed one.
Hold a Spend for Approval
Write an approval-required rule that stops a spend before dispatch, poll the hold from the caller side, decide it as an operator, and read which refusal a retry can fix.