Verify every incoming request with signatures
Without verification an attacker can send fake events that trigger order fulfillment or account changes. Stripe includes a signature in the Stripe-Signature header computed with HMAC-SHA256 over the exact request body and a timestamp. Your code must read the raw body before any framework parses it, because re-serializing JSON changes whitespace or key order and breaks the match.
Libraries supplied by the provider perform the check and also enforce a default five-minute tolerance on the timestamp to block replay attacks. Use NTP on your server so the clock stays synchronized. IP allow-listing offers a second layer but is insufficient alone because addresses can be spoofed or change.
- Reject any request whose signature does not match the v1 scheme.
- Ignore older signatures or timestamps outside the tolerance window.
- Store the webhook secret in an environment variable never committed to source control.
Acknowledge delivery before doing work
The sender treats any 2xx response as successful delivery. Anything else, including timeouts or redirects, counts as failure and triggers retries. Stripe therefore requires you to return 200 before updating invoices or other long-running tasks. GitHub similarly terminates connections that exceed ten seconds without a 2xx reply.
When traffic spikes, such as at the start of a billing month, synchronous processing can overwhelm your server. The safe pattern is to place the event on an internal queue and return success immediately. Background workers then perform the actual work with their own error handling and retry logic.
- Configure your web framework to read the raw body without modification.
- Use an asynchronous queue for any operation that might exceed a few hundred milliseconds.
- Monitor queue depth and worker latency to detect back-pressure early.
Expect automatic retries and design for them
Stripe retries failed deliveries for up to three days in live mode using exponential backoff. Sandbox deliveries receive only three attempts over a few hours. If your endpoint is disabled when a retry occurs, future attempts stop. Re-enabling the endpoint before the next scheduled retry resumes the schedule.
Manual resends do not cancel the provider’s automatic retry behavior. Therefore your handler must remain idempotent even after you have manually replayed an event. Track the next expected retry time in your monitoring dashboard so you can investigate persistent failures before the three-day window closes.
- Log the delivery attempt count and next scheduled retry for every event.
- Disable an endpoint only when you intend to stop all future deliveries.
- Test your retry handling by deliberately returning 5xx responses in a staging environment.
Detect and ignore duplicate deliveries
At-least-once delivery means the same event can arrive more than once. Stripe states that endpoints may receive the same Event object multiple times and sometimes generates two distinct Event objects for the same underlying change. The recommended deduplication key is the event ID for the first case and the combination of data.object.id plus event.type for the second case.
Store processed event IDs in a durable table with a unique constraint. Attempt the insert before any business logic; if the insert fails because the ID already exists, return 200 without further action. This atomic check prevents race conditions when the same event arrives on two parallel connections.
- Use the event ID (evt_...) as the primary idempotency key.
- Fall back to data.object.id plus event.type when two separate Event objects describe the same change.
- Expire old event ID records only after the provider’s maximum retry window has passed.
Do not rely on event creation order
Stripe does not guarantee that events arrive in the order they were generated. A subscription.created event may reach your endpoint after the corresponding invoice.paid event. Snapshot timestamps are recorded only to the second, so two distinct events can share the same created value. Use the event ID to detect duplicates rather than comparing created times.
When an event arrives out of order, retrieve any missing objects through the provider’s API instead of waiting for the earlier event. This approach keeps your local state consistent without depending on delivery sequence.
- Never use the created timestamp to decide whether an event has already been processed.
- Call the API to fetch the current state of related objects when an expected earlier event is missing.
- Design downstream systems to accept events in any order or to buffer them until dependencies arrive.
Combine the practices into a single handler
A reliable webhook handler performs the following steps in order: read the raw body, verify the signature, parse the JSON, attempt to record the event ID atomically, return 200 immediately if the ID already exists, otherwise enqueue the payload for background processing. Each step is small, fast, and independently testable. The same pattern appears in guidance from Stripe and independent analyses of webhook reliability.
Document the exact headers and body format your endpoint expects. Include a short example request in your internal runbook so on-call engineers can reproduce failures quickly. Review the handler whenever the provider updates its retry or signing behavior.
- Keep the HTTP handler under 200 ms of CPU time before returning a response.
- Expose metrics for signature failures, duplicate receipts, and queue latency.
- Revisit the handler after any change to the provider’s webhook specification.