ADACTION
DOCK

ACTIONDOCK FIELD NOTES

Webhook Signature Verification for AI Agent Callbacks

Verify AI-agent webhook callbacks with the raw body, HMAC-SHA256, a timestamp window, constant-time comparison, and atomic delivery deduplication.

An amber callback signal passing through cryptographic, freshness, and deduplication checkpoints before reaching a receiver.

Webhook signature verification for an AI-agent callback needs four independent controls: recompute the HMAC over the exact signed bytes, compare it in constant time, reject timestamps outside a short freshness window, and atomically deduplicate the delivery ID before causing any effect. Only then should the receiver trust the event enough to parse it, enqueue work, or resume an agent.

A valid signature establishes that someone holding the shared callback secret produced the signed message and that the signed bytes were not changed. It does not encrypt the payload, prove that the event is fresh, make delivery exactly once, authorize a new downstream action, or prove the current state of the external system.

That distinction matters for agent callbacks. A forged, replayed, or duplicated “job succeeded” event can wake a reasoning loop at the wrong time. The safe receiver verifies the transport evidence, persists one delivery, then reads authoritative job state before a consequential next step.

Verify five things before resuming the agent

Treat the callback as untrusted input until every applicable check passes.

Check What to use What it prevents
Signed bytes The provider’s exact timestamp and raw request body Re-serialized JSON or modified bytes being accepted as the signed message
Message authentication The documented HMAC algorithm, shared secret, and signature header Forged or altered payloads from callers without the secret
Freshness A signed timestamp and a bounded local tolerance An old valid request being replayed indefinitely
Delivery uniqueness A stable delivery ID claimed atomically in durable storage Sender retries or captured deliveries triggering the same work twice
Event authority Expected event type, workspace, schema, and an authenticated state read when needed A validly signed but irrelevant, stale, or over-privileged event steering the workflow

The checks solve different problems. A timestamp does not identify a delivery. A delivery ID does not authenticate the sender. A signature without a freshness rule authenticates an old replay. And all three can be valid while the event type is irrelevant to the workflow receiving it.

Preserve the raw body before parsing JSON

HMAC is a message authentication construction defined in RFC 2104. Both sender and receiver must calculate it over the same byte sequence. Equivalent JSON is not necessarily the same sequence:

{"status":"succeeded","jobId":"job_123"}

and

{
  "jobId": "job_123",
  "status": "succeeded"
}

represent the same fields, but their whitespace and key order differ. Hashing the parsed object after JSON.stringify() can therefore produce a different digest from the sender’s.

GitHub’s official webhook validation guide warns that payload or header modification before verification causes failures. Stripe likewise requires the raw request body for signature verification and documents framework middleware as a common source of accidental mutation.

Configure the callback route to expose a Buffer or untouched UTF-8 body before global JSON parsing. Verify that body first. Parse it once only after the signature and timestamp pass. Do not “fix” a failed signature by normalizing whitespace, sorting keys, or decoding and re-encoding the request.

The current ActionDock callback contract

A workspace can currently configure one HTTPS callback. In normal job operation, ActionDock sends terminal events to that endpoint. The callback contract includes these headers:

X-ActionDock-Timestamp: <Unix time in seconds>
X-ActionDock-Signature: v1=<HMAC-SHA256 hex digest>
X-ActionDock-Delivery: <stable delivery ID>

The signed input is exactly:

<X-ActionDock-Timestamp>.<raw request body>

The JSON envelope contains an event ID, event type, creation time, workspace ID, and event data. Terminal types include job.succeeded, job.failed, job.rejected, and job.execution_unknown. ActionDock retries non-2xx callback responses and preserves delivery attempts; authenticated polling remains available if the callback path is unavailable.

Use the callback signing secret only for callback verification. Keep it in server-side secret storage, never in browser code, agent context, logs, or the repository. GitHub’s guidance recommends a random, high-entropy webhook secret and server-side storage; the same principle applies here.

Do not substitute the event ID, delivery ID, JSON creation time, or a guessed canonical body into the signed input. Use the timestamp header exactly as received, a literal period, and the exact raw body.

A Node.js and TypeScript verifier

This verifier is deliberately framework-neutral. Pass it the untouched body bytes and the three headers from the callback route.

import { createHmac, timingSafeEqual } from "node:crypto";

const FRESHNESS_WINDOW_SECONDS = 5 * 60;
const SIGNATURE_PATTERN = /^v1=([a-f0-9]{64})$/;

type VerificationResult =
  | { ok: true; deliveryId: string }
  | { ok: false; reason: string };

export function verifyActionDockCallback(input: {
  rawBody: Buffer;
  signature: string | undefined;
  timestamp: string | undefined;
  deliveryId: string | undefined;
  secret: string;
  nowSeconds?: number;
}): VerificationResult {
  const {
    rawBody,
    signature,
    timestamp,
    deliveryId,
    secret,
    nowSeconds = Math.floor(Date.now() / 1000),
  } = input;

  if (!signature || !timestamp || !deliveryId) {
    return { ok: false, reason: "missing_headers" };
  }

  if (!/^\d+$/.test(timestamp)) {
    return { ok: false, reason: "invalid_timestamp" };
  }

  const timestampSeconds = Number(timestamp);
  if (!Number.isSafeInteger(timestampSeconds)) {
    return { ok: false, reason: "invalid_timestamp" };
  }

  const signatureMatch = SIGNATURE_PATTERN.exec(signature);
  if (!signatureMatch) {
    return { ok: false, reason: "invalid_signature_format" };
  }

  const expected = createHmac("sha256", secret)
    .update(timestamp, "utf8")
    .update(".", "utf8")
    .update(rawBody)
    .digest();
  const received = Buffer.from(signatureMatch[1], "hex");

  if (
    received.length !== expected.length ||
    !timingSafeEqual(received, expected)
  ) {
    return { ok: false, reason: "signature_mismatch" };
  }

  if (Math.abs(nowSeconds - timestampSeconds) > FRESHNESS_WINDOW_SECONDS) {
    return { ok: false, reason: "stale_timestamp" };
  }

  return { ok: true, deliveryId };
}

Node’s crypto.createHmac and crypto.timingSafeEqual documentation covers both primitives. Check the byte lengths before calling timingSafeEqual, because the function requires equal-length inputs. Also keep the surrounding verification path simple: the Node documentation cautions that one constant-time comparison does not automatically make every surrounding operation timing-safe.

The five-minute window above is an example receiver policy, not a universal ActionDock requirement. Choose a tolerance that covers expected clock skew and delivery latency without leaving a broad replay window. Stripe’s webhook guidance uses a five-minute default and recommends synchronized clocks, but every receiver should document and test its own operating window.

Deduplicate before the first side effect

Signature verification does not stop a valid request from being delivered again. A sender may retry because your endpoint returned non-2xx, timed out, or returned a success that the sender never received. An attacker may also replay a recently captured request while its timestamp remains fresh.

Claim X-ActionDock-Delivery with a unique database constraint. Persist the callback and enqueue its work in the same transaction:

BEGIN;

WITH accepted AS (
  INSERT INTO callback_receipts (delivery_id, received_at, event_type, event_id)
  VALUES ($1, NOW(), $3, $4)
  ON CONFLICT (delivery_id) DO NOTHING
  RETURNING delivery_id
)
INSERT INTO agent_resume_queue (delivery_id, event_type, event_id)
SELECT delivery_id, $3, $4
FROM accepted;

COMMIT;

The exact schema will differ, and a production implementation should ensure concurrent requests cannot enqueue the same receipt twice. The invariant is what matters: durable acceptance and the work claim must be atomic. A process-local Set, an in-memory cache, or “check then insert” without a uniqueness constraint fails across restarts or concurrent deliveries.

If a verified delivery is already stored, return a successful 2xx response without repeating its work. GitHub recommends using its stable delivery header for replay protection, and Stripe recommends recording processed event IDs to handle duplicates. Those provider-specific formats differ, but the receiver principle is the same.

Do not use the signature as the deduplication key. A retry may have a new timestamp and therefore a new signature while still representing the same stable ActionDock delivery.

Acknowledge receipt before long agent work

After verification and durable acceptance, return a 2xx response promptly and process the event from a queue. Do not keep the HTTP request open while an agent reasons, calls tools, or waits for another service.

Use response behavior deliberately:

Receiver outcome Suggested behavior
Missing, malformed, mismatched, or stale authentication data Reject; do not parse into trusted workflow state
Valid delivery already accepted Return 2xx; do not enqueue it again
Valid new delivery persisted and queued Return 2xx, then process asynchronously
Temporary database or queue failure before durable acceptance Return non-2xx so the sender can retry

GitHub’s webhook best practices recommend a quick 2xx followed by asynchronous processing and explicitly warn receivers to check event types. Stripe gives the same asynchronous-processing guidance. The point is not a universal timeout number; it is to separate reliable receipt from potentially long-running business logic.

Re-read state before a consequential next step

A signed callback is a notification, not blanket permission for the agent to act. Validate the envelope schema and allow only event types the workflow expects. Check the workspace and known job identifiers rather than accepting an event-supplied tool name, URL, credential, or instruction as executable input.

For a sensitive transition, use the callback to wake the agent and then fetch the job through the authenticated ActionDock API. Compare the returned job ID, workspace context, action, and terminal state with the workflow you already hold. This narrows the callback’s authority: it signals that state may have changed, while the authenticated read supplies current ActionDock state.

The boundary remains limited. job.succeeded means ActionDock recorded a usable successful provider response. It is not independent proof that the provider’s wider business process finished or that its current state still matches the request. The AI agent audit-trail guide explains how to separate gateway evidence from provider evidence. If the callback reports job.execution_unknown, follow the reconciliation process and do not turn the notification into a blind retry.

ActionDock’s callback wakes the external agent; it does not take over the agent’s goal, reasoning, or workflow. The durable actions workflow shows how callbacks and polling fit around a supervised write.

Test the failure cases, not only the happy path

Build verification tests from captured raw-body fixtures and known signatures. At minimum, cover:

Also test secret rotation as an operating procedure. If your receiver supports an overlap period, try the active secrets in a fixed, documented way and remove the old secret after the window. Never silently fall back to accepting an unsigned request because rotation configuration is incomplete.

Webhook verification checklist

Before connecting an agent workflow to a production callback:

Start with ActionDock’s signed callback contract, implement the verifier against a raw fixture, and force one duplicate and one lost-response retry in staging. A callback path is ready when those failures produce one durable receipt and one agent resume—not when the happy-path POST merely returns 200.

Frequently asked questions

Can I parse JSON before verifying the webhook signature?

Preserve the raw bytes first and verify those exact bytes. You may parse a separate copy after verification. Re-serializing a parsed object is unsafe because whitespace, escaping, encoding, or key order may change the HMAC input.

Does a valid HMAC prevent replay attacks?

No. A captured request keeps its valid signature. Bind a timestamp into the signed input, reject stale timestamps, and durably deduplicate a stable delivery ID.

Should a callback directly trigger another AI-agent tool call?

Usually not from the HTTP handler. Verify and persist the callback, acknowledge it, validate the expected event in a worker, and re-read authenticated job state before a consequential next step. Any new external write should still pass its own policy and approval boundary.

What happens if my 2xx response is lost?

The sender may deliver the event again. The receiver should recognize the existing delivery ID, return 2xx again, and avoid repeating the queued work or side effect.