Verify quickstart — allow, deny, revoke
By the end of this page your own sample API will have executed an agent’s authorized action, refused an action that was never delegated, refused a proof the agent already held after you revoked it, and you will read all three decisions back from your audit history.
One sentence to keep in mind throughout: Ratify returns the decision;
your application enforces it. The verify call tells you allow or
deny with a stable reason. Whether the protected code runs is your
middleware’s choice, and you are about to run that middleware.
export RATIFY_API=https://api-dev.identities.aiexport JAR=$(mktemp) # session cookie jar — holds credentialschmod 600 "$JAR"1. Sign up
Section titled “1. Sign up”Authentication is email-code based and cookie-only; tokens never appear in response bodies.
curl -fsS -c "$JAR" -X POST $RATIFY_API/v1/auth/email/send-code \ -H "Content-Type: application/json" \# {"resent":false,"status":"code_sent"}Grab the 6-digit code from your inbox, then:
curl -fsS -b "$JAR" -c "$JAR" -X POST $RATIFY_API/v1/auth/email/verify-code \ -H "Content-Type: application/json" \State-changing account calls need the CSRF double-submit header. Export it once from the jar, and re-export it after any call that refreshes the session:
export CSRF=$(awk '/ratify_csrf/ {print $NF}' "$JAR")test -n "$CSRF" || { echo "FAILED: no CSRF cookie — repeat the verify-code step" >&2; false; }Complete onboarding. individual gives you a personal workspace with
alpha credits and no monthly minimum:
curl -fsS -b "$JAR" -X POST $RATIFY_API/v1/auth/onboarding/profile \ -H "Content-Type: application/json" -H "X-Ratify-CSRF: $CSRF" \ -d '{"first_name": "Ada", "last_name": "Lovelace"}'
curl -fsS -b "$JAR" -X POST $RATIFY_API/v1/auth/onboarding/account-type \ -H "Content-Type: application/json" -H "X-Ratify-CSRF: $CSRF" \ -d '{"account_type": "individual"}'# {"onboarding_step":"complete"}Behind that second call, Ratify created your cryptographic root identity (a hybrid Ed25519 + ML-DSA-65 keypair) and protected its private key for the managed alpha flow.
2. Register a platform and pick your scopes
Section titled “2. Register a platform and pick your scopes”A platform is the thing you are building. Registering one issues the API
key your backend will use. default_allowed_scopes is the ceiling on what
any agent on this platform may ever be delegated.
Scopes come from a fixed vocabulary. Ratify validates every scope
against the canonical vocabulary (scopes like data:read and
payments:send), or your own namespace under the custom: prefix, such
as custom:ticket:read. Anything else is rejected with invalid_scopes.
This quickstart models a ticket desk, so it uses two custom scopes. See
Concepts → Scopes for the full vocabulary, the
wildcard rules, and which scopes are marked sensitive.
RESP=$(curl -fsS -b "$JAR" -X POST $RATIFY_API/v1/ratify/platforms/personal \ -H "Content-Type: application/json" -H "X-Ratify-CSRF: $CSRF" \ -d '{ "name": "Ticket Desk", "surface_type": "agentic_api", "default_allowed_scopes": ["custom:ticket:read", "custom:ticket:refund"] }')echo "$RESP" | jq .{ "api_key": "rat_live_...", "api_key_prefix": "rat_live_...", "connection_id": "ca750a39-993f-4d23-abac-c67e6be86162", "description": "", "expires_at": "2027-07-21T07:38:04.398366-07:00", "name": "Ticket Desk", "platform_id": "fd06b9d8-f976-4163-abed-6c08060db073", "publish_state": "draft", "signing_secret": "rat_psec_...", "surface_type": "agentic_api", "webhook_signing_secret": "rat_whsec_..."}The api_key and both secrets are shown exactly once. This quickstart
uses only api_key; in a real integration, store the signing_secret and
webhook_signing_secret now, because they cannot be shown again.
Capture what the rest of the quickstart needs. These captures use jq -e,
so a missing field makes the assignment itself fail with a nonzero status;
if you see a FAILED line, stop and inspect $RESP before continuing:
RATIFY_API_KEY=$(echo "$RESP" | jq -er '.api_key | select(type == "string" and length > 0)') \ || { echo "FAILED: no api_key in \$RESP — stop here" >&2; false; }CONNECTION_ID=$(echo "$RESP" | jq -er '.connection_id | select(type == "string" and length > 0)') \ || { echo "FAILED: no connection_id in \$RESP — stop here" >&2; false; }export RATIFY_API_KEY CONNECTION_ID3. Create an agent
Section titled “3. Create an agent”Custodial mode: Ratify generates and holds the agent’s hybrid keypair, so you can run this whole page from curl. (Holding your own keys? Use the SDKs and build proof bundles yourself.)
AGENT_ID=$(curl -fsS -X POST \ $RATIFY_API/v1/ratify/connections/$CONNECTION_ID/agents \ -H "X-Ratify-API-Key: $RATIFY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Support Agent", "agent_type": "api_agent"}' \ | jq -er '.agent_id | select(type == "string" and length > 0)') \ || { echo "FAILED: agent creation — stop here" >&2; false; }export AGENT_IDecho $AGENT_IDThe response is flat JSON with agent_id, name, agent_type,
custody_mode, and public_key_json.
4. Delegate less than was asked
Section titled “4. Delegate less than was asked”The delegation request asks for the connection’s full scope ceiling (both ticket scopes). This is where the human decides, and the human can grant less. Approve only the read scope:
REQUEST_ID=$(curl -fsS -b "$JAR" -X POST \ $RATIFY_API/v1/ratify/delegations/self-request \ -H "Content-Type: application/json" -H "X-Ratify-CSRF: $CSRF" \ -d "{\"connection_id\": \"$CONNECTION_ID\"}" \ | jq -er '.request_id | select(type == "string" and length > 0)') \ || { echo "FAILED: delegation request — stop here" >&2; false; }export REQUEST_ID
APPROVE=$(curl -fsS -b "$JAR" -X POST \ $RATIFY_API/v1/ratify/delegations/requests/$REQUEST_ID/approve \ -H "Content-Type: application/json" -H "X-Ratify-CSRF: $CSRF" \ -d '{"approved_scope": ["custom:ticket:read"]}')echo "$APPROVE" | jq .CERT_ID=$(echo "$APPROVE" | jq -er '.cert_id | select(type == "string" and length > 0)') \ || { echo "FAILED: approval — stop here and inspect \$APPROVE" >&2; false; }export CERT_ID{ "approved_scope": ["custom:ticket:read"], "cert_id": "cert-aea5c8d8-6108-430d-aa2d-4e18fd557195", "expires_at": null, "issued_at": "2026-07-21T14:45:12.000000Z", "status": "approved"}A signed delegation certificate now exists saying you authorized this
agent for custom:ticket:read, and nothing else. Keep CERT_ID; it is
your revocation handle in step 6.
5. Enforce decisions in your own API
Section titled “5. Enforce decisions in your own API”The sample service is a complete, runnable ticket API whose middleware calls Ratify before every protected action. Download it and start it in the background of this same terminal (so it inherits your API key), with a cleanup function armed for when you close the shell:
curl -fsSO https://docs.identities.ai/examples/ticket-desk.mjsnode ticket-desk.mjs > ticket-desk.log 2>&1 &export TICKET_DESK_PID=$!cleanup() { if test -n "${TICKET_DESK_PID:-}"; then kill "$TICKET_DESK_PID" 2>/dev/null || true fi rm -f "${JAR:-}" held-proof.json ticket-desk.mjs ticket-desk.log}trap cleanup EXITsleep 1 && cat ticket-desk.log# ticket desk on 127.0.0.1:8787The part worth reading is requireScope, the enforcement boundary. The
mapping is exhaustive, and only an explicit allow reaches the handler.
Operational failures are never dressed up as authority decisions: a
Ratify 400 means the caller demonstrated no authority (403), but a bad
service key, exhausted credits, rate limiting, a verifier 5xx, a timeout,
an unparseable response, or an unrecognized decision all mean no decision
exists, so the sample returns 503 and refuses to act. The code below is
the downloadable asset itself, rendered from the same file you just
fetched, so it cannot drift:
// ticket-desk.mjs — a minimal ticket API that enforces Ratify decisions.// Ratify returns the decision; THIS middleware decides whether the action// executes. Companion to the Verify quickstart:// https://docs.identities.ai/verify/quickstart///// Run: RATIFY_API_KEY=rat_live_... node ticket-desk.mjsimport http from "node:http";
const RATIFY_API = process.env.RATIFY_API ?? "https://api-dev.identities.ai";const API_KEY = process.env.RATIFY_API_KEY;if (!API_KEY) { console.error("set RATIFY_API_KEY"); process.exit(1); }
// The sample application's fail-closed budget for the authorization call.// Not a latency promise: if Verify has not answered by then, the action is// refused rather than left hanging.const VERIFY_TIMEOUT_MS = Number(process.env.VERIFY_TIMEOUT_MS ?? 10000);const PORT = Number(process.env.PORT ?? 8787);
// The "database": tickets, and a refund ledger we can inspect to PROVE a// denied refund never executed.const tickets = [{ id: 1, subject: "Printer on fire", status: "open" }];let refundsProcessed = 0;
// requireScope: verify the caller's proof bundle for a scope, then map the// outcome exhaustively. Only an explicit "allow" reaches the handler.//// Ratify 200, decision allow -> execute// Ratify 200, decision deny -> 403 (denied on the merits)// Ratify 200, decision indeterminate -> 503 (Ratify failed closed)// Ratify 200, unrecognized decision -> 503 (never guess)// Ratify 200, unparseable body -> 503// Ratify 400 (bad/spent proof) -> 403 (no authority demonstrated)// Ratify 401/402/429/5xx/other -> 503 (operational failure, not a// decision about this agent)// timeout / network failure -> 503function requireScope(scope, handler) { return async (req, res, body) => { let r; try { r = await fetch(`${RATIFY_API}/v1/ratify/verify`, { method: "POST", headers: { "Content-Type": "application/json", "X-Ratify-API-Key": API_KEY }, body: JSON.stringify({ proof_bundle: body.proof_bundle, required_scope: scope }), signal: AbortSignal.timeout(VERIFY_TIMEOUT_MS), }); } catch { return json(res, 503, { error: "authorization_unavailable", detail: "verifier unreachable or timed out" }); }
if (r.status === 400) { const e = await r.json().catch(() => ({})); return json(res, 403, { error: "not_authorized", detail: e.error?.code ?? "invalid_proof" }); } if (r.status !== 200) { return json(res, 503, { error: "authorization_unavailable", detail: `verifier status ${r.status}` }); }
let verdict; try { verdict = await r.json(); } catch { return json(res, 503, { error: "authorization_unavailable", detail: "invalid verifier response" }); } if (verdict.decision === "allow") return handler(req, res, body, verdict); if (verdict.decision === "deny") return json(res, 403, { error: "not_authorized", detail: verdict.decision_reason }); if (verdict.decision === "indeterminate") return json(res, 503, { error: "authorization_unavailable", detail: verdict.decision_reason }); return json(res, 503, { error: "authorization_unavailable", detail: "unrecognized verifier response" }); };}
const routes = { "POST /tickets/read": requireScope("custom:ticket:read", (req, res) => json(res, 200, { tickets })), "POST /tickets/refund": requireScope("custom:ticket:refund", (req, res) => { refundsProcessed += 1; // this line must be unreachable without authority return json(res, 200, { refunded: true, refundsProcessed }); }), "GET /ledger": (req, res) => json(res, 200, { refundsProcessed }),};
function json(res, code, obj) { if (res.headersSent) return; res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify(obj));}
http.createServer(async (req, res) => { try { const route = routes[`${req.method} ${req.url}`]; if (!route) return json(res, 404, { error: "no_such_route" }); let body = {}; if (req.method === "POST") { const chunks = []; for await (const c of req) chunks.push(c); try { body = JSON.parse(Buffer.concat(chunks).toString() || "{}"); } catch { return json(res, 400, { error: "bad_json" }); } } return await route(req, res, body); } catch { // Defensive boundary: any unexpected middleware/handler rejection fails // closed with a real response instead of a dropped connection. With the // current handlers this path is not reachable from HTTP input (every // parse and fetch above is individually guarded); it exists so future // handler edits inherit fail-closed behavior. return json(res, 503, { error: "authorization_unavailable", detail: "internal error" }); }}).listen(PORT, "127.0.0.1", () => console.log(`ticket desk on 127.0.0.1:${PORT}`));Now define one helper. It fetches a fresh challenge (each is single-use), has Ratify sign it with the agent’s custodial key, and emits the proof bundle your agent would attach to its requests:
proof() { local issued challenge presented issued=$(curl -fsS -X POST $RATIFY_API/v1/ratify/challenge \ -H "X-Ratify-API-Key: $RATIFY_API_KEY") \ || { echo "FAILED: challenge request" >&2; return 1; } challenge=$(echo "$issued" \ | jq -er '.challenge | select(type == "string" and length > 0)') \ || { echo "FAILED: no challenge in response" >&2; return 1; } presented=$(curl -fsS -X POST \ $RATIFY_API/v1/ratify/connections/$CONNECTION_ID/agents/$AGENT_ID/present \ -H "X-Ratify-API-Key: $RATIFY_API_KEY" -H "Content-Type: application/json" \ -d "{\"challenge\": \"$challenge\"}") \ || { echo "FAILED: present" >&2; return 1; } echo "$presented" | jq -e '{proof_bundle: .proof_bundle} | select(.proof_bundle != null)' \ || { echo "FAILED: no proof bundle in response" >&2; return 1; }}Each stage is captured separately (no pipelines between curl and jq), so
a failure at any stage makes proof itself return nonzero instead of
emitting an empty bundle.
The authorized read executes ($0.002 metered per verification):
PROOF=$(proof) || { echo "FAILED: could not build proof — stop here" >&2; false; }curl -sS -X POST http://127.0.0.1:8787/tickets/read \ -H "Content-Type: application/json" -d "$PROOF"# {"tickets":[{"id":1,"subject":"Printer on fire","status":"open"}]}The refund is refused, and provably never ran. Same agent, same valid
certificate, but custom:ticket:refund was never delegated. The denial
calls print their HTTP status so you can see the 403 for yourself (no
-f here: the failing status is the demonstration):
curl -sS http://127.0.0.1:8787/ledger# {"refundsProcessed":0}
PROOF=$(proof) || { echo "FAILED: could not build proof — stop here" >&2; false; }curl -sS -w '\n[HTTP %{http_code}]\n' -X POST http://127.0.0.1:8787/tickets/refund \ -H "Content-Type: application/json" -d "$PROOF"# {"error":"not_authorized","detail":"not_delegated"}# [HTTP 403]
curl -sS http://127.0.0.1:8787/ledger# {"refundsProcessed":0} <- the refund handler never executed6. Revoke, and defeat a proof already in the agent’s hands
Section titled “6. Revoke, and defeat a proof already in the agent’s hands”The strongest revocation story is not “the agent can no longer get proofs”. It is “the proof the agent is already holding stops working”.
First refresh your session, so nothing interrupts the sequence that follows (proofs are freshness-limited; do the session maintenance before minting one, not after):
curl -fsS -b "$JAR" -c "$JAR" -X POST $RATIFY_API/v1/auth/refresh \ -H "X-Ratify-CSRF: $CSRF" > /dev/nullexport CSRF=$(awk '/ratify_csrf/ {print $NF}' "$JAR")Now: the agent builds and holds a proof, you revoke immediately, and the held proof is submitted after the fact.
PROOF=$(proof) || { echo "FAILED: could not build proof — stop here" >&2; false; }printf '%s' "$PROOF" > held-proof.json
curl -fsS -b "$JAR" -X POST $RATIFY_API/v1/ratify/delegations/$CERT_ID/revoke \ -H "Content-Type: application/json" -H "X-Ratify-CSRF: $CSRF" \ -d '{"reason": "quickstart revocation demo"}' | jq .# {"cert_id": "cert-...", "status": "revoked"}
curl -sS -w '\n[HTTP %{http_code}]\n' -X POST http://127.0.0.1:8787/tickets/read \ -H "Content-Type: application/json" -d @held-proof.json# {"error":"not_authorized","detail":"delegation_revoked"}# [HTTP 403]And Ratify refuses to mint new proofs for the revoked delegation at all.
This is an intentional failure, so call /present directly (without
-f) to see the stable error code and status for yourself:
C=$(curl -fsS -X POST $RATIFY_API/v1/ratify/challenge \ -H "X-Ratify-API-Key: $RATIFY_API_KEY" | jq -er .challenge)curl -sS -w '\n[HTTP %{http_code}]\n' -X POST \ $RATIFY_API/v1/ratify/connections/$CONNECTION_ID/agents/$AGENT_ID/present \ -H "X-Ratify-API-Key: $RATIFY_API_KEY" -H "Content-Type: application/json" \ -d "{\"challenge\": \"$C\"}"# {"error":{"code":"no_active_delegation","message":"No active delegation cert for this agent — issue one first (delegate-to-self or consent flow)","status":404,...}}# [HTTP 404]Two distinct properties, both enforced: proofs already in circulation are rejected at verification time, and new proofs can no longer be created.
7. Read the decisions back
Section titled “7. Read the decisions back”curl -fsS -b "$JAR" "$RATIFY_API/v1/ratify/audit/personal?limit=25" \| jq '[.events[] | select(.event_type == "proof.verified" or .event_type == "proof.rejected") | {event_type, decision: .details.decision, reason: .details.decision_reason}]'The API returns events newest first, so the list reads bottom-up as the story you just lived:
[ { "event_type": "proof.rejected", "decision": "deny", "reason": "delegation_revoked" }, { "event_type": "proof.rejected", "decision": "deny", "reason": "not_delegated" }, { "event_type": "proof.verified", "decision": "allow", "reason": "authorized" }]Your history also contains proof.presented audit events. Those carry no
decision fields, and that is correct: presenting builds a proof, it does
not authorize anything. Only POST /v1/ratify/verify produces decisions.
Clean up
Section titled “Clean up”The cleanup function from step 5 stops the sample service and removes
the cookie jar, the held proof, the downloaded sample, and its log. Call
it, then disarm the trap so a long-lived shell can never signal a reused
PID later:
cleanuptrap - EXITunset TICKET_DESK_PID RATIFY_API_KEY CONNECTION_ID AGENT_ID REQUEST_ID CERT_ID CSRF RESP APPROVE PROOFIf something fails
Section titled “If something fails”invalid_scopesat registration — the scope is not canonical and notcustom:-prefixed. See Concepts → Scopes.auth_requiredon an account call — your 15-minute session expired. Run the refresh block in step 6 and re-exportCSRF.invalid_challengeon verify — challenges are single-use. Fetch a fresh one (theproofhelper always does).rate_limited(429) on send-code — wait a minute; the code-send endpoint is rate limited per IP.- HTTP 5xx from verify — if Verify cannot return a decision, the application must refuse the action and retry with a fresh challenge (the sample middleware returns 503 in this case). During alpha, recording is not exactly-once; a rare ambiguous failure may leave an earlier recorded/billed event, and such cases are reconciled operationally.
Where to go next
Section titled “Where to go next”- Verify proofs your customers’ agents present to you: the same
POST /v1/ratify/verifycall from your middleware, whatever the source of the bundle. - Hold your own keys: SDK quickstart builds and verifies bundles offline, no platform account needed.
- Understand what you just did: Delegate → Present → Verify.
- Console: dev.identities.ai shows the dashboard view of everything you just created.