---
title: "Approvals"
description: "Manage approval policies and vote on held tool calls through the Obol control plane."
---
A destructive tool call in prod is held at the gateway until a human decides it. Control owns the approval policies that decide *which* calls are held and *who* may review them, and the inbox where reviewers vote. See [Approvals](/policy/approvals) for the model.
There is no endpoint that creates an approval request. Requests arrive from the gateway when a call is held; these routes read them and record votes.
| Route | Action | Roles |
|---|---|---|
| `GET /approval-policies`, `GET /approval-policies/{id}` | `workspace.read` | viewer and above |
| `POST`, `PUT`, `POST …/disable` on approval policies | `approval.policy.manage` | owner only |
| `GET /approvals`, `GET /approvals/{id}` | `workspace.read` | viewer and above |
| `POST /approvals/{id}/votes` | `approval.decide` | admin and above |
Mutating routes re-read the workspace and the membership under the mutation lock before authorizing, so a role or freeze change that lands mid-request is seen.
## Approval policy object
The `operator_approval_policy` contract, frozen in `packages/proto/operator_approval_policy.schema.json`.
Currently `1`.
Prefixed `apy_`.
Prefixed `ws_`.
Unique within the workspace.
`active` or `disabled`.
Monotonic revision number, starting at 1. This is the value you pass as `expected_revision`.
Prefixed `ayr_`.
The stored, normalized rules. Every list inside a rule is deduplicated and sorted on store, so what you read back may not be ordered as you sent it.
RFC 3339 UTC.
RFC 3339 UTC.
### Rule fields
1–64 characters matching `^[A-Za-z0-9][A-Za-z0-9_.:-]*$`. Unique within the policy.
`allow` or `deny`.
Exactly one entry, `"tool.call"`. The action must be named explicitly; there is no wildcard.
1–100 tool patterns. Each is at most 256 characters and supports literal names and `*` only — `?`, `[`, `]`, and whitespace are rejected.
1–3 entries from `dev`, `stage`, `prod`.
Up to 2 entries from `admin`, `owner`. May be empty, but a rule must name at least one approver by role or by user id.
Up to 100 control user ids, each prefixed `usr_` and at most 64 characters. Every id must be a current member of this workspace, or the request is rejected with `422`.
`1` or `2`. The number of approvals required.
## List approval policies
`GET /api/v1/workspaces/{workspace_id}/approval-policies`
Requires `workspace.read`. Returns every policy in the workspace, disabled ones included, ordered by name then id. Not paginated.
Workspace id, prefixed `ws_`.
```bash cURL
curl "$OBOL_CONTROL_URL/api/v1/workspaces/ws_acme_prod/approval-policies" \
-H "Authorization: Bearer $OBOL_SESSION_TOKEN"
```
```json Response
{
"approval_policies": [
{
"schema_version": 1,
"policy_id": "apy_01hxyz",
"workspace_id": "ws_acme_prod",
"name": "production writes",
"status": "active",
"revision": 1,
"revision_id": "ayr_01hxyz",
"rules": [
{
"rule_id": "refunds",
"effect": "allow",
"actions": ["tool.call"],
"tools": ["stripe.create_refund"],
"environments": ["prod"],
"approver_roles": ["admin", "owner"],
"approver_user_ids": [],
"quorum": 2
}
],
"created_at": "2026-09-04T12:00:00Z",
"updated_at": "2026-09-04T12:00:00Z"
}
]
}
```
## Get an approval policy
`GET /api/v1/workspaces/{workspace_id}/approval-policies/{policy_id}`
Requires `workspace.read`. Returns the approval policy object.
Workspace id, prefixed `ws_`.
Approval policy id, prefixed `apy_`.
### Errors
| Status | Condition |
|---|---|
| `404` | `{"detail": "approval policy not found"}`, or no such workspace / membership. |
## Create an approval policy
`POST /api/v1/workspaces/{workspace_id}/approval-policies`
Requires `approval.policy.manage` (owner). Returns `200` with the new policy at revision 1.
1–128 characters, and must not be blank after trimming. Unique within the workspace.
1–100 rules. See [Rule fields](#rule-fields).
Must be omitted on create. Sending it is a `422`.
Additional properties are rejected.
```bash cURL
curl -X POST "$OBOL_CONTROL_URL/api/v1/workspaces/ws_acme_prod/approval-policies" \
-H "Authorization: Bearer $OBOL_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "production writes",
"rules": [
{
"rule_id": "refunds",
"effect": "allow",
"actions": ["tool.call"],
"tools": ["stripe.create_refund"],
"environments": ["prod"],
"approver_roles": ["admin", "owner"],
"approver_user_ids": [],
"quorum": 2
}
]
}'
```
```json Response
{
"schema_version": 1,
"policy_id": "apy_01hxyz",
"workspace_id": "ws_acme_prod",
"name": "production writes",
"status": "active",
"revision": 1,
"revision_id": "ayr_01hxyz",
"rules": [
{
"rule_id": "refunds",
"effect": "allow",
"actions": ["tool.call"],
"tools": ["stripe.create_refund"],
"environments": ["prod"],
"approver_roles": ["admin", "owner"],
"approver_user_ids": [],
"quorum": 2
}
],
"created_at": "2026-09-04T12:00:00Z",
"updated_at": "2026-09-04T12:00:00Z"
}
```
### Errors
| Status | Condition |
|---|---|
| `401` | No session, or the session did not verify. |
| `403` | Role does not grant `approval.policy.manage`. |
| `404` | No such workspace, or the operator is not a member. |
| `409` | Workspace is frozen, or `{"detail": "approval policy name already exists"}`. |
| `422` | `{"detail": "expected_revision is only valid when updating"}`, a blank name, a named approver who is not a current workspace member, or any rule-validation failure. |
Rule-validation messages include `rules must contain between 1 and 100 entries`, `rule fields are invalid`, `rule_id must be unique`, `effect must be allow or deny`, `actions must explicitly match tool.call`, `tools must not be empty`, `tool patterns support literal names and * only`, `environments are invalid`, `approver principals are invalid`, and `quorum must be 1 or 2`.
## Revise an approval policy
`PUT /api/v1/workspaces/{workspace_id}/approval-policies/{policy_id}`
Requires `approval.policy.manage` (owner). Replaces the name and the whole rule set, creating revision `current + 1`. Returns `200` with the new revision.
Approval policy id, prefixed `apy_`.
1–128 characters, not blank after trimming.
1–100 rules. The full replacement set — this is not a patch.
The `revision` you are replacing, at least 1. Required on update.
```bash cURL
curl -X PUT "$OBOL_CONTROL_URL/api/v1/workspaces/ws_acme_prod/approval-policies/apy_01hxyz" \
-H "Authorization: Bearer $OBOL_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "production writes",
"expected_revision": 1,
"rules": [
{
"rule_id": "refunds",
"effect": "allow",
"actions": ["tool.call"],
"tools": ["stripe.create_refund", "stripe.cancel_subscription"],
"environments": ["prod"],
"approver_roles": ["owner"],
"approver_user_ids": [],
"quorum": 1
}
]
}'
```
### Errors
| Status | Condition |
|---|---|
| `403` | Role does not grant `approval.policy.manage`. |
| `404` | `{"detail": "approval policy not found"}`, or no such workspace / membership. |
| `409` | `{"detail": "approval policy revision changed"}`, a duplicate name, or a frozen workspace. |
| `422` | `{"detail": "expected_revision is required"}`, or any rule-validation failure listed above. |
## Disable an approval policy
`POST /api/v1/workspaces/{workspace_id}/approval-policies/{policy_id}/disable`
Requires `approval.policy.manage` (owner). Sets `status` to `disabled` without deleting the policy or its revisions. Returns `200` with the policy object.
The `revision` you observed, at least 1.
```bash cURL
curl -X POST "$OBOL_CONTROL_URL/api/v1/workspaces/ws_acme_prod/approval-policies/apy_01hxyz/disable" \
-H "Authorization: Bearer $OBOL_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{"expected_revision": 1}'
```
Changing or disabling an approval policy changes the workspace's policy-set hash. Every pending or approved-but-unexecuted approval that was raised under the old set becomes `invalidated` and can no longer be voted on or executed.
### Errors
| Status | Condition |
|---|---|
| `403` | Role does not grant `approval.policy.manage`. |
| `404` | `{"detail": "approval policy not found"}`. |
| `409` | `{"detail": "approval policy revision changed"}`, or a frozen workspace. |
| `422` | `expected_revision` missing or below 1. |
## Approval object
The `operator_approval` contract, frozen in `packages/proto/operator_approval.schema.json`.
Prefixed `apr_`.
Prefixed `ws_`.
The original request's environment (`dev`, `stage`, `prod`), not a union of matching rule scopes.
The virtual key that made the held call, prefixed `key_`.
The inbound request, prefixed `req_`. Also the `X-Request-Id`.
The logical tool call, prefixed `inv_`, when one is bound.
The namespaced tool name the rules matched.
The namespaced tool name the invocation names.
Prefixed `conn_`.
Numeric context only — currently `amount_usd`. Never arbitrary argument names or values: raw tool arguments are not in this projection.
Why the call was held.
`pending`, `approved`, `denied`, `expired`, or `invalidated`. This is the **effective** status: a stored `pending` or `approved` request that is unexecuted and past `expires_at` reports `expired`, and one whose policy-set hash no longer matches the workspace's current set reports `invalidated`.
`awaiting_quorum`, `quorum_met`, `default_deny`, `reviewer_deny`, `expired`, `policy_changed`, or `execution_failed`.
`not_requested`, `pending`, `succeeded`, or `failed`.
The approval's own optimistic-concurrency counter. Pass it as `expected_revision` when voting.
RFC 3339 UTC.
RFC 3339 UTC. After this, the approval can no longer be voted on or executed.
RFC 3339 UTC, or `null`.
RFC 3339 UTC, or `null`.
`policy_set_hash` plus the `rules` that matched, each carrying its `policy_id`, `revision_id`, `rule_id`, `effect`, `actions`, `tools`, `environments`, `approver_roles`, `approver_user_ids`, and `quorum`. This is the rule set as it stood when the call was held.
Every vote cast, each with `user_id`, `decision`, `reason`, and `created_at`.
`receipt_id`, `error_code`, and `digest` from the resumed execution, each `null` until it runs.
Whether the signed-in operator could vote right now. Informational only — control rechecks authorization under its row lock when the vote arrives.
`not_pending`, `expired`, `frozen`, `role`, `policy_changed`, `not_eligible`, or `already_voted`. `null` when `can_vote` is true.
The signed-in operator's own vote (`decision`, `reason`, `created_at`), or `null`.
Approvals needed.
Approvals cast so far.
## List approvals
`GET /api/v1/workspaces/{workspace_id}/approvals`
Requires `workspace.read`. Keyset-paginated, newest first by `(created_at, approval_id)`.
1–50.
Opaque cursor from a previous `next_cursor`, at most 1024 characters. An undecodable cursor is a `422` (`{"detail": "invalid cursor"}`).
One of `pending`, `approved`, `denied`, `expired`, `invalidated`. Filters on the **effective** status, so `pending` excludes rows that have expired or been invalidated by a policy change, and `expired` includes stored-`pending` rows past their expiry.
```bash cURL
curl "$OBOL_CONTROL_URL/api/v1/workspaces/ws_acme_prod/approvals?decision_status=pending&limit=25" \
-H "Authorization: Bearer $OBOL_SESSION_TOKEN"
```
```json Response
{
"approvals": [
{
"schema_version": 1,
"approval_id": "apr_test",
"workspace_id": "ws_acme_prod",
"key_id": "key_approval",
"request_id": "req_01hxyz",
"invocation_id": "inv_01hxyz",
"tool": "stripe.create_refund",
"invocation_tool": "stripe.create_refund",
"connection_id": "conn_01hxyz",
"env": "prod",
"safe_summary": { "amount_usd": 12.0 },
"reason": "destructive tool in prod",
"decision_status": "pending",
"decision_reason": "awaiting_quorum",
"execution_status": "not_requested",
"revision": 1,
"created_at": "2026-09-04T12:00:00Z",
"expires_at": "2026-09-04T12:15:00Z",
"decided_at": null,
"executed_at": null,
"policy_snapshot": { "policy_set_hash": "…", "rules": [] },
"votes": [],
"result": { "receipt_id": null, "error_code": null, "digest": null },
"can_vote": true,
"vote_block_reason": null,
"current_user_vote": null,
"quorum_required": 2,
"approvals_received": 0
}
],
"next_cursor": null
}
```
Approval objects.
Pass back as `cursor`. `null` when the listing is exhausted.
## Get an approval
`GET /api/v1/workspaces/{workspace_id}/approvals/{approval_id}`
Requires `workspace.read`. Returns one approval object.
Approval id, prefixed `apr_`.
### Errors
| Status | Condition |
|---|---|
| `404` | `{"detail": "approval not found"}`, or no such workspace / membership. |
## Vote on an approval
`POST /api/v1/workspaces/{workspace_id}/approvals/{approval_id}/votes`
Requires `approval.decide` (admin or owner) **and** eligibility under the rules that matched when the call was held. Returns `200` with the updated approval object, including the recomputed quorum.
The approval row is locked for the duration, so two reviewers racing to the last slot cannot both succeed.
`approve` or `deny`.
1–512 characters. Whitespace-only is rejected — a review that records nothing is not a review.
The approval's `revision` as you read it, at least 1.
Additional properties are rejected.
```bash cURL
curl -X POST "$OBOL_CONTROL_URL/api/v1/workspaces/ws_acme_prod/approvals/apr_test/votes" \
-H "Authorization: Bearer $OBOL_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"decision": "approve",
"reason": "checked refund against the ticket",
"expected_revision": 1
}'
```
### Errors
| Status | Condition |
|---|---|
| `401` | No session, or the session did not verify. |
| `403` | Role does not grant `approval.decide`, or `{"detail": "operator is not an eligible approver"}` — the role is sufficient but no matched rule names this operator. |
| `404` | `{"detail": "approval not found"}`, or no such workspace / membership. |
| `409` | Workspace is frozen; `{"detail": "approval revision changed"}`; `{"detail": "approval is not pending"}`; `{"detail": "approval has expired"}`; `{"detail": "approval policy changed"}`; `{"detail": "operator already voted"}`. |
| `422` | `{"detail": "review reason must not be blank"}`, or body validation failed. |
`vote_block_reason` on the approval object tells you in advance which of these a vote would hit. It is a hint for rendering, not an authorization result: control re-evaluates every condition under the row lock.