Test OpenAI Webhooks Locally
InvalidWebhookSignatureError: The given webhook signature does not match the expected signature, or its sibling, Webhook timestamp is too old. Get a stable HTTPS URL OpenAI can post to, read the exact webhook-signature and raw body it sent, and replay that one delivery until unwrap() stops throwing.
30-second setup
From a stuck handler to a live OpenAI delivery you can read and replay.
- 1Start LocalCan and point it at your local server:
localcan http 3000. It opens the tunnel and prints your.localdomains plus a public HTTPS URL. - 2Grab your persistent URL, something like
your-app-12.localcan.dev. It is saved to your project file and comes back identical after every restart, so you wire it up once. - 3Open the webhook settings page at Dashboard → Settings → Project → Webhooks (
platform.openai.com/settings/project/webhooks). Webhooks are configured per project, so check the project selector first. Create an endpoint, paste your LocalCan URL with your route, e.g.https://your-app-12.localcan.dev/webhooks/openai, and subscribe to the event types you want. OpenAI then shows the signing secret (whsec_…). Copy it intoOPENAI_WEBHOOK_SECRETbefore you leave the page, because in OpenAI’s words you “won’t be able to view it again”. - 4From that same settings page, trigger a test event with sample data. It is the only on-demand event OpenAI gives you: there is no
openai webhooks trigger, and a real one arrives when the work finishes. A Batch job can take up to 24 hours. - 5Open Inspect Traffic in LocalCan. The delivery lands with full headers and body. Expand it, fix your handler, and hit Replay to send the exact same request again. No new test event needed.
Why localhost can't receive OpenAI webhooks
OpenAI webhooks exist because the interesting calls are asynchronous. Background Responses, Batch, fine-tuning, Evals and Realtime SIP finish minutes or hours after you kicked them off, so OpenAI posts the result from its own servers to a URL on the public Internet. An ordinary Chat Completion sends nothing at all.
localhost:3000 is not on the public Internet, and OpenAI’s guide states the requirement plainly: testing webhooks “requires a URL that is available on the public Internet”. It then recommends ngrok by name. Two rules shape that URL: every working setup uses HTTPS, and 3xx redirects are not followed, so an http→https redirect or a trailing-slash bounce in front of your handler counts as a failed delivery rather than a hop. You also want a URL that does not change, for a reason specific to OpenAI: the signing secret is bound to the endpoint, and it is shown once.
What makes the OpenAI loop fast
One endpoint, one secret, kept
Changing your URL is not a copy-paste job here. The whsec_ secret belongs to the endpoint you created and OpenAI displays it exactly once, so a new URL costs you a new endpoint, a re-picked event list, a secret you cannot recover if you miss it, and an OPENAI_WEBHOOK_SECRET edit before anything verifies again.
The endpoint you abandoned does not go quiet either: it retries a dead address with exponential backoff for up to 72 hours. A …localcan.dev URL is written to your project file and comes back identical after every restart, so you visit the settings page once and the secret in your .env stays the right one.
See all three webhook-* headers
unwrap() throws one message for several unrelated causes, which is why a wrong secret and a slept laptop look identical in the stack trace. The inspector shows webhook-id, webhook-timestamp and webhook-signature beside the untouched body, every input the HMAC covers.
Read them and the diagnosis takes seconds. A webhook-timestamp well behind your clock is the 300-second tolerance, not your code. A body that reaches your verifier as [object Object] is a global express.json(). A signature that is present, well-formed and still mismatched is the secret from the other project.
Replay instead of paying for it twice
This is where OpenAI differs from every payments or Git provider on this hub. Re-firing a real batch.completed means re-running the batch: real tokens, real spend, and a queue window that can stretch to 24 hours.
Capture it once and Replay pushes those exact signed bytes into your handler as many times as your parser needs, at no cost and no wait. OpenAI publishes no way to redeliver a real event on request, so the copy LocalCan holds may be the only one you get.
Inspect & replay OpenAI events
Common events
response.completedA background or Deep Research Response finished. `data.id` is the Response id to fetch.response.failedPair it with `response.incomplete` and `response.cancelled`.batch.completedThe Batch API job finished. The event most worth capturing, since re-running it costs money and hours.batch.expiredThe batch hit its completion window unfinished, the quiet failure people forget to handle.fine_tuning.job.succeededA fine-tune is ready. `fine_tuning.job.failed` and `.cancelled` complete the set.eval.run.succeededAn Evals run finished, usually the trigger for a CI or scorecard update.realtime.call.incomingAn inbound SIP call for a Realtime agent. Your handler answers while a human is on the line.
These are the surfaces that actually emit webhooks. Fire a test event from the settings page, watch it land in Inspect Traffic with its webhook-id / webhook-timestamp / webhook-signature headers and JSON body, and expand it to read type and data.id, the id you hand back to the API to collect the real result. Then fix your handler and hit Replay.
Two caveats specific to OpenAI. realtime.call.incoming is a blocking step in call setup, so a cold-start dev server drops a live phone call instead of logging an error, which makes a captured copy the only sane thing to iterate against. And verification enforces a 300-second window on webhook-timestamp, so a capture from this morning fails as too old unless you widen the tolerance.
- Timeout
- OpenAI publishes no timeout. Its guide says only that a delivery which “doesn’t respond within a few seconds” is retried, so acknowledge with a
2xximmediately and do the work asynchronously. - Retries
- Exponential backoff for up to 72 hours.
3xxredirects are not followed and count as failures. Duplicates are possible, so dedupe onwebhook-idwith a TTL longer than the retry window. - Auto-disable
- Not documented. OpenAI publishes no auto-disable rule, so assume a stale endpoint collects retries for the full 72 hours rather than switching itself off.
Verify the OpenAI request
- Header
- webhook-signature
- Algorithm
- HMAC-SHA256, base64-encoded (space-delimited
v1,<sig>list) - What's signed
- OpenAI signs the string
{webhook-id}.{webhook-timestamp}.{raw body}: thewebhook-idheader, a literal dot, thewebhook-timestampheader, a literal dot, then the exact bytes of the body. The HMAC-SHA256 key is not thewhsec_string: strip thewhsec_prefix and base64-decode the rest for the key bytes. The digest is base64-encoded and compared against each space-separated entry inwebhook-signatureonce itsv1,prefix is removed. Several entries appear while a secret is rotating, and any may match. - Secret
- The endpoint signing secret (
whsec_…), issued when you create the endpoint atplatform.openai.com/settings/project/webhooksand shown once. The SDKs read it fromOPENAI_WEBHOOK_SECRET. It is scoped to that endpoint in that project, so a secret that looks right but never matches is usually the right secret for the wrong project. Lose it and the only route back is rotating it from the same page.
import express from 'express'
import OpenAI from 'openai'
const client = new OpenAI() // reads OPENAI_API_KEY
const webhookSecret = process.env.OPENAI_WEBHOOK_SECRET // whsec_...
const app = express()
// OpenAI signs the RAW bytes. Mount express.raw on this route only. A global
// express.json() hands unwrap() a parsed object and every signature fails.
app.post('/webhooks/openai', express.raw({ type: 'application/json' }), async (req, res) => {
try {
// unwrap() recomputes HMAC-SHA256 over "{webhook-id}.{webhook-timestamp}.{body}",
// checks webhook-timestamp against a tolerance, then JSON.parses the payload.
// The 4th argument is that tolerance in seconds and defaults to 300. Widen it
// in dev to replay an older capture:
// client.webhooks.unwrap(body, req.headers, webhookSecret, 86400)
const event = await client.webhooks.unwrap(
req.body.toString('utf8'),
req.headers,
webhookSecret,
)
if (event.type === 'response.completed') {
// event.data.id is the Response id. Fetch the result AFTER responding.
}
// Acknowledge first, work later: anything that is not a 2xx "within a few
// seconds" is retried with backoff for up to 72 hours.
res.status(200).send('ok')
} catch (err) {
// InvalidWebhookSignatureError: parsed body, wrong secret, or stale timestamp.
res.status(400).send('Invalid signature: ' + err.message)
}
})
app.listen(3000)LocalCan does not verify the signature for you (client.webhooks.unwrap() does), but it shows every input that hash depends on, so the failure is legible instead of one opaque exception. The inspector holds the real webhook-id, webhook-timestamp and webhook-signature next to the untouched raw body, which separates the three causes that throw the same error: a body a JSON parser rewrote, a timestamp outside the 300-second window, and a whsec_ from another project. Fix the handler, hit Replay, and the identical bytes come back through. No second batch run, no second research charge.
Not by default, and OpenAI hands you the fix. webhook-timestamp is signed into the digest and unwrap() rejects anything more than 300 seconds old with Webhook timestamp is too old, so a capture from five minutes ago verifies and one from this morning does not. The fourth argument to unwrap() is that tolerance in seconds. Pass 86400 behind a dev-only flag and yesterday’s captured batch.completed replays exactly as it arrived. Keep the default in production, where the window is the replay-attack defence.
LocalCan vs the OpenAI dashboard
There is nothing to compare against on the CLI side. The OpenAI CLI covers Responses, Images, Speech, Transcription, Files and the Admin API, with no webhooks command group and no listen or forward mode, and the SDKs ship client.webhooks.unwrap(), a verifier rather than a tunnel. So the real choice is the dashboard’s own test affordances against a public URL pointed at your machine. The dashboard is the better place to make an event happen. It is not a place to see what your handler received, and it has no documented way to send a past event again.
| LocalCan | the OpenAI dashboard | |
|---|---|---|
| Reach localhost at all | Persistent …localcan.dev HTTPS URL | Nothing: OpenAI posts to a public URL only |
| Fire an event on demand | Use the dashboard’s test event | Test event with sample data, built in |
| Choose which events arrive | Not its job: it forwards whatever is sent | Per-endpoint event-type subscriptions |
| See the raw headers and body | GUI inspector, headers + body, on by default | Not shown |
| Re-send a real delivery | One-click Replay of the exact captured request | No documented redelivery of a past event |
| Keep the signing secret valid | URL never changes, so the saved whsec_ keeps working | Shown once at creation; a new URL means a new secret |
| Works across providers | One URL for OpenAI, Stripe, GitHub… | OpenAI only |
| Two developers, one project | Each gets their own persistent URL and endpoint | Endpoints are per project, so both see the project’s events |
OpenAI webhook FAQ
Test webhooks for other providers
- Test Stripe Webhooks Locally →
- Test GitHub Webhooks Locally →
- Test Shopify Webhooks Locally →
- Test Twilio Webhooks Locally →
- Test Slack Webhooks Locally →
- Test Clerk Webhooks Locally →
- Test Supabase Webhooks Locally →
- Test GitLab Webhooks Locally →
- Test Square Webhooks Locally →
- Test Discord Webhooks Locally →
- Test Resend Webhooks Locally →