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:
- The exact body, timestamp, secret, and signature pass.
- One changed body byte fails, even when the parsed JSON would be equivalent.
- A wrong secret and a malformed digest fail.
- Old and unreasonably future timestamps fail.
- A missing delivery ID fails before work is accepted.
- Two concurrent requests with one delivery ID enqueue exactly one job.
- A retry after a lost 2xx returns success without repeating work.
- Storage failure before commit returns non-2xx and leaves no partial receipt.
- Unknown event types and mismatched workspace or job IDs do not resume the workflow.
- A sensitive transition re-reads authenticated job state before the next action.
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:
- The route preserves the untouched request body before JSON middleware.
- The verifier implements the sender’s exact signed-input and header format.
- HMAC digests are compared with a constant-time primitive after a length check.
- The signed timestamp is checked against a documented, non-zero tolerance.
- The delivery ID has a durable unique constraint.
- Receipt persistence and queue insertion form one atomic acceptance step.
- Duplicate verified deliveries return 2xx without repeating work.
- Only expected event types and workflow identifiers are accepted.
- Sensitive next steps fetch authenticated current state.
- Secrets and full sensitive payloads stay out of code, prompts, and logs.
- Polling remains available when callback delivery is delayed or unavailable.
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.
