Webhooks
Receive signed approval.completed callbacks — delivery config, HMAC verification, endpoint constraints, retries, and error codes.
When a request reaches a final decision, AgentFlow can POST a signed
approval.completed event to your connection's callback URL. Events
are queued atomically with the signed proof and delivered with
at-least-once semantics.
Enabling webhook delivery
An operator enables webhook delivery on the connection in the console (AI agents → <connection> → Delivery) and sets the HTTPS callback URL — see Using the platform → Agent connections.
The webhook signing secret is shown once — when webhooks are first enabled. It is never retrievable afterward; store it immediately. Rotating the secret from the connection page does not invalidate in-flight events, because each event is signed with the secret that was live when it was queued.
The callback URL must be https:// — non-HTTPS callbacks are rejected
at configuration time, before any delivery is attempted.
The approval.completed event
Body (the raw bytes of this JSON are what you verify):
{
"version": 1,
"eventId": "evt_9f4b2c1d",
"type": "approval.completed",
"occurredAt": "2026-11-10T14:23:11.041Z",
"request": {
"id": "apr_1234_v1",
"externalId": "REQ-1001",
"version": 1,
"status": "approved"
},
"decisionId": "di_0021",
"receiptId": "prf_9d3a"
}request.statusis the finalized request status (approved,rejected).receiptIdidentifies the proof; fetch the full signed receipt withgetProof.- Delivery is at-least-once — deduplicate on
eventId. - A webhook is a notification, not proof. Treat it as a trigger to
call
getStatus/getProof; the signed receipt is what authorizes the action.
Headers and signature verification
Each delivery includes:
| Header | Purpose |
|---|---|
content-type | Always application/json |
x-agentflow-event-id | Unique event ID (evt_…); deduplicate on this |
x-agentflow-timestamp | Unix seconds at signing time |
x-agentflow-signature | v1=<hex> — HMAC-SHA256 of timestamp + "." + rawBody under your webhook secret |
Verification recipe:
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyAgentflowWebhook(
rawBody: string, // exact request body bytes
timestamp: string, // x-agentflow-timestamp
signatureHeader: string, // x-agentflow-signature: "v1=<hex>"
secret: string,
): boolean {
if (!signatureHeader.startsWith('v1=')) return false;
const supplied = Buffer.from(signatureHeader.slice(3), 'hex');
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest();
return supplied.length === expected.length &&
timingSafeEqual(supplied, expected);
}- Read the raw body bytes before JSON parsing — reserialization changes the signature input.
- Check
x-agentflow-timestampis inside your replay window (e.g. ±5 minutes) before verifying. - Compute
HMAC-SHA256(secret, timestamp + "." + rawBody), compare hex against thev1=value with a timing-safe compare. - Reject (any non-2xx) on mismatch; return 2xx only after the event is durably recorded.
Endpoint constraints
Callback endpoints are held to strict delivery rules:
- HTTPS only. Non-HTTPS URLs are rejected at config time and at delivery time; URLs must not contain embedded credentials.
- Public addresses only.
localhost,*.localhost, and any hostname resolving to a private, loopback, link-local, or reserved address is refused (forbidden_address). - No redirects. Redirect responses are not followed; a 3xx status
is a permanent delivery failure (
http_3xx). - TLS verification stays on. Certificate verification is never disabled.
- Payload cap. Event bodies larger than 64 KiB fail permanently
as
payload_too_large.
If you can't open a public HTTPS endpoint during development, run a
tunnel (e.g. ngrok) and set its public URL as the callback — there
are no localhost exceptions.
Retry policy
| Behavior | Detail |
|---|---|
| Attempts | Up to 8 |
| Backoff | Exponential: 1s, 2s, 4s … capped at 300s, with random jitter |
| Request timeout | 5s per attempt |
| Transient failures (retried) | HTTP 408, 429, 5xx; timeout; DNS resolution failure; network errors |
| Permanent failures (not retried) | Any other non-2xx status, including 3xx and 4xx; malformed URL; non-public address; oversized payload; event_id_mismatch |
| Exhaustion | After the last attempt the delivery is marked failed with max_attempts:<cause> |
Delivery error codes
The lastErrorCode on a delivery record is one of:
| Code | Class | Meaning |
|---|---|---|
event_id_mismatch | permanent | Event ID header did not match the payload |
invalid_url | permanent | Callback URL malformed, non-HTTPS, or contains credentials |
forbidden_address | permanent | Hostname is localhost or resolves to a non-public IP |
dns_error | transient | Hostname failed to resolve or returned no addresses |
payload_too_large | permanent | Signed body exceeded the payload cap |
timeout | transient | Receiver did not answer within the request timeout |
network_error | transient | TCP/TLS level failure |
http_<status> | transient or permanent | Receiver returned 408/429/5xx (transient) or any other non-2xx (permanent) |
missing_webhook_secret | permanent | No signing secret on record for the delivery |
max_attempts:<cause> | terminal | Retry budget exhausted; suffix is the last transient cause |
delivery_error | — | Fallback code when no specific cause was recorded |
Inspecting and retrying deliveries
Operators can inspect deliveries in the console on the connection
page — each record shows the event ID, status
(pending/delivered/failed), HTTP status code, attempt count,
last error, and callback URL. A failed delivery can be requeued from
there once you've fixed the receiver or callback URL.
Callback failures never change the recorded human decision. If your
endpoint was down, the canonical state is always available through
getStatus polling, and the receipt through getProof.