---
title: "What an Allowance Spent"
description: "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."
---

<MetaStrip items={[
  { label: "TIME", value: "~20 min" },
  { label: "STACK", value: (<><ServerChip name="HTTP / SDK REST" accent /><span style={{ color: "var(--color-fd-muted-foreground)", fontSize: 12 }}>read-only, no agent</span></>) },
  { label: "SERVERS", value: (<><ServerChip name="none" /><span style={{ color: "var(--color-fd-muted-foreground)", fontSize: 12 }}>platform routes only</span></>) },
]} />

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.

<EventPipeline
  title="READING A SPEND"
  subtitle="Ledger feed → receipt → delivery proof → audit chain"
  nodes={[
    { tag: "1 · FEED", name: "One line per debit", meta: "GET /v1/consumers/mandates/{id}/receipts", tone: "event" },
    { tag: "2 · RECEIPT", name: "Did money move?", meta: "payment.money_moved · payment.sandbox", tone: "validation" },
    { tag: "3 · SEAL", name: "Bind the proof", meta: "POST /v1/consumers/receipts/{id}/delivery", tone: "loop" },
  ]}
  loopSteps={[
    { tool: "walletHold", description: "kind='hold', in native units, before the rail is called" },
    { tool: "psp dispatch", description: "the rail runs, or refuses with 502" },
    { tool: "walletDebit", description: "kind='debit', external_ref=psp_tx_id, metadata purpose and payee" },
    { tool: "insertAgenticReceipt", description: "mandate_consumed + mandate_call_attempted + receipt_sealed, one transaction" },
  ]}
  note="the ledger line is written before the HTTP response and before the receipt exists"
/>

## Prerequisites

```bash
npm install @codespar/sdk
```

```bash title=".env"
CODESPAR_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](/docs/concepts/authentication).

<Callout type="warn" title="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](/docs/concepts/projects) and [Test mode](/docs/concepts/test-mode).
</Callout>

## The whole journey in one file

```typescript title="consumer-spend-ledger.ts"
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

```bash
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.

<Callout type="warn" title="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.
</Callout>

<Callout type="warn" title="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](/docs/concepts/refunds) and [Wallets](/docs/concepts/wallets).
</Callout>

<Callout type="warn" title="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.
</Callout>

`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.

```bash
curl -H "Authorization: Bearer $CODESPAR_API_KEY" \
  https://api.codespar.dev/v1/consumers/csm_0a1b2c3d4e5f6071/receipts
```

The 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](/docs/concepts/audit-chain).

<Callout type="info" title="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.
</Callout>

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

```bash
curl -H "Authorization: Bearer $CODESPAR_API_KEY" \
  https://api.codespar.dev/v1/consumers/receipts/rcp_8f2a41c9
```

Same 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.

<ReceiptCard />

## Step 4. Seal the delivery proof

```bash
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:

1. Zod parses the body. A failure here is `400 invalid_body` and nothing was read.
2. The receipt is loaded by org and project. Miss is `404 receipt_not_found`.
3. If the receipt state is `voided`, the call answers **200 with the receipt untouched**. That is not an error and not a write.
4. 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.
5. The hash chain is rebuilt with the new delivery.
6. The consumer secret version is resolved from the vault. A miss is `409 consumer_secret_unavailable`, and nothing was written.
7. The chain is signed.
8. One transaction appends the `receipt_delivered` audit entry and updates the row. Any failure there is `500 receipt_reseal_failed` and neither half survives.

<Callout type="warn" title="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.
</Callout>

### 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.

<Callout type="warn" title="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.
</Callout>

<Callout type="warn" title="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.
</Callout>

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.

```bash
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"
```

```typescript title="page-the-audit-chain.ts"
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.

<Callout type="warn" title="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.
</Callout>

## 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.

<Callout type="warn" title="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](/docs/concepts/directed-pay).
</Callout>

## 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`. |

<Callout type="warn" title="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.
</Callout>

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:

```bash
codespar consumers list-receipts csm_0a1b2c3d4e5f6071
codespar consumers get-receipts rcp_8f2a41c9
```

Both accept only `-q/--query` and `--timeout`. See [the consumers CLI reference](/docs/cli/reference/consumers).

`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:

```bash
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](/docs/concepts/meta-tools/ledger).

<Callout type="warn" title="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.
</Callout>

## 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](/docs/api/reference/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`, no `sandbox` and no `state` on 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 has `payment.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 no `mandate.*` event type exists in the emitted catalogue, which carries only `commerce.*` and `wallet.created`. You cannot be told that a spend landed or that a receipt was sealed. Polling is the only option. See [Webhooks](/docs/concepts/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 `voided` state 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_attempted` and `receipt_sealed` appear nowhere in the docs outside this page. Only `receipt_delivered` is published, inside the text of [the receipts reference](/docs/api/reference/receipts).
- **Two codes are not on the errors index.** `consumer_secret_unavailable` and `receipt_reseal_failed` are absent from [the errors page](/docs/errors) 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}/receipts` and none for `POST /v1/consumers/receipts/{id}/delivery`.
- **No meta-tool action that reads the ledger feed by mandate.** `codespar_ledger` has five actions; `receipt` and `receipts` hit the agentic receipts table, while `entry`, `balance` and `account` route to the tenant's own ledger, which is a different book. The `wallet_ledger` feed 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](/docs/api/sdk/rest-client/receipts) and [for consumer mandates](/docs/api/sdk/rest-client/consumer-mandates).

## Next steps

<NextStepsGrid items={[
  { label: "REFERENCE", title: "Receipts", description: "Every parameter, body and documented response for the three receipt operations.", href: "/docs/api/reference/receipts" },
  { label: "REFERENCE", title: "Audit events", description: "The only paginated surface here: event_type, before_sequence, and the chain hashes.", href: "/docs/api/reference/audit-events" },
  { label: "CONCEPT", title: "Mandates", description: "What an allowance is, where it is born per channel, and what spending against it means.", href: "/docs/concepts/mandates" },
]} />
