How to Build a Webhook System (2026)
Webhooks let your app notify other services when events happen. They're how Stripe tells you about payments, GitHub notifies CI, and Clerk sends user events. Here's how to build them.
Sending Webhooks
Basic Implementation
// app/api/webhooks/send/route.ts
export async function sendWebhook(url: string, event: string, data: any) {
const payload = {
event,
data,
timestamp: Date.now(),
}
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'MyApp/1.0',
},
body: JSON.stringify(payload),
})
if (!response.ok) {
throw new Error(`Webhook failed: ${response.status}`)
}
}
// Usage
await sendWebhook('https://customer.com/webhooks', 'user.created', {
userId: user.id,
email: user.email,
})
With Retry Logic
async function sendWebhookWithRetry(url: string, payload: any, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(10000), // 10s timeout
})
if (response.ok) return { success: true }
// Don't retry 4xx errors (client fault)
if (response.status >= 400 && response.status < 500) {
return { success: false, error: 'Client error' }
}
// Retry on 5xx errors
if (attempt < maxRetries - 1) {
await sleep(Math.pow(2, attempt) * 1000) // Exponential backoff
}
} catch (error) {
if (attempt === maxRetries - 1) throw error
await sleep(Math.pow(2, attempt) * 1000)
}
}
}
Queue-Based (Production)
import { Queue } from 'bullmq'
const webhookQueue = new Queue('webhooks', {
connection: { host: 'localhost', port: 6379 },
})
// Enqueue webhook
await webhookQueue.add('send', {
url: 'https://customer.com/webhooks',
event: 'payment.succeeded',
data: { orderId: '123', amount: 99.99 },
}, {
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
})
// Worker
import { Worker } from 'bullmq'
new Worker('webhooks', async (job) => {
await sendWebhook(job.data.url, job.data.event, job.data.data)
}, { connection: { host: 'localhost', port: 6379 } })
Receiving Webhooks
Verify Signatures (Security)
// Generate signature when sending
import crypto from 'crypto'
const secret = process.env.WEBHOOK_SECRET!
const signature = crypto
.createHmac('sha256', secret)
.update(JSON.stringify(payload))
.digest('hex')
await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature,
},
body: JSON.stringify(payload),
})
// Verify signature when receiving
export async function POST(req: Request) {
const body = await req.text()
const receivedSig = req.headers.get('x-webhook-signature')
const expectedSig = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET!)
.update(body)
.digest('hex')
if (receivedSig !== expectedSig) {
return Response.json({ error: 'Invalid signature' }, { status: 401 })
}
const payload = JSON.parse(body)
// Process webhook
if (payload.event === 'user.created') {
await handleUserCreated(payload.data)
}
return Response.json({ received: true })
}
Idempotency (Prevent Duplicates)
const processedWebhooks = new Set<string>()
export async function POST(req: Request) {
const payload = await req.json()
const webhookId = req.headers.get('x-webhook-id')
// Check if already processed
if (processedWebhooks.has(webhookId)) {
return Response.json({ received: true }) // Acknowledge duplicate
}
// Process webhook
await handleWebhook(payload)
// Mark as processed
processedWebhooks.add(webhookId)
return Response.json({ received: true })
}
Better: store in database with unique constraint on webhook ID.
Webhook Dashboard
// Database schema
export const webhookLogs = pgTable('webhook_logs', {
id: uuid('id').primaryKey().defaultRandom(),
url: text('url').notNull(),
event: text('event').notNull(),
payload: jsonb('payload'),
status: text('status'), // 'success', 'failed', 'retrying'
attempts: integer('attempts').default(0),
lastError: text('last_error'),
createdAt: timestamp('created_at').defaultNow(),
})
// Log each attempt
await db.insert(webhookLogs).values({
url,
event,
payload,
status: response.ok ? 'success' : 'failed',
attempts: attempt + 1,
lastError: response.ok ? null : await response.text(),
})
Best Practices
- Always use HTTPS — no plain HTTP webhooks
- Sign payloads — prevent tampering with HMAC signatures
- Implement retries — exponential backoff, max 3-5 attempts
- Timeout requests — 10-30 seconds max
- Return 200 quickly — acknowledge receipt, process async
- Include webhook ID — for deduplication
- Version your webhooks —
v1/webhooks,v2/webhooks - Log everything — debug delivery issues
- Let customers test — provide a test mode
FAQ
Should I use webhooks or polling?
Webhooks for real-time notifications. Polling for occasional checks. Webhooks are far more efficient.
How do I test webhooks locally?
Use ngrok to expose localhost: ngrok http 3000. Or use webhook.site to inspect payloads.
What if the customer's endpoint is down?
Retry with exponential backoff (1s, 2s, 4s, 8s, 16s). After max retries, mark as failed and notify the customer to check their endpoint.
Bottom Line
Send webhooks via a queue (BullMQ + Redis) with retry logic. Sign payloads with HMAC. When receiving, verify signatures and process idempotently. Log all attempts for debugging. Webhooks are the standard for event-driven integrations — implement them properly from day one.