OpenAI Webhook Verification and Recovery
Build a webhook receiver that preserves raw bytes, rejects hostile deliveries, acknowledges quickly, deduplicates IDs, and reconciles background work.
OpenAI webhook verification answers one operational question: how can a backend accept asynchronous events without trusting altered bytes or repeating work? This tutorial builds a raw-body boundary, durable webhook-ID claim, fast acknowledgement, worker receipt, and reconciliation loop.
OpenAI webhook verification begins with raw bytes
Read the request body exactly once as bytes or text, before JSON middleware transforms whitespace, ordering, or encoding. Signature verification authenticates that transport representation. A parsed object is useful after trust is established, but recreating JSON from the object may not reproduce the signed payload. Put the webhook route before generic body parsing or configure a route-specific raw-body reader.
The OpenAI webhooks guide documents the current headers and helper flow. The Node SDK offers helpers for verification and parsing, but the architectural boundary remains yours: cap body size, retain the received headers needed by the helper, and reject malformed or oversized requests before expensive work. OpenAI webhook verification should fail closed without logging the signing secret or full event payload.
The committed delivery corpus contains six local strings: valid, tampered, stale, duplicated, out-of-order, and worker-failure cases. It uses a fixture-only secret and a deterministic HMAC routine; it makes no network request and represents no captured OpenAI delivery. Each expected result is derived from those committed bytes.
Include the raw-body capture order in the route test, because a correct verifier called after destructive middleware still receives the wrong evidence. Make that ordering assertion fail loudly.
Verify signature and freshness before parsing
A valid cryptographic signature proves that the signer possessed the shared secret for the exact payload. It does not by itself prove that a delivery is recent. Check the signed timestamp inside the documented tolerance, reject timestamps too far in either direction for your clock policy, and monitor clock synchronization. Do not quietly widen the window when verification failures rise.
Use the SDK helper where it matches your runtime, because header formats and signature versions are protocol details. The OpenAI Node SDK webhook section shows the supported integration surface. Keep the secret in server-only configuration and rotate it through a controlled overlap if the platform supports multiple active values. An OpenAI webhook signature must never be shipped to browser code or echoed in an error response.
Run parsing only after OpenAI webhook verification passes. At that point validate the event shape and version again before selecting a handler. Authentication says who formed the envelope; schema validation says whether your code understands its contents. Unknown event types should enter an inspectable ignored state rather than a default handler with broad side effects.
Return one generic authentication failure to the sender while recording only a bounded reason code for operators; diagnostic detail must not become a signature oracle. Keep response timing uninformative too.
- Capture the exact request bytes before parsing.
- Verify the signature and freshness against those bytes.
- Only then parse and route the event by a stable identifier.
| Signal | Interpretation |
|---|---|
| Exploded signed webhook envelope | Raw bytes, timestamp, webhook identifier, signature, and parsed event separate into verifiable layers. |
Claim webhook IDs in a durable dedupe table
Transport systems retry, so the receiver must treat repeated delivery as normal. Extract the stable webhook identifier documented by the protocol and attempt one atomic insert into a dedupe table with a unique constraint. The row should record first receipt time, event type, payload digest, processing state, and the application work ID. A duplicate then reads the existing receipt instead of launching another job.
This is webhook-id deduplication, not a memory cache. Process restarts, parallel instances, and deployment overlap defeat per-process sets. The idempotency-key lifecycle guide applies because the retention window and replay behavior are part of the contract. OpenAI webhook verification should precede the database claim so hostile traffic cannot fill the dedupe store with unauthenticated IDs.
If the same ID arrives with a different payload digest, quarantine it and alert. Do not overwrite the original record or treat the mismatch as an ordinary duplicate. The fixture models duplicate equality and mismatch as separate outcomes, proving that stable identity and stable content travel together through the receipt.
Choose retention from the provider retry horizon plus your investigation needs, then expire rows through a reviewed job that preserves aggregate operational evidence. Document the deletion owner.
The hostile-delivery corpus drives signature rejection, durable claims, event reduction, and worker recovery through six named webhook cases.
Runnable artifact — openai-webhook-corpus.mjs
import assert from "node:assert/strict";
import crypto from "node:crypto";
const secret = "fixture_secret_only", now = 2_000_000;
const sign = (id, time, body) => crypto.createHmac("sha256", secret).update(id + "." + time + "." + body).digest("hex");
const delivery = (name, id, time, event, signatureOverride = null) => { const body = JSON.stringify(event); return { name, id, time, body, signature: signatureOverride || sign(id, time, body) }; };
const accepted = delivery("accepted", "wh_1", now, { response: "r1", sequence: 2, type: "response.completed" });
const duplicate = { ...accepted, name: "duplicate" };
const mismatch = delivery("id-payload-mismatch", "wh_1", now, { response: "r1", sequence: 3, type: "response.failed" });
const tampered = { ...delivery("tampered", "wh_2", now, { response: "r2", sequence: 1, type: "response.completed" }), signature: "bad" };
const stale = delivery("stale", "wh_3", now - 601, { response: "r3", sequence: 1, type: "response.completed" });
const outOfOrder = delivery("out-of-order", "wh_4", now, { response: "r1", sequence: 1, type: "response.in_progress" });
const workerFailure = delivery("worker-after-effect", "wh_5", now, { response: "r5", sequence: 1, type: "response.completed" });
const claims = new Map(), queue = [], projection = new Map([["r1", { sequence: 2, type: "response.completed" }]]), effects = new Map(), jobs = new Map();
const receive = (item) => {
if (Math.abs(now - item.time) > 300) return "stale";
if (sign(item.id, item.time, item.body) !== item.signature) return "tampered";
const digest = crypto.createHash("sha256").update(item.body).digest("hex");
if (claims.has(item.id)) return claims.get(item.id) === digest ? "duplicate" : "mismatch";
claims.set(item.id, digest); queue.push(item.id); jobs.set(item.id, { status: "queued", item }); return "accepted";
};
assert.deepEqual([accepted, duplicate, mismatch, tampered, stale, outOfOrder, workerFailure].map(receive), ["accepted", "duplicate", "mismatch", "tampered", "stale", "accepted", "accepted"]);
const reduce = (item) => { const event = JSON.parse(item.body), current = projection.get(event.response); if (!current || event.sequence > current.sequence) projection.set(event.response, event); return projection.get(event.response); };
assert.equal(reduce(outOfOrder).type, "response.completed");
const runWorker = (id, crashAfterEffect = false) => { const job = jobs.get(id), event = JSON.parse(job.item.body); if (!effects.has(id)) effects.set(id, { response: event.response, applied: true }); job.status = "effect-recorded"; if (crashAfterEffect) throw new Error("fixture worker crash after effect"); job.status = "processed"; };
assert.throws(() => runWorker("wh_5", true), /after effect/);
assert.equal(jobs.get("wh_5").status, "effect-recorded");
const recover = (id) => { const job = jobs.get(id); if (effects.has(id)) job.status = "processed"; else runWorker(id); return job.status; };
assert.equal(recover("wh_5"), "processed");
assert.equal(effects.size, 1);
assert.deepEqual(queue, ["wh_1", "wh_4", "wh_5"]);
console.log(JSON.stringify({ deliveries: 7, claims: claims.size, queue, recovered: "wh_5", projection: projection.get("r1").type }));
console.log("PASS: hostile deliveries rejected and duplicate claimed once");
Run node openai-webhook-corpus.mjs. Expected receipt: PASS: hostile deliveries rejected and duplicate claimed once.
Acknowledge transport before business completion
A webhook request is a delivery handshake, not the place to finish model-result indexing, send email, update analytics, or call another slow service. After authentication, schema validation, and durable claiming, enqueue bounded work and return the success status expected by the provider. This shortens the request path and makes internal retries independent of provider delivery retries.
The queue record needs the webhook ID, event type, payload reference or authorized projection, attempt policy, and trace correlation. It must be committed before acknowledgement. If enqueue and dedupe claim cannot be atomic in your design, define the recoverable intermediate state and a scanner that repairs it. OpenAI webhook verification alone cannot prevent an acknowledged event from being stranded between two stores.
A background response webhook may announce that remote work reached a terminal state, yet your application still needs a completion receipt for its own derived actions. The background-job completion receipts pattern separates provider status from product completion without delaying the HTTP acknowledgement.
When the queue is unavailable, prefer a deliberate non-success response over acknowledging a record that exists only in process memory and cannot be recovered. Provider retry is safer than silent loss.
- Every valid delivery may be acknowledged quickly.
- The webhook ID claims one durable receipt even when transport retries.
- Business completion is reconciled outside the request deadline.
| Signal | Interpretation |
|---|---|
| Retry spiral and dedupe vault | Delivery attempts curve toward one dedupe vault while a worker lane continues separately. |
Make worker effects idempotent and observable
The worker loads the claimed receipt and transitions it through named states under a lease or compare-and-set update. Every side effect receives an idempotency key derived from the application work ID and effect name. If a worker dies after an effect commits but before it records success, the replacement checks the effect provider or reuses the same key rather than assuming nothing happened.
Record processing_started, effect_attempted, effect_confirmed, and processing_finished timestamps without storing sensitive payloads by default. Preserve the primary error and the next retry time. Webhook retry handling inside your queue should distinguish transient transport trouble, rate limits, invalid permanent data, and ambiguous outcomes. OpenAI webhook verification protects entry, while the worker state machine protects execution.
The local corpus injects one worker failure after a derived record is written. Its replacement observes the effect receipt and marks the job complete without writing a second record. That outcome comes from a bounded in-memory fixture and is not a claim about a production queue or database.
Worker dashboards should group failures by transition and effect name, allowing repair without exposing model output or customer payload in broad telemetry. Restrict deeper inspection separately.
Handle duplicates and out-of-order events explicitly
Two different webhook IDs may concern the same remote response and arrive in an order your product did not expect. Dedupe prevents identical deliveries; it does not impose business ordering. Store provider object identity and event version or timestamp as evidence, then reduce events into an application state with explicit transition rules. A later terminal fact may make an earlier progress event irrelevant without making the earlier delivery invalid.
Do not reject every older timestamp at the signature layer. Freshness protects against transport replay relative to receipt time; domain ordering decides whether a verified event advances the projection. The AI agent event sourcing article offers a useful model: append the authenticated fact, then rebuild a state that ignores stale transitions. OpenAI webhook verification and domain reduction solve different problems.
The frozen sequence delivers completed before in_progress for one synthetic response. Both envelopes authenticate and receive separate dedupe rows. The reducer keeps completed as the terminal projection and marks the late progress fact as non-advancing, producing an auditable explanation instead of silently dropping it.
Keep the reducer pure over stored facts so ordering decisions can be replayed after a rule change and compared before a corrected projection is promoted. Version each reducer rule set.
Reconcile when delivery evidence is incomplete
Webhooks are notifications, not the sole source of truth. Schedule reconciliation for work that remains accepted or processing beyond a bounded threshold, for repeated worker ambiguity, and for provider objects whose terminal event may have been missed. Query the authoritative API using the stored provider object ID and compare that status with the local receipt before deciding what to repair.
Reconciliation should be rate-limited, resumable, and safe to repeat. It may enqueue missing business work, close a stale local projection, or flag a contradiction for review. It should not fabricate a webhook or erase the original delivery trail. OpenAI webhook verification gives trustworthy received evidence; reconciliation fills documented gaps with an independently fetched state.
The webhook replay defenses article covers replay windows in more depth. Pair those defenses with a dashboard showing accepted age, queue age, retry count, quarantined digest mismatches, and reconciliation outcomes. Keep payload contents out of broad operational views unless specifically authorized.
Reconciliation credentials should be narrower than the webhook receiver secret: one authenticates incoming bytes, while the other reads authorized remote state. Rotate the credentials independently.
| State | Durable evidence |
|---|---|
| Accepted | verified webhook ID |
| Queued | work claim |
| Processed | effect receipt |
| Reconciled | provider status agrees |
| Signal | Interpretation |
|---|---|
| Webhook receipt state grid | A staircase connects accepted, queued, processed, and reconciled cells with failure exits. |
Ship the receiver as a recovery contract
Document maximum body size, signature helper version, timestamp tolerance, secret rotation procedure, dedupe retention, acknowledgement boundary, worker retry classes, effect idempotency, and reconciliation cadence. Provide a dead-letter inspection path that preserves evidence and requires deliberate replay. An engineer should be able to trace a webhook ID from transport receipt to every derived effect.
Test missing headers, malformed timestamps, wrong signatures, altered bytes, expired messages, duplicate equality, duplicate mismatch, handler exceptions, queue unavailability, worker death after commit, and out-of-order domain facts. OpenAI webhook verification passes only when hostile transport cases are rejected and valid retry cases converge on one application outcome.
Keep one operational promise: a verified envelope is durably recorded before success is acknowledged, and every accepted record eventually becomes processed, ignored with reason, quarantined, or reconciled. That contract is more valuable than a controller that merely returns quickly on the happy path.
Run the corpus during secret rotation and deployment overlap, not only in a unit suite, so configuration ownership remains part of the recovery contract. Archive the bounded receipt.