Skip to Content
API referenceApprovals API
Open in Claude Open in ChatGPT

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 through POST /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" }
FieldNotes
approval_idUUID
statuspending / approved / denied / expired
resultPresent when status === "approved" AND the post-approval re-execution has run. { status, data } with the upstream HTTP status and body
execution_errorPresent 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_at10 minutes from created_at
executed_atWhen the post-approval re-execution ran, or null if not yet
decided_atWhen 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

StatusCause
400invalid id — the id is not a UUID
401Missing or invalid agent JWT
404not_found — no approval with that id, or it belongs to a different agent (existence is not revealed)
429rate_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

StatusCause
400decision not approved or denied, or the id is not a UUID
401No valid session JWT
403admin_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
404No approval with that id
409already_actioned: the request has already been decided
410expired: the request has expired or been cancelled. Cancelling a request sets it to expired with expires_at set to now
503decision_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/decide returns admin_only, approver_deleted or self_approval. approver_deleted is returned when the signed-in owner’s account has been deleted but its sign-in is still attached. self_approval applies to approving only. Every other refusal comes back as admin_only. A removed member’s sign-in no longer belongs to the organisation, so it is refused with 403 owner_not_found.
  • Passkey sign/finish returns admin_only, approver_deleted or self_approval. Passkey sign/begin with a session answers 401 for a removed member, and session-less sign/begin does not offer their passkey.
  • Session-less passkey sign/begin also returns no_eligible_approver.
CodeMessage
admin_onlyOnly an organisation admin can approve or deny this request.
not_in_orgYou are not an approver for the organisation this request belongs to.
delegate_inactiveYour approval delegation has been turned off.
delegation_not_on_planApproval delegation is not included in this organisation’s plan.
approver_deletedYour access to this organisation has been removed.
self_approvalThis organisation requires someone other than the agent’s owner to approve.
no_eligible_approverNo 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.

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.outcomeMeaning
sentThe email provider accepted it. provider_message_id is its id, for a delivery lookup. Accepted is not the same as delivered.
failedThe provider refused it (reason: provider_error).
skippedIt 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=expired

See How pending approvals work for the dashboard-side narrative.

Last updated on