Skip to Content
AgentFlow
AI agents

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.status is the finalized request status (approved, rejected).
  • receiptId identifies the proof; fetch the full signed receipt with getProof.
  • 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:

HeaderPurpose
content-typeAlways application/json
x-agentflow-event-idUnique event ID (evt_…); deduplicate on this
x-agentflow-timestampUnix seconds at signing time
x-agentflow-signaturev1=<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);
}
  1. Read the raw body bytes before JSON parsing — reserialization changes the signature input.
  2. Check x-agentflow-timestamp is inside your replay window (e.g. ±5 minutes) before verifying.
  3. Compute HMAC-SHA256(secret, timestamp + "." + rawBody), compare hex against the v1= value with a timing-safe compare.
  4. 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

BehaviorDetail
AttemptsUp to 8
BackoffExponential: 1s, 2s, 4s … capped at 300s, with random jitter
Request timeout5s 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
ExhaustionAfter the last attempt the delivery is marked failed with max_attempts:<cause>

Delivery error codes

The lastErrorCode on a delivery record is one of:

CodeClassMeaning
event_id_mismatchpermanentEvent ID header did not match the payload
invalid_urlpermanentCallback URL malformed, non-HTTPS, or contains credentials
forbidden_addresspermanentHostname is localhost or resolves to a non-public IP
dns_errortransientHostname failed to resolve or returned no addresses
payload_too_largepermanentSigned body exceeded the payload cap
timeouttransientReceiver did not answer within the request timeout
network_errortransientTCP/TLS level failure
http_<status>transient or permanentReceiver returned 408/429/5xx (transient) or any other non-2xx (permanent)
missing_webhook_secretpermanentNo signing secret on record for the delivery
max_attempts:<cause>terminalRetry 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.

On this page