
1. Choose one event and store the secret
A webhook receiver has three separate jobs: authenticate the incoming message, decide whether this event belongs to your application, and prevent a repeated delivery from repeating its side effect. A valid signature answers only the first question. This guide uses OpenAI API's response.completed event and deliberately makes the side effect a local counter so you can see each decision. Replace that counter with a durable job reservation before deployment.
In the OpenAI dashboard, create a webhook endpoint for your API project, choose response.completed, and record the signing secret when it is shown. OpenAI's webhook guide says the endpoint needs a public URL and that the secret cannot be viewed again after creation. Keep it in server-side secret storage or an environment variable; do not put it in a browser, repository, article example, or log. An API key and a webhook signing secret serve different purposes: a key authorizes your outbound API requests, while the signing secret lets the receiver check inbound messages.
For the local exercise, run npm init -y and npm install express openai in an empty test project. Create a disposable whsec_ value with node -e "console.log('whsec_' + require('node:crypto').randomBytes(32).toString('base64'))", then set OPENAI_WEBHOOK_SECRET to that value in both terminals. Do not run a fixture that knows the real production secret. A public endpoint is needed for actual provider delivery, but the local fixture can exercise the receiver without exposing a port to the Internet.
2. Preserve the body and verify before branching
Use a raw text parser on this route. Parsing JSON and serializing it again may change whitespace or key order, so the bytes passed to signature verification would differ from those delivered. OpenAI's official Express sample uses express.text({ type: "application/json" }) and client.webhooks.unwrap(req.body, req.headers). The helper verifies the signature and returns the parsed event. Keep any generic express.json() middleware away from this route.
Save the following as receiver.mjs. The Set and handled count exist only to make this local test observable. They disappear on restart and do not protect multiple processes. The verification gate comes before the event-type decision, duplicate check, or count increment.
import express from "express";
import OpenAI from "openai";
if (!process.env.OPENAI_WEBHOOK_SECRET) {
throw new Error("Set OPENAI_WEBHOOK_SECRET");
}
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY ?? "local-fixture-only",
webhookSecret: process.env.OPENAI_WEBHOOK_SECRET
});
const app = express();
const seen = new Set(); // Local demonstration only; use durable storage in production.
let handled = 0;
app.post("/webhook", express.text({ type: "application/json" }), async (req, res) => {
let event;
try {
event = await client.webhooks.unwrap(req.body, req.headers);
} catch (error) {
if (error instanceof OpenAI.InvalidWebhookSignatureError) {
return res.status(400).json({ result: "invalid_signature" });
}
return res.status(400).json({ result: "invalid_event" });
}
const deliveryId = req.headers["webhook-id"];
if (event.type !== "response.completed" || !event.data?.id ||
typeof deliveryId !== "string") {
return res.status(200).json({ result: "ignored" });
}
if (seen.has(deliveryId)) {
return res.status(200).json({ result: "duplicate", handled });
}
seen.add(deliveryId);
handled += 1;
return res.status(200).json({ result: "accepted", handled });
});
app.listen(8000, "127.0.0.1", () => console.log("Receiver on localhost:8000"));
The fixture sends a plausible event envelope, but the receiver does not fetch the response or grant application access. In a real system, check that event.data.id maps to a response or job your application started and that the intended action is allowed for that job. Signature verification cannot make an unfamiliar resource authorized. If you need to retrieve the completed response, do that in a worker after a durable reservation; OpenAI recommends responding quickly and offloading nontrivial processing.
3. Send an accepted fixture and a changed body
The Standard Webhooks specification, which OpenAI says its API webhooks follow, signs the message ID, timestamp, and exact body text. The next script signs a dummy body with a freshly generated local key. It then sends the same headers with one changed body field. Keep the receiver running, save this as probe.mjs, and run node probe.mjs in a second terminal. The script checks response status and body, so a failure is visible.
import { createHmac } from "node:crypto";
const secret = process.env.OPENAI_WEBHOOK_SECRET;
if (!secret?.startsWith("whsec_")) throw new Error("Set a disposable whsec_ secret");
const key = Buffer.from(secret.slice(6), "base64");
const id = "wh_local_probe_1";
const timestamp = String(Math.floor(Date.now() / 1000));
const body = JSON.stringify({
object: "event", id: "evt_local_1", type: "response.completed",
created_at: Number(timestamp), data: { id: "resp_local_1" }
});
const signature = createHmac("sha256", key)
.update(`${id}.${timestamp}.${body}`).digest("base64");
const headers = {
"content-type": "application/json",
"webhook-id": id,
"webhook-timestamp": timestamp,
"webhook-signature": `v1,${signature}`
};
async function send(payload) {
const response = await fetch("http://127.0.0.1:8000/webhook", {
method: "POST", headers, body: payload
});
const result = await response.json();
console.log(response.status, result);
return { status: response.status, ...result };
}
const actual = [
await send(body),
await send(body.replace("resp_local_1", "resp_changed")),
await send(body)
];
const expected = [
{ status: 200, result: "accepted", handled: 1 },
{ status: 400, result: "invalid_signature" },
{ status: 200, result: "duplicate", handled: 1 }
];
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error("Webhook probe failed: compare the three responses above");
}
console.log("Webhook probe passed");
Start the receiver, then run the probe while its timestamp is fresh. The Standard Webhooks reference implementation decodes the whsec_ secret and checks a five-minute timestamp window; this describes the reference library, not a guarantee about every OpenAI SDK release. If the accepted fixture fails, first compare the environment variable, current clock, body text, and all three headers.
4. Deduplicate by delivery ID
The third send repeats the original signed delivery. OpenAI documents that duplicate copies can occur and recommends the webhook-id header as the idempotency key. Here, the expected sequence is 200 accepted handled=1, 400 invalid_signature, and 200 duplicate handled=1. A changed payload must not increment the count. A duplicate should acknowledge receipt but must not repeat work. These are expected results for a correctly configured local exercise, not results claimed from a test run for this article.
For deployment, replace the in-memory Set with a durable, atomic reservation keyed by the verified webhook-id: for example, insert into a table with a unique constraint in the same transaction that records work, or enqueue into a system that enforces a unique job key. A lookup followed by a separate insert can race when two copies arrive together. Return 2xx after the durable handoff succeeds. If storage fails before that handoff, return a failure so the provider can retry. OpenAI's guide says unsuccessful or slow requests may be retried for up to 72 hours; this is why an in-memory flag is insufficient after a restart. The broader queue migration plan covers how to move longer work behind an endpoint, and the AI reliability guide covers incident and fallback decisions.
5. Record the result and repeat with a real delivery
Keep a short test record with the SDK version, receiver revision, disposable secret identifier (not its value), fixture timestamp, the three status/result pairs, and the final handled count. A pass requires one accepted action, zero action on the changed body, and zero additional action on the duplicate. A 200 from all three calls is a failure even if your log says “verified”; it means the rejection boundary was not demonstrated. A 400 from all three calls means you have not yet proved the positive path.
Then configure a reachable HTTPS endpoint and trigger one subscribed response.completed event from your own API project. Record the verified delivery ID and event type without logging the signing secret or sensitive payload. Confirm the event maps to a job you own, the job is reserved exactly once, and a second copy cannot repeat the action. This live-provider check is a separate deployment gate; the local synthetic fixture proves only that your receiver handles a signature shaped according to the documented scheme. For session events, use the separate Agents API session webhook guide and its event-specific recovery behavior. No provider event or local fixture described here was actually sent during preparation of this article.