---
title: "Hold a Spend for Approval"
description: "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."
---

<MetaStrip items={[
  { label: "TIME", value: "~20 min" },
  { label: "STACK", value: (<><ServerChip name="@codespar/sdk" accent /><ServerChip name="curl" /><span style={{ color: "var(--color-fd-muted-foreground)", fontSize: 12 }}>no CLI command covers this</span></>) },
  { label: "SERVERS", value: (<><ServerChip name="any governed tool" /><ServerChip name="wallet:pay" /><ServerChip name="wallet:withdraw" /></>) },
]} />

Write an approval-required rule, watch it hold a matching spend before the provider is ever called, poll the hold from the agent side, and decide it from an operator seat.

<EventPipeline
  title="HOLD AND DECIDE"
  subtitle="Rule matches before dispatch, caller polls, operator decides, approved calls replay"
  nodes={[
    { tag: "1 · CALL", name: "Agent calls a governed tool", meta: "POST /v1/sessions/{id}/proxy_execute", tone: "event" },
    { tag: "2 · HOLD", name: "Rule matches, nothing dispatches", meta: "row apv_… + approval_pending, one transaction", tone: "validation" },
    { tag: "3 · DECIDE", name: "Admin approves or denies", meta: "POST /v1/approvals/{id}/decide", tone: "loop" },
  ]}
  loopSteps={[
    { tool: "approval_call_attempted", description: "witness written before the provider is touched" },
    { tool: "provider call", description: "outside the decide transaction, PROXY_MODE=real only" },
    { tool: "approval_executed", description: "or approval_execution_failed, with execution_result" },
  ]}
  note="the decide handler re-reads the row after the replay, so its 200 can carry status: execution_failed"
/>

## Prerequisites

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

Three credentials, and they are not interchangeable.

| Step | Credential | Why |
|---|---|---|
| Create and order the rule | Service credential: `x-codespar-service-key` plus `x-codespar-org` | A bearer key is refused by a plugin hook before any policy handler runs |
| Call the tool, poll the hold | Project API key (`csk_…`) | This is the only part of the journey a project key completes end to end |
| Decide the hold | Service credential, plus an org member holding at least `admin` | A bearer credential is refused on the first check of the decide route |

The dashboard does both service-credential steps for you: rules live at `/dashboard/policies`, the queue at `/dashboard/approvals`.

<Callout type="warn">
The served reference at [POST /v1/policies](/docs/api/reference/policies) opens with a Bearer token and sends `Authorization: Bearer $CODESPAR_API_KEY` in every example. Those examples cannot work. All six policy operations answer 403 `bearer_token_cannot_manage_policies` to any caller that is not service auth, and that refusal is not in the served document either.
</Callout>

## 1. Write the rule

`type` is `approval-required`. The match is `(agents, tools)` by glob, with `*` as the only metacharacter.

```bash
curl -X POST https://api.codespar.dev/v1/policies \
  -H "x-codespar-service-key: $CODESPAR_SERVICE_KEY" \
  -H "x-codespar-org: $CODESPAR_ORG_ID" \
  -H "content-type: application/json" \
  -d '{
    "name": "Hold payouts for review",
    "type": "approval-required",
    "agents": ["*"],
    "tools": ["wallet:pay", "wallet:withdraw", "asaas:post:transfers"],
    "config": {},
    "enabled": true
  }'
```

The 201 returns the `Policy`, including its `pol_…` id. Keep it: step 2 needs it.

<Callout type="warn">
**A rule created with the schema defaults never fires.** `PolicyCreate` defaults both `agents` and `tools` to `[]`, and the matcher iterates the array and returns false when it is empty. An approval-required rule with no tools and no agents is inert, and the 201 looks exactly like a working one. Always send both arrays.
</Callout>

`config` accepts an `approvers` array. It is validated and stored, and nothing reads it. Who can decide is decided by org role, not by that list.

## 2. Move the rule above every allow rule that matches

Creation inserts at `MAX(sort_order) + 1`, so a new rule lands last. The engine scans in order and stops at the first match. One earlier `allow` rule covering the same pair swallows the hold completely, and nothing warns you.

```bash
curl -X POST https://api.codespar.dev/v1/policies/reorder \
  -H "x-codespar-service-key: $CODESPAR_SERVICE_KEY" \
  -H "x-codespar-org: $CODESPAR_ORG_ID" \
  -H "content-type: application/json" \
  -d '{ "ids": ["pol_hold_payouts", "pol_allow_reads", "pol_daily_budget"] }'
```

204, no body. Send every rule id you have. The endpoint accepts a partial list and assigns `index + 1` only to the ids you sent, so posting 2 of 5 rules leaves the other three tied at positions you did not choose.

<Callout>
The rule cache is per process with a 55 second TTL, and invalidation only clears the process that took the write. On a multi-replica deployment a new rule can take up to 55 seconds to bind everywhere. Confirm with a real call, not with the 201.
</Callout>

## 3. The call gets held

The guard runs before the route handler, so the provider is never touched. What the caller receives depends on which path it used.

| Path | Shape of the hold |
|---|---|
| `POST /v1/sessions/{id}/proxy_execute` | 403 with `{ reason, ruleType, ruleId, approval_id, expires_at }` |
| `POST /v1/sessions/{id}/execute` | The same 403, and in the catalog branch a 200 whose `error` opens with the `approval_required:` prefix |
| `POST /v1/sessions/{id}/send` | A structured tool result: `{ code: "approval_required", approval_id, expires_at, message }` |
| `POST /v1/wallets/{id}/execute` and `/transfer` | The same 403 envelope, plus an `error` field the session guard does not send |

That 403 envelope mixes conventions in one object: `reason`, `ruleType` and `ruleId` in camelCase, `approval_id` and `expires_at` in snake_case.

```typescript title="agent/spend.ts"
import { CodeSpar, CodesparApiError } from "@codespar/sdk";

const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY! });
const session = await cs.create("user_123", { servers: ["asaas"] });

interface HeldCall {
  reason?: string;
  ruleType?: string;
  ruleId?: string;
  approval_id?: string;
  expires_at?: string;
}

try {
  const res = await session.proxyExecute({
    server: "asaas",
    endpoint: "/v3/transfers",
    method: "POST",
    body: { value: 250.0, pixAddressKey: "loja@example.com" },
  });
  console.log("dispatched", res.status, res.data);
} catch (e) {
  if (!(e instanceof CodesparApiError) || e.status !== 403) throw e;

  const held = e.body as HeldCall;
  if (!held.approval_id) {
    // A deny rule matched, not an approval rule. There is no hold to decide.
    throw e;
  }
  console.log("held", held.approval_id, "by rule", held.ruleId, "until", held.expires_at);
}
```

`proxyExecute` throws `CodesparApiError` and hands you the parsed body. `execute` does not throw on 403: it returns `{ success: false, data: null, error: "403: <raw body text>" }`, so the approval id is inside a string you have to parse. Prefer `proxyExecute` when you plan to catch a hold, and note that it is also the only session path that records the real `tool_input`.

In the chat loop the hold arrives as a tool result, and the SDK ships a guard for it.

```typescript title="agent/chat.ts"
import { CodeSpar, isApprovalRequired } from "@codespar/sdk";

const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY! });
const session = await cs.create("user_123", { preset: "brazilian" });

for await (const event of session.sendStream("Pay R$250 to loja@example.com")) {
  if (event.type !== "tool_result") continue;
  const out = event.toolCall.output;
  if (isApprovalRequired(out)) {
    console.log("held", out.approval_id, "until", out.expires_at);
  }
}
```

There is a third way to find the hold, and it needs no string parsing and no exception. Every match also writes an evaluation row carrying the approval id.

```typescript
const rows = await cs.api.get("/v1/policy-evaluations", { query: { limit: 20 } });
// Each row carries decision and approvalId. GET /v1/evaluations is a deprecated
// alias of the same handler.
```

## 4. Poll the hold

There is one poll endpoint, and a project key can use it. It is scoped to the caller's org **and** project.

```typescript title="agent/wait-for-approval.ts"
import { CodeSpar } from "@codespar/sdk";

const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY! });
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export async function waitForApproval(id: string, timeoutMs = 15 * 60_000) {
  const deadline = Date.now() + timeoutMs;

  while (Date.now() < deadline) {
    const status = await cs.api.get("/v1/approvals/{id}/status", { path: { id } });

    // approval_status: pending | approved | denied | expired | execution_failed
    if (status.approval_status !== "pending") {
      return status; // carries decided_by, decision_reason, execution_result
    }
    await sleep(5_000);
  }
  throw new Error(`approval ${id} is still pending after ${timeoutMs} ms`);
}
```

A missing or out-of-scope id answers 404 `approval_not_found` here. The operator read at `GET /v1/approvals/{id}` answers 404 `not_found` instead. Two different literals for the same idea, on two endpoints in the same journey.

<Callout type="warn">
**A hold past its expiry still reads `pending`.** The decide route compares `expires_at` in its update and answers 410, but the `status` column only flips to `expired` when the expiry sweep runs, and that sweep is a separate job, not part of the API server. So this poll can return `pending` forever for a row that nobody can decide any more. Compare `expires_at` against the clock yourself and stop polling when it passes.
</Callout>

## 5. Read the queue and decide

The two operator reads are org-scoped, not project-scoped, and they accept a project key as well. Only the decide write refuses one. That asymmetry has a consequence: a key bound to one project sees holds raised under sibling projects in this queue, and cannot poll any of them at step 4.

```bash
# Pending only. Omitting status does NOT mean "all".
curl https://api.codespar.dev/v1/approvals \
  -H "x-codespar-service-key: $CODESPAR_SERVICE_KEY" \
  -H "x-codespar-org: $CODESPAR_ORG_ID"

# History, once you know you want it
curl -G https://api.codespar.dev/v1/approvals \
  -H "x-codespar-service-key: $CODESPAR_SERVICE_KEY" \
  -H "x-codespar-org: $CODESPAR_ORG_ID" \
  --data-urlencode "status=approved,denied,expired"
```

The response is a raw array, with no envelope and no cursor. `limit` defaults to 100 and is capped at 100, so past the 100 most recent rows there is no second page. A `status` value outside the enum, or a `limit` outside 1 to 100, answers 400 `invalid_query`.

Read one row to see what is actually being approved.

```bash
curl https://api.codespar.dev/v1/approvals/apv_8ZqK3m1e4xW7tb \
  -H "x-codespar-service-key: $CODESPAR_SERVICE_KEY" \
  -H "x-codespar-org: $CODESPAR_ORG_ID"
```

Two fields on that row mislead if you read them as identifiers. `tool_name` has no single format: the proxy lane writes `<server>:<method>:<endpoint>` with the slashes turned into colons, the execute lane writes whatever string the caller sent, the over-cap settlement lane writes `codespar_pay`, and the operator lanes write `admin:account_status_set` or `admin:withdrawal_resolve`. `matched_rule_id` is not always a `pol_` id either: the non-overridable deny list and the settlement lanes write their own literals, such as `boleto-over-cap`, so `GET /v1/policies/{id}` with that value answers 404 for most holds.

Then decide it. Both fields are required, and `reason` must be 1 to 8000 characters of something real.

```bash
curl -X POST https://api.codespar.dev/v1/approvals/apv_8ZqK3m1e4xW7tb/decide \
  -H "x-codespar-service-key: $CODESPAR_SERVICE_KEY" \
  -H "x-codespar-org: $CODESPAR_ORG_ID" \
  -H "x-codespar-user-token: $CLERK_USER_TOKEN" \
  -H "x-codespar-user: $CLERK_USER_ID" \
  -H "content-type: application/json" \
  -d '{
    "decision": "approve",
    "reason": "Vendor invoice 4471 matches the purchase order."
  }'
```

### The order the checks run in

This is what tells you how much already happened when a refusal lands.

1. Org mismatch on the id, answered 404.
2. Identity gate: bearer refused, then user token verified, then role floor. 403 or 503.
3. Rate limit for the org. 429.
4. Body validation. 400.
5. Only then the transaction: read the row's project, compare-and-set `WHERE id AND org_id AND status = 'pending' AND expires_at > now`, and on a hit write the decision, mirror it into the evaluation row, and append `approval_decided`, all in the same `BEGIN`.

<Callout type="warn">
The rate limit is charged **before** the body is validated. A POST with an empty `reason` burns a token from the bucket and still comes back 400. The bucket is 30 per minute per org by default, and it is in-memory per process: with several replicas the effective ceiling is 30 per minute per replica.
</Callout>

### What the decide route answers

<StatusTable rows={[
  { code: "200", meaning: "Decided. The body is the row **re-read after the replay**, so it can say `status: \"execution_failed\"`." },
  { code: "400", meaning: "`reason_invalid` for empty, whitespace-only or over 8000 characters; `invalid_body` for any other schema problem, such as a `decision` outside `approve` and `deny`." },
  { code: "403", meaning: "`bearer_token_cannot_decide`, `user_token_required`, `user_token_invalid`, `user_token_identity_mismatch` or `insufficient_role`. The refusal names which one." },
  { code: "404", meaning: "`not_found`. The id does not exist, or belongs to another org. The two are indistinguishable on purpose: a 403 would confirm the id is real." },
  { code: "409", meaning: "`already_decided`. The body carries the **complete approval**, so you can see who decided what without a second read." },
  { code: "410", meaning: "`expired`. The hold aged out before anyone decided. The body also carries the complete approval." },
  { code: "429", meaning: "`rate_limit_exceeded`, with `retry_after_seconds` in the body and a `Retry-After` header." },
  { code: "503", meaning: "`user_token_verification_unconfigured`. The deployment has no `CLERK_ISSUER_URL`. Nothing about the request is wrong." },
]} />

### Which refusal a retry fixes

| Refusal | What already happened | Does a retry help |
|---|---|---|
| 403 `bearer_token_cannot_decide` | Nothing. First check on the route, never gated by a flag | No, ever. A bearer key cannot approve a spend it could have asked for. Use the service path |
| 403 `user_token_required` | Nothing. Only fires when `APPROVAL_DECIDE_ENFORCE_USER_TOKEN` is on | Yes, once you send `x-codespar-user-token` |
| 403 `user_token_invalid` | Nothing. Signature, issuer, expiry or algorithm failed. Never gated | Yes, with a fresh Clerk token. They are short-lived |
| 403 `user_token_identity_mismatch` | Nothing. `x-codespar-user` and the token `sub` name different people | Yes, if both come from the same auth call, or send only the token |
| 403 `insufficient_role` | Nothing. The verified person is below `admin`. The body says `required: "admin"` | No. Promote the person, or have an admin decide |
| 429 `rate_limit_exceeded` | Nothing decided, one bucket token spent | Yes, after `retry_after_seconds` |
| 400 `reason_invalid` | Nothing decided, one bucket token spent | Yes, with a real reason |
| 409 `already_decided` | The row left `pending` earlier. Your decision was not applied | No. Deciding is not idempotent, and a second call is not a silent no-op |
| 410 `expired` | The hold aged out. Nothing ran | No. A hold cannot be reopened. The agent has to ask again |
| 503 `user_token_verification_unconfigured` | Nothing. The deployment is missing an env var | No. Set `CLERK_ISSUER_URL` and restart the service, because the env loader memoizes |

<Callout type="warn">
**Dual control is shipped but not switched on.** Requiring the verified user token is gated on `APPROVAL_DECIDE_ENFORCE_USER_TOKEN`, which defaults to false. While it is off, a service-key holder decides by naming any admin in the `x-codespar-user` header, and the audit chain records that as `approval.decide_unverified` with `decided_by_source: "header_asserted"`. Verification itself is never gated: send a token and you get the verified path today. One edge to know about: with the flag off, a caller that sends a token and no header still gets the 503 when the deployment cannot verify. And with the flag off, no token and no header answers `insufficient_role`, not `user_token_required`.
</Callout>

## What approve actually does

Denying ends there. Approving starts a replay that runs **outside** the decide transaction, in three parts: a witness event `approval_call_attempted`, the provider call, then `approval_executed` on a 2xx or `approval_execution_failed` on anything else, carrying `execution_result` with `ok`, `upstream_status`, `data`, `error` and `duration_ms`.

The replay's idempotency key is imposed by the server as `apv-<approval_id>`. Any `Idempotency-Key` the agent sent is kept in `tool_input` for correlation only.

<Callout type="warn">
**Approving a hold raised on `/execute` does not replay anything useful.** That path mounts the guard without a tool-input resolver, so the engine stores `{}`. The replay then reads `server`, `method` and `endpoint` out of that empty object, gets empty strings, and the row ends as `execution_failed` with `credential_unavailable` or `proxy_mode_unconfigured`. The same applies to `wallet:pay` and `wallet:withdraw`, whose recorded input is `{ amount, currency, recipient }` rather than a call. Only `proxy_execute` records a call the replay can rebuild.
</Callout>

<Callout type="warn">
**A 200 from decide is not proof the money moved.** The handler re-reads the row after the replay and returns that state, so `status` can be `execution_failed` inside a 200. Branch on `status` and `execution_result`, never on the HTTP code.
</Callout>

The replay only calls a real provider when `PROXY_MODE=real`. With `PROXY_MODE=mock` or `NODE_ENV=test` the result is stamped `mocked: true`. Any other value answers `proxy_mode_unconfigured` and the row becomes `execution_failed`, on purpose, so the chain never holds an `approval_executed` that cannot be told apart from a real one.

Header stripping is wider than the spec describes. The document mentions `authorization` and `cookie`; the sanitizer also drops any header key ending in `-token` or `-key`, case-insensitive.

## Read the chain

Five event types cover the whole journey, and `event_type` is an exact match unless the value ends in a dot. These five are underscore-named, so no prefix catches them together: that is five queries.

```bash
for t in approval_pending approval_decided approval_call_attempted \
         approval_executed approval_execution_failed; do
  curl -s -G https://api.codespar.dev/v1/audit-events \
    -H "authorization: Bearer $CODESPAR_API_KEY" \
    --data-urlencode "event_type=$t" \
    --data-urlencode "limit=100"
done
```

The default window is the last 7 days. Pass `from` and `to` for anything older, and `before_sequence` to walk backwards. A project key reads this endpoint, which makes the chain the one place a caller-side key can reconstruct what happened without operator access.

`GET /v1/approvals/health` reports `pending_count` and `oldest_pending_age_seconds` for your own org, but `last_sweep_at` is not yours: the sweep log has one row per sweep type and no org column, so that timestamp moves for reasons unrelated to your queue. Read it as "sweeps are alive on this deployment", never as "my queue was swept".

## What this does not do

- **No way to create a hold.** There is no `POST /v1/approvals`. A hold is born only from an approval-required rule that matched, or from an internal guard such as the boleto mandate cap or the operator lanes.
- **No amount threshold.** An approval-required rule matches on agents and tools, nothing else. "Hold anything above R$ 5.000" is not expressible: a budget rule denies, it does not hold. [Guardrails](/docs/concepts/guardrails) currently claims otherwise, and the rule grammar does not support it. A budget rule is also a poor substitute from the SDK, because `session.execute` sends no `estimatedCost` and the guard reads that as a cost of zero.
- **No per-rule expiry.** `APPROVAL_TTL_HOURS` is deployment-wide and defaults to 24. Nothing in the create body takes a deadline.
- **No webhook, no trigger, no stream.** No `approval.*` event is published anywhere, and the poll has no streaming variant. Poll, or read the audit chain.
- **`config.approvers` is decorative.** It is validated and stored, and never read. Anyone holding `admin` in the org can decide any hold in it.
- **No status alias under `/v1/orgs`.** The org-prefixed aliases cover list, read, health and decide. The caller poll exists only at the canonical path.
- **No CLI command.** `approvals`, `policies`, `policy-evaluations` and `evaluations` are all declared outside the CLI surface on purpose.
- **No SDK namespace.** There is no `cs.approvals` and no `session.approvals`, only the generic REST client. `cs.api.post("/v1/approvals/{id}/decide", …)` compiles and always answers 403 at runtime, because the client can only build an `Authorization: Bearer` header, and a bearer credential is exactly what the route refuses. The six policy operations fail the same way. There is also no way to attach `x-codespar-user-token` through the typed client: the operation declares no headers, so TypeScript rejects the key.
- **The served document is incomplete here.** It does not describe the bearer refusal on the policy routes, it types `PolicyCreate.config` as an open object with no per-type shape, and its `PolicyEvaluation` schema is missing `approvalId`, `decidedAt` and `decidedBy` while its `decision` enum has no `approval_required` value the handler actually returns. The 404 it documents on the approvals list is unreachable at the canonical path: only the `/v1/orgs/{orgId}/approvals` alias, called with someone else's org, produces it.

## Next steps

<NextStepsGrid items={[
  { label: "REFERENCE", title: "Approvals API", description: "Every field on the held row, the five operations, and the org aliases.", href: "/docs/api/reference/approvals" },
  { label: "REFERENCE", title: "Policies API", description: "The six rule operations, the Policy shape, and reorder.", href: "/docs/api/reference/policies" },
  { label: "CONCEPT", title: "Audit chain", description: "How the approval events are hashed, anchored and exported.", href: "/docs/concepts/audit-chain" },
]} />
