NewLocalCan 3.0 with CLI, Multi-region and more ⟶

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.

Start Free Trial →DownloadFree trial. No credit card required.

30-second setup

From a stuck handler to a live OpenAI delivery you can read and replay.

  1. 1Start LocalCan and point it at your local server: localcan http 3000. It opens the tunnel and prints your .local domains plus a public HTTPS URL.
  2. 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.
  3. 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 into OPENAI_WEBHOOK_SECRET before you leave the page, because in OpenAI’s words you “won’t be able to view it again”.
  4. 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.
  5. 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 2xx immediately and do the work asynchronously.
Retries
Exponential backoff for up to 72 hours. 3xx redirects are not followed and count as failures. Duplicates are possible, so dedupe on webhook-id with 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}: the webhook-id header, a literal dot, the webhook-timestamp header, a literal dot, then the exact bytes of the body. The HMAC-SHA256 key is not the whsec_ string: strip the whsec_ prefix and base64-decode the rest for the key bytes. The digest is base64-encoded and compared against each space-separated entry in webhook-signature once its v1, 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 at platform.openai.com/settings/project/webhooks and shown once. The SDKs read it from OPENAI_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.
Node.js
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.

LocalCanthe OpenAI dashboard
Reach localhost at allPersistent …localcan.dev HTTPS URLNothing: OpenAI posts to a public URL only
Fire an event on demandUse the dashboard’s test eventTest event with sample data, built in
Choose which events arriveNot its job: it forwards whatever is sentPer-endpoint event-type subscriptions
See the raw headers and bodyGUI inspector, headers + body, on by defaultNot shown
Re-send a real deliveryOne-click Replay of the exact captured requestNo documented redelivery of a past event
Keep the signing secret validURL never changes, so the saved whsec_ keeps workingShown once at creation; a new URL means a new secret
Works across providersOne URL for OpenAI, Stripe, GitHub…OpenAI only
Two developers, one projectEach gets their own persistent URL and endpointEndpoints are per project, so both see the project’s events

OpenAI webhook FAQ

Stop guessing why the webhook 401'd. Get a stable OpenAI endpoint, read every delivery, and replay it until your handler is right.

Free trial. No credit card required.