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
stripelibrary'sconstructEvent()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:
- Verify the signature — HMAC-SHA256 of the raw body with your shared secret
- Use timing-safe comparison —
crypto.timingSafeEqual/hmac.Equal/hash_equals— never=== - Check the timestamp — reject requests where
tis older than 5 minutes (prevents replay attacks) - Use HTTPS only — never register a plain HTTP endpoint for production
- Respond fast, process async — queue the job, return
200immediately - 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.