Toolmingo
Guides7 min read

What Is a Webhook? A Practical Guide with Examples

Learn what webhooks are, how they work, and how to receive, verify, and debug them in JavaScript, Python, Go, and PHP — with security best practices and a quick reference.

When something happens in one system — a payment is processed, a file is uploaded, a PR is merged — how does another system find out? You could poll an API every few seconds, but that's wasteful. Webhooks are the solution.

This guide explains how webhooks work, how to build a receiver in four languages, and the security practices that keep your endpoint safe.


What Is a Webhook?

A webhook is an HTTP callback: when an event occurs in system A, it sends a POST request to a URL you provide in system B.

Stripe (event: payment.succeeded)
  → POST https://yourapp.com/webhooks/stripe
    Body: { "type": "payment_intent.succeeded", "data": { ... } }

You control the receiving endpoint. You define what URL to register, and you write the code that handles the incoming request.

Webhook vs polling vs regular API:

Approach How it works Efficiency Latency
Polling You ask "any news?" every N seconds Low (99% empty responses) Up to N seconds
REST API You request data on demand Medium Near-instant, but you initiate
Webhook They notify you when something happens High (only real events) Near-instant, they initiate
WebSocket Persistent two-way connection High Instant

How Webhooks Work Step by Step

1. You register a URL with the sender
   (e.g. Stripe Dashboard → "Add endpoint → https://yourapp.com/hooks/stripe")

2. An event occurs in their system
   (customer completes checkout)

3. Their server sends a POST request to your URL
   Headers: Content-Type: application/json
            X-Stripe-Signature: t=1234,v1=abc...
   Body:    { "type": "payment_intent.succeeded", "data": { ... } }

4. Your server processes the event and returns HTTP 200
   (any non-2xx response = failure → they retry)

5. If your server is down, they retry with backoff
   (Stripe retries up to 72 hours)

The sender doesn't wait — they fire the request and move on. You must respond quickly (under 5–30 seconds depending on the platform) and process heavy work asynchronously.


Quick Reference

Task Details
Receive webhook Accept POST, return 200 OK fast
Parse body req.body (raw buffer for signature verification)
Verify signature HMAC-SHA256 of raw body with shared secret
Handle duplicates Check event ID — webhooks may be delivered more than once
Respond to retries Return 200 even for already-processed events
Debug locally Use ngrok or Cloudflare Tunnel to expose localhost

Receiving a Webhook

JavaScript (Node.js + Express)

import express from "express";
import crypto from "crypto";

const app = express();

// IMPORTANT: use raw body, not parsed JSON — needed for signature verification
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), (req, res) => {
  const sig = req.headers["x-stripe-signature"];
  const secret = process.env.STRIPE_WEBHOOK_SECRET;

  // Verify the signature
  const hmac = crypto.createHmac("sha256", secret);
  const [tPart, v1Part] = sig.split(",");
  const timestamp = tPart.replace("t=", "");
  const payload = `${timestamp}.${req.body}`;
  hmac.update(payload);
  const expected = `v1=${hmac.digest("hex")}`;
  const received = v1Part.replace("v1=", "");

  // Use timingSafeEqual to prevent timing attacks
  const match = crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(`v1=${received}`)
  );
  if (!match) {
    return res.status(400).send("Invalid signature");
  }

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

  switch (event.type) {
    case "payment_intent.succeeded":
      console.log("Payment succeeded:", event.data.object.id);
      // → queue fulfillment job, don't do it inline
      break;
    case "customer.subscription.deleted":
      console.log("Subscription cancelled:", event.data.object.customer);
      break;
    default:
      console.log("Unhandled event type:", event.type);
  }

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

app.listen(3000);

Use the official stripe library's constructEvent() in production — it handles all edge cases.

Python (Flask)

import hmac
import hashlib
import os
import json
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"]

@app.route("/webhooks/stripe", methods=["POST"])
def stripe_webhook():
    raw_body = request.get_data()  # raw bytes — don't use request.json
    sig_header = request.headers.get("X-Stripe-Signature", "")

    # Parse t= and v1= from the header
    parts = {k: v for k, v in (p.split("=", 1) for p in sig_header.split(","))}
    timestamp = parts.get("t", "")
    received_sig = parts.get("v1", "")

    # Build signed payload
    signed_payload = f"{timestamp}.{raw_body.decode('utf-8')}"
    expected_sig = hmac.new(
        WEBHOOK_SECRET.encode("utf-8"),
        signed_payload.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(expected_sig, received_sig):
        abort(400, "Invalid signature")

    event = json.loads(raw_body)

    if event["type"] == "payment_intent.succeeded":
        payment_intent = event["data"]["object"]
        print(f"Payment succeeded: {payment_intent['id']}")
        # queue job — never block here

    return {"received": True}, 200

Go

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strings"
)

var webhookSecret = []byte(os.Getenv("STRIPE_WEBHOOK_SECRET"))

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "cannot read body", http.StatusBadRequest)
        return
    }

    sigHeader := r.Header.Get("X-Stripe-Signature")
    parts := map[string]string{}
    for _, part := range strings.Split(sigHeader, ",") {
        kv := strings.SplitN(part, "=", 2)
        if len(kv) == 2 {
            parts[kv[0]] = kv[1]
        }
    }

    signedPayload := parts["t"] + "." + string(body)
    mac := hmac.New(sha256.New, webhookSecret)
    mac.Write([]byte(signedPayload))
    expected := hex.EncodeToString(mac.Sum(nil))

    if !hmac.Equal([]byte(expected), []byte(parts["v1"])) {
        http.Error(w, "invalid signature", http.StatusBadRequest)
        return
    }

    var event map[string]any
    if err := json.Unmarshal(body, &event); err != nil {
        http.Error(w, "invalid json", http.StatusBadRequest)
        return
    }

    eventType, _ := event["type"].(string)
    switch eventType {
    case "payment_intent.succeeded":
        fmt.Println("Payment succeeded")
    default:
        fmt.Println("Unhandled event:", eventType)
    }

    w.Header().Set("Content-Type", "application/json")
    w.Write([]byte(`{"received":true}`))
}

func main() {
    http.HandleFunc("/webhooks/stripe", webhookHandler)
    http.ListenAndServe(":8080", nil)
}

PHP

<?php
$secret = getenv('STRIPE_WEBHOOK_SECRET');
$rawBody = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_X_STRIPE_SIGNATURE'] ?? '';

// Parse t= and v1= from header
$parts = [];
foreach (explode(',', $sigHeader) as $part) {
    [$key, $val] = explode('=', $part, 2);
    $parts[$key] = $val;
}

$signedPayload = $parts['t'] . '.' . $rawBody;
$expected = hash_hmac('sha256', $signedPayload, $secret);

// hash_equals prevents timing attacks
if (!hash_equals($expected, $parts['v1'] ?? '')) {
    http_response_code(400);
    exit('Invalid signature');
}

$event = json_decode($rawBody, true);

switch ($event['type']) {
    case 'payment_intent.succeeded':
        $id = $event['data']['object']['id'];
        // add to a job queue — don't do heavy work here
        error_log("Payment succeeded: $id");
        break;
    default:
        error_log("Unhandled event: {$event['type']}");
}

http_response_code(200);
echo json_encode(['received' => true]);

Webhook Security Checklist

Always verify webhook authenticity before processing:

  1. Verify the signature — HMAC-SHA256 of the raw body with your shared secret
  2. Use timing-safe comparisoncrypto.timingSafeEqual / hmac.Equal / hash_equals — never ===
  3. Check the timestamp — reject requests where t is older than 5 minutes (prevents replay attacks)
  4. Use HTTPS only — never register a plain HTTP endpoint for production
  5. Respond fast, process async — queue the job, return 200 immediately
  6. Handle duplicates (idempotency) — store processed event IDs to avoid double-processing

Debugging Webhooks Locally

Webhook senders can't reach localhost:3000. Use a tunnel:

# ngrok (most popular)
ngrok http 3000
# → https://abc123.ngrok.io → localhost:3000

# Cloudflare Tunnel (free, no account for short sessions)
cloudflared tunnel --url http://localhost:3000

# Stripe CLI (replays events, great for testing)
stripe listen --forward-to localhost:3000/webhooks/stripe
stripe trigger payment_intent.succeeded

Use the Stripe Dashboard → Webhooks → Event logs (or GitHub → Settings → Webhooks → Recent Deliveries) to inspect the exact payload and resend events during development.


Common Mistakes

Mistake Problem Fix
Parsing JSON before verifying signature Signature is over the raw bytes — a parsed-and-re-serialized body won't match Always pass raw body to HMAC; parse only after verification
Using === for signature comparison Leaks timing information → attackers can forge signatures Use timingSafeEqual / hmac.Equal / hash_equals
Doing heavy work in the handler Timeout → sender sees failure → retries → duplicate processing Queue the job, return 200 in under 5 seconds
Not handling duplicates Webhook senders guarantee "at least once" delivery, not "exactly once" Check event ID in DB before processing
Ignoring the timestamp Replay attacks: attacker captures a valid request and replays it later Reject events where t is > 5 minutes old
Registering HTTP instead of HTTPS Signature is useless if the payload can be intercepted in transit HTTPS only in production

FAQ

Q: What's the difference between a webhook and an API?
A: With an API, you call them. With a webhook, they call you. APIs are pull-based; webhooks are push-based. Most webhook senders also expose a REST API so you can fetch the same data on demand.

Q: What happens if my server is down when a webhook fires?
A: The sender will retry. Retry policies vary: Stripe retries over 72 hours with exponential backoff; GitHub retries a few times over minutes. When your server recovers, you'll receive the queued events. Make sure your handler is idempotent.

Q: Do I need to return specific data in my response?
A: Usually no — just 200 OK (or any 2xx). The body is typically ignored. Returning a 4xx tells the sender "bad request" (they may stop retrying). Returning a 5xx tells them "server error" (they will retry).

Q: How do I test webhooks during development without deploying?
A: Use ngrok, Cloudflare Tunnel, or the official CLI tools (stripe listen, gh webhook forward). These create a public HTTPS URL that tunnels to your local server.

Q: Can I register multiple webhook endpoints for the same event?
A: It depends on the platform. Stripe lets you register multiple endpoints; GitHub lets you configure multiple webhooks per repo. Many platforms support webhook fanout — registering once and fanning out internally is your responsibility.

Q: What is a webhook secret?
A: A shared secret is a string you and the webhook sender both know. The sender uses it to compute an HMAC signature of the request body and includes it in a header. You compute the same HMAC on your end and compare. If they match, the request genuinely came from the sender — not an attacker who guessed your URL.

Related tools

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools