← Back to articles

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

  1. Always use HTTPS — no plain HTTP webhooks
  2. Sign payloads — prevent tampering with HMAC signatures
  3. Implement retries — exponential backoff, max 3-5 attempts
  4. Timeout requests — 10-30 seconds max
  5. Return 200 quickly — acknowledge receipt, process async
  6. Include webhook ID — for deduplication
  7. Version your webhooksv1/webhooks, v2/webhooks
  8. Log everything — debug delivery issues
  9. 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.

Get AI tool guides in your inbox

Weekly deep-dives on the best AI coding tools, automation platforms, and productivity software.