Skip to content

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.

Terminal window
export RATIFY_API=https://api-dev.identities.ai
export JAR=$(mktemp) # session cookie jar — holds credentials
chmod 600 "$JAR"

Authentication is email-code based and cookie-only; tokens never appear in response bodies.

Terminal window
curl -fsS -c "$JAR" -X POST $RATIFY_API/v1/auth/email/send-code \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]"}'
# {"resent":false,"status":"code_sent"}

Grab the 6-digit code from your inbox, then:

Terminal window
curl -fsS -b "$JAR" -c "$JAR" -X POST $RATIFY_API/v1/auth/email/verify-code \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "code": "123456"}'

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:

Terminal window
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:

Terminal window
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.

Terminal window
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:

Terminal window
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_ID

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.)

Terminal window
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_ID
echo $AGENT_ID

The response is flat JSON with agent_id, name, agent_type, custody_mode, and public_key_json.

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:

Terminal window
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.

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:

Terminal window
curl -fsSO https://docs.identities.ai/examples/ticket-desk.mjs
node 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 EXIT
sleep 1 && cat ticket-desk.log
# ticket desk on 127.0.0.1:8787

The 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 (the downloadable asset, shown in full)
// 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.mjs
import 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 -> 503
function 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:

Terminal window
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):

Terminal window
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):

Terminal window
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 executed

6. 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):

Terminal window
curl -fsS -b "$JAR" -c "$JAR" -X POST $RATIFY_API/v1/auth/refresh \
-H "X-Ratify-CSRF: $CSRF" > /dev/null
export 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.

Terminal window
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:

Terminal window
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.

Terminal window
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.

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:

Terminal window
cleanup
trap - EXIT
unset TICKET_DESK_PID RATIFY_API_KEY CONNECTION_ID AGENT_ID REQUEST_ID CERT_ID CSRF RESP APPROVE PROOF
  • invalid_scopes at registration — the scope is not canonical and not custom:-prefixed. See Concepts → Scopes.
  • auth_required on an account call — your 15-minute session expired. Run the refresh block in step 6 and re-export CSRF.
  • invalid_challenge on verify — challenges are single-use. Fetch a fresh one (the proof helper 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.
  • Verify proofs your customers’ agents present to you: the same POST /v1/ratify/verify call 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.