Skip to content

API reference

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://<project>.supabase.co/functions/v1/approvals — your Supabase functions host plus the approvals function. Machine-readable versions: /openapi.json (OpenAPI 3.1) and /llms.txt (key facts and links for LLMs and agents; the whole reference as text at /llms-full.txt).

Try it

One request, one human. Your API key is shown in Settings → API key — the full value is visible once, when you create or rotate it, so copy it then. Pick an approver who has confirmed their phone, and run:

bash
# 1. Settings → API key shows your key (apk_live_…; the full value is visible once, when you create or rotate it).
# 2. Approvers → copy the id (a UUID) of someone who has confirmed their phone.
export APPROV_API_BASE="https://<project>.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 }
# Their phone buzzes within seconds. Reply there, then read the decision with GET /:id.
Nothing in this call is irreversible: an approval is a question, not an action. The id you get back is the handle for everything else on this page.

Quickstart

Two calls. Create an approval — AI Approvel messages your approver on WhatsApp or SMS and returns immediately with an id — then poll that id (or receive a webhook) to learn the decision, and only then run the risky step.

1. Create

Request

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
  }'

Response

http
HTTP/1.1 202 Accepted

{
  "id": "ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f",
  "status": "pending",
  "expires_at": "2026-10-01T12:30:02.000Z",
  "replayed": false
}

2. Poll

Request

bash
# every few seconds, until status is
# decided, expired or failed
curl "$APPROV_API_BASE/ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f" \
  -H "Authorization: Bearer $APPROV_KEY"

Response

http
HTTP/1.1 200 OK

{
  "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"
  },
  "…": "…"
}

decision is null until the approver replies; branch on decision.outcome (approved | rejected). Ready-made versions of this loop for your framework are under Recipes.

Authentication

Send your project API key as a Bearer token on every request. Create and rotate keys in Settings. Keys look like apk_live_<8 hex>_<secret>; the part before the second underscore (apk_live_1a2b3c4d) is a non-secret identifier the dashboard shows so you can tell keys apart, everything after it is the secret. The full key is shown once; keep it server-side and never ship it in a browser or app bundle. A missing, malformed, rotated or unknown key returns 401 unauthorized.

http
Authorization: Bearer apk_live_1a2b3c4d_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Create an approval

POST/

Returns 202 with an id while delivery happens in the background. Send an Idempotency-Key header so a retry can never ask twice — see Idempotency. The body is validated strictly: unknown fields are rejected with 400 validation_error and details.fieldErrors names the offenders. Only approvers who have confirmed their phone number (opted in) can receive requests; otherwise you get 400 validation_error with details.approver_id.

FieldTypeNotes
approver_idstring (uuid)Required. Who approves — an id from the Approvers page.
actionstringRequired. 1–500 characters. The text the approver sees.
amountnumberOptional. Money at stake; finite and ≥ 0.
currencystringOptional. ISO 4217, exactly three letters (upper-cased for you). Send it whenever amount is set.
contextobjectOptional. Arbitrary JSON stored with the approval and written into the approval.requested audit event.
params_hashstringOptional, 1–128 chars. Hash of the exact parameters this approval is bound to; returned on GET /:id so you can refuse to run if they changed.
callback_urlstringOptional. https:// on a public host. Receives the signed decision webhook.
localeen | arOptional. Language of the message. Default en.
timeout_secondsintegerOptional, > 0. How long the approver has to reply. Default 3600; capped at 86400. expired means not approved.
metadataobjectOptional. Arbitrary JSON written into the approval.requested audit event.

Request

json
POST /
Idempotency-Key: refund-8831-attempt-1

{
  "approver_id": "bb07d4a7-22f6-4188-a139-66f16dc2b6e8",
  "action": "Refund $4,200 to customer #8831",
  "amount": 4200,
  "currency": "USD",
  "context": { "ticket": "ZD-4411", "source": "support-agent" },
  "params_hash": "c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00",
  "callback_url": "https://agent.example.com/hooks/approv",
  "locale": "en",
  "timeout_seconds": 1800,
  "metadata": { "run_id": "run_42" }
}

Response

http
HTTP/1.1 202 Accepted

{
  "id": "ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f",
  "status": "pending",
  "expires_at": "2026-10-01T12:30:02.000Z",
  "replayed": false
}

# Same Idempotency-Key again → the same body
# with "replayed": true. Nobody is messaged twice.

List approvals

GET/?status=&q=&cursor=&limit=50

Returns { count, approvals, next_cursor, has_more }— the project's approvals, newest first, as summary rows with the decision included ({ outcome, decided_at, decided_via } or null; fetch GET /:id for decided_by, params_hash and locale). Filtering and paging happen server-side. Reads are not rate-limited and never count against your quota.

FieldTypeNotes
statusstringOptional. pending, delivered, decided, expired or failed — or approved / rejected, meaning decided with that outcome.
qstringOptional, ≤ 200 chars. Case-insensitive substring of action. %, _ and \ match literally.
limitintegerOptional. 1–200, default 50.
cursorstringOptional. The previous page's next_cursor. Opaque (encodes created_at + id); anything else is a 400 validation_error. Keep status and q the same across pages.

Request

bash
# Decided-and-approved refunds, newest first, 50 per page
curl "$APPROV_API_BASE/?status=approved&q=refund&limit=50" \
  -H "Authorization: Bearer $APPROV_KEY"

# Next page: pass next_cursor back as ?cursor= (same filters)
curl "$APPROV_API_BASE/?status=approved&q=refund&cursor=WyIyMDI2LTEwLTAxVDEyOjAwOjAyLjAwMFoiLCJmZjViYjNiZS0uLi4iXQ" \
  -H "Authorization: Bearer $APPROV_KEY"

Response

json
{
  "count": 2,
  "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" }
    },
    {
      "id": "3c0d55a9-e7f1-4b2a-9d6e-0f1a2b3c4d5e",
      "status": "decided",
      "action": "Refund $180 to customer #8714",
      "amount": 180,
      "currency": "USD",
      "channel_used": "sms",
      "created_at": "2026-09-30T09:10:41.000Z",
      "expires_at": "2026-09-30T10:10:41.000Z",
      "decision": { "outcome": "approved", "decided_at": "2026-09-30T09:12:03.000Z", "decided_via": "sms_reply" }
    }
  ],
  "next_cursor": "WyIyMDI2LTA5LTMwVDA5OjEwOjQxLjAwMFoiLCIzYzBkNTVhOS0uLi4iXQ",
  "has_more": true
}

# "decision" is null until the approver replies.
# Keep calling with ?cursor=next_cursor until
# has_more is false (next_cursor is then null).

The dashboard's Approvals page uses exactly these parameters: the status segments map to status (Approved / Rejected to the outcome aliases), the search box to q, and “Load more” follows next_cursor. Only the date presets are applied in the browser. See Status lifecycle for what each status means.

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.5s. Stop polling once status is terminal (decided, expired or failed).

Request

bash
curl "$APPROV_API_BASE/ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f" \
  -H "Authorization: Bearer $APPROV_KEY"

# While the approver has not replied:
# { "status": "delivered", "decision": null, … }

# decided_via is one of whatsapp_button,
# whatsapp_text, sms_reply, web_link, console, api.
# channel_used is whatsapp, sms or console.

Response

json
{
  "id": "ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f",
  "status": "decided",
  "action": "Refund $4,200 to customer #8831",
  "amount": 4200,
  "currency": "USD",
  "params_hash": "c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00",
  "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"
  }
}

Status lifecycle

Every approval walks the same small state machine. status tells you where it is; decision is only ever set in decided. The same transitions appear as events in the audit trail.

Approval status lifecyclepending moves to delivered once the message reaches the approver, then to decided when they reply. From delivered it can instead expire when the window lapses without a reply. From pending it can fail when the message cannot be delivered. decided, expired and failed are terminal.sentrepliedno reply in timecould not deliverpendingdelivereddecidedexpiredfailed
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.
FieldTypeNotes
pendinginitialCreated; the message is queued. decision is null.
deliveredwaitingThe message was handed to the carrier for the approver (channel_used is whatsapp or sms).
decidedterminalThe approver replied. decision.outcome is approved or rejected; the webhook fires.
expiredterminaltimeout_seconds (default 3600) lapsed without a reply. A late reply is refused — expired never flips back.
failedterminalThe 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 public key and the exact formula so anyone can verify it independently — you don't have to trust us.

Request

bash
curl "$APPROV_API_BASE/ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f/audit" \
  -H "Authorization: Bearer $APPROV_KEY"

# Event types: approval.requested, message.queued,
# message.sent, message.failed, approval.decided,
# approval.expired, webhook.delivered, webhook.failed

Response

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", "…": "…" },
      "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" }
  ]
}

Verify the audit trail

Each event is signed with Ed25519 and hash-chained. You don't have to trust us — recompute it with the public key in the response. Three checks, in order:

  1. Chain: seq runs from 0 and every event's prev_hash equals the previous event's hash; the first event chains to the genesis hash, 64 zeros.
  2. Hash: recompute hash with algorithm.hash_formula — sha256( prev_hash | event_type | canonical_json(payload) | created_at ). The | is notation: the four strings are joined with algorithm.field_separator, the single character U+241F (␟, SYMBOL FOR UNIT SEPARATOR), which cannot occur in JSON or a timestamp. canonical_json is JSON with keys sorted recursively and no whitespace ({} when there is no payload); the 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 it with public_key.key (spki-der-base64) and pin the key by public_key.key_id (also on every event as public_key_id) so a rotated key can't silently re-sign history.
node
import { createHash, createPublicKey, verify } from "node:crypto";

const BASE = process.env.APPROV_API_BASE; // https://<project>.supabase.co/functions/v1/approvals
const res = await fetch(`${BASE}/${approvalId}/audit`, {
  headers: { Authorization: `Bearer ${process.env.APPROV_KEY}` },
});
const trail = await res.json();

// JSON with sorted keys and no whitespace, recursively.
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);

// 1. Load the Ed25519 public key (public_key.format === "spki-der-base64").
const publicKey = createPublicKey({
  key: Buffer.from(trail.public_key.key, "base64"),
  format: "der",
  type: "spki",
});

// 2. Walk the chain. trail.algorithm.hash_formula is the source of truth:
//    sha256( prev_hash | event_type | canonical_json(payload) | created_at )
//    where "|" means "joined with algorithm.field_separator" = U+241F (␟),
//    and seq 0 chains to the genesis hash (64 zeros).
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}`);

  // 3. The signature is base64 Ed25519 over the UTF-8 bytes of the hex hash string.
  const ok = verify(null, Buffer.from(ev.hash, "utf8"), publicKey, Buffer.from(ev.signature, "base64"));
  if (!ok) throw new Error(`bad signature at seq ${ev.seq}`);

  prev = ev.hash;
}
console.log(`verified ${trail.events.length} events with key ${trail.public_key.key_id}`);

The hash_formula and field_separator strings returned by the API are authoritative; if they ever change, follow the response rather than this page. Download the JSON from the Audit drawer in the dashboard to keep a copy that verifies without us — Verify offline has a ready-made script for it.

Verify offline

A dependency-free script runs the three checks above on the file the dashboard's Download trail button saves — the exact body of GET /:id/audit. Node 18 or newer, nothing to install, no network: it never talks to AI Approvel.

bash
# 1. Dashboard → open the approval → Audit → "Download trail"
#    (saves ai-approvel-audit-<id>.json — the exact body of GET /:id/audit)
# 2. Fetch the verifier once. Plain Node 18+, nothing to npm install.
curl -fsSLO https://ai-approvel.com/verify-audit.mjs

# 3. Verify
node verify-audit.mjs ai-approvel-audit-ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f.json

Valid trail · exit 0

output
✓ 4 events verified in ai-approvel-audit-ff5bb3be….json · sha256 chain + ed25519 signatures · key approv-2026-10
  formula  sha256( prev_hash | event_type | canonical_json(payload) | created_at )
  fields   prev_hash ␟ event_type ␟ canonical_json(payload) ␟ created_at  (joined with U+241F (␟); prev_hash of seq 0 is 64 zeros; payload {} when absent)
  signing  base64 ed25519 over the utf-8 bytes of the hex hash
$ echo $?
0

Tampered trail · exit 1

output
✗ 3 problems in ai-approvel-audit-ff5bb3be….json · 4 events · key approv-2026-10
  seq 2     hash      recomputed 9d42c0a1b7e3… ≠ stored 51728ba464f7…
  seq 2     signature signature 5oM1pTMpN+ao… does not verify with key approv-2026-10
  seq 3     chain     prev_hash 51728ba464f7… ≠ hash of seq 2 (9d42c0a1b7e3…)
  formula  sha256( prev_hash | event_type | canonical_json(payload) | created_at )
  fields   prev_hash ␟ event_type ␟ canonical_json(payload) ␟ created_at  (…)
  signing  base64 ed25519 over the utf-8 bytes of the hex hash
  warning  the server marked this trail valid when it was downloaded; it does not verify now — the file was altered after download, or the key/formula changed
$ echo $?
1

Exit code 0 means every check passed; 1 lists each failing event by seq and check (chain, hash or signature); 2is a usage error or unreadable file — so it drops straight into CI or a nightly job. The report always prints the formula, the separator and how the signature was checked, so if the API's hash_formula or field_separatorever changes you see the mismatch instead of guessing. The script is tested against a trail produced by the backend's own audit engine.

bash
node verify-audit.mjs trail.json --key-id approv-2026-10   # fail unless it was signed by the key you pinned
node verify-audit.mjs trail.json --json        # machine-readable result, e.g. for CI
NO_COLOR=1 node verify-audit.mjs trail.json    # or --no-color, for logs
cat trail.json | node verify-audit.mjs -       # read from stdin

# Or import it from your own tooling:
import { verifyTrail } from "./verify-audit.mjs";
const { valid, failures } = verifyTrail(JSON.parse(await readFile("trail.json", "utf8")));
Source: /verify-audit.mjs. It exports verifyTrail(), computeEventHash() and canonicalJson(), and only runs its CLI when executed directly, so you can import it into your own tooling.

Webhooks

Pass a callback_url when creating an approval. On decision, AI Approvel sends a signed POST so your agent can continue automatically — no polling required. The URL must be https:// and resolve to a public host; private, loopback and cloud-metadata addresses are rejected.

What we send

http
POST <your callback_url>
Content-Type: application/json
X-Approv-Event: approval.decided
X-Approv-Timestamp: 1790251451
X-Approv-Signature: 5f1d3c8e…   # hex HMAC-SHA256 of "<timestamp>.<raw body>"

{
  "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"
}

# Exactly these fields, in this order (you verify
# the raw bytes). params_hash, amount and currency
# are null when not supplied at creation. Fields are
# only ever appended, never changed.

What you return

http
HTTP/1.1 200 OK

# Any 2xx within 10 seconds acknowledges
# the delivery; the body is ignored.
#
# Non-2xx, a redirect or a timeout → retried from
# the queue (about a minute apart, up to 5 tries),
# each attempt recorded as webhook.delivered or
# webhook.failed in the audit trail.

The body carries the decision and what it was about — id, status, outcome, decided_via, decided_at, action, params_hash, approver_id, amount, currency — in a fixed field order, and X-Approv-Event names the event. Respond with any 2xx within 10 seconds. A non-2xx, a redirect or a timeout is retried from the queue (roughly a minute apart, up to five attempts), and every attempt is recorded in the audit trail as webhook.delivered or webhook.failed. Make your handler idempotent on id — a retry can deliver the same decision twice. Compare params_hash with the parameters you are about to act on before you act; GET /:id has the full record if you need more.

Webhook endpoints

Instead of a callback_url on every request, add up to ten endpoints under Webhooks in the dashboard. Every enabled endpoint subscribed to approval.decided receives each decision, signed with its own whsec_… secret (shown once when the endpoint is created, rotatable any time), with the same headers as above plus X-Approv-Delivery: <id>. The Deliveries tab keeps every attempt — request body, response status, error — filterable by endpoint, status and approval, and lets you replay one. The legacy callback_url delivery keeps working and is signed with the project secret.

FieldTypeNotes
GET /webhooks/endpoints→ 200{ endpoints: [{ id, url, description, events, enabled, secret_prefix, created_at, last_delivery_at, last_status }] }. Owner or admin.
POST /webhooks/endpoints→ 201Body { url, description?, events? } → { endpoint, secret }. https:// on a public host (the callback_url SSRF check). The secret is shown once. Max 10 → 409 conflict.
PATCH /webhooks/endpoints/:id→ 200Body { url?, description?, enabled?, events? } → { endpoint }. Disabled endpoints are skipped.
DELETE /webhooks/endpoints/:id→ 200{ deleted: true }. Past deliveries stay in the log.
POST /webhooks/endpoints/:id/rotate-secret→ 200{ secret } — the old secret stops verifying immediately.
POST /webhooks/endpoints/:id/test→ 200Sends a signed webhook.test event; returns { delivery }.
GET /webhooks/deliveries→ 200?endpoint_id=&approval_id=&status=&cursor=&limit= → { deliveries, next_cursor, has_more }, newest first, keyset-paginated like GET /. status ∈ pending | succeeded | failed.
GET /webhooks/deliveries/:id→ 200{ delivery } including request_body (the exact signed bytes) and response_status.
POST /webhooks/deliveries/:id/replay→ 200A new pending delivery with the same body, signed with the endpoint's current secret, enqueued with the usual retries.
These routes are built to the Phase C contract (x-approv-status: "phase-c" in /openapi.json) and are authenticated with your dashboard session, not an API key.

Verify a webhook

Always verify before acting. The signature is the lowercase hex HMAC-SHA256 of ${timestamp}.${rawBody} using your webhook signing secret. Compute it over the raw request bytes (not a re-serialised object), compare in constant time, and reject anything whose timestamp is more than 300 seconds from your clock. Each project has its own whsec_… signing secret: reveal and copy it under Settings → Webhooks (or GET /account/webhook-secret with your user session — see Dashboard & account endpoints). Rotating it (POST /account/webhook-secret/rotate) takes effect on the very next delivery and the old secret is not honoured in parallel, so deploy the new secret to your verifier first, then rotate.

Node

node
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

/**
 * rawBody MUST be the exact bytes we sent — read it before any JSON parser
 * touches the request (Express: app.post(path, express.raw({ type: "*/*" }), handler)).
 */
export function verifyApprovWebhook(rawBody, headers, secret) {
  const timestamp = headers["x-approv-timestamp"];
  const signature = headers["x-approv-signature"];
  if (!timestamp || !signature) return false;

  // 1. Reject stale or replayed deliveries.
  const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(ageSeconds) || ageSeconds > TOLERANCE_SECONDS) return false;

  // 2. Recompute hex HMAC-SHA256 over "${timestamp}.${rawBody}".
  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest("hex");

  // 3. Constant-time compare (lengths must match first or timingSafeEqual throws).
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(String(signature), "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

// Usage (Express):
// app.post("/hooks/approv", express.raw({ type: "*/*" }), (req, res) => {
//   if (!verifyApprovWebhook(req.body, req.headers, process.env.APPROV_WEBHOOK_SECRET)) {
//     return res.status(401).end();
//   }
//   const event = JSON.parse(req.body.toString("utf8"));
//   // event.id, event.outcome === "approved" | "rejected"
//   res.status(200).end();
// });

Python

python
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify_approv_webhook(raw_body: bytes, headers: dict, secret: str) -> bool:
    """raw_body must be the exact request bytes (Flask: request.get_data(); FastAPI: await request.body())."""
    timestamp = headers.get("X-Approv-Timestamp")
    signature = headers.get("X-Approv-Signature")
    if not timestamp or not signature:
        return False

    # 1. Reject stale or replayed deliveries.
    try:
        if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
            return False
    except ValueError:
        return False

    # 2. Recompute hex HMAC-SHA256 over f"{timestamp}.{raw_body}".
    expected = hmac.new(
        secret.encode("utf-8"),
        f"{timestamp}.".encode("utf-8") + raw_body,
        hashlib.sha256,
    ).hexdigest()

    # 3. Constant-time compare.
    return hmac.compare_digest(expected, signature.lower())


# Usage (Flask):
# @app.post("/hooks/approv")
# def approv_hook():
#     if not verify_approv_webhook(request.get_data(), request.headers, os.environ["APPROV_WEBHOOK_SECRET"]):
#         abort(401)
#     event = request.get_json()
#     # event["id"], event["outcome"] in ("approved", "rejected")
#     return "", 200

Idempotency

Send an Idempotency-Key header on every POST / — any string that is unique per approval you intend to create (your job id, for instance). A retry with the same key returns the original approval, still with 202, and "replayed": true. Nothing new is created, the approver is not messaged again, and the replay is not charged against your monthly quota.

http
POST /
Idempotency-Key: refund-8831-attempt-1
→ 202 { "id": "ff5bb3be-…", "status": "pending",   "replayed": false }

POST /   (same key, say after a network timeout)
Idempotency-Key: refund-8831-attempt-1
→ 202 { "id": "ff5bb3be-…", "status": "delivered", "replayed": true }
  • Keys are scoped to your project and never expire; two 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 and poll it rather than retrying blind.
  • The key is not echoed in responses or the webhook; put your own correlation id in context or metadata if you need it in the audit trail.

Rate limits & quotas

Two separate ceilings: a per-minute rate limit on requests, and a monthly quota on approvals created. Both come back as a stable error code you can branch on.

Rate limit

Each project may create 60 approvals per minute by default (fixed one-minute window, counted on POST / before validation; reads are not limited). Over the limit you get 429 rate_limited with a Retry-After header in seconds — back off for that long (or until the next minute boundary if the header is absent), then retry with the same Idempotency-Key. Need more? Contact us and we'll raise it for your project.

http
HTTP/1.1 429 Too Many Requests
Retry-After: 17

{ "error": { "code": "rate_limited", "message": "Rate limit of 60 approvals/minute exceeded. Try again in 17s.", "details": null } }

Monthly quota

Your plan includes a number of approvals per calendar month (Free: 100, Growth: 5,000, Scale: unlimited). Creating an approval past the quota returns 402 quota_exceeded with details: { plan, limit, used, period } (period is the UTC month, YYYY-MM). It is a billing condition, not a transient one — there is no Retry-After and retrying will not help; stop and upgrade. Only genuinely new, valid requests count — idempotent replays and rejected bodies do not. Usage resets on the 1st; see it and upgrade under Settings → Plan & billing. Reads (GET) never count against the quota.

json
{
  "error": {
    "code": "quota_exceeded",
    "message": "Monthly plan limit of 100 approvals reached on the Free plan. Upgrade your plan in the dashboard (Billing) to send more this month.",
    "details": { "plan": "free", "limit": 100, "used": 100, "period": "2026-10" }
  }
}

Errors

Errors return a JSON body with a stable code, a human message and details — the field-level validation errors ({ formErrors, fieldErrors }), { approver_id } for approver problems, otherwise null. Branch on the code, never on the message.

failed400 validation_error · 401 unauthorized · 402 quota_exceeded · 404 not_found · 429 rate_limited · 500 internal_error

With scoped keys and teams (Phase C) the dashboard routes add 403 forbidden (your role), 403 insufficient_scope (the key lacks approvals:write or approvals:read), 403 invite_mismatch, 410 invite_expired, 409 last_owner and 409 pending_invite — see Team, roles and invites.

json
{
  "error": {
    "code": "validation_error",
    "message": "Invalid request body.",
    "details": { "formErrors": [], "fieldErrors": { "action": ["String must contain at least 1 character(s)"] } }
  }
}

OpenAPI spec

The whole API is described in an OpenAPI 3.1 document at https://ai-approvel.com/openapi.json, including the approval.decided webhook. Use it to generate a client, mock the API in tests, or browse it with any OpenAPI viewer:

bash
npx @redocly/cli preview-docs https://ai-approvel.com/openapi.json

The spec was checked line by line against the backend source, API build 2026-10 included (list filters and cursors, decision on list rows, 402 quota_exceeded, Retry-After, per-project webhook secrets); nothing in it is inferred. The dashboard's account endpoints are summarised in its info.description. For LLMs and coding agents there is also /llms.txt, a plain-text summary with links.

Dashboard & account endpoints

The dashboard talks to a second edge function, /account, authenticated with the signed-in user's Supabase session (Authorization: Bearer <access token>), never with an API key. You will rarely call these yourself, but they explain what the dashboard shows and are listed here so nothing is a black box. Every error follows the same { error: { code, message, details } } shape as the approvals API.

FieldTypeNotes
POST /account/bootstrap→ 200Find-or-create your workspace. { project: { id, name, plan }, api_key, has_api_key, api_key_prefix, api_key_created_at, api_key_last_used_at }. api_key is the full secret only on the call that created it; afterwards it is null and the dashboard shows the non-secret prefix. Last-used is refreshed at most every 5 minutes. Also provisions the webhook secret if missing.
POST /account/rotate-key→ 200Revokes every active key and mints a new one: { api_key, api_key_prefix, api_key_created_at }. At most 5 per minute per project, then 429 rate_limited with Retry-After.
GET /account/usage→ 200{ this_month, since } — approvals created this UTC calendar month.
GET /account/stats→ 200{ period_start, total_month, decided, approved, rejected, pending, expired, failed, median_time_to_decision_ms, approval_rate, last_7_days: [{ day, count }] }. Counts cover approvals created since period_start (start of the UTC month); pending includes delivered; approval_rate = approved ÷ (approved + rejected), null with no decisions; last_7_days has exactly 7 UTC-day entries, today included, oldest first. The Overview's Activity card reads this.
GET /account/approvers→ 200{ approvers: [{ id, name, phone_e164, opted_in, opt_in_token, locale }] }. locale (en | ar) is the language the approver is messaged in.
POST /account/approvers→ 201Body { name, phone_e164, locale? } (locale defaults to en). Creates a pending approver; share https://…/optin/<opt_in_token> with them.
DELETE /account/approvers/:id→ 200Removes an approver that has no approvals yet.
GET /account/webhook-secret→ 200{ webhook_secret: "whsec_…", created_at } — the project's signing secret, generated on first read. Retrievable any time (unlike the API key).
POST /account/webhook-secret/rotate→ 200A new { webhook_secret, created_at }. The old secret stops verifying immediately: update your verifier first, then rotate.
POST /account/delete→ 202Self-service erasure of the workspace, its keys, approvers, approvals, audit trail and the auth user. 409 active_subscription while a subscription is active, trialing or past_due — cancel it in Billing first.

Team, roles and invites

A workspace has owner, admin and member roles. Owners do everything, including billing, transferring ownership and deleting the workspace; admins manage approvers, API keys, webhooks and members (but cannot grant or remove owner); members can view approvals, approvers, stats and usage. Every route checks the role and answers 403 forbiddenotherwise. The dashboard's Team page drives these; invite links are /invite/<token> and work for 7 days, only for the invited address.

FieldTypeNotes
GET /account/members→ 200{ me: { id, role }, members: [{ id, user_id, email, role, status: active | invited, invited_at, accepted_at, last_seen_at }] }.
POST /account/members/invite→ 201Body { email, role: admin | member } → { member, invite_url }. Emails the link when configured. Re-inviting an invited address refreshes the token; inviting an active member → 409 conflict.
PATCH /account/members/:id→ 200Body { role } → { member }. Only an owner may grant or remove owner; demoting the only owner → 409 last_owner.
DELETE /account/members/:id→ 200{ deleted: true }. Removes a member or revokes an invite; a user may remove themselves. The only owner → 409 last_owner.
GET /account/invites/:token→ 200{ workspace: { id, name }, role, email, invited_by_email, expires_at }; 410 invite_expired, 404 unknown. Any signed-in user.
POST /account/invites/:token/accept→ 200{ project: { id, name }, role }. The signed-in email must match the invite (403 invite_mismatch). Already in another workspace → 409 conflict ("Leave your current workspace first").
POST /account/bootstrap→ 409pending_invite with details { invite_token, workspace, role, expires_at } when the user has no workspace but an open invite — the dashboard sends them to /invite/<token>. Successful bootstraps now include role.

Named API keys with scopes

Projects can hold up to 20 active keys, each with a name, a set of scopes and an optional expiry. approvals:write allows POST /; approvals:read allows the three GET routes. A key without the needed scope gets 403 insufficient_scope; an expired or revoked key gets 401. Manage them under API keys.

FieldTypeNotes
GET /account/keys→ 200{ keys: [{ id, name, prefix, scopes, created_at, created_by_email, last_used_at, expires_at, revoked_at }] }. Revoked keys stay for 30 days. Owner or admin.
POST /account/keys→ 201Body { name (1..60), scopes (non-empty subset), expires_in_days (1..365) | null } → { key, api_key }. The full key is shown once. More than 20 active → 409 conflict.
DELETE /account/keys/:id→ 200{ revoked: true, id }. Requests with the key get 401 immediately.
POST /account/rotate-key→ 200Legacy: revokes every active key and mints one Default key with both scopes (Settings → API key → Advanced).

Approver opt-in page

The invitation link your approver receives points at /optin/<token>; the token is the only credential. GET /optin/:token returns { approver_name, phone_e164, workspace, opted_in, locale }; POST /optin/:token with an optional { "locale": "en" | "ar" } body records consent and stores the language the approver read the page in, so their approval messages arrive in it. Re-posting is idempotent and can change the locale.

SDKs

Official SDKs for TypeScript and Python are in progress and will ship on npm and PyPI. They will expose the same requireApproval({ action, amount }) call as the helper under Recipes, so code written against it today ports with an import change. Until then the API is small enough to call with fetch or requests, or generate a typed client from the spec:

bash
# TypeScript (fetch)
npx @openapitools/openapi-generator-cli generate \
  -i https://ai-approvel.com/openapi.json -g typescript-fetch -o ./approv-client

# Python
npx @openapitools/openapi-generator-cli generate \
  -i https://ai-approvel.com/openapi.json -g python -o ./approv-client

Recipes

Minimal, copy-pastable integrations. Each one does the same thing: right before a risky tool runs, POST / an approval, wait for GET /:id to report a decision, and only execute the side effect on approved. A rejection is returned to the model as text so it can explain itself instead of retrying.

  • All framework recipes import the requireApproval helper below; drop it in as approv.ts / approv.py first.
  • Polling is the simplest. For waits longer than a request timeout (serverless, queues) pass a callback_url, persist the id, and continue from your webhook handler instead.
  • Framework calls that may differ between versions are marked check against <framework> docs for your version; the AI Approvel calls are exact.

requireApproval helper — TypeScript and Python

Zero dependencies. Creates the approval, polls every 2.5 seconds, and resolves to "approved" or "rejected"; it throws if the approval expired, failed to deliver, or the timeout passed, so a tool can never run on a non-answer. The upcoming SDKs mirror this signature.

TypeScript · approv.ts

typescript
// approv.ts — no dependencies. Node 18+, Bun, Deno, Workers.
const BASE = process.env.APPROV_API_BASE!;        // https://<project>.supabase.co/functions/v1/approvals
const KEY = process.env.APPROV_KEY!;              // Settings → API key
const APPROVER = process.env.APPROV_APPROVER_ID!; // approver id (UUID) from the Approvers page

export type Outcome = "approved" | "rejected";
type Status = "pending" | "delivered" | "decided" | "expired" | "failed";
type Approval = { id: string; status: Status; decision: { outcome: Outcome } | null };

async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
  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}`); // see Errors
  return body as T;
}

/**
 * Ask a human before a risky side effect. Resolves "approved" | "rejected";
 * throws if the approval expired, failed to deliver, or timeoutMs passed.
 */
export async function requireApproval(opts: {
  action: string;                    // what the approver reads (1–500 chars)
  amount?: number;
  currency?: string;                 // ISO 4217; defaults to USD when amount is set
  context?: Record<string, unknown>; // stored and echoed in the webhook, never shown on the phone
  approverId?: string;
  timeoutMs?: number;                // default 15 min
  pollMs?: number;                   // default 2.5 s (what the dashboard uses)
}): Promise<Outcome> {
  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<Approval>(`/${id}`);
    if (a.decision) return a.decision.outcome; // status === "decided"
    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`);
}

Python · approv.py

python
# approv.py — standard library only. Python 3.9+.
import json
import os
import time
import urllib.error
import urllib.request

BASE = os.environ["APPROV_API_BASE"]         # https://<project>.supabase.co/functions/v1/approvals
KEY = os.environ["APPROV_KEY"]               # Settings → API key
APPROVER = os.environ["APPROV_APPROVER_ID"]  # approver id (UUID) from the Approvers page


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"]  # see Errors
        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):
    """Ask a human before a risky side effect. Returns "approved" or "rejected";
    raises if the approval expired, failed to deliver, or timeout_s passed."""
    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"):  # status == "decided"
            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

The guard lives inside the tool, so the model can call refund_customer freely and the human only hears about amounts that matter.

python
# pip install openai-agents
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)  # the risky side effect, only after approval
    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.")
print(result.final_output)

LangGraph (Python) — interrupt-style gate node

A human_gate node blocks the run until the approver replies and writes the outcome into state; a conditional edge then either continues to the side effect or ends the graph.

python
# pip install langgraph
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:
    """Blocks this run until a human answers on WhatsApp/SMS, then records the outcome."""
    outcome = require_approval(
        action=f"Refund ${state['amount']:,.2f} to customer {state['customer_id']}",
        amount=state["amount"],
        currency="USD",
        context={"graph": "refund", "customer_id": state["customer_id"]},
    )
    return {"outcome": outcome}


def do_refund(state: State) -> dict:
    billing.refund(state["customer_id"], state["amount"])  # only reachable when approved
    return {}


def after_gate(state: State) -> str:
    return "do_refund" if state["outcome"] == "approved" else "stop"


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", after_gate, {"do_refund": "do_refund", "stop": END})
graph.add_edge("do_refund", END)
app = graph.compile()

app.invoke({"customer_id": "8831", "amount": 4200, "outcome": None})

# Long waits? Make the gate non-blocking instead: POST the approval with a callback_url,
# then interrupt({"approval_id": id}) inside the node (needs a checkpointer) and resume
# from your webhook handler with app.invoke(Command(resume=event["outcome"]), config).
# check against LangGraph docs for your version

Vercel AI SDK (TypeScript) — execute wrapper

withApproval wraps any tool's execute: it turns the arguments into the sentence the approver reads, waits for the decision, and only then calls the original function.

typescript
// npm i ai zod
import { generateText, tool } from "ai"; // check against Vercel AI SDK docs for your version
import { z } from "zod";
import { requireApproval, type Outcome } from "./approv";

/** Wrap a tool's execute so a human signs off before it runs. A rejection goes back to the model as text. */
function withApproval<A extends { amount?: number }, R>(
  describe: (args: A) => string,
  execute: (args: A) => Promise<R>,
) {
  return async (args: A): Promise<R | string> => {
    const outcome: Outcome = await requireApproval({ action: describe(args), amount: args.amount, context: { args } });
    if (outcome !== "approved") return "A human rejected this action. Do not retry.";
    return execute(args);
  };
}

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 calls this `parameters`
  execute: withApproval(
    (a) => `Refund $${a.amount} to customer ${a.customerId}`,
    async (a) => billing.refund(a.customerId, a.amount),
  ),
});

const { text } = await generateText({
  model, // e.g. openai("gpt-4o") — check against Vercel AI SDK docs for your version
  tools: { refundCustomer },
  prompt: "Customer 8831 wants a $4,200 refund for order 5512.",
  // To let the model answer after the tool ran, add stopWhen (5.x) / maxSteps (4.x).
});

CrewAI (Python) — tool

Same guard as the OpenAI recipe, as a CrewAI tool any agent in the crew can be given.

python
# pip install crewai
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:
        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)  # the risky side effect, only after approval
    return f"Refunded ${amount:,.2f} to {customer_id}."


support = Agent(role="Support agent", goal="Resolve tickets", backstory="…", tools=[refund_customer])
ticket = Task(
    description="Customer 8831 wants a $4,200 refund for order 5512.",
    expected_output="What was done and why",
    agent=support,
)
Crew(agents=[support], tasks=[ticket]).kickoff()

MCP (Model Context Protocol)

An MCP server is on the roadmap: a request_approval tool that Claude, Cursor or any MCP client can call without you writing glue. Until it ships, the pattern is the same as above — call requireApprovalinside your own MCP tool handler before the side effect, and return the rejection as the tool's text result.