Approvals
5 operations under /v1/approvals (GET 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.
These operations follow a held call: a policy rule or a non-overridable guard stopped a spend before dispatch and wrote an approval row with an expiry. Poll GET /v1/approvals/{id}/status until it leaves pending. The write that decides it is POST /v1/approvals/{id}/decide (also reachable at the older /v1/orgs/{orgId}/approvals/{id}/decide), and it refuses API keys on purpose: the key that asked for a spend must not be the key that approves it. Who decides, where, and the pattern for confirming a one-off spend in your own channel before dispatch are on the concept page, Mandates, receipts and approvals, and in Guardrails.
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/approvals
https://api.codespar.dev/v1/approvalsList approvals held for review
The holds, newest first.
What a key sees. A project API key or an OAuth token reads only the holds raised under ITS OWN project, and only their status: tool_input, the held request itself, is not in the response. A hold raised outside any project is not a key's either. The dashboard (service auth) reads the whole organization's queue with tool_input, because the approver works there.
Without status, you get only the pending ones, not all of them. It is the useful default for an operator queue, and the trap for anyone expecting a history: ask for status=approved,denied to see what has already been decided.
The response is an ARRAY, with no envelope and no cursor.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer,null | no | — |
status | string | no | Comma-separated, from pending, approved, denied, expired and execution_failed. ABSENT IS NOT "all": the default is pending. |
Responses
| Status | Body | Description |
|---|---|---|
200 | array of object | OK |
400 | object | The query did not match the schema. Raw body. |
404 | object | Organization not resolved. Raw body. |
curl -X GET https://api.codespar.dev/v1/approvals \
-H "Authorization: Bearer $CODESPAR_API_KEY"GET /v1/approvals HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.get(
"https://api.codespar.dev/v1/approvals",
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/approvals", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const result = await cs.api.get("/v1/approvals");[
{
"id": "obj_0000000000000000",
"org_id": "org_0000000000000000",
"project_id": "prj_0000000000000000",
"session_id": "ses_0000000000000000",
"agent_id": "agt_0000000000000000",
"origin": {
"kind": "agent"
},
"matched_rule_id": "matchedrule_0000000000000000",
"matched_rule_name": "Example",
"tool_name": "Example",
"tool_input": {},
"status": "pending",
"created_at": "2026-01-15T12:00:00.000Z",
"expires_at": "2026-01-15T12:00:00.000Z",
"decided_at": "2026-01-15T12:00:00.000Z",
"decided_by": "string",
"decision_reason": "string",
"executed_at": "2026-01-15T12:00:00.000Z",
"execution_result": {}
}
]GET /v1/approvals/health
https://api.codespar.dev/v1/approvals/healthApproval queue health
How deep this organization's approval queue is, how long the oldest hold has been waiting, and whether the sweeps are running.
pending_count and oldest_pending_age_seconds are computed over the caller's own organization for the dashboard (service auth), and over the caller's own PROJECT for an API key or an OAuth token, the same rows the list shows it. The second is null exactly when the first is 0: both come from the same status = 'pending' filter over the same rows.
last_sweep_at IS NOT SCOPED TO THE CALLER, and cannot be. It is the newest run of the expiry and orphan sweeps for the whole deployment: the table it reads holds one row per sweep type and carries no organization column, so the value moves for reasons that have nothing to do with this organization. Read it as 'the sweeps are alive here', never as 'my queue was swept'. Null means neither sweep has ever recorded a run.
status is the verdict on that timestamp and nothing else: degraded with status_reason: sweep_never_ran when it is null, degraded with sweep_stale when the newest run is older than 30 minutes (six ticks of the 5-minute orphan schedule), ok otherwise with status_reason: null. Queue depth never degrades it; alert on oldest_pending_age_seconds for that.
The age is in whole seconds, rounded.
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
last_sweep_at | string,null (date-time) | yes | Deployment-wide, not per-organization. See the description. |
oldest_pending_age_seconds | integer,null | yes | Age of the oldest pending hold, in seconds. Null when none is pending. |
pending_count | integer | yes | — |
status | "ok" | "degraded" | yes | — |
status_reason | "sweep_never_ran" | "sweep_stale" | yes | Why status is degraded; null exactly when it is ok. |
curl -X GET https://api.codespar.dev/v1/approvals/health \
-H "Authorization: Bearer $CODESPAR_API_KEY"GET /v1/approvals/health HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.get(
"https://api.codespar.dev/v1/approvals/health",
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/approvals/health", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const result = await cs.api.get("/v1/approvals/health");{
"status": "ok",
"status_reason": "sweep_never_ran",
"pending_count": 1,
"oldest_pending_age_seconds": 0,
"last_sweep_at": "2026-01-15T12:00:00.000Z"
}GET /v1/approvals/{id}
https://api.codespar.dev/v1/approvals/{id}Read one approval
One held call. The dashboard gets it with the arguments recorded when it was held; an API key or an OAuth token gets its status only.
SCOPE. The lookup matches the id AND the caller's organization, so an id from another organization answers 404 rather than 403: a 403 would confirm the id exists, which is what a caller sweeping ids is asking. For an API key or an OAuth token it also matches the credential's project, the way the agent-facing poll at GET /v1/approvals/{id}/status does: a hold raised under a sibling project, or outside any project, is the same 404. The dashboard (service auth) reads any hold in the organization.
tool_input is ABSENT for an API key or an OAuth token: it is the held request itself, which can carry a Pix key, a CPF/CNPJ or account data, and the approver who needs it reads it on the dashboard.
tool_input IS NOT THE CALL. It is what the lane that raised the hold recorded, and two things happen to it on the way in. A hold raised on the session execute path carries {}, because that path hands the policy engine no tool input at all and the engine stores an empty object when it gets none. A hold raised on the proxy path carries { server, method, endpoint, body, params, headers }, with the authorization and cookie request headers removed before the row is written. So an empty object here means 'this lane records nothing', not 'the agent sent nothing'.
tool_name HAS NO SINGLE FORMAT, because four lanes write it. The proxy path writes <server>:<method>:<endpoint> with the leading slash dropped and the remaining slashes turned into colons (asaas, POST, /payments/create becomes asaas:post:payments:create). The session execute path writes the caller's own tool string verbatim, which is any non-empty string that caller chose. The over-cap settlement lane writes codespar_pay. The operator account-status lane writes admin:account_status_set and the operator withdrawal-resolution lane admin:withdrawal_resolve. Match on it only knowing which lane you are reading.
expires_at is the end of the hold, not a display hint: once it passes, the row stops being decidable and the expiry sweep moves it to expired.
execution_result stays null until an approved call has actually been replayed, which is the state of every pending row and of every denied one. The row's decision_history and metadata columns are deliberately not in this response.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | — |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
404 | object | Not Found |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
agent_id | string | yes | — |
created_at | string (date-time) | yes | — |
decided_at | string,null (date-time) | yes | — |
decided_by | string,null | yes | The approver, null while pending. |
decision_reason | string,null | yes | — |
executed_at | string,null (date-time) | yes | — |
execution_result | object,null | yes | Null until an approved call has been replayed. Open shape, passed through verbatim. |
expires_at | string (date-time) | yes | When the hold stops being decidable. |
id | string | yes | apv_ + nanoid(16). |
matched_rule_id | string | yes | The policy rule that held the call. |
matched_rule_name | string | yes | — |
org_id | string | yes | — |
origin | object | object | yes | Who put the item in the inbox. A trigger's review is not an agent's approval: group by agent only the agent ones. |
project_id | string,null | yes | Null for a hold raised outside any project. |
session_id | string,null | yes | — |
status | "pending" | "approved" | "denied" | "expired" | "execution_failed" | yes | — |
tool_input | object | no | The arguments as RECORDED, which is not always the arguments as sent. Empty for a hold raised on the session execute path, and header-stripped on the proxy path. ABSENT unless the caller is the dashboard (service auth): an API key or an OAuth token never receives it. |
tool_name | string | yes | The held call, in whatever form the lane that raised the hold writes. There is no single format: see the operation description before matching on it. |
curl -X GET https://api.codespar.dev/v1/approvals/{id} \
-H "Authorization: Bearer $CODESPAR_API_KEY"GET /v1/approvals/{id} HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.get(
"https://api.codespar.dev/v1/approvals/{id}",
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/approvals/{id}", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const result = await cs.api.get("/v1/approvals/{id}", {
path: {
id: "approval_0000000000000000"
}
});{
"id": "obj_0000000000000000",
"org_id": "org_0000000000000000",
"project_id": "prj_0000000000000000",
"session_id": "ses_0000000000000000",
"agent_id": "agt_0000000000000000",
"origin": {
"kind": "agent"
},
"matched_rule_id": "matchedrule_0000000000000000",
"matched_rule_name": "Example",
"tool_name": "Example",
"tool_input": {},
"status": "pending",
"created_at": "2026-01-15T12:00:00.000Z",
"expires_at": "2026-01-15T12:00:00.000Z",
"decided_at": "2026-01-15T12:00:00.000Z",
"decided_by": "string",
"decision_reason": "string",
"executed_at": "2026-01-15T12:00:00.000Z",
"execution_result": {}
}POST /v1/approvals/{id}/decide
https://api.codespar.dev/v1/approvals/{id}/decideApprove or deny a held call
Approves or denies a hold, and it is THE step that releases (or kills) the call the policy held.
reason is required for both decisions. Up to 8000 characters, and whitespace alone does not count: the audit trail of a human decision with no written reason is worth nothing later.
Deciding takes a PERSON, through the dashboard. Only service auth (the dashboard) may call this. A project API key or an OAuth token is refused with bearer_token_cannot_decide before the body is read, even when it carries a user token: a key or token proves which integration is calling, never that a person looked at the hold. On the dashboard's call the approver's user token has to come with it, be verifiable, and its identity has to match the one the header asserts. That is what separates the organization decided from someone with the key decided.
A decision already taken is 409, and an expired one is 410. In both cases the full approval comes in the body, so the caller can see who decided what without a second read.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | — |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
decision | "approve" | "deny" | yes | — |
reason | string | yes | Required for both decisions. Whitespace alone is refused. |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
400 | object | Body outside the schema. reason_invalid when the problem is the reason (empty, whitespace only, or over the limit), invalid_body otherwise. Raw body, with limit repeating the ceiling of 8000. |
403 | object | The caller cannot decide. bearer_token_cannot_decide when the request is not the dashboard's (service auth): a project API key or an OAuth token, with or without a user token; user_token_required, user_token_invalid and user_token_identity_mismatch when the user token is missing, does not verify, or does not match the identity asserted in the header; insufficient_role when the person is below admin. Raw body. |
404 | object | Does not exist, or belongs to another organization: indistinguishable on purpose. Raw body. |
409 | object | Already decided. The full approval comes with it, so the caller can see who decided what without a second read. Raw body. |
410 | object | Expired before anyone decided. The full approval comes with it. Raw body. |
429 | object | Decision attempt rate limit. retry_after_seconds says how long to wait. Raw body. |
503 | object | The deployment has no user token verification configured, so nobody can decide here until it does. Raw body. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
agent_id | string | yes | — |
created_at | string (date-time) | yes | — |
decided_at | string,null (date-time) | yes | — |
decided_by | string,null | yes | The approver, null while pending. |
decision_reason | string,null | yes | — |
executed_at | string,null (date-time) | yes | — |
execution_result | object,null | yes | Null until an approved call has been replayed. Open shape, passed through verbatim. |
expires_at | string (date-time) | yes | When the hold stops being decidable. |
id | string | yes | apv_ + nanoid(16). |
matched_rule_id | string | yes | The policy rule that held the call. |
matched_rule_name | string | yes | — |
org_id | string | yes | — |
origin | object | object | yes | Who put the item in the inbox. A trigger's review is not an agent's approval: group by agent only the agent ones. |
project_id | string,null | yes | Null for a hold raised outside any project. |
session_id | string,null | yes | — |
status | "pending" | "approved" | "denied" | "expired" | "execution_failed" | yes | — |
tool_input | object | no | The arguments as RECORDED, which is not always the arguments as sent. Empty for a hold raised on the session execute path, and header-stripped on the proxy path. ABSENT unless the caller is the dashboard (service auth): an API key or an OAuth token never receives it. |
tool_name | string | yes | The held call, in whatever form the lane that raised the hold writes. There is no single format: see the operation description before matching on it. |
curl -X POST https://api.codespar.dev/v1/approvals/{id}/decide \
-H "Authorization: Bearer $CODESPAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"decision": "approve",
"reason": "string"
}'POST /v1/approvals/{id}/decide HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json
{
"decision": "approve",
"reason": "string"
}import os
import requests
res = requests.post(
"https://api.codespar.dev/v1/approvals/{id}/decide",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
json={
"decision": "approve",
"reason": "string"
},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/approvals/{id}/decide", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"decision": "approve",
"reason": "string"
}),
});
const data = await res.json();const r = await cs.api.response("post", "/v1/approvals/{id}/decide", {
path: {
id: "approval_0000000000000000"
},
body: {
decision: "approve",
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);
}{
"id": "obj_0000000000000000",
"org_id": "org_0000000000000000",
"project_id": "prj_0000000000000000",
"session_id": "ses_0000000000000000",
"agent_id": "agt_0000000000000000",
"origin": {
"kind": "agent"
},
"matched_rule_id": "matchedrule_0000000000000000",
"matched_rule_name": "Example",
"tool_name": "Example",
"tool_input": {},
"status": "pending",
"created_at": "2026-01-15T12:00:00.000Z",
"expires_at": "2026-01-15T12:00:00.000Z",
"decided_at": "2026-01-15T12:00:00.000Z",
"decided_by": "string",
"decision_reason": "string",
"executed_at": "2026-01-15T12:00:00.000Z",
"execution_result": {}
}GET /v1/approvals/{id}/status
https://api.codespar.dev/v1/approvals/{id}/statusPoll a held call for its outcome
The caller's half of the approval loop. When a policy rule holds a tool call, the execute path answers 403 with approval_id and expires_at in the body; this turns that id into an outcome.
SCOPE. Organization AND project, with a project-less credential matching a project-less row. Anything else is approval_not_found, which is also the answer for an id that never existed. Note the literal: this route does not answer the not_found the operator read answers.
approval_status is pending, meaning poll again, or one of four terminal values: approved (decided and replayed, with the outcome in execution_result), denied (no execution happened), expired (the hold ran out before anyone decided), and execution_failed (approved, but the replay errored and execution_result.error says how).
A stored status this build does not recognize is reported as pending rather than as a terminal value. That is deliberate: a client polling a row written by a newer schema keeps polling instead of concluding something false about it.
execution_result is null until the replay writes one. What it writes today is { ok, upstream_status, data, error, duration_ms }, plus mocked: true when a mock executor produced the outcome. It is handed through verbatim, so read it as an open object rather than as a closed shape.
tool_name here is the same string the operator read returns, with the same caveat about its format. There is no streaming variant of this endpoint.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | — |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
404 | object | Not Found: no approval with that id in the caller's org and project. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
approval_id | string | yes | — |
approval_status | "pending" | "approved" | "denied" | "expired" | "execution_failed" | yes | — |
created_at | string (date-time) | yes | — |
decided_at | string,null (date-time) | yes | — |
decided_by | string,null | yes | — |
decision_reason | string,null | yes | — |
executed_at | string,null (date-time) | yes | — |
execution_result | object,null | yes | — |
expires_at | string (date-time) | yes | — |
tool_name | string | yes | — |
curl -X GET https://api.codespar.dev/v1/approvals/{id}/status \
-H "Authorization: Bearer $CODESPAR_API_KEY"GET /v1/approvals/{id}/status HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.get(
"https://api.codespar.dev/v1/approvals/{id}/status",
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/approvals/{id}/status", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const result = await cs.api.get("/v1/approvals/{id}/status", {
path: {
id: "approval_0000000000000000"
}
});{
"approval_id": "approval_0000000000000000",
"approval_status": "pending",
"tool_name": "Example",
"created_at": "2026-01-15T12:00:00.000Z",
"expires_at": "2026-01-15T12:00:00.000Z",
"decided_at": "2026-01-15T12:00:00.000Z",
"decided_by": "string",
"decision_reason": "string",
"executed_at": "2026-01-15T12:00:00.000Z",
"execution_result": {}
}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.
Policies
6 operations under /v1/policies (GET POST PATCH DELETE): parameters, status codes, refusal bodies, and the same request in curl, HTTP, Python and TypeScript.