Webhooks as an audit layer: signed events for agent observability

The verify response tells your integration what was decided. Webhooks tell everything else — your SIEM, your alerting system, your audit database — asynchronously and in real time. Here's how BehalfID's outbox-backed delivery model works.

webhooksobservability

The verify response is synchronous — your integration gets the decision before the executor runs. But not every system that cares about agent decisions is in the request path. Your security tooling, alerting pipeline, compliance log, and audit database all need to know what happened, and they shouldn't have to poll.

That's what webhooks are for. BehalfID emits a signed event for every verify decision — verification.allowed, verification.denied, or verification.requires_approval — and delivers it to your configured endpoint through an outbox-backed retry system.

Each webhook event carries the same information as the verify response, plus routing metadata:

verification.denied event
{
  "eventId": "evt_01hx…",
  "type": "verification.denied",
  "createdAt": "2026-05-10T14:22:08.412Z",
  "data": {
    "requestId": "req_01hvz8…",
    "agentId": "agent_ollie",
    "decision": "denied",
    "reason": "No active purchase permission",
    "action": "purchase",
    "vendor": "coachella.com",
    "amount": 742,
    "riskLevel": "medium"
  }
}

eventId is unique per event and stable across delivery retries — use it to deduplicate. requestId links back to the verify call that produced this event and to the audit log entry.

Every event is signed with HMAC-SHA256 over timestamp.rawBody using the derived key from your whsec_ secret. Verify the signature before processing any event — never trust an unsigned or unverified payload.

receiver.ts
import { verifyWebhookSignature } from "@behalfid/sdk";

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get("behalfid-signature") ?? "";
  const timestamp = req.headers.get("behalfid-timestamp") ?? "";

  const event = verifyWebhookSignature({
    body,
    signature,
    timestamp,
    secret: process.env.BEHALFID_WEBHOOK_SECRET!
  });

  // event is now verified — safe to process
  if (event.type === "verification.denied") {
    await alertingPipeline.send(event.data);
  }

  return new Response("ok", { status: 200 });
}
Return 200as soon as you've verified and enqueued the event. Do not block the webhook response on downstream processing — slow receivers trigger retries.

BehalfID writes events to an outbox before delivering them. If your endpoint is unreachable or returns a non-2xx response, delivery is retried up to five times with a capped backoff. After five failures, the event moves to dead-letter state.

  • At-least-once delivery. An event may be delivered more than once — on retries, or after a manual replay. Always deduplicate by eventId.
  • Dead-letter replay. Dead-lettered events are visible in the console and can be replayed manually. Useful when your receiver was down during a burst of denials you care about.
  • No ordering guarantee. Events are delivered roughly in order but retry jitter can cause out-of-order arrival. Use createdAt to reconstruct sequence if needed.

Webhooks are the right hook for anything that needs to react to agent decisions outside the request path:

  • Alerting. Page on-call when a high-risk action is denied or when a single agent produces an unusual spike of denials within a window.
  • Compliance logging. Pipe every verification.* event to an append-only audit store with the requestId, eventId, agent, action, and timestamp. Immutable records per decision.
  • Human-in-the-loop queues. On verification.requires_approval, push the event to a review queue where a human can approve or deny before the agent is unblocked.
  • Revocation triggers. If an agent produces a pattern of high-risk denials, automatically disable the agent or revoke a specific scope.

The verify API handles the decision. Webhooks handle everything that needs to react to it.