What Is a Webhook, and How Does It Work?

featured article thumbnail

A webhook is an HTTP callback that sends data from one application to another when a specific event occurs. Instead of constantly checking for updates (polling), webhooks push information the moment something happens, giving your application real-time data without wasting resources on empty requests.

How Webhooks Work

A webhook follows a simple flow: an event happens, and a server sends an HTTP POST request to a URL you provide. Here is what that looks like step by step.

┌──────────────┐         ┌──────────────────┐         ┌──────────────┐
│              │         │                  │         │              │
│  Event       │────────▶│  Source System    │────────▶│  Your Server │
│  (payment,   │ triggers│  (Stripe, GitHub) │  HTTP   │  (webhook    │
│   push, PR)  │         │                  │  POST   │   endpoint)  │
│              │         │                  │         │              │
└──────────────┘         └──────────────────┘         └──────┬───────┘
                                                             │
                                                             ▼
                                                      Process event
                                                      Return 200 OK
  1. You register a URL. Tell the source system where to send events. This URL is your webhook endpoint.
  2. An event occurs. A customer pays an invoice, a developer opens a pull request, or a message arrives.
  3. The source sends a POST request. The request body contains a JSON payload with event details.
  4. Your server processes the event. Parse the payload, run your logic, then return a 200 OK status code.
  5. The source confirms delivery. If your server does not respond with a 2xx status, most providers retry the request.

That is the entire flow. No polling, no wasted requests, no delays.

Webhooks vs Polling

Polling means your application asks "anything new?" on a loop. Webhooks flip this around — the source tells you when something happens.

Polling Webhooks
Direction Your app asks the server Server tells your app
Timing Periodic (every 30s, 1m, 5m) Instant when event occurs
Wasted requests Most requests return nothing Only fires when there is data
Server load High (constant requests) Low (event-driven)
Complexity Simple to implement Requires a public endpoint
Best for Small datasets, infrequent checks Real-time updates, high volume

Use polling when you need data on a schedule or the source does not support webhooks. Use webhooks for everything else.

Anatomy of a Webhook Payload

When a webhook fires, the source sends an HTTP POST request to your URL. Here is what a real Stripe webhook payload looks like:

{
  "id": "evt_1SXJAxIvz5j75Cdbc84ixIMX",
  "object": "event",
  "type": "invoice.payment_failed",
  "created": 1764065326,
  "data": {
    "object": {
      "id": "in_1SXJAtIvz5j75CdbK1yFGg0d",
      "customer": "cus_TUHkz63gW41i4G",
      "amount_due": 2000,
      "currency": "usd",
      "status": "open",
      "attempt_count": 1
    }
  }
}

Every webhook payload shares the same basic structure:

  • Event type — What happened (invoice.payment_failed, push, pull_request.opened).
  • Timestamp — When it happened.
  • Data — The details. For a payment, this includes amount, currency, and customer ID. For a pull request, it includes the title, author, and diff URL.

The request also includes HTTP headers. These carry metadata like content type, delivery IDs, and — critically — signature headers for verification. GitHub uses X-Hub-Signature-256. Stripe uses Stripe-Signature. These headers prove the request came from the real source and was not tampered with.

Build a Webhook Receiver

A webhook receiver is a server endpoint that accepts POST requests from external services. Here is a minimal receiver in Node.js using Express:

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

app.use(express.json());

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

  console.log(`Received event: ${event.type}`);

  switch (event.type) {
    case 'payment.completed':
      // Send a receipt email
      console.log(`Payment received: $${event.data.amount / 100}`);
      break;
    case 'user.created':
      // Set up the new account
      console.log(`New user: ${event.data.email}`);
      break;
    default:
      console.log(`Unhandled event type: ${event.type}`);
  }

  res.sendStatus(200);
});

app.listen(3000, () => {
  console.log('Webhook receiver running on port 3000');
});

This covers the basics: accept POST requests, parse the JSON body, route by event type, return a 200 status. For a hands-on walkthrough with more examples, see the webhooks tutorial.

Test Locally with cURL

You can test your receiver without a real webhook provider. Send a fake event with cURL:

curl -X POST http://localhost:3000/webhooks \
  -H "Content-Type: application/json" \
  -d '{
    "type": "payment.completed",
    "data": {
      "amount": 4999,
      "currency": "usd",
      "customer_id": "cus_abc123"
    }
  }'

Your server logs should print Payment received: $49.99.

Expose Your Local Server

Webhook providers need a public URL to send requests to. During development, use ngrok to expose your local server:

ngrok http 3000

Copy the generated HTTPS URL (like https://a1b2c3.ngrok.io) and paste it into the webhook settings of your provider. Add /webhooks to the end.

Verify Webhook Signatures

Anyone can send a POST request to your webhook URL. Without verification, an attacker could send fake events and trick your application into processing them. This is why webhook providers include a signature with every request.

The most common pattern is HMAC-SHA256. The provider signs the request body with a shared secret, and you verify the signature on your end.

Here is how signature verification works in Node.js:

const crypto = require('crypto');

function verifySignature(payload, signature, secret) {
  const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');

  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

app.post('/webhooks', (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const rawBody = JSON.stringify(req.body);

  if (!verifySignature(rawBody, signature, process.env.WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  // Signature is valid — process the event
  res.sendStatus(200);
});

Key points for verification:

  • Use the raw request body, not a re-serialized version. Re-serializing can change whitespace or key order, which breaks the signature.
  • Use timing-safe comparison (crypto.timingSafeEqual). Regular string comparison leaks information through timing differences.
  • Check timestamps when available. Stripe includes a timestamp in its signature header. Reject events older than 5 minutes to prevent replay attacks.

Different providers use different header names and formats. Here are the most common ones:

Provider Signature Header Algorithm
Stripe Stripe-Signature HMAC-SHA256 with timestamp
GitHub X-Hub-Signature-256 HMAC-SHA256
Twilio X-Twilio-Signature HMAC-SHA1
Shopify X-Shopify-Hmac-Sha256 HMAC-SHA256 (Base64)

For provider-specific guides, see how to verify Stripe webhooks and GitHub webhooks.

Handle Retries and Failures

Webhook providers expect a fast response. If your server does not return a 2xx status within a few seconds, the provider assumes delivery failed and retries.

Best practices for handling retries:

  • Return 200 immediately. Process the event asynchronously if it takes more than a few seconds. Acknowledge receipt first, then handle the business logic in a background job.
  • Make your handler idempotent. Retries mean your server might receive the same event twice. Use the event ID to track what you have already processed.
  • Log failed deliveries. If your server is down, you will miss events. Most providers have a dashboard where you can see failed deliveries and manually retry them.
const processedEvents = new Set();

app.post('/webhooks', (req, res) => {
  const eventId = req.body.id;

  // Skip if already processed
  if (processedEvents.has(eventId)) {
    return res.sendStatus(200);
  }

  processedEvents.add(eventId);
  // Process event...

  res.sendStatus(200);
});

In production, store processed event IDs in a database instead of an in-memory set.

Common Webhook Providers

Most services you work with support webhooks. Here are the most popular ones and what you can do with them:

For tools that help manage webhook infrastructure at scale, see the guide to open-source webhook services.

Webhooks vs APIs

Webhooks and APIs solve different problems. An API lets you request data on demand. A webhook delivers data when something happens. Many systems use both together.

API (Pull):
Your App ──── "Any new orders?" ────▶ Server
Your App ◀──── "No." ──────────────── Server
Your App ──── "Any new orders?" ────▶ Server
Your App ◀──── "Yes, here's one." ─── Server

Webhook (Push):
Server ──── "New order just placed" ────▶ Your App

Use APIs when you need data on demand (loading a user profile, searching products). Use webhooks when you need to react to events in real time (payment received, code pushed, message sent). Most production systems combine both: webhooks trigger actions, APIs fetch additional data when needed.

Best Practices

Follow these rules to build reliable webhook integrations:

  1. Always use HTTPS. Webhook payloads often contain sensitive data like customer information or payment details. Never accept webhooks over plain HTTP.
  2. Verify every request. Check the HMAC signature before processing any event. Reject unsigned or incorrectly signed requests.
  3. Respond fast. Return a 200 status within 3 seconds. Move heavy processing to a background queue.
  4. Handle duplicates. Store processed event IDs and skip events you have already handled.
  5. Log everything. Record the event type, timestamp, and processing result for every webhook. This makes debugging much easier when something goes wrong.
  6. Set up monitoring. Track your webhook endpoint's response time, error rate, and queue depth. Alert on failures so you can fix issues before they affect users.
  7. Use a dedicated endpoint. Do not mix webhook handling with your regular API routes. A separate /webhooks path makes it easier to apply middleware, rate limiting, and monitoring.

Next Steps

Now that you understand how webhooks work, dive deeper with these guides:

To send webhook-triggered notifications across Slack, email, push, and in-app from a single API, check out MagicBell.