Webhooks Done Right: Implementation Guide

Meta Description: Build reliable webhook systems with proper delivery guarantees, retry logic, security verification, and idempotency. Learn from real-world examples. Keywords: webhooks, event-driven architecture, api callbacks, webhook security, webhook retry, webhook best practices Word Count: ~2,400 words Your API needs to notify external systems when events happen. You could

TRY NANO BANANA FOR FREE

Webhooks Done Right: Implementation Guide

TRY NANO BANANA FOR FREE
Contents

Meta Description: Build reliable webhook systems with proper delivery guarantees, retry logic, security verification, and idempotency. Learn from real-world examples.

Keywords: webhooks, event-driven architecture, api callbacks, webhook security, webhook retry, webhook best practices

Word Count: ~2,400 words


Your API needs to notify external systems when events happen. You could make them poll your API every minute, but that's wasteful.

Webhooks solve this. When an event occurs, your API sends an HTTP request to the client's URL. They get notified instantly without polling.

But webhooks are harder than they look. Messages get lost. Clients go offline. Requests fail. You need proper delivery guarantees, retry logic, and security.

Here's how to build webhooks that work.

What Are Webhooks?

Webhooks are "reverse APIs." Instead of clients calling your API, your API calls theirs.

Traditional API (client pulls):

Client → GET /orders/123/status → API
Client → GET /orders/123/status → API (poll every minute)
Client → GET /orders/123/status → API

Webhook (server pushes):

API → POST https://client.com/webhooks → Client
(only when status changes)

Webhooks are event-driven. Clients register a URL, and you POST events to it.

Webhook Registration

Clients register webhook URLs through your API:

POST /v1/webhooks
{
  "url": "https://client.com/webhooks/petstore",
  "events": ["pet.adopted", "pet.status_changed"],
  "secret": "whsec_abc123..."
}

Response: 201 Created
{
  "id": "wh_019b4132-70aa-764f-b315-e2803d882a24",
  "url": "https://client.com/webhooks/petstore",
  "events": ["pet.adopted", "pet.status_changed"],
  "status": "active",
  "createdAt": "2026-03-13T10:30:00Z"
}

Store this configuration in your database:

CREATE TABLE webhooks (
  id UUID PRIMARY KEY,
  user_id UUID NOT NULL,
  url TEXT NOT NULL,
  events TEXT[] NOT NULL,
  secret TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'active',
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

Webhook Payload Format

Use a consistent payload structure:

{
  "id": "evt_019b4145-8f3a-7c2d-a1b5-f4e8d9c3a7b2",
  "type": "pet.adopted",
  "createdAt": "2026-03-13T10:35:00Z",
  "data": {
    "pet": {
      "id": "019b4132-70aa-764f-b315-e2803d882a24",
      "name": "Max",
      "species": "DOG",
      "status": "ADOPTED"
    },
    "adopter": {
      "id": "019b4137-6d8e-5c2b-e9f4-a3b5c8d7e2f1",
      "name": "John Doe",
      "email": "john@example.com"
    },
    "adoptedAt": "2026-03-13T10:35:00Z"
  }
}

Key fields: - id: Unique event ID (for idempotency) - type: Event type (for routing) - createdAt: When the event occurred - data: Event-specific payload

Webhook Security

Webhooks are public endpoints. Anyone can POST to them. You need to verify requests come from your API.

HMAC Signatures

Sign webhook payloads with HMAC:

const crypto = require('crypto');

function signPayload(payload, secret) {
  const hmac = crypto.createHmac('sha256', secret);
  hmac.update(JSON.stringify(payload));
  return hmac.digest('hex');
}

// When sending webhook
const payload = {
  id: 'evt_123',
  type: 'pet.adopted',
  data: { ... }
};

const signature = signPayload(payload, webhook.secret);

await fetch(webhook.url, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Webhook-Signature': signature,
    'X-Webhook-ID': payload.id,
    'X-Webhook-Timestamp': Date.now().toString()
  },
  body: JSON.stringify(payload)
});

Clients verify the signature:

function verifySignature(payload, signature, secret) {
  const expectedSignature = signPayload(payload, secret);
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

// In webhook handler
app.post('/webhooks/petstore', (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const payload = req.body;

  if (!verifySignature(payload, signature, WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Process webhook
  processWebhook(payload);
  res.status(200).json({ received: true });
});

Timestamp Validation

Prevent replay attacks by checking timestamps:

function verifyTimestamp(timestamp, maxAge = 300000) { // 5 minutes
  const now = Date.now();
  const age = now - parseInt(timestamp);
  return age >= 0 && age <= maxAge;
}

app.post('/webhooks/petstore', (req, res) => {
  const timestamp = req.headers['x-webhook-timestamp'];

  if (!verifyTimestamp(timestamp)) {
    return res.status(401).json({ error: 'Timestamp too old' });
  }

  // Verify signature and process
});

IP Allowlisting

Publish your webhook source IPs. Clients can allowlist them:

Webhook requests come from:
- 203.0.113.10
- 203.0.113.11
- 203.0.113.12

This adds defense in depth but shouldn't replace signature verification.

Delivery Guarantees

Webhooks can fail. Networks are unreliable. Clients go offline. You need retry logic.

Retry Strategy

Use exponential backoff with jitter:

const RETRY_DELAYS = [
  1000,      // 1 second
  5000,      // 5 seconds
  30000,     // 30 seconds
  300000,    // 5 minutes
  1800000,   // 30 minutes
  3600000    // 1 hour
];

async function deliverWebhook(webhook, payload, attempt = 0) {
  try {
    const response = await fetch(webhook.url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Webhook-Signature': signPayload(payload, webhook.secret),
        'X-Webhook-ID': payload.id,
        'X-Webhook-Timestamp': Date.now().toString(),
        'X-Webhook-Attempt': (attempt + 1).toString()
      },
      body: JSON.stringify(payload),
      timeout: 10000 // 10 second timeout
    });

    if (response.status >= 200 && response.status < 300) {
      // Success
      await markWebhookDelivered(payload.id, webhook.id);
      return { success: true };
    }

    // Failed, retry
    if (attempt < RETRY_DELAYS.length) {
      const delay = RETRY_DELAYS[attempt] + Math.random() * 1000; // Add jitter
      await scheduleRetry(webhook, payload, attempt + 1, delay);
      return { success: false, retry: true };
    }

    // Max retries exceeded
    await markWebhookFailed(payload.id, webhook.id);
    await disableWebhook(webhook.id); // Disable after repeated failures
    return { success: false, retry: false };

  } catch (error) {
    // Network error, retry
    if (attempt < RETRY_DELAYS.length) {
      const delay = RETRY_DELAYS[attempt] + Math.random() * 1000;
      await scheduleRetry(webhook, payload, attempt + 1, delay);
      return { success: false, retry: true };
    }

    await markWebhookFailed(payload.id, webhook.id);
    await disableWebhook(webhook.id);
    return { success: false, retry: false };
  }
}

Delivery Tracking

Store delivery attempts:

CREATE TABLE webhook_deliveries (
  id UUID PRIMARY KEY,
  webhook_id UUID NOT NULL REFERENCES webhooks(id),
  event_id TEXT NOT NULL,
  attempt INT NOT NULL DEFAULT 1,
  status TEXT NOT NULL, -- 'pending', 'delivered', 'failed'
  response_status INT,
  response_body TEXT,
  error TEXT,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  delivered_at TIMESTAMP
);

This lets you: - Show delivery status to users - Debug failed webhooks - Retry manually - Generate delivery reports

Idempotency

Webhooks may be delivered multiple times (due to retries). Clients must handle duplicates.

Event IDs

Include a unique event ID:

{
  "id": "evt_019b4145-8f3a-7c2d-a1b5-f4e8d9c3a7b2",
  "type": "pet.adopted",
  "data": { ... }
}

Clients store processed event IDs:

const processedEvents = new Set();

app.post('/webhooks/petstore', async (req, res) => {
  const event = req.body;

  // Check if already processed
  if (processedEvents.has(event.id)) {
    return res.status(200).json({ received: true, duplicate: true });
  }

  // Process event
  await processEvent(event);

  // Mark as processed
  processedEvents.add(event.id);
  await db.query('INSERT INTO processed_events (event_id) VALUES ($1)', [event.id]);

  res.status(200).json({ received: true });
});

Idempotent Operations

Design operations to be idempotent:

Bad (not idempotent):

// Increment counter
await db.query('UPDATE stats SET adoption_count = adoption_count + 1');

If the webhook is delivered twice, the counter increments twice.

Good (idempotent):

// Set specific value
await db.query('UPDATE pets SET status = $1 WHERE id = $2', ['ADOPTED', petId]);

Setting a value is idempotent. Doing it twice has the same effect as once.

Webhook Management

Testing Webhooks

Provide a test endpoint:

POST /v1/webhooks/{id}/test
{
  "eventType": "pet.adopted"
}

This sends a test event to the webhook URL. Clients can verify their endpoint works before going live.

Webhook Logs

Show delivery logs to users:

GET /v1/webhooks/{id}/deliveries

Response:
{
  "data": [
    {
      "id": "del_123",
      "eventId": "evt_456",
      "eventType": "pet.adopted",
      "attempt": 1,
      "status": "delivered",
      "responseStatus": 200,
      "createdAt": "2026-03-13T10:35:00Z",
      "deliveredAt": "2026-03-13T10:35:01Z"
    },
    {
      "id": "del_124",
      "eventId": "evt_457",
      "eventType": "pet.status_changed",
      "attempt": 3,
      "status": "failed",
      "responseStatus": 500,
      "error": "Internal Server Error",
      "createdAt": "2026-03-13T10:40:00Z"
    }
  ]
}

This helps users debug webhook issues.

Manual Retry

Let users retry failed webhooks:

POST /v1/webhooks/{id}/deliveries/{deliveryId}/retry

This re-sends the event immediately.

Webhook Disabling

Automatically disable webhooks after repeated failures:

async function checkWebhookHealth(webhookId) {
  const recentFailures = await db.query(`
    SELECT COUNT(*) as failures
    FROM webhook_deliveries
    WHERE webhook_id = $1
      AND status = 'failed'
      AND created_at > NOW() - INTERVAL '1 hour'
  `, [webhookId]);

  if (recentFailures.rows[0].failures >= 10) {
    await db.query(`
      UPDATE webhooks
      SET status = 'disabled',
          disabled_reason = 'Too many failures'
      WHERE id = $1
    `, [webhookId]);

    // Notify user
    await sendEmail(webhook.userId, {
      subject: 'Webhook Disabled',
      body: 'Your webhook has been disabled due to repeated failures.'
    });
  }
}

Best Practices

1. Return 200 Quickly

Process webhooks asynchronously:

app.post('/webhooks/petstore', async (req, res) => {
  const event = req.body;

  // Verify signature
  if (!verifySignature(event, req.headers['x-webhook-signature'], SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Queue for processing
  await queue.add('process-webhook', event);

  // Return immediately
  res.status(200).json({ received: true });
});

Don't make the webhook sender wait for your processing.

2. Document Event Types

Clearly document all event types:

## Event Types

### pet.adopted
Triggered when a pet is adopted.

Payload:
- pet: Pet object
- adopter: User object
- adoptedAt: ISO 8601 timestamp

### pet.status_changed
Triggered when a pet's status changes.

Payload:
- pet: Pet object
- oldStatus: Previous status
- newStatus: New status
- changedAt: ISO 8601 timestamp

3. Version Webhooks

Include API version in webhook payloads:

{
  "id": "evt_123",
  "type": "pet.adopted",
  "apiVersion": "2024-03-01",
  "data": { ... }
}

This lets you evolve webhook payloads without breaking clients.

4. Provide Webhook Playground

Build a webhook testing tool where users can: - See example payloads - Test their endpoints - View delivery logs - Retry failed deliveries

This improves developer experience significantly.

Conclusion

Webhooks are powerful but require careful implementation:

  1. Security: HMAC signatures, timestamp validation
  2. Reliability: Retry logic, exponential backoff
  3. Idempotency: Event IDs, idempotent operations
  4. Monitoring: Delivery tracking, automatic disabling
  5. Developer Experience: Testing tools, clear documentation

The Modern PetStore API implements all these patterns. Check the webhook documentation for complete examples.

Build webhooks that work reliably, and your users will thank you.


Related Articles: - WebSocket vs Server-Sent Events: Real-Time APIs Explained - Building Event-Driven APIs: From Webhooks to Message Queues - API Security Best Practices: Authentication and Authorization