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.
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.
Prerequisites
npm install @codespar/sdkThree 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.
The served reference at POST /v1/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.
1. Write the rule
type is approval-required. The match is (agents, tools) by glob, with * as the only metacharacter.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
# 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.
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.
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.
- Org mismatch on the id, answered 404.
- Identity gate: bearer refused, then user token verified, then role floor. 403 or 503.
- Rate limit for the org. 429.
- Body validation. 400.
- 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 appendapproval_decided, all in the sameBEGIN.
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.
What the decide route answers
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 |
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.
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.
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.
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.
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.
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"
doneThe 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 currently claims otherwise, and the rule grammar does not support it. A budget rule is also a poor substitute from the SDK, because
session.executesends noestimatedCostand the guard reads that as a cost of zero. - No per-rule expiry.
APPROVAL_TTL_HOURSis 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.approversis decorative. It is validated and stored, and never read. Anyone holdingadminin 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-evaluationsandevaluationsare all declared outside the CLI surface on purpose. - No SDK namespace. There is no
cs.approvalsand nosession.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 anAuthorization: Bearerheader, and a bearer credential is exactly what the route refuses. The six policy operations fail the same way. There is also no way to attachx-codespar-user-tokenthrough 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.configas an open object with no per-type shape, and itsPolicyEvaluationschema is missingapprovalId,decidedAtanddecidedBywhile itsdecisionenum has noapproval_requiredvalue the handler actually returns. The 404 it documents on the approvals list is unreachable at the canonical path: only the/v1/orgs/{orgId}/approvalsalias, called with someone else's org, produces it.
Next steps
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.
Verify the Audit Chain
Anchor, verify and export the audit trail: what each verdict means, what the signed manifest covers, and which refusals a retry will never fix.