A webhook timeout means your endpoint did not respond within the provider's timeout window. The provider marks the delivery as failed and retries — sometimes with exponential backoff over hours. If your handler is slow, you will receive duplicate events. The fix is always the same: respond immediately with HTTP 200, then process asynchronously.
Provider Timeout Windows
| Provider | Timeout | Retry Policy |
|---|---|---|
| Stripe | 30 seconds | Exponential backoff, up to 3 days |
| Slack | 3 seconds | 3 retries over 1 hour |
| Shopify | 5 seconds | 19 retries over 48 hours |
| GitHub | 10 seconds | Manual redelivery only |
| Twilio | 15 seconds | 4 retries |
| HubSpot | 5 seconds | 3 retries |
Why Your Handler Is Slow
Handlers that do synchronous work inside the request/response cycle will hit timeouts:
- Writing to a database without a connection pool
- Calling external APIs (sending emails, Slack notifications, payment confirmations)
- Running image processing or file conversion
- Querying multiple tables without indexes
The Fix: Respond First, Process Later
Return HTTP 200 as the first thing your handler does, then process the event in the background.
Node.js (Express + queue)
const queue = []
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
res.status(200).json({ received: true }) // respond immediately
const event = JSON.parse(req.body)
queue.push(event) // process later
})
async function processQueue() {
while (true) {
const event = queue.shift()
if (event) await handleEvent(event)
await new Promise(r => setTimeout(r, 100))
}
}
processQueue()
Python (FastAPI + BackgroundTasks)
from fastapi import FastAPI, BackgroundTasks, Request
app = FastAPI()
async def process_event(payload: dict):
await send_confirmation_email(payload)
await update_database(payload)
@app.post('/webhook')
async def webhook(request: Request, background_tasks: BackgroundTasks):
payload = await request.json()
background_tasks.add_task(process_event, payload)
return {'received': True}
Idempotency: Handle Retries Safely
Since providers retry on timeout, your handler will receive the same event multiple times. Use the provider's event ID to deduplicate:
const redis = require('redis')
const client = redis.createClient()
async function handleEventOnce(event) {
const key = `webhook:processed:${event.id}`
const set = await client.set(key, '1', { NX: true, EX: 86400 })
if (!set) return // already processed
await processEvent(event)
}
FAQ
My handler responds in under 1 second locally but times out in production — why?
Cold starts, connection pool exhaustion, or DNS resolution delays add latency that doesn't appear locally. Add connection pooling (e.g. pg-pool for Postgres), keep your DB connection alive, and profile the handler with timing logs.
How do I know if timeouts are causing duplicate events?
Check whether the provider's event ID appears more than once in your processing logs. WebhookWhisper shows retry deliveries separately — if you see the same payload delivered multiple times, your handler is timing out.
Should I return 200 even if processing fails?
Return 200 as soon as you have successfully enqueued the event. Only return non-2xx if you cannot safely enqueue it. Never return non-2xx because downstream processing failed — the provider will retry and you will get duplicate events.