RELIABLE WEBHOOKSNo lost or doubled eventsYour app takes webhooks from Stripe, Shopify and your CRM, and now and then an order stays unpaid or a customer gets the same email twice. The providers document why: events can arrive late, twice or out of order, and Shopify says delivery isn't always guaranteed. A reliable endpoint expects all of it.
Why do events go missing or run twice?
Mostly because of retries. If your endpoint errors, times out or replies with anything other than a 2xx status, the provider sends the event again. Stripe and Shopify count a redirect as a failure too, so an endpoint URL that redirects fails on every delivery.
- Stripe retries live events for up to three days, with exponential backoff.
- Shopify retries 8 times over 4 hours; after 8 consecutive failures it deletes a subscription created through the Admin API.
- HubSpot retries up to 10 times over 24 hours.
A handler that replies late runs the event twice; one that keeps failing runs out of retries and misses the change.
How do you know a webhook is genuine?
Check the signature on every request, against the raw body, before trusting any field. Anyone can post to the URL, and Stripe warns that a fake event could fulfil an order or grant account access.
- Stripe: the
Stripe-Signatureheader and the endpoint'swhsec_secret, checked with Stripe's library, which also rejects stale timestamps to stop replays. - Shopify: a base64 HMAC-SHA256 in
X-Shopify-Hmac-SHA256, computed from the raw body with your app's client secret. - HubSpot:
X-HubSpot-Signature, built from your app secret and the un-parsed body.
Stripe says any change to the body makes verification fail, and Shopify says to verify before any body-parsing middleware runs. Reject a failed check with a 4xx and log it.
What should the endpoint do before it replies?
Verify, save, reply, nothing more; the real work runs later in a background job. Shopify allows five seconds for the whole request and HubSpot retries replies slower than that. Stripe gives no figure, but says to return a 2xx before any complex logic and to use an asynchronous queue, so spikes such as monthly renewals don't overwhelm your servers.
// app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
const body = await req.text() // raw body, unparsed
let event
try {
event = stripe.webhooks.constructEvent(body,
req.headers.get('stripe-signature') ?? '', process.env.STRIPE_WEBHOOK_SECRET!)
} catch {
return new Response('Invalid signature', { status: 400 })
}
// unique key on (source, eventId): a repeat inserts nothing
await db.webhookEvents.insertIgnoreDuplicate({
source: 'stripe', eventId: event.id, type: event.type, payload: body,
})
return new Response(null, { status: 200 })
}The table doubles as the queue: a worker processes new rows and marks each done or failed, so a crash leaves the event waiting, not lost.
How do you stop the same event running twice?
Store each event id under a unique key, skip any id you have seen, and still reply with a 2xx so the retries stop.
- Stripe: log processed event ids. In some cases it sends two separate Event objects, so also check the
data.objectid with the event type. - Shopify: dedupe on
X-Shopify-Webhook-Id. Two subscriptions to one topic get different webhook ids that share oneX-Shopify-Event-Id. - HubSpot: a request can carry a batch, a notification can repeat and
eventIdisn't guaranteed unique, so key on more than one field.
Then make the work safe to repeat, as Shopify recommends: set an order to paid rather than adding a payment, and update an existing record instead of creating another.
What if events arrive out of order?
Never depend on order, because neither Stripe nor Shopify guarantees it. A new Stripe subscription can deliver invoice.paid before customer.subscription.created, and a Shopify products/update can land before products/create. Don't sort Stripe events by created either: it is recorded in seconds.
- Compare times before you overwrite: Shopify recommends
X-Shopify-Triggered-Atorupdated_at, and HubSpot sendsoccurredAt. Ignore anything older than what you applied. - Or treat the event as a prompt and fetch the current object from the API. Stripe suggests this, and its thin events, recommended for new integrations, include a method for it.
How do you find and replay failed events?
Log every delivery with its source, id, type, status and error, and alert a named person when events fail or sit unprocessed. Stripe says signature failures most often mean the wrong signing secret. Then recover with each provider's tools.
- Stripe: Workbench's Event deliveries tab marks events Delivered, Pending or Failed. Dashboard Resend works for 15 days after an event is created and
stripe events resendin the CLI for 30. A resend doesn't cancel automatic retries, so dedupe matters here too. - Shopify: for Dev Dashboard and CLI apps, the Logs page shows the past 7 days, and warning emails go to the app's emergency developer address. To recover, Shopify suggests fetching the missed period through its API and running a regular reconciliation job.
Where to start
Check each endpoint, starting with the one that moves money:
- Signature checked against the raw body; failures rejected and logged.
- A 2xx within seconds, with slow work in a background job.
- Event ids under a unique key, repeats skipped with a 2xx, and writes safe to repeat.
- No assumption about order; current state fetched before anything critical.
- Failures alerted to a named person, with a replay plan for each provider.
Our web app development service builds webhook endpoints this way into Next.js products. If events are already going missing, send us the details and we will trace where they drop.
FAQ
Frequently asked questions
Is IP allowlisting enough instead of checking signatures?
No. Stripe publishes the IP addresses it sends webhooks from and recommends using an allowlist and signature verification together. An allowlist limits who can reach the endpoint; only the signature proves the event wasn't sent or modified by a third party. On Shopify, the HMAC check applies to HTTPS deliveries, while its Pub/Sub and EventBridge options don't need one.
What status code should we return for event types we don't handle?
A 2xx, after the signature check, with the type logged. Anything else counts as a failure and triggers retries for an event you will never process. Better still, subscribe only to the types you use: Stripe says listening for extra events, or all of them, puts undue strain on your server.
Why does signature verification fail in production but work locally?
Usually the secret. Stripe generates a separate signing secret for each endpoint, different again for test and live mode, and the secret printed by stripe listen differs from the one on a Dashboard endpoint. The next suspect is middleware or a framework that parses, reorders or re-encodes the body before your code reads it.
What happens if our endpoint is down for a day?
Stripe keeps retrying live events for up to three days, so most arrive once you are back, and your dedupe absorbs any repeats. Shopify stops after 8 attempts over 4 hours, so fetch the missing period through its API and, for subscriptions created through the Admin API, check they still exist. HubSpot's retries stop after 24 hours.
How long should we keep processed event ids?
At least as long as the provider can still deliver the event. For Stripe that is 30 days, because the CLI can resend an event for that long after it was created. We keep the full event log longer, because it is also the record you check when a customer asks what happened to their order.
How do we test webhook handling before launch?
Use the providers' own tools. stripe listen --forward-to sends events to your local endpoint and stripe trigger creates test events, while on Shopify, creating or updating something in a development store fires the matching webhook. Then deliver the same event twice and confirm the second one changes nothing.

