Webhook URL Example — Production-Ready Endpoint Patterns for Stripe, GitHub, and Custom Events
Quick answer
💡A webhook URL is an HTTPS endpoint on your server that receives HTTP POST requests from an external service when an event occurs. The endpoint must return a 2xx response within the provider's timeout (Stripe: 20 seconds, GitHub: 10 seconds) or the event is retried. Always verify the request signature before processing, return 200 immediately, and process the payload in a background job to avoid timeouts. Use the HTTP Request Builder to simulate incoming webhook payloads during development.
Error symptoms
- ✕
Stripe dashboard shows webhook deliveries failing with timeout errors - ✕
GitHub webhook shows red X on recent deliveries with 'Connection refused' or 5xx response - ✕
Same event processed multiple times because idempotency is not implemented - ✕
Webhook signature verification fails even though the secret is correct - ✕
Local development webhook URL returns 404 because ngrok is not running - ✕
Replay attack: old webhook payloads being accepted without timestamp validation
Common causes
- •Endpoint takes longer than the provider timeout to respond — processing happens synchronously
- •Signature verification uses raw string comparison instead of timing-safe comparison
- •Payload is parsed before signature verification — HMAC must be computed on the raw body
- •No idempotency check — the same event ID processed multiple times on provider retries
- •HTTP endpoint used instead of HTTPS — most providers refuse insecure webhook URLs
- •Local development server not exposed via ngrok or equivalent tunnel
When it happens
- •When the webhook endpoint performs database writes synchronously before returning 200
- •After deploying to a new environment where the webhook secret is not configured
- •During high-traffic events when the webhook endpoint is slow due to database load
- •When testing locally without a public URL exposed via a tunneling tool
- •After a secret rotation where the old secret is still being used to verify signatures
Examples and fixes
A production-ready Stripe webhook endpoint that verifies signature, deduplicates events, and processes asynchronously.
Stripe webhook with signature verification and idempotency
❌ Wrong
// Wrong — no signature verification, synchronous processing
app.post('/webhooks/stripe', express.json(), async (req, res) => {
const event = req.body;
// Dangerous: any HTTP client can send fake events
if (event.type === 'payment_intent.succeeded') {
// Synchronous DB write — may exceed 20s Stripe timeout
await fulfillOrder(event.data.object);
await sendConfirmationEmail(event.data.object);
}
res.json({ received: true });
});✅ Fixed
// Correct — verify signature, return 200 fast, process async
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }), // Must use raw body
async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, sig, process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(`Webhook error: ${err.message}`);
}
// Idempotency: skip if already processed
const alreadyProcessed = await db.events.findOne({ stripeId: event.id });
if (alreadyProcessed) return res.json({ received: true });
await db.events.create({ stripeId: event.id, type: event.type });
// Enqueue for async processing — return 200 immediately
await queue.add('stripe-event', event);
res.json({ received: true }); // Must respond within 20s
}
);Three things are critical in this fix. First, use express.raw() not express.json() — Stripe's signature verification requires the raw request body bytes. Parsing JSON first changes the byte representation and breaks HMAC verification. Second, call stripe.webhooks.constructEvent() before any other processing — reject requests with invalid signatures immediately. Third, enqueue the event for async processing and return 200 immediately. Stripe retries any webhook that does not receive a 2xx within 20 seconds, so synchronous processing that takes more than a few seconds will trigger duplicate deliveries.
GitHub sends X-Hub-Signature-256 with every webhook — verify it with timing-safe comparison.
GitHub webhook with HMAC-SHA256 verification
❌ Wrong
// Wrong — string comparison is vulnerable to timing attacks
app.post('/webhooks/github', express.json(), (req, res) => {
const signature = req.headers['x-hub-signature-256'];
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET)
.update(req.body) // Already parsed JSON — wrong!
.digest('hex');
// Dangerous: string comparison leaks timing information
if (signature !== expected) return res.status(401).send('Invalid');
handleGitHubEvent(req.body);
res.sendStatus(200);
});✅ Fixed
// Correct — raw body + timing-safe comparison
app.post('/webhooks/github',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['x-hub-signature-256'];
if (!sig) return res.status(401).send('Missing signature');
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET)
.update(req.body) // raw Buffer — correct
.digest('hex');
// crypto.timingSafeEqual prevents timing-based secret extraction
const sigBuf = Buffer.from(sig);
const expBuf = Buffer.from(expected);
if (sigBuf.length !== expBuf.length ||
!crypto.timingSafeEqual(sigBuf, expBuf)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString());
const eventType = req.headers['x-github-event'];
// Process synchronously only for fast operations
handleGitHubEvent(eventType, event);
res.sendStatus(200);
}
);Two security requirements apply to webhook signature verification. First, compute the HMAC over the raw request bytes, not over the parsed JSON object. JSON serialization is not deterministic — whitespace and key ordering can differ, producing a different HMAC than GitHub computed. Use express.raw() to preserve the original bytes. Second, use crypto.timingSafeEqual() for comparison. Regular string comparison (===) returns early on the first mismatched character, leaking information about how many characters matched — a timing oracle that can be used to brute-force the secret. timingSafeEqual always compares all bytes in constant time.
How webhook delivery and retry logic works
A webhook is a server-to-server HTTP POST request that an external service (Stripe, GitHub, Shopify) sends to your endpoint when an event occurs. Unlike a regular API where you initiate the request, with webhooks the provider initiates the request to you. Your endpoint must be publicly accessible over HTTPS, respond quickly with a 2xx status, and be idempotent — capable of processing the same event multiple times without creating duplicate side effects.
Every provider has a response timeout. Stripe requires a response within 20 seconds or it marks the delivery as failed and schedules a retry. GitHub requires a response within 10 seconds. Shopify allows 30 seconds. If your endpoint performs database writes, sends emails, or calls other APIs synchronously, it is easy to exceed these limits under production load. The pattern that works at scale is: verify the signature, store the raw event, return 200 immediately, then process the event in a background worker queue.
Webhook providers retry failed deliveries on an exponential schedule. Stripe retries after 1 hour, then 3 hours, then 6 hours, 12 hours, and 24 hours — up to 4 more attempts after the initial failure. GitHub retries 3 times with 5-second delays between attempts. Because providers retry, your endpoint must be idempotent. Store processed event IDs in a database table with a unique constraint on the provider's event ID, and skip processing if the ID already exists. This prevents double-processing when the same event is delivered twice.
HTTPS is required by virtually every webhook provider. Stripe refuses to send webhooks to HTTP URLs. GitHub sends webhooks to HTTP in development mode but warns against it. A self-signed certificate is not accepted — your endpoint needs a certificate from a recognized CA. For production, this is not an issue if you use a cloud provider or a reverse proxy like nginx with Let's Encrypt. For local development, use ngrok (ngrok http 3000) which creates a public HTTPS URL that tunnels to your local port. The Stripe CLI also provides a local webhook forwarding feature (stripe listen --forward-to localhost:3000/webhooks/stripe) that forwards real Stripe events without requiring ngrok.
IP allowlisting provides an additional layer of defense for high-security webhook endpoints. Stripe publishes their webhook source IP ranges at stripe.com/files/ips/ips_webhooks.txt. GitHub publishes its IP ranges via the GitHub Meta API endpoint. Configuring your server firewall or load balancer to only accept requests from these IP ranges prevents webhook forgery from arbitrary internet addresses. This defense complements signature verification — use both.
Debugging failed webhook deliveries
The first place to look for failed webhook deliveries is the provider's dashboard. Stripe provides a webhook events log at dashboard.stripe.com/webhooks — click on your webhook endpoint to see every delivery attempt with the request headers, request body, response status, response body, and response time. GitHub shows delivery history at github.com/settings/hooks or your repository's settings. Both dashboards let you redeliver a specific event, which is invaluable for debugging without waiting for new events.
The response body your endpoint returns is visible in the provider dashboard. When debugging a failing webhook, return detailed error information in the response body (for non-production environments). If signature verification fails, include the expected vs received signature prefix in the response. If idempotency check fails, include the event ID. This information appears in the Stripe and GitHub delivery log and speeds up diagnosis significantly.
In the browser DevTools or server logs, check whether your endpoint is receiving the request at all. A '404 Not Found' means the URL path does not match your route definition. A '405 Method Not Allowed' means the route only accepts GET but webhooks send POST. A '413 Payload Too Large' means your body parser has a size limit that is too small for the webhook payload — GitHub pushes with large diffs can exceed the default 100KB limit of express.json(). A timeout with no response means your processing code is hanging or blocking the event loop.
For signature verification failures specifically, the most common cause is a body parser conflict. If you have a global express.json() middleware and also use express.raw() in the webhook route, the request body may already be parsed when it reaches the raw middleware. Ensure the webhook route is registered before the global JSON middleware, or use a conditional body parser that switches based on the route path. Log the raw request body bytes and the signature header before the verification call to confirm they match what you expect.
Use /tools/http-request-builder to send test POST requests to your webhook endpoint with manually crafted payloads and headers. You can test your signature verification logic by computing the correct HMAC for a test payload and sending it with the appropriate signature header. Test the rejection path by sending an incorrect signature and verifying your endpoint returns 401. This workflow tests your endpoint behavior before connecting it to a real provider.
Implementation patterns for reliable webhooks
The reliable webhook implementation follows a three-phase pattern: authenticate, acknowledge, process. Authenticate means verifying the signature before touching the payload. Acknowledge means returning 200 as fast as possible — within milliseconds of signature verification. Process means handling the event business logic asynchronously in a worker queue after the 200 response is already sent.
For signature verification, always compute the HMAC over the raw request body bytes. In Node.js with Express, register the webhook route with express.raw() middleware specifically for that path, not express.json(). express.raw() gives you a Buffer in req.body. Compute the HMAC with crypto.createHmac('sha256', secret).update(req.body).digest('hex'). Compare with crypto.timingSafeEqual() to prevent timing attacks. Stripe's SDK handles this internally — use stripe.webhooks.constructEvent() with the raw Buffer.
For idempotency, create a processed_events table with columns event_id (unique, varchar), event_type, processed_at, and source (stripe, github, etc). Before processing any event, attempt to insert the event ID. If the INSERT succeeds, process the event. If it fails with a unique constraint violation, the event was already processed — return 200 without re-processing. This pattern is safe under concurrent delivery: if two deliveries of the same event arrive simultaneously, only one INSERT will succeed.
For local development, run the Stripe CLI with 'stripe listen --forward-to localhost:3000/webhooks/stripe'. This forwards real Stripe events from your account to your local server and prints the webhook signing secret you should use for local verification. For GitHub webhooks, ngrok is the standard tool: 'ngrok http 3000' gives you a public HTTPS URL like https://abc123.ngrok.io that GitHub can reach. Set this URL in your GitHub webhook settings and update it each time ngrok restarts (or use ngrok's static domains feature with a paid plan).
For replay attack prevention, validate the timestamp in the webhook payload. Stripe includes a timestamp in the Stripe-Signature header (t=1234567890). stripe.webhooks.constructEvent() validates that this timestamp is within 300 seconds of the current time by default. This prevents an attacker who intercepted a valid webhook payload from replaying it hours later. If the provider does not include a timestamp in the signature, store the event ID with a processed timestamp and reject events that claim to be older than your retention window.
Webhook edge cases that cause production incidents
Webhook fan-out creates a thundering herd when a single user action triggers many webhook events simultaneously. Shopify can send 50 or more webhook events for a single order if multiple apps are subscribed to overlapping event types. If your endpoint processes each synchronously, the database receives 50 concurrent writes at once. Use a message queue with a concurrency limit to smooth out the processing rate and prevent database connection pool exhaustion.
Event ordering is not guaranteed by most webhook providers. Stripe explicitly states that events may arrive out of order. Your webhook processing logic must not assume that a 'payment_intent.succeeded' event arrives before a 'charge.created' event for the same payment. Design your database schema and event handlers to be order-independent — use upsert operations rather than assuming insert-before-update, and derive the final state from the event data itself rather than from the processing order.
Secret rotation creates a window where webhooks fail if not handled carefully. When you rotate a webhook secret, update your endpoint to accept both the old and new secret during the transition period. Generate the HMAC with both secrets, then accept the request if either HMAC matches. After all providers have been updated to use the new secret and all in-flight deliveries are resolved, remove the old secret from your verification logic. This graceful rotation prevents 401 rejections during the transition.
Dead letter queues prevent silent event loss when your queue worker fails repeatedly. If a worker crashes processing a Stripe event after the 200 was already sent, the provider does not retry (it already got 200). Configure your message queue to move failed jobs to a dead letter queue after a maximum retry count. Monitor the dead letter queue and implement alerting for any events that land there. For critical business events like payments, the dead letter queue is your safety net against data loss.
Webhook URL versioning helps when you need to change the payload format or processing logic without disrupting active integrations. Register webhook URLs with version paths: /webhooks/v1/stripe vs /webhooks/v2/stripe. Both versions can be live simultaneously, allowing you to migrate providers one at a time. This also simplifies rollback — if the new version has a bug, repoint the provider to the v1 URL without changing application code.
Webhook mistakes that cause silent data loss
Returning 200 without actually storing the event is the most dangerous webhook mistake. If your endpoint returns 200 but the subsequent database write or queue enqueue fails, the event is silently lost — the provider believes delivery succeeded and will not retry. The correct sequence is: verify signature, store the raw event payload to a durable store (database or queue), then return 200. Storage must succeed before the response is sent. If storage fails, return 500 so the provider retries.
Using JSON body parsing before signature verification breaks HMAC verification in a way that is difficult to debug. HMAC is computed over exact bytes. When express.json() parses the body, it converts the Buffer to a JavaScript object and back to a string on serialization — whitespace, key ordering, and Unicode escaping may differ from the original. The HMAC computed over this re-serialized string will not match the HMAC the provider computed over the original bytes. Always use express.raw() for webhook routes and parse JSON manually after verification.
Not verifying the event type from the provider's authoritative source creates a category of security vulnerabilities. Even with signature verification, a provider may send event types your code does not handle. More critically, your code must not trust the event type field in the payload without re-fetching the event from the provider API for sensitive operations like payment fulfillment. Stripe recommends using the event ID to retrieve the event from stripe.events.retrieve() before acting on payment-related events — this confirms the event is real and current, not a modified replay.
Expecting webhook delivery in development without a tunnel is a common setup mistake that causes confusion. localhost is not reachable from the internet. When you register a webhook URL in Stripe or GitHub that points to localhost, no events will arrive. Set up ngrok or the Stripe CLI before starting webhook development work. Document this requirement in your project's development setup guide to save future developers the confusion of missing webhook events during local testing.
Webhook endpoint design for reliability and security
Structure your webhook endpoint as a thin authentication layer over a message queue. The endpoint does three things: verifies the signature, stores the event ID for idempotency, and enqueues the payload. Everything else happens in the worker. This means the endpoint is always fast (milliseconds), always reliable (no heavy operations), and the worker can be independently scaled, monitored, and retried without affecting the endpoint's availability.
Choose a message queue that provides at-least-once delivery guarantees with visibility timeouts. AWS SQS, RabbitMQ, and BullMQ with Redis are all appropriate. Visibility timeout means that if a worker claims a job but does not delete it within the timeout, the job becomes visible again for another worker. This prevents event loss if a worker crashes mid-processing. Combined with idempotency in your processing logic, at-least-once delivery is equivalent to exactly-once from the application's perspective.
Monitor webhook endpoint health with a dedicated health check that the provider dashboard can ping. Most providers allow you to send a test event — ensure this test event appears in your processing logs within seconds. Set up alerts for webhook endpoint error rates above 1 percent and for dead letter queue depth above zero. A spike in webhook errors is often the first visible signal of a deployment problem or infrastructure issue, making it valuable as an early warning signal.
Document your webhook endpoint contract for every provider integration. Record the webhook URL, the event types subscribed to, the signing secret location in your secrets manager, the retry policy, and the idempotency key field name. When the webhook secret needs to be rotated or the endpoint URL changes, this documentation tells the engineer on call exactly what to update and in what order. Include a runbook for common failure scenarios: signature mismatch, endpoint timeout, and dead letter queue events.
Use /tools/http-request-builder to send test webhook payloads during development and verify your endpoint's response. Compute the correct HMAC for a test payload using your webhook secret and include it in the appropriate signature header. Test the rejection path by sending a tampered payload or wrong signature. Use /tools/cors-tester to verify that your webhook endpoint does not accidentally require CORS headers — webhook requests come from servers, not browsers, but some middleware configurations add CORS requirements to all routes.
Production webhook checklist
- ✓Verify the webhook signature (HMAC-SHA256) before processing any payload
- ✓Use raw body bytes for HMAC computation — not parsed JSON
- ✓Use crypto.timingSafeEqual() for signature comparison, never string equality
- ✓Return 200 within the provider timeout (Stripe: 20s, GitHub: 10s) — process async
- ✓Implement idempotency: store processed event IDs and skip duplicates
- ✓Validate the timestamp in the signature to prevent replay attacks
- ✓Use HTTPS — no HTTP webhook URLs in production
- ✓Monitor dead letter queue and alert on any events that fail all retries
Related guides
Frequently asked questions
What is the difference between a webhook and an API call?
In a regular API call, your application initiates an HTTP request to a server and waits for a response. In a webhook, the external service initiates an HTTP POST request to your endpoint when an event occurs. Webhooks are push-based (event-driven) while API calls are pull-based (request-driven). Webhooks deliver real-time event notifications without polling, reducing latency and API rate limit usage.
Why does Stripe require HTTPS for webhook URLs?
Stripe requires HTTPS to prevent the webhook payload from being intercepted or modified in transit. Webhook payloads contain sensitive financial event data. Even with signature verification, an HTTP endpoint exposes the payload to network observers. All major webhook providers require HTTPS. For local development, use ngrok or the Stripe CLI to get a public HTTPS URL that tunnels to your local server.
What is webhook signature verification and why is it critical?
Signature verification proves the webhook payload was sent by the legitimate provider, not an attacker. The provider computes an HMAC-SHA256 of the payload using a shared secret and sends it as a header. Your endpoint computes the same HMAC and compares. Without verification, any HTTP client can send a fake payment.succeeded event to your endpoint and trigger order fulfillment for free. Always verify before processing.
Why must I use the raw request body for signature verification?
HMAC is computed over exact bytes. If you parse the JSON body first and then re-serialize it for HMAC computation, the byte representation may differ from the original due to whitespace, key ordering, or Unicode escaping differences. The provider computed the HMAC over the original raw bytes. Use express.raw() in Node.js and read the raw body before any parsing. Parse JSON manually after verification passes.
What happens if my webhook endpoint is slow and times out?
If your endpoint does not respond within the provider's timeout (Stripe: 20 seconds, GitHub: 10 seconds), the provider marks the delivery as failed and schedules a retry. The event will be delivered again on the retry schedule. If your endpoint times out consistently, you will receive the same event multiple times. Implement idempotency to handle duplicate deliveries safely, and return 200 immediately while processing asynchronously in a background queue.
How do I test webhooks locally without a public URL?
Use ngrok (ngrok http 3000) to create a public HTTPS URL that tunnels to your local server. Register this URL in the provider's webhook settings. For Stripe specifically, the Stripe CLI offers 'stripe listen --forward-to localhost:3000/webhooks/stripe' which forwards real events and provides the correct local webhook signing secret. Update the ngrok URL in provider settings each time ngrok restarts unless you use a paid static domain.
What is webhook idempotency and how do I implement it?
Idempotency means processing the same event twice has the same effect as processing it once. Providers retry failed deliveries, so your endpoint may receive the same event multiple times. Implement idempotency by storing the provider's event ID (such as Stripe's evt_... ID) in a database with a unique constraint. Before processing, check if the ID exists. If it does, return 200 without re-processing. If it does not, insert it and process.
How do I handle webhook secret rotation without downtime?
During secret rotation, temporarily accept both old and new secrets. Compute the HMAC with both secrets and accept the request if either matches. After updating the secret in the provider's dashboard and confirming new deliveries use the new secret, remove the old secret from your verification logic. This transition window of a few minutes prevents 401 rejections for in-flight deliveries signed with the old secret.
All tools run in your browser. Your data never leaves your device. Last updated: 2026-05-06.