Webhooks Guide

Event-driven push notifications for REST APIs — how webhooks work, HMAC signature verification, delivery guarantees, and production-ready implementation patterns.

Last Updated:

What Are Webhooks?

A webhook is an HTTP callback — a POST request that a server sends to a client-configured URL when a specific event occurs. Instead of polling an API repeatedly to check for changes, your application registers a URL and receives event notifications in real time.

Pull vs Push: Polling vs Webhooks

Aspect Polling (Pull) Webhooks (Push)
Latency Depends on polling interval Near real-time
Efficiency Many wasted requests Only sends when events occur
Server load Higher — constant requests Lower — event-driven
Client complexity Low — just a loop Higher — must handle POST requests
Reliability Client controls retries Provider must implement retries
Firewall-friendly Yes Requires public endpoint

Common Webhook Use Cases

  • Payments: Stripe sends payment_intent.succeeded, invoice.paid, charge.refunded
  • Version control: GitHub sends push, pull_request, release events
  • E-commerce: Shopify sends orders/create, fulfillments/create
  • Communications: Twilio sends message delivery status callbacks
  • CI/CD: Build systems notify on deployment success or failure

Webhook Payload Structure

A webhook payload is a standard HTTP POST request with a JSON body. Well-designed webhooks include an event type, a unique event ID, a timestamp, and the event data.

POST /webhooks/stripe HTTP/1.1
Host: api.yourapp.com
Content-Type: application/json
Stripe-Signature: t=1714000000,v1=abc123...

{
  "id": "evt_3OxZKL2eZvKYlo2C1234567",
  "type": "payment_intent.succeeded",
  "created": 1714000000,
  "livemode": true,
  "data": {
    "object": {
      "id": "pi_3OxZKL2eZvKYlo2C1234567",
      "amount": 9900,
      "currency": "usd",
      "status": "succeeded",
      "metadata": {
        "order_id": "order_abc123"
      }
    }
  }
}

Key Fields

  • Event ID: Unique identifier for the event — use this for deduplication
  • Event type: Namespaced string like payment_intent.succeeded — allows filtering
  • Created timestamp: Unix timestamp for ordering and replay
  • Data object: The current state of the resource that triggered the event

HMAC Signature Verification

Without signature verification, anyone who discovers your webhook URL can send fake events. Webhook providers sign payloads using HMAC-SHA256 with a shared secret that you configure in your provider's dashboard.

How HMAC Signing Works

# Provider (sender) generates signature:
secret = "whsec_your_webhook_secret"
payload = request_body  # raw bytes, before any parsing
signature = HMAC-SHA256(key=secret, message=payload)

# Sends in header:
X-Hub-Signature-256: sha256=abc123def456...
# or for Stripe:
Stripe-Signature: t=1714000000,v1=abc123def456...

Node.js: Verifying Stripe Webhooks

const express = require('express');
const crypto = require('crypto');
const app = express();

// IMPORTANT: Use raw body parser — signature covers raw bytes
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['stripe-signature'];
  const secret = process.env.STRIPE_WEBHOOK_SECRET;

  // Stripe signature format: t=timestamp,v1=signature
  const parts = Object.fromEntries(sig.split(',').map(p => p.split('=')));
  const timestamp = parts.t;
  const receivedSig = parts.v1;

  // Compute expected signature
  const signedPayload = timestamp + '.' + req.body.toString();
  const expectedSig = crypto
    .createHmac('sha256', secret)
    .update(signedPayload, 'utf8')
    .digest('hex');

  // Use constant-time comparison to prevent timing attacks
  const sigBuffer = Buffer.from(receivedSig, 'hex');
  const expectedBuffer = Buffer.from(expectedSig, 'hex');

  if (sigBuffer.length !== expectedBuffer.length ||
      !crypto.timingSafeEqual(sigBuffer, expectedBuffer)) {
    console.error('Invalid webhook signature');
    return res.status(400).json({ error: 'Invalid signature' });
  }

  // Check timestamp to prevent replay attacks (reject events older than 5 minutes)
  const eventTime = parseInt(timestamp, 10);
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - eventTime) > 300) {
    return res.status(400).json({ error: 'Webhook timestamp too old' });
  }

  const event = JSON.parse(req.body);
  handleWebhookEvent(event);

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

Generic HMAC Verification

function verifyWebhookSignature(rawBody, signature, secret) {
  // signature may be in format "sha256=abc123"
  const hash = signature.startsWith('sha256=')
    ? signature.slice(7)
    : signature;

  const expectedHash = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  // Constant-time comparison
  return crypto.timingSafeEqual(
    Buffer.from(hash, 'hex'),
    Buffer.from(expectedHash, 'hex')
  );
}

Delivery, Retries, and Idempotency

Respond Quickly — Defer Heavy Work

Webhook providers expect a response within a short timeout — typically 5–30 seconds (Stripe: 30s, GitHub: 10s). If your endpoint exceeds the timeout, the provider treats the delivery as failed and schedules a retry.

Best practice: Acknowledge the webhook immediately with a 200 OK, then process the event asynchronously via a job queue (Bull, Celery, SQS, etc.).

app.post('/webhooks', async (req, res) => {
  // Verify signature first
  if (!verifyWebhookSignature(req.rawBody, req.headers['x-signature'], secret)) {
    return res.status(400).send('Invalid signature');
  }

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

  // Enqueue for async processing (don't await)
  await queue.add('process-webhook', req.body);
});

Retry Strategies

Most webhook providers retry on non-2xx responses or timeouts, using exponential backoff:

Provider Retry Schedule Max Retries
Stripe Immediately, 5 min, 30 min, 2 hr, 5 hr, 10 hr, 24 hr... ~72 hours total
GitHub Immediately, then not retried automatically Manual redeliver available
Shopify 19 retries over 48 hours 19
SendGrid Exponential backoff, up to 3 days ~72 hours total

Idempotent Event Processing

Because webhooks can be delivered more than once (due to retries or network issues), your event handler must be idempotent. Use the event ID to deduplicate:

async function handleWebhookEvent(event) {
  // Check if we've already processed this event
  const processed = await db.webhookEvents.findOne({ eventId: event.id });
  if (processed) {
    console.log('Duplicate event, skipping:', event.id);
    return;
  }

  // Process the event
  switch (event.type) {
    case 'payment_intent.succeeded':
      await fulfillOrder(event.data.object.metadata.order_id);
      break;
    case 'invoice.payment_failed':
      await sendPaymentFailureEmail(event.data.object.customer);
      break;
    default:
      console.log('Unhandled event type:', event.type);
  }

  // Mark as processed
  await db.webhookEvents.insert({
    eventId: event.id,
    type: event.type,
    processedAt: new Date()
  });
}

Registering Webhooks via REST API

If your platform provides webhooks to customers, you'll need a REST API for managing webhook registrations. Standard patterns:

Webhook Registration Endpoints

# Register a new webhook
POST /webhooks
{
  "url": "https://app.customer.com/webhooks/payments",
  "events": ["payment.succeeded", "payment.failed", "refund.created"],
  "secret": "auto-generated-by-server"
}

→ 201 Created
{
  "id": "wh_abc123",
  "url": "https://app.customer.com/webhooks/payments",
  "events": ["payment.succeeded", "payment.failed", "refund.created"],
  "secret": "whsec_Km9pLqR2xYvNzBdTjW...",  # Show once; store securely
  "active": true,
  "created_at": "2026-04-22T10:00:00Z"
}

# List webhooks
GET /webhooks

# Update events or URL
PATCH /webhooks/wh_abc123
{ "events": ["payment.succeeded"] }

# Delete webhook
DELETE /webhooks/wh_abc123

# Test webhook (send a test event)
POST /webhooks/wh_abc123/test
{ "event_type": "payment.succeeded" }

Security Best Practices for Webhook Registration

  • Generate the signing secret on your server — never let the client provide it
  • Return the secret only on creation — never include it in subsequent GET responses
  • Allow customers to rotate secrets without downtime (support both old and new during a transition window)
  • Validate that the target URL is HTTPS and reachable before accepting the registration
  • Rate limit webhook deliveries per customer to prevent self-DDoS

Testing Webhooks Locally

Webhooks need a public URL to receive requests. For local development, use a tunneling tool to expose your localhost:

Tool Command Notes
ngrok ngrok http 3000 Most popular; free tier available; static domain on paid plan
Stripe CLI stripe listen --forward-to localhost:3000/webhooks Built-in Stripe webhook forwarding and replay
localtunnel npx localtunnel --port 3000 Open source; free; ephemeral URLs
webhook.site Browser-based No local server needed; inspect incoming webhook payloads

Replaying Failed Webhooks

In production, keep a log of all incoming webhooks (event ID, payload, processing status) so you can replay failed events. Many providers offer a dashboard to manually redeliver events, but having your own replay mechanism is more reliable:

# Re-process a stored webhook event
async function replayEvent(eventId) {
  const event = await db.webhookEvents.findOne({ eventId, status: 'failed' });
  if (!event) throw new Error('Event not found');

  await handleWebhookEvent(event.payload);
  await db.webhookEvents.update(
    { eventId },
    { status: 'processed', replayedAt: new Date() }
  );
}

Frequently Asked Questions

What happens if my webhook endpoint is down?

The provider will retry the delivery according to their schedule. Ensure your endpoint returns quickly and your infrastructure has high availability. If you're in a maintenance window, most providers allow you to temporarily disable webhook deliveries or catch up via event log APIs (Stripe events API, GitHub delivery logs).

Should I process webhooks synchronously or asynchronously?

Asynchronously, always. Return 200 OK immediately and enqueue the event for background processing. Synchronous processing risks timeouts (causing retries and duplicate deliveries) and couples your webhook availability to your database/service availability.

How do I handle webhook events arriving out of order?

This is a real problem in distributed systems. An order.cancelled event may arrive before order.created due to network delays. Strategies:

  • Always fetch the current resource state from the API after receiving a webhook, rather than trusting the payload alone
  • Use the event's created timestamp to detect and discard stale events
  • Design your state machine to be tolerant of out-of-order transitions

What is the difference between webhooks and WebSockets?

Webhooks are one-way server-to-server HTTP callbacks — your server receives an event when something happens. No persistent connection. Works over standard HTTPS. WebSockets provide a persistent, bidirectional connection between client and server, enabling real-time two-way communication. Use webhooks for server-to-server event notification; use WebSockets for real-time browser UIs (chat, live dashboards).