Approvals API
Two surfaces:
GET /v1/approvals/:id— agent-facing status poll. Auth: agent JWT.POST /v1/approvals/:id/decide— owner-facing decision. Auth: dashboard session JWT, org admins only. Delegates decide throughPOST /v1/approve(their emailed link), not this endpoint.
When POST /v1/actions returns 202 pending_approval, the agent polls the
status endpoint. The owner decides via the dashboard, mobile passkey
(see Passkeys), or magic-link email (delegates).
GET /v1/approvals/:id
GET /v1/approvals/7c1e4b2a-9d3f-4e8a-b6c5-2f0a1d9e8b34 HTTP/1.1
Host: api.agentvalet.ai
Authorization: Bearer <agent-rs256-jwt>Response
{
"approval_id": "7c1e4b2a-9d3f-4e8a-b6c5-2f0a1d9e8b34",
"status": "approved",
"result": { "status": 200, "data": { "ok": true } },
"execution_error": null,
"expires_at": "2026-05-19T08:00:00Z",
"executed_at": "2026-05-18T10:42:11Z",
"created_at": "2026-05-18T10:40:55Z",
"decided_at": "2026-05-18T10:42:10Z"
}| Field | Notes |
|---|---|
approval_id | UUID |
status | pending / approved / denied / expired |
result | Present when status === "approved" AND the post-approval re-execution has run. { status, data } with the upstream HTTP status and body |
execution_error | Present when the post-approval re-execution failed or was refused (e.g. credential rotated since the request was queued). String. The replay’s re-checks use stable codes: agent_not_active, child_liveness_failed, grant_expired, grant_revoked (the grant was deleted, or no grant covers the connection the call resolved to), platform_suspended, scope_revoked (the scope was removed from the grant while the call was held), endpoint_scope_mismatch, recipient_not_allowed, repo_not_allowed, external_blocked. |
expires_at | 10 minutes from created_at |
executed_at | When the post-approval re-execution ran, or null if not yet |
decided_at | When a person approved or denied the request, on any path (dashboard, passkey, emailed link or push). null while it is pending, or if it expired or was cancelled without a decision. The signed decision receipt carries the same time, to the second. Who decided is not returned to the agent. |
This endpoint is read-only: it never triggers re-execution. The replay
runs when the owner decides, so if status is approved but
executed_at is still null, poll again.
Errors
| Status | Cause |
|---|---|
| 400 | invalid id — the id is not a UUID |
| 401 | Missing or invalid agent JWT |
| 404 | not_found — no approval with that id, or it belongs to a different agent (existence is not revealed) |
| 429 | rate_limited — 60/min per agent |
POST /v1/approvals/:id/decide
Owner-facing. The dashboard and mobile shell call this. The approval
magic-link page calls POST /v1/approve instead.
Before it shows anything to decide, that page asks POST /v1/approve/preview
with the same token in the body. The token is checked exactly as
/v1/approve checks it (401 if it was never issued for that request). The
answer says what the request is (agent, platform, action, method, endpoint and
the agent’s reason), whether it is still pending, and whether this link may
still approve or deny it (can_approve, can_deny), read from your
organisation as it is now. It never says who decided. The page shows Approve or
Deny only for a pending request it can identify and that the link may still
decide. A decided, cancelled or expired request, or one it can’t identify,
gets a card with nothing to click.
POST /v1/approvals/7c1e4b2a-9d3f-4e8a-b6c5-2f0a1d9e8b34/decide HTTP/1.1
Host: api.agentvalet.ai
Authorization: Bearer <dashboard-session-jwt>
Content-Type: application/json
{ "decision": "approved" }Response
{ "status": "approved" }Only an admin of the request’s organisation can decide here. Members cannot. The approver’s role is read when they decide. Active approval delegates (when the plan includes delegation) decide through POST /v1/approve, not this endpoint. After updating the row, the proxy runs the
queued action before responding, so the agent’s next poll sees a complete result. If the
re-execution fails (e.g. the agent has been suspended in the interim),
execution_error is recorded on the approval row and exposed via the
status poll.
Errors
| Status | Cause |
|---|---|
| 400 | decision not approved or denied, or the id is not a UUID |
| 401 | No valid session JWT |
| 403 | admin_only (you are not an approver for this request, including members and requests from another organisation), approver_deleted (your access to the organisation has been removed; returned by the one-click link and passkey finish) or self_approval (the organisation requires someone other than the agent’s owner to approve; it blocks approving only, and the agent’s owner can always deny). See the table below |
| 404 | No approval with that id |
| 409 | already_actioned: the request has already been decided |
| 410 | expired: the request has expired or been cancelled. Cancelling a request sets it to expired with expires_at set to now |
| 503 | decision_context_unavailable: AgentValet could not check who may approve. Nothing was decided. Try again |
Refusal codes
Which code you see depends on the path:
POST /v1/approve(the one-click or emailed link) returns the specific code for every refusal.POST /v1/approvals/:id/decidereturnsadmin_only,approver_deletedorself_approval.approver_deletedis returned when the signed-in owner’s account has been deleted but its sign-in is still attached.self_approvalapplies to approving only. Every other refusal comes back asadmin_only. A removed member’s sign-in no longer belongs to the organisation, so it is refused with 403owner_not_found.- Passkey
sign/finishreturnsadmin_only,approver_deletedorself_approval. Passkeysign/beginwith a session answers 401 for a removed member, and session-lesssign/begindoes not offer their passkey. - Session-less passkey
sign/beginalso returnsno_eligible_approver.
| Code | Message |
|---|---|
admin_only | Only an organisation admin can approve or deny this request. |
not_in_org | You are not an approver for the organisation this request belongs to. |
delegate_inactive | Your approval delegation has been turned off. |
delegation_not_on_plan | Approval delegation is not included in this organisation’s plan. |
approver_deleted | Your access to this organisation has been removed. |
self_approval | This organisation requires someone other than the agent’s owner to approve. |
no_eligible_approver | No one in this organisation can approve this request right now. |
The same approver rules apply on every path: dashboard, passkey, one-click link and
push notification, and a removed or deleted approver cannot approve on any of them. The first valid decision wins. A refusal for a removed
approver or for self_approval is recorded as approval.decision_refused.
A request that nobody can approve is recorded as approval.no_eligible_approver.
Require a different approver
When an admin turns on Require a different approver in Settings, nobody
can approve a request from an agent they own. A delegate with the owner’s
email counts as the owner. The refusal is 403 self_approval. It blocks
approving only: the agent’s owner can always deny. The setting
can only be switched on when at least 2 people can approve, otherwise the
request fails with 409 insufficient_approvers. If eligible approvers later
drop below 2, the setting stays on and requests nobody can approve expire
unapproved, recorded as approval.no_eligible_approver. Changes to the
setting are recorded as org.approval_settings_changed, and every approve or
deny receipt carries distinct_approver_required.
Delegate approvals (magic link)
Active approval delegates receive emails with a single-use magic-link token.
Following the link lands at
https://app.agentvalet.ai/approve?token=<jwt> and uses a separate
delegate-only endpoint (POST /v1/approve) — not decide. Delegates do
not need an AgentValet account.
The token is one-time-use: once approved or denied, the same link
returns 409 already_actioned. The action it approves expires after 10
minutes — a link followed after that returns 410 expired, not a token
error.
Each email’s subject carries the first 8 characters of the approval id and
the request’s method, for example
Approval required [89a22945]: nightly → github PUT, so two requests of the
same kind never share a subject and a mail client won’t thread a new request
under an old one. The request’s path is in the email body only, never the
subject: a path can contain a customer id, a private repository name or a
person’s name, and subjects show on lock screens and in notification
previews. In the body the path never includes the query string or fragment,
control characters are removed, and it is shortened if long.
Every delegate an email is meant for gets an approval.notification audit
row, in the audit export:
metadata.outcome | Meaning |
|---|---|
sent | The email provider accepted it. provider_message_id is its id, for a delivery lookup. Accepted is not the same as delivered. |
failed | The provider refused it (reason: provider_error). |
skipped | It was never sent. reason says why: token_mint_failed, or token_store_failed (the link could not be recorded, so it would not have worked). |
If the delegates could not be listed at all, one row with
recipient_kind: "delegates" says why nobody was emailed:
decision_context_unavailable or eligible_lookup_failed. Rows name a
delegate by id (recipient_id), never by email address, and carry the
held call’s scope in metadata.scope. They are not governed calls: they
have no scopes_used and don’t count towards usage. sent rows have
result allowed; failed and skipped rows have result error.
Lifecycle
request hits /v1/actions with require_approval=true
→ approval_queue row created (status=pending, expires_at = now + 10 min)
→ owner notified (push, email to delegates)
→ owner decides (dashboard / mobile passkey / magic link)
→ status set to approved/denied
→ if approved: reExecuteApproval() runs the original call,
persists result_json or execution_error
→ agent polls GET /v1/approvals/:id
→ returns final status + result (or execution_error)
→ past expires_at: /v1/approve refuses with 410; a 5-minute
sweep flips rows still pending → status=expiredSee How pending approvals work for the dashboard-side narrative.