Callbacks and Webhooks
When you register a platform, Ratify stores a callback_url for that platform or connection.
Ratify uses that URL to deliver asynchronous events back to your platform. This is how your app learns about connection lifecycle changes, delegation decisions, key rotation, and revocation state without polling.
The callback URL is part of the commercial surface operated by Identities AI. It complements the free protocol and SDKs; it does not replace them.
What it is used for
Section titled “What it is used for”Use the callback URL to keep your platform in sync with Ratify-managed state:
- record that a connection was created
- persist the connection’s API key prefix and webhook secret
- react when a delegation is approved or denied
- invalidate caches when a delegation is revoked
- update a connected workspace when a key rotates
- deactivate a customer connection when it is removed
Think of it as a control-plane feed. It is not where proof verification happens. It is where your platform learns, “something changed, update your local state.”
If the callback succeeds, Ratify assumes your system has consumed the event. If the callback fails, Ratify retries later.
What Ratify sends
Section titled “What Ratify sends”Current events include:
connection.createdconnection.key_rotatedconnection.removeddelegation.approveddelegation.denieddelegation.revoked
Each delivery is POSTed as JSON over HTTPS.
Delivery envelope
Section titled “Delivery envelope”Every delivery includes:
Content-Type: application/jsonX-Ratify-SignatureX-Ratify-TimestampX-Ratify-Delivery-ID
The JSON body always includes an event field and event-specific fields.
How to consume it
Section titled “How to consume it”Your platform should:
- read the raw request body
- verify the signature using the shared webhook secret
- reject stale timestamps
- deduplicate by delivery ID
- parse
event - update local state for that event
- return
204 No Contentor any other 2xx once the event is consumed
Do not do proof verification inside the callback handler. The callback is for lifecycle state, not for live authorization checks.
What the endpoint must do
Section titled “What the endpoint must do”Your endpoint should:
- accept only HTTPS
- verify
X-Ratify-Signature - verify
X-Ratify-Timestamp - reject stale deliveries
- deduplicate using
X-Ratify-Delivery-ID - return a 2xx status to acknowledge success
- return a non-2xx status if you want Ratify to retry the delivery
Ratify signs the payload with HMAC-SHA256 using the platform’s webhook secret. The signature covers the timestamp and the body.
Verification example
Section titled “Verification example”func verifyRatifyWebhook(secret string, timestamp string, sig string, body []byte) bool { mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(timestamp)) mac.Write([]byte(".")) mac.Write(body) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(sig), []byte(expected))}import { createHmac, timingSafeEqual } from "node:crypto";
function verifyRatifyWebhook(secret: string, timestamp: string, sig: string, body: string): boolean { const expected = createHmac("sha256", secret) .update(`${timestamp}.${body}`) .digest("hex"); return timingSafeEqual(Buffer.from(sig), Buffer.from(expected));}def verify_ratify_webhook(secret: str, timestamp: str, sig: str, body: bytes) -> bool: expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest() return hmac.compare_digest(sig, expected)fn verify_ratify_webhook(secret: &str, timestamp: &str, sig: &str, body: &[u8]) -> bool { let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap(); mac.update(timestamp.as_bytes()); mac.update(b"."); mac.update(body); hex::encode(mac.finalize().into_bytes()) == sig}Consumer example
Section titled “Consumer example”func handleRatifyWebhook(w http.ResponseWriter, r *http.Request, secret string) { body, _ := io.ReadAll(r.Body) timestamp := r.Header.Get("X-Ratify-Timestamp") sig := r.Header.Get("X-Ratify-Signature") deliveryID := r.Header.Get("X-Ratify-Delivery-ID")
if !verifyRatifyWebhook(secret, timestamp, sig, body) { http.Error(w, "invalid signature", http.StatusUnauthorized) return } if isDuplicateDelivery(deliveryID) { w.WriteHeader(http.StatusNoContent) return }
var event map[string]any _ = json.Unmarshal(body, &event)
switch event["event"] { case "connection.created": markConnectionActive(event["connection_id"].(string), event["api_key"].(string)) case "connection.key_rotated": updateAPIKeyPrefix(event["connection_id"].(string), event["api_key_prefix"].(string)) case "delegation.approved": storeDelegation(event["cert_json"]) case "delegation.denied": markRequestDenied(event["request_id"].(string)) case "delegation.revoked": invalidateCert(event["cert_id"].(string)) case "connection.removed": deactivateConnection(event["connection_id"].(string)) }
rememberDelivery(deliveryID) w.WriteHeader(http.StatusNoContent)}import { createHmac, timingSafeEqual } from "node:crypto";
export async function handleRatifyWebhook(req: Request): Promise<Response> { const body = await req.text(); const timestamp = req.headers.get("X-Ratify-Timestamp") ?? ""; const sig = req.headers.get("X-Ratify-Signature") ?? ""; const deliveryId = req.headers.get("X-Ratify-Delivery-ID") ?? "";
const expected = createHmac("sha256", WEBHOOK_SECRET) .update(`${timestamp}.${body}`) .digest("hex"); if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { return new Response("invalid signature", { status: 401 }); } if (await isDuplicateDelivery(deliveryId)) { return new Response(null, { status: 204 }); }
const event = JSON.parse(body) as { event: string; [key: string]: unknown }; switch (event.event) { case "connection.created": await markConnectionActive(String(event.connection_id), String(event.api_key)); break; case "connection.key_rotated": await updateAPIKeyPrefix(String(event.connection_id), String(event.api_key_prefix)); break; case "delegation.approved": await storeDelegation(event.cert_json); break; case "delegation.denied": await markRequestDenied(String(event.request_id)); break; case "delegation.revoked": await invalidateCert(String(event.cert_id)); break; case "connection.removed": await deactivateConnection(String(event.connection_id)); break; }
await rememberDelivery(deliveryId); return new Response(null, { status: 204 });}def handle_ratify_webhook(request): body = request.get_data() timestamp = request.headers.get("X-Ratify-Timestamp", "") sig = request.headers.get("X-Ratify-Signature", "") delivery_id = request.headers.get("X-Ratify-Delivery-ID", "")
if not verify_ratify_webhook(WEBHOOK_SECRET, timestamp, sig, body): return ("invalid signature", 401) if is_duplicate_delivery(delivery_id): return ("", 204)
event = json.loads(body) etype = event["event"] if etype == "connection.created": mark_connection_active(event["connection_id"], event["api_key"]) elif etype == "connection.key_rotated": update_api_key_prefix(event["connection_id"], event["api_key_prefix"]) elif etype == "delegation.approved": store_delegation(event["cert_json"]) elif etype == "delegation.denied": mark_request_denied(event["request_id"]) elif etype == "delegation.revoked": invalidate_cert(event["cert_id"]) elif etype == "connection.removed": deactivate_connection(event["connection_id"])
remember_delivery(delivery_id) return ("", 204)fn handle_ratify_webhook(req: Request, secret: &str) -> Response { let timestamp = req.header("X-Ratify-Timestamp").unwrap_or(""); let sig = req.header("X-Ratify-Signature").unwrap_or(""); let delivery_id = req.header("X-Ratify-Delivery-ID").unwrap_or(""); let body = req.body_bytes();
if !verify_ratify_webhook(secret, timestamp, sig, &body) { return response(401, "invalid signature"); } if is_duplicate_delivery(delivery_id) { return response(204, ""); }
let event: serde_json::Value = serde_json::from_slice(&body).unwrap(); match event["event"].as_str().unwrap_or("") { "connection.created" => mark_connection_active( event["connection_id"].as_str().unwrap(), event["api_key"].as_str().unwrap(), ), "connection.key_rotated" => update_api_key_prefix( event["connection_id"].as_str().unwrap(), event["api_key_prefix"].as_str().unwrap(), ), "delegation.approved" => store_delegation(&event["cert_json"]), "delegation.denied" => mark_request_denied(event["request_id"].as_str().unwrap()), "delegation.revoked" => invalidate_cert(event["cert_id"].as_str().unwrap()), "connection.removed" => deactivate_connection(event["connection_id"].as_str().unwrap()), _ => {} }
remember_delivery(delivery_id); response(204, "")}Minimal verification logic
Section titled “Minimal verification logic”expected = HMAC_SHA256(secret, timestamp + "." + raw_body)accept only if expected matches X-Ratify-Signatureaccept only if abs(now - timestamp) < 5 minutesEvent payloads
Section titled “Event payloads”connection.created
Section titled “connection.created”Use this to persist the platform connection and mark the integration active.
Important fields:
connection_idplatform_idorg_idorg_nameallowed_scopescallback_urlapi_keywebhook_signing_secret
Consumer action:
- store the connection record
- store the API key prefix or key reference
- store the webhook secret securely
- mark the integration as active
- optionally pre-seed the workspace with the allowed scopes
connection.key_rotated
Section titled “connection.key_rotated”Use this to replace the stored API key prefix or update connection state after key rotation.
Important fields:
connection_idapi_key_prefixprevious_prefix
Consumer action:
- replace the stored key reference
- invalidate any cached API auth material
- notify operators if the platform relies on long-lived credentials
delegation.approved
Section titled “delegation.approved”Use this to record that a human approved a delegation request and to provision downstream access if needed.
Important fields:
request_idconnection_idorg_idagent_idapproved_scopecert_jsondelegation_key_statement
Consumer action:
- persist the approved delegation certificate
- mark the request as approved in your UI
- let the agent runtime resume or continue the session
- attach the cert to your audit log
delegation.denied
Section titled “delegation.denied”Use this to stop pending authorization flows and surface the denial in your UI or audit log.
Important fields:
request_idconnection_idorg_idagent_id
Consumer action:
- stop the pending authorization flow
- surface the denial reason in your UI
- keep the agent blocked until a new request is approved
delegation.revoked
Section titled “delegation.revoked”Use this to invalidate cached permissions immediately.
Important fields:
cert_idagent_idconnection_idorg_idreason
Consumer action:
- remove the cert from your cache
- reject any session or token derived from that cert
- notify the connected user or admin that access was revoked
connection.removed
Section titled “connection.removed”Use this to disable the integration and remove any tokens or local state associated with that connection.
Important fields:
connection_idorg_idremoved_at
Consumer action:
- disable the integration locally
- delete API credentials and cached scopes
- stop accepting proofs for that connection
What your code should do after verification
Section titled “What your code should do after verification”Treat the callback as a state update, not a request to do business logic synchronously.
Good handling:
- update your local connection record
- refresh cached scopes
- record the event in your audit log
- notify the workspace admin if a connection was removed or revoked
Bad handling:
- trusting the body before verifying the signature
- using the callback as the source of a user-facing action without idempotency
- retrying forever on 4xx errors
Response rules
Section titled “Response rules”- return
204 No Contentwhen you have processed the event - return another 2xx if your framework prefers it
- return
401only for bad signatures or stale timestamps - return
500/503for temporary local failures so Ratify retries - never return
200before your handler has stored or queued the event
Example platform endpoint
Section titled “Example platform endpoint”POST /ratify-webhookHeaders: X-Ratify-Signature: <hmac> X-Ratify-Timestamp: <unix-seconds> X-Ratify-Delivery-ID: <uuid>Body: JSON event payloadfunc handleRatifyWebhook(w http.ResponseWriter, r *http.Request, secret string) { timestamp := r.Header.Get("X-Ratify-Timestamp") sig := r.Header.Get("X-Ratify-Signature") deliveryID := r.Header.Get("X-Ratify-Delivery-ID") body, _ := io.ReadAll(r.Body)
mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(timestamp)) mac.Write([]byte(".")) mac.Write(body)
if !hmac.Equal([]byte(sig), []byte(hex.EncodeToString(mac.Sum(nil)))) { http.Error(w, "invalid signature", http.StatusUnauthorized) return }
_ = deliveryID // use this for idempotency w.WriteHeader(http.StatusNoContent)}The callback URL is your integration surface. Ratify is the sender. Your platform is the receiver. The SDKs are still used for proof creation and verification elsewhere in the flow.
If you are implementing the protocol yourself, host this endpoint in your own control plane. If you are using the managed product, configure it in the Identities AI app.