# AI Approvel — full API reference (text version of https://ai-approvel.com/docs) > Human approval for AI agents over WhatsApp/SMS, with a signed, hash-chained audit trail. This file is the reference page flattened to Markdown for LLMs and agents. The short version with links is /llms.txt; the machine-readable spec is /openapi.json. A tiny REST API. Your agent asks a human before it acts, and you get back a signed, tamper-proof record of the decision. Base URL: `https://.supabase.co/functions/v1/approvals` — your Supabase functions host plus the `approvals` function (`https://.functions.supabase.co/approvals` is equivalent). All bodies are JSON. Ids are UUIDs. Timestamps are RFC 3339 / ISO 8601 in UTC with milliseconds. Errors always have the shape `{ "error": { "code", "message", "details" } }`. Everything below was checked against the backend source on 2026-10-01, including API build 2026-10 (server-side list filters and cursors, `decision` on list rows, 402 `quota_exceeded`, `Retry-After`, per-project webhook secrets, approver `locale`). ## Try it Your API key (`apk_live_…`) is shown in the dashboard under Settings → API key; the full value is visible once, when you create or rotate it. Pick an approver who has confirmed their phone (Approvers page; the id is a UUID). ```bash export APPROV_API_BASE="https://.supabase.co/functions/v1/approvals" export APPROV_KEY="apk_live_…" curl -X POST "$APPROV_API_BASE/" \ -H "Authorization: Bearer $APPROV_KEY" \ -H "Idempotency-Key: hello-1" \ -H "Content-Type: application/json" \ -d '{ "approver_id": "bb07d4a7-22f6-4188-a139-66f16dc2b6e8", "action": "Hello from the API docs" }' # → { "id": "ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f", "status": "pending", "expires_at": "…", "replayed": false } ``` An approval is a question, not an action; nothing in this call is irreversible. ## Quickstart 1. Create an approval. AI Approvel messages the approver and returns immediately with an `id`. 2. Poll `GET /{id}` every few seconds (or receive the webhook) until `status` is terminal; branch on `decision.outcome`; only then run the risky step. ```bash curl -X POST "$APPROV_API_BASE/" -H "Authorization: Bearer $APPROV_KEY" -H "Idempotency-Key: refund-8831" -H "Content-Type: application/json" \ -d '{ "approver_id": "bb07d4a7-22f6-4188-a139-66f16dc2b6e8", "action": "Refund $4,200 to customer #8831", "amount": 4200, "currency": "USD", "context": { "ticket": "ZD-4411" }, "timeout_seconds": 1800 }' # 202 → { "id": "ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f", "status": "pending", "expires_at": "2026-10-01T12:30:02.000Z", "replayed": false } curl "$APPROV_API_BASE/ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f" -H "Authorization: Bearer $APPROV_KEY" # 200 → { "id": "ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f", "status": "decided", "decision": { "outcome": "approved", "decided_via": "whatsapp_button", "decided_by": "+14155550101", "decided_at": "2026-10-01T12:04:11.000Z" }, … } ``` ## Authentication Send the project API key as a Bearer token on every request: `Authorization: Bearer apk_live_1a2b3c4d_…`. Keys look like `apk_live_<8 hex>_`; the part before the second underscore (`apk_live_1a2b3c4d`) is a non-secret identifier the dashboard shows, everything after it is the secret. Keep it server-side; never ship it in a browser or app bundle. A missing, malformed, rotated or unknown key returns `401` with code `unauthorized`. ## Create an approval — POST / Returns `202 Accepted` with an id while delivery happens in the background. Send an `Idempotency-Key` header (see Idempotency). The body is strict: unknown fields are rejected with `400 validation_error` and `details.fieldErrors` names them. The approver must belong to your project and have confirmed their phone number; otherwise `400 validation_error` with `details.approver_id`. | Field | Type | Notes | |---|---|---| | `approver_id` | string (uuid) | Required. An id from the Approvers page. | | `action` | string | Required. 1–500 characters. The text the approver sees. | | `amount` | number | Optional. Money at stake; finite, ≥ 0. | | `currency` | string | Optional. ISO 4217, exactly 3 letters (upper-cased for you). Send it whenever `amount` is set. | | `context` | object | Optional. Arbitrary JSON stored with the approval and written into the `approval.requested` audit event. Not shown on the phone; not in the webhook. | | `params_hash` | string | Optional, 1–128 chars. Hash of the exact parameters this approval is bound to; returned on `GET /{id}`. | | `callback_url` | string | Optional. `https://` on a public host (no private, loopback, link-local or cloud-metadata addresses). Receives the signed decision webhook. | | `locale` | `en` \| `ar` | Optional. Language of the message. Default `en`. | | `timeout_seconds` | integer | Optional, > 0. How long the approver has to reply. Default 3600; capped at 86400. | | `metadata` | object | Optional. Arbitrary JSON written into the `approval.requested` audit event. | There is no `expires_in_seconds`. Response: `{ "id": "", "status": "pending", "expires_at": "…", "replayed": false }`. `replayed` is true when the `Idempotency-Key` matched an existing approval and that one was returned instead of a new one. ## List approvals — GET /?limit=50 Returns `{ count, approvals, next_cursor, has_more }`: the project's approvals, newest first, as summary rows (`id, status, action, amount, currency, channel_used, created_at, expires_at, decision` where `decision` is `{ outcome, decided_at, decided_via }` or null). Parameters: `status` (`pending | delivered | decided | expired | failed`, or `approved` / `rejected` = decided with that outcome), `q` (case-insensitive substring of `action`, ≤ 200 chars), `limit` (default 50, max 200), `cursor` (the previous page's `next_cursor`; invalid → 400). Pass `next_cursor` back as `cursor` until `has_more` is false, keeping the filters the same. Reads are not rate-limited and never count against the quota. ```json { "count": 1, "approvals": [ { "id": "ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f", "status": "decided", "action": "Refund $4,200 to customer #8831", "amount": 4200, "currency": "USD", "channel_used": "whatsapp", "created_at": "2026-10-01T12:00:02.000Z", "expires_at": "2026-10-01T12:30:02.000Z", "decision": { "outcome": "approved", "decided_at": "2026-10-01T12:04:11.000Z", "decided_via": "whatsapp_button" } } ], "next_cursor": null, "has_more": false } ``` ## Get status — GET /{id} Poll for the decision. `decision` is `null` until the approver replies. Polling every few seconds is fine (the dashboard polls every 2.5 s). Stop once `status` is terminal. ```json { "id": "ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f", "status": "decided", "action": "Refund $4,200 to customer #8831", "amount": 4200, "currency": "USD", "params_hash": "c0ffee00…", "channel_used": "whatsapp", "locale": "en", "expires_at": "2026-10-01T12:30:02.000Z", "created_at": "2026-10-01T12:00:02.000Z", "updated_at": "2026-10-01T12:04:11.000Z", "decision": { "outcome": "approved", "decided_via": "whatsapp_button", "decided_by": "+14155550101", "decided_at": "2026-10-01T12:04:11.000Z" } } ``` `decided_via` is one of `whatsapp_button`, `whatsapp_text`, `sms_reply`, `web_link`, `console`, `api`. `decided_by` is the approver's E.164 number, verbatim. `channel_used` is `whatsapp`, `sms` or `console` (dev deployments), `null` until delivered. ## Status lifecycle `pending → delivered → decided`. Side exits: `delivered → expired` when nobody replies in time; `pending → failed` when the message cannot be delivered. `decided`, `expired` and `failed` are terminal. | Status | Kind | Meaning | |---|---|---| | `pending` | initial | Created; the message is queued. `decision` is null. | | `delivered` | waiting | The message was handed to the carrier for the approver (`channel_used` is `whatsapp` or `sms`). | | `decided` | terminal | The approver replied. `decision.outcome` is `approved` or `rejected`; the webhook fires. | | `expired` | terminal | `timeout_seconds` lapsed without a reply. A late reply is refused; `expired` never flips back. | | `failed` | terminal | The message could not be delivered. Fix the approver's number and create a new approval. | ## Get audit trail — GET /{id}/audit Every state change is hash-chained and signed with Ed25519. The response includes the server's own verification result, the public key, the formula and the field separator so anyone can re-verify offline. ```json { "approval_request_id": "ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f", "algorithm": { "hash": "sha256", "signature": "ed25519", "hash_formula": "sha256( prev_hash | event_type | canonical_json(payload) | created_at )", "field_separator": "U+241F (␟)" }, "public_key": { "key_id": "approv-2026-10", "format": "spki-der-base64", "key": "MCowBQYDK2VwAyEA…" }, "verification": { "valid": true, "event_count": 4, "issues": [] }, "events": [ { "seq": 0, "event_type": "approval.requested", "payload": { "action": "Refund $4,200 to customer #8831", "amount": 4200, "…": "…" }, "prev_hash": "0000…0000", "hash": "d58c…", "signature": "r4Rx…", "public_key_id": "approv-2026-10", "created_at": "2026-10-01T12:00:02.103Z" }, { "seq": 1, "event_type": "message.sent", "payload": { "channel": "whatsapp", "provider": "meta", "attemptNo": 1 }, "prev_hash": "d58c…", "hash": "8d35…", "signature": "2ow5…", "public_key_id": "approv-2026-10", "created_at": "2026-10-01T12:00:04.418Z" }, { "seq": 2, "event_type": "approval.decided", "payload": { "outcome": "approved", "decidedVia": "whatsapp_button", "decidedByPhone": "+14155550101", "rawInput": "approve", "decidedAt": "…" }, "prev_hash": "8d35…", "hash": "5172…", "signature": "5oM1…", "public_key_id": "approv-2026-10", "created_at": "2026-10-01T12:04:11.007Z" }, { "seq": 3, "event_type": "webhook.delivered", "payload": { "callbackUrl": "https://agent.example.com/hooks/approv", "statusCode": 200, "outcome": "approved" }, "prev_hash": "5172…", "hash": "80f4…", "signature": "v85N…", "public_key_id": "approv-2026-10", "created_at": "2026-10-01T12:04:12.250Z" } ] } ``` Event types: `approval.requested`, `message.queued`, `message.sent`, `message.failed`, `approval.decided`, `approval.expired`, `webhook.delivered`, `webhook.failed`. `verification.issues` items are `{ seq, reason }`. ## Verify the audit trail Three checks, in order: 1. Chain: `seq` runs from 0; every event's `prev_hash` equals the previous event's `hash`, and the first event's `prev_hash` is the genesis hash — 64 zeros. 2. Hash: recompute `hash` = `sha256( prev_hash ␟ event_type ␟ canonical_json(payload) ␟ created_at )`. The `|` in `algorithm.hash_formula` is notation: the four strings are joined with `algorithm.field_separator`, the single character U+241F (`␟`, SYMBOL FOR UNIT SEPARATOR). `canonical_json` is JSON with keys sorted recursively and no whitespace (`{}` when there is no payload). Digest is lowercase hex over the UTF-8 bytes. 3. Signature: `signature` is **base64** Ed25519 over the **UTF-8 bytes of the hex `hash` string** (not the raw 32-byte digest). Verify with `public_key.key` (`spki-der-base64`). Pin the key by `public_key.key_id` (also on each event as `public_key_id`). ```js import { createHash, createPublicKey, verify } from "node:crypto"; const canonicalJson = (v) => v && typeof v === "object" && !Array.isArray(v) ? "{" + Object.keys(v).sort().map((k) => JSON.stringify(k) + ":" + canonicalJson(v[k])).join(",") + "}" : Array.isArray(v) ? "[" + v.map(canonicalJson).join(",") + "]" : JSON.stringify(v); const publicKey = createPublicKey({ key: Buffer.from(trail.public_key.key, "base64"), format: "der", type: "spki" }); const SEP = "\u241F"; let prev = "0".repeat(64); for (const ev of trail.events) { if (ev.prev_hash !== prev) throw new Error(`chain broken at seq ${ev.seq}`); const recomputed = createHash("sha256") .update([ev.prev_hash, ev.event_type, canonicalJson(ev.payload ?? {}), ev.created_at].join(SEP), "utf8").digest("hex"); if (recomputed !== ev.hash) throw new Error(`hash mismatch at seq ${ev.seq}`); if (!verify(null, Buffer.from(ev.hash, "utf8"), publicKey, Buffer.from(ev.signature, "base64"))) throw new Error(`bad signature at seq ${ev.seq}`); prev = ev.hash; } ``` The `hash_formula` and `field_separator` strings returned by the API are authoritative; if they ever change, follow the response. ## Verify offline A dependency-free Node ≥ 18 script runs the three checks on the file the dashboard's "Download trail" button saves (the exact body of `GET /{id}/audit`). It never talks to AI Approvel. ```bash curl -fsSLO https://ai-approvel.com/verify-audit.mjs node verify-audit.mjs ai-approvel-audit-ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f.json # ✓ 4 events verified … · sha256 chain + ed25519 signatures · key approv-2026-10 (exit 0) # ✗ 3 problems … seq 2 hash recomputed … ≠ stored … (exit 1, each failure by seq and check) ``` Exit codes: 0 valid, 1 failures listed by `seq` and check (`chain`, `hash`, `signature`), 2 usage error or unreadable file. Flags: `--key-id ` pins the signing key, `--json` prints the structured result, `--no-color` (or `NO_COLOR`) for logs, `-` reads stdin. The script exports `verifyTrail()`, `computeEventHash()`, `verifyEventSignature()` and `canonicalJson()` and only runs its CLI when executed directly. It is tested against a trail produced by the backend's own audit engine. ## Webhooks Pass a `callback_url` when creating an approval. On decision, AI Approvel POSTs a signed JSON body: ```http POST Content-Type: application/json X-Approv-Event: approval.decided X-Approv-Timestamp: 1790251451 X-Approv-Signature: 5f1d3c8e… # lowercase hex HMAC-SHA256 of "." { "id": "ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f", "status": "decided", "outcome": "approved", "decided_via": "whatsapp_button", "decided_at": "2026-10-01T12:04:11.000Z", "action": "Refund $4,200 to customer #8831", "params_hash": "c0ffee00…", "approver_id": "bb07d4a7-22f6-4188-a139-66f16dc2b6e8", "amount": 4200, "currency": "USD" } ``` That is the whole body — exactly `id`, `outcome`, `decided_via`, `decided_at`; call `GET /{id}` for `action`, `amount`, `params_hash` and the rest. Respond with any 2xx within 10 seconds; the body is ignored. A non-2xx, a redirect or a timeout is retried from the queue (about a minute apart, up to 5 attempts) and every attempt is recorded in the audit trail as `webhook.delivered` / `webhook.failed`. Make the handler idempotent on `id`. ### Webhook endpoints (Phase C) Instead of a `callback_url` per request, add up to ten `https://` endpoints in the dashboard (Webhooks page). Every enabled endpoint subscribed to `approval.decided` receives each decision with the same headers plus `X-Approv-Delivery: `, signed with its own `whsec_…` secret (shown once on creation; rotatable). The Deliveries log keeps request body, response status, error and attempts per delivery, filterable by endpoint, status and approval, with one-click replay. Session-authenticated routes (owner or admin): `GET`/`POST /webhooks/endpoints`, `PATCH`/`DELETE /webhooks/endpoints/:id`, `POST /webhooks/endpoints/:id/rotate-secret`, `POST /webhooks/endpoints/:id/test`, `GET /webhooks/deliveries?endpoint_id=&approval_id=&status=&cursor=&limit=`, `GET /webhooks/deliveries/:id`, `POST /webhooks/deliveries/:id/replay`. Marked `x-approv-status: "phase-c"` in /openapi.json until the backend is verified against the contract. ## Verify a webhook Signature = lowercase hex HMAC-SHA256 of `${timestamp}.${rawBody}` with your webhook signing secret. Compute it over the raw request bytes, compare in constant time, and reject when |now − timestamp| > 300 seconds. Each project has its own `whsec_…` secret: Settings → Webhooks in the dashboard, or `GET /account/webhook-secret` (user session). `POST /account/webhook-secret/rotate` mints a new one and the old one stops verifying immediately, so update your verifier first. ```js import { createHmac, timingSafeEqual } from "node:crypto"; export function verifyApprovWebhook(rawBody, headers, secret) { const timestamp = headers["x-approv-timestamp"], signature = headers["x-approv-signature"]; if (!timestamp || !signature) return false; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex"); const a = Buffer.from(expected, "hex"), b = Buffer.from(String(signature), "hex"); return a.length === b.length && timingSafeEqual(a, b); } ``` ```python import hashlib, hmac, time def verify_approv_webhook(raw_body: bytes, headers: dict, secret: str) -> bool: timestamp, signature = headers.get("X-Approv-Timestamp"), headers.get("X-Approv-Signature") if not timestamp or not signature or abs(time.time() - int(timestamp)) > 300: return False expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature.lower()) ``` ## Idempotency Send an `Idempotency-Key` header on every `POST /` — any string unique per approval you intend to create (your job id). A retry with the same key returns the original approval, still `202`, with `"replayed": true`; nothing new is created, the approver is not messaged again, and the replay is not charged to the monthly quota. Keys are scoped to the project and do not expire; concurrent requests with the same key resolve to one approval. Without the header the server generates a random key, so every request creates a new approval — store the returned `id` immediately. The key is not echoed back; put your own correlation id in `context` or `metadata` if you want it in the audit trail. ## Rate limits and quotas - Rate limit: 60 `POST /` requests per minute per project by default (fixed one-minute window, counted before validation; reads are not limited) → `429 rate_limited` with a `Retry-After` header in seconds (fall back to the next minute boundary if absent). Retry with the same `Idempotency-Key`. - Monthly quota: approvals created per calendar month by plan (Free 100, Growth 5,000, Scale unlimited) → `402 quota_exceeded` with `details = { plan, limit, used, period }` (no `Retry-After`: upgrade, retrying won't help). Only new, valid requests count; replays and rejected bodies do not. Resets on the 1st. Reads never count. ## Errors `{ "error": { "code": "…", "message": "…", "details": … } }`. Codes: `validation_error` (400; `details` is `{ formErrors, fieldErrors }` for body errors or `{ approver_id }` for an unknown / not-opted-in approver), `unauthorized` (401), `quota_exceeded` (402; `details` = `{ plan, limit, used, period }`), `not_found` (404), `rate_limited` (429; `Retry-After` in seconds), `internal_error` (500). Branch on `code`, never on `message`. ## OpenAPI spec https://ai-approvel.com/openapi.json — OpenAPI 3.1 including the `approval.decided` webhook. `npx @redocly/cli preview-docs https://ai-approvel.com/openapi.json` to browse. The spec was checked against the backend source, API build 2026-10 included; no node is marked `x-approv-inferred` any more. Dashboard (`/account/*`, user-JWT) endpoints — `stats`, `webhook-secret` (+ `/rotate`), `approvers` with `locale`, and the opt-in page's `locale` — are described in its `info.description` and at https://ai-approvel.com/docs#account. ## SDKs Official TypeScript and Python SDKs are in progress (npm / PyPI) and will expose the same `requireApproval({ action, amount })` call as the helper below. Until then use `fetch` / `requests`, or generate a client: `npx @openapitools/openapi-generator-cli generate -i https://ai-approvel.com/openapi.json -g typescript-fetch -o ./approv-client` (or `-g python`). ## Recipes Pattern: right before a risky tool runs, `POST /` an approval, poll `GET /{id}` until `decision` is set (or `status` is `expired` / `failed`), and only execute the side effect on `approved`. Return a rejection to the model as text. For long waits pass `callback_url` and continue from the webhook handler. Framework calls that may differ by version are marked "check against docs for your version"; the AI Approvel calls are exact. ### requireApproval helper (TypeScript, zero dependencies) ```ts const BASE = process.env.APPROV_API_BASE!, KEY = process.env.APPROV_KEY!, APPROVER = process.env.APPROV_APPROVER_ID!; export type Outcome = "approved" | "rejected"; type Approval = { id: string; status: "pending" | "delivered" | "decided" | "expired" | "failed"; decision: { outcome: Outcome } | null }; async function api(path: string, init: RequestInit = {}): Promise { const res = await fetch(`${BASE}${path}`, { ...init, headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" } }); const body = await res.json().catch(() => ({})); if (!res.ok) throw new Error(body?.error?.code ?? `HTTP ${res.status}`); return body as T; } export async function requireApproval(opts: { action: string; amount?: number; currency?: string; context?: Record; approverId?: string; timeoutMs?: number; pollMs?: number }): Promise { const { id } = await api<{ id: string }>("/", { method: "POST", body: JSON.stringify({ approver_id: opts.approverId ?? APPROVER, action: opts.action, amount: opts.amount, currency: opts.amount != null ? (opts.currency ?? "USD") : undefined, context: opts.context }) }); const deadline = Date.now() + (opts.timeoutMs ?? 15 * 60_000); while (Date.now() < deadline) { const a = await api(`/${id}`); if (a.decision) return a.decision.outcome; if (a.status === "expired" || a.status === "failed") throw new Error(`approval ${id} ${a.status}`); await new Promise((r) => setTimeout(r, opts.pollMs ?? 2_500)); } throw new Error(`approval ${id} timed out`); } ``` ### require_approval helper (Python, standard library) ```python import json, os, time, urllib.error, urllib.request BASE, KEY, APPROVER = os.environ["APPROV_API_BASE"], os.environ["APPROV_KEY"], os.environ["APPROV_APPROVER_ID"] def _api(path, body=None): req = urllib.request.Request(BASE + path, data=json.dumps(body).encode() if body is not None else None, method="POST" if body is not None else "GET", headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}) try: with urllib.request.urlopen(req, timeout=15) as res: return json.load(res) except urllib.error.HTTPError as e: try: code = json.load(e)["error"]["code"] except (ValueError, KeyError): code = f"HTTP {e.code}" raise RuntimeError(code) from e def require_approval(action, amount=None, currency="USD", context=None, approver_id=None, timeout_s=900, poll_s=2.5): """Returns "approved" or "rejected"; raises if expired, failed or timed out.""" body = {"approver_id": approver_id or APPROVER, "action": action} if amount is not None: body.update(amount=amount, currency=currency) if context: body["context"] = context approval_id = _api("/", body)["id"] deadline = time.monotonic() + timeout_s while time.monotonic() < deadline: a = _api(f"/{approval_id}") if a.get("decision"): return a["decision"]["outcome"] if a["status"] in ("expired", "failed"): raise RuntimeError(f"approval {approval_id} {a['status']}") time.sleep(poll_s) raise TimeoutError(f"approval {approval_id} timed out") ``` ### OpenAI Agents SDK (Python) — tool guard ```python from agents import Agent, Runner, function_tool # check against OpenAI Agents SDK docs for your version from approv import require_approval @function_tool def refund_customer(customer_id: str, amount: float) -> str: """Refund a customer. Anything over $100 is confirmed by a human first.""" if amount > 100: outcome = require_approval(action=f"Refund ${amount:,.2f} to customer {customer_id}", amount=amount, currency="USD", context={"tool": "refund_customer", "customer_id": customer_id}) if outcome != "approved": return "A human rejected this refund. Do not retry." billing.refund(customer_id, amount) return f"Refunded ${amount:,.2f} to {customer_id}." agent = Agent(name="Support", instructions="Resolve tickets. Issue refunds only through refund_customer.", tools=[refund_customer]) result = Runner.run_sync(agent, "Customer 8831 wants a $4,200 refund for order 5512.") ``` ### LangGraph (Python) — gate node ```python from typing import Literal, Optional, TypedDict from langgraph.graph import END, START, StateGraph # check against LangGraph docs for your version from approv import require_approval class State(TypedDict): customer_id: str; amount: float; outcome: Optional[Literal["approved", "rejected"]] def human_gate(state: State) -> dict: return {"outcome": require_approval(action=f"Refund ${state['amount']:,.2f} to customer {state['customer_id']}", amount=state["amount"], currency="USD")} def do_refund(state: State) -> dict: billing.refund(state["customer_id"], state["amount"]); return {} graph = StateGraph(State) graph.add_node("human_gate", human_gate); graph.add_node("do_refund", do_refund) graph.add_edge(START, "human_gate") graph.add_conditional_edges("human_gate", lambda s: "do_refund" if s["outcome"] == "approved" else "stop", {"do_refund": "do_refund", "stop": END}) graph.add_edge("do_refund", END) app = graph.compile() # Non-blocking variant for long waits: create with callback_url, interrupt({"approval_id": id}) with a checkpointer, # resume from the webhook handler with app.invoke(Command(resume=event["outcome"]), config). check against LangGraph docs for your version ``` ### Vercel AI SDK (TypeScript) — execute wrapper ```ts import { generateText, tool } from "ai"; // check against Vercel AI SDK docs for your version import { z } from "zod"; import { requireApproval } from "./approv"; function withApproval(describe: (a: A) => string, execute: (a: A) => Promise) { return async (args: A): Promise => { const outcome = await requireApproval({ action: describe(args), amount: args.amount, context: { args } }); return outcome === "approved" ? execute(args) : "A human rejected this action. Do not retry."; }; } const refundCustomer = tool({ description: "Refund a customer. A human confirms before money moves.", inputSchema: z.object({ customerId: z.string(), amount: z.number() }), // AI SDK 4.x: `parameters` execute: withApproval((a) => `Refund $${a.amount} to customer ${a.customerId}`, async (a) => billing.refund(a.customerId, a.amount)), }); const { text } = await generateText({ model, tools: { refundCustomer }, prompt: "Customer 8831 wants a $4,200 refund." }); ``` ### CrewAI (Python) — tool ```python from crewai import Agent, Crew, Task from crewai.tools import tool # check against CrewAI docs for your version from approv import require_approval @tool("refund_customer") def refund_customer(customer_id: str, amount: float) -> str: """Refund a customer. Anything over $100 is confirmed by a human first.""" if amount > 100 and require_approval(action=f"Refund ${amount:,.2f} to customer {customer_id}", amount=amount, currency="USD") != "approved": return "A human rejected this refund. Do not retry." billing.refund(customer_id, amount) return f"Refunded ${amount:,.2f} to {customer_id}." support = Agent(role="Support agent", goal="Resolve tickets", backstory="…", tools=[refund_customer]) Crew(agents=[support], tasks=[Task(description="Customer 8831 wants a $4,200 refund.", expected_output="Resolution", agent=support)]).kickoff() ``` ### MCP An MCP server (a `request_approval` tool for Claude, Cursor or any MCP client) is on the roadmap. Until then, call `requireApproval` inside your own MCP tool handler before the side effect and return a rejection as the tool's text result. ## Teams, roles and named API keys (Phase C) - Roles: `owner` (everything incl. billing, ownership transfer, deleting the workspace), `admin` (approvers, API keys, webhooks, members — cannot grant or remove owner), `member` (view only). Role failures are `403 forbidden`. - Team routes (user session): `GET /account/members` → `{ me: { id, role }, members: [...] }`; `POST /account/members/invite` `{ email, role: admin | member }` → 201 `{ member, invite_url }` (409 `conflict` for an active member); `PATCH /account/members/:id` `{ role }` (409 `last_owner` when demoting the only owner); `DELETE /account/members/:id` (409 `last_owner`); `GET /account/invites/:token` (410 `invite_expired`); `POST /account/invites/:token/accept` (403 `invite_mismatch` when the signed-in email differs, 409 `conflict` "Leave your current workspace first"). Invite links are `/invite/`, valid 7 days. `POST /account/bootstrap` answers 409 `pending_invite` (`details.invite_token`) for a user with no workspace but an open invite, and returns `role`. - API keys (user session, owner or admin): `GET /account/keys`, `POST /account/keys` `{ name (1..60), scopes ⊆ [approvals:write, approvals:read], expires_in_days (1..365) | null }` → 201 `{ key, api_key }` (full key once; max 20 active → 409 `conflict`), `DELETE /account/keys/:id`. A key used outside its scope gets `403 insufficient_scope`; expired or revoked keys get 401. Legacy `POST /account/rotate-key` revokes every key and mints a `Default` one. ## Links - Docs: https://ai-approvel.com/docs - OpenAPI: https://ai-approvel.com/openapi.json - Offline verifier: https://ai-approvel.com/verify-audit.mjs - Pricing: https://ai-approvel.com/pricing · Changelog: https://ai-approvel.com/changelog · Security: https://ai-approvel.com/security