Collect
Don’t call us. We’ll call you.
A webhook is a URL you give someone else. When something happens on their side, they POST JSON to that URL. Polling is the opposite: you keep asking an API if anything changed. This page uses both ideas on this site’s own events.
What a webhook is
Polling is a timer. Every minute you GET /orders?since=… and almost every answer is “nothing new.” A webhook inverts that. The order system already knows an order closed, so it calls you once.
The contract is small. You publish an https URL. They POST a JSON body. You answer 2xx quickly if you accept it. If you time out or answer 5xx, they try again. You treat a repeated delivery id as the same event.
The Calls lab is you asking this site. The API lab is the same direction, through the server. A webhook is the other direction.
1
Something happens
A page view, a consent change, or a score crossing 40.
2
The sender POSTs
JSON goes to the URL you registered. You do not keep asking.
3
You answer 2xx
A fast success. The slow work waits in a queue.
4
Or the sender retries
A timeout or a 5xx. Same delivery id, later attempt.
The bar is one delivery moving from the event to a stored 2xx. If your endpoint fails, the same delivery comes back along that line.
Anatomy of the request
Vendors name the headers differently. The jobs are the same.
- Method and URL. POST, to the exact URL you registered. A GET is not a delivery.
- Content-Type. Almost always application/json. Read the bytes before you decide they are JSON.
- Signature. HMAC of the raw body with a shared secret. This lab uses X-BBP-Signature: t=<unix>,v1=<hex> over the string timestamp.body.
- Event type. X-BBP-Event, and again inside the JSON, so a queue can route without opening the body.
- Delivery id. X-BBP-Delivery. Retries reuse it. Your store should, too.
- Timestamp. X-BBP-Timestamp, unix seconds. Reject it when it is more than five minutes off, so a captured request cannot be replayed next week.
- JSON body. An id, a type, a created time, and a data object. No cookies, no account email, no visitor id.
How other systems label the same idea
These snippets are illustrations of public documentation patterns. This site does not call Stripe, GitHub, Shopify, Slack, or Segment, and these are not live payloads from those companies.
Stripe
Illustrative. Stripe’s public docs sign t.rawBody with HMAC-SHA256 and send Stripe-Signature.
POST /webhooks
Content-Type: application/json
Stripe-Signature: t=1492774577,v1=5257a869…
{"id":"evt_1","type":"checkout.session.completed","data":{"object":{"id":"cs_test"}}}GitHub
Illustrative. GitHub’s public docs put the event name and a delivery id in headers, and HMAC-SHA256 of the raw body in X-Hub-Signature-256.
POST /hooks/github
Content-Type: application/json
X-GitHub-Event: push
X-GitHub-Delivery: 5c0e1b3a-2f4d-4c8a-9b1e-0a6d7e8f9c10
X-Hub-Signature-256: sha256=abcdef…
User-Agent: GitHub-Hookshot/1
{"ref":"refs/heads/main","repository":{"full_name":"example/app"}}Shopify
Illustrative. Shopify’s public docs send the topic, a webhook id, and a base64 HMAC-SHA256 of the raw body.
POST /webhooks/shopify
Content-Type: application/json
X-Shopify-Topic: orders/create
X-Shopify-Webhook-Id: b1c2d3e4-0000-4000-8000-000000000001
X-Shopify-Hmac-Sha256: aG1hYy1iYXNlNjQ=
{"id":820982911946154508,"financial_status":"paid"}Slack
Illustrative. Slack’s public docs sign v0:timestamp:rawBody and send X-Slack-Signature plus a timestamp you reject when it is old.
POST /slack/events
Content-Type: application/json
X-Slack-Request-Timestamp: 1531420618
X-Slack-Signature: v0=a2114d57b48eac39b9ad189dd8316235a7bdc556d01a…
{"type":"event_callback","event":{"type":"app_mention"}}Segment
Illustrative. A classic Segment webhook is a track payload POSTed to your URL. The shared secret is an HMAC of the raw body, often in X-Signature. Check the destination you actually use.
POST /segment
Content-Type: application/json
X-Signature: 3f4c9a…
{"type":"track","event":"Order Completed","userId":"anon-1","properties":{"revenue":19}}Inspector
The buttons below are the sender. POST /api/lab/webhooks/deliver signs the body with the lab secret and hands it to /api/lab/webhooks/receive. The log keeps the last 25 deliveries for this browser for 24 hours. Apply drizzle/0011_webhook_delivery.sql on Postgres before those rows will persist. Until then, the attempt from the request you just made still appears under This request.
Trigger an event
Each button builds a payload from the controls and asks this site to sign it and POST it to the built-in receiver. Reset my data does not clear this browser. It only sends the event a reset would emit, with applied: false.
The banner is not Accept. Put that in the payload if you want the event to match it. The demo still sends when Accept is off. It does not load a third-party tag.
Leave this empty to use only the built-in receiver. A pasted URL must be https on a public host. Localhost, private addresses, redirects, and this site are refused. The server checks the address before it connects, times out in 2.5 seconds, and does not store the response body.
Exact body
Before a send, this is the data block for a page view with the controls above. After a send, the panel is the raw JSON that was signed, plus the signature header.
{
"type": "page_view",
"data": {
"path": "/lab/webhooks",
"consent": "unknown",
"title": "Webhooks"
}
}Delivery log
The last deliveries for this browser’s anonymous id. Rows expire after 24 hours. The log refreshes on its own.
Reading this browser’s delivery log.
Security
The lab secret is bbp-lab-webhook-secret. It is published on this page on purpose. It signs only these demo deliveries. Do not reuse it for a real endpoint.
A real receiver keeps the secret in the environment, not in the page. Compare the computed HMAC with the header using a timing-safe compare, so the time you spend checking does not tell an attacker which character was wrong. Reject a timestamp outside a short window, five minutes on this lab, so a copied request cannot be replayed later. Store the delivery id and, when you see it again after a 2xx, return 2xx and do not repeat the side effect.
Serve the URL over https only. An IP allowlist is a second lock for vendors who publish their egress ranges. It is not a substitute for the signature, because those ranges change and a shared proxy can sit inside them. Rotate the secret by accepting the new value and the previous one for a day, then delete the previous one.
The optional URL on this page is the dangerous direction: your server would be calling an address a visitor typed. The route allows https only, refuses localhost, link-local, and private ranges, refuses this site’s own hosts, resolves DNS and connects only to those public addresses, does not follow redirects, times out at 2.5 seconds, and discards the response body. Query strings are not stored. Ten sends a minute per browser.
Verify this signature
The sender signed the original bytes with the lab secret and the fixed timestamp 1760000000. Change the payload or the secret, then check. The comparison is against that original signature, not a new one of the edited text.
No check yet.
Reliability
Answer 2xx as soon as the signature checks and the body is stored. Do the CRM write, the warehouse insert, or the Slack post on a queue. If you do that work inside the request, the sender’s timeout fires and you get the same event again while the first one is still running.
Retries use exponential backoff: a few seconds, then a minute, then longer, with a cap. This inspector compresses that. Fail the first attempt and the second try is 400 milliseconds later, with the same delivery id. A production sender would wait longer, and would eventually give up.
The give-up pile is a dead letter. Something a person can replay after you fix the bug. Ordering is not part of the contract. A later event can arrive before an earlier one, and the same event can arrive twice. Your handler keys off the delivery id, not off “this is the next one.”
Alert on the rate of non-2xx, on the age of the oldest unacked delivery, and on a sudden stop. A silent endpoint is worse than a loud 500, because the sender may be retrying into a hole you are not watching.
A receiver
Each sample reads the raw body, checks the timestamp and the HMAC, and only then parses JSON. Replace the secret. The curl uses the published lab secret against this site’s receive route.
Next.js route handler
request.text() is the raw body. request.json() would consume it first, and a later stringify would not match the signature.
import { createHmac, timingSafeEqual } from "node:crypto"
import { NextResponse, type NextRequest } from "next/server"
export const dynamic = "force-dynamic"
const SECRET = process.env.WEBHOOK_SECRET ?? ""
export async function POST(request: NextRequest) {
const raw = await request.text()
const timestamp = request.headers.get("x-bbp-timestamp") ?? ""
const header = request.headers.get("x-bbp-signature") ?? ""
if (!verify(raw, timestamp, header, SECRET)) {
return NextResponse.json({ error: "signature" }, { status: 401 })
}
const event = JSON.parse(raw) as { id?: string }
// Enqueue event.id. Do the slow work after this response.
return NextResponse.json({ received: true })
}
function verify(raw: string, timestamp: string, header: string, secret: string) {
const age = Math.abs(Date.now() / 1000 - Number(timestamp))
if (!secret || !Number.isFinite(age) || age > 300) return false
const expected = createHmac("sha256", secret).update(timestamp + "." + raw).digest("hex")
const given = header.split(",").map((part) => part.trim()).find((part) => part.startsWith("v1="))?.slice(3) ?? ""
const left = Buffer.from(expected)
const right = Buffer.from(given)
return left.length === right.length && timingSafeEqual(left, right)
}
Python (FastAPI)
request.body() is the raw bytes. hmac.compare_digest is the timing-safe compare.
import hashlib, hmac, time
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
SECRET = b"replace-with-your-secret"
@app.post("/webhooks/bbp")
async def receive(request: Request):
raw = await request.body()
timestamp = request.headers.get("x-bbp-timestamp", "")
header = request.headers.get("x-bbp-signature", "")
if not verify(raw, timestamp, header):
raise HTTPException(status_code=401, detail="signature")
# Queue the raw body. Return before the slow work.
return {"received": True}
def verify(raw: bytes, timestamp: str, header: str) -> bool:
try:
age = abs(time.time() - int(timestamp))
except ValueError:
return False
if age > 300:
return False
expected = hmac.new(SECRET, f"{timestamp}.".encode() + raw, hashlib.sha256).hexdigest()
given = ""
for part in header.split(","):
part = part.strip()
if part.startswith("v1="):
given = part[3:]
return hmac.compare_digest(expected, given)
curl
Simulates a sender. The delivery id is del_lab_demo. Send it twice and the second row is a duplicate.
BODY='{"id":"evt_lab_demo","type":"page_view","created":0,"data":{"path":"/lab/webhooks","consent":"unknown"}}'
TS=$(date +%s)
SIG=$(printf '%s' "${TS}.${BODY}" | openssl dgst -sha256 -hmac 'bbp-lab-webhook-secret' | awk '{print $2}')
curl -sS -D - -o /tmp/webhook-body.txt -X POST 'https://www.blackboxpersonalization.com/api/lab/webhooks/receive' \
-H 'content-type: application/json' \
-H "x-bbp-timestamp: ${TS}" \
-H "x-bbp-signature: t=${TS},v1=${SIG}" \
-H 'x-bbp-event: page_view' \
-H 'x-bbp-delivery: del_lab_demo' \
--data-binary "${BODY}"
Where analytics actually uses this
Offline conversions and the server-side lab call Google’s Measurement Protocol. That is an API: you POST to Google. It is not a webhook. Use it when a deal closes in a system Google cannot see, and you still have the client id.
A webhook fits when the other system is the one that learns the fact. A CRM such as Salesforce or HubSpot can POST a lead or a stage change to your URL. A consent change on this site can POST to a tag server or a CRM so they stop using an id you just dropped. A high-intent score can POST to Slack. Zapier and Make are webhook routers: they receive the POST, then call the next API.
A CDP forwarding an event is the same shape. The CDP is the sender. Your warehouse, your ad platform, or your support tool is the receiver. Prefer a webhook when the sender knows the moment and can retry. Prefer an API when you are the one who knows, or when you must pull a report. Prefer a stream when you need a retained, ordered log that many consumers read at their own pace. A webhook is a notification. It is not a database.
Consent on this site stays in the browser until Accept. The inspector will still send when you click, because you asked it to. With Accept on, the click also records webhook_lab_view with the event name and whether a custom URL was set. The URL itself is not in that hit.
Testing and the raw body
webhook.site and similar request bins give you an https URL and show the POST. Paste that URL into the inspector if you want to see the signed body arrive outside this site. ngrok and other tunnels do the same for a server on your machine. This page’s inspector is the same idea with the receiver already running here.
Replay by sending again. A new click mints a new delivery id. The curl sample reuses del_lab_demo, which is how you watch the duplicate path. Log the delivery id, the status you returned, and the latency. Do not log the secret or a query string from someone else’s URL.
The usual failures are a timeout, because the handler did the work before answering, a 401, because the secret or the timestamp or the bytes differ, and a parse error that happens before verification. In a Next.js route, call await request.text() and verify those characters. request.json() reads the stream, so you cannot get the original bytes afterward. Even if you could, JSON.stringify of the parsed value can reorder keys or change spacing. A proxy that adds a newline changes the HMAC too. Sign and verify one canonical buffer, and keep that buffer to requeue.
Practice
- In the inspector, leave “Fail the first built-in attempt” on. Set the intent score to 40 or more and send Intent threshold. This request should show attempt 1 as HTTP 500 and attempt 2 as HTTP 200, with one delivery id.
- In Verify this signature, add a space to the raw body and check. It should fail. Put the original body back. It should pass. Change one character of the secret. It should fail again.
- Send the same curl twice. The second stored outcome should be duplicate, and nothing in the payload should have run twice. On this lab the payload does not run a side effect either way. The row is the proof.
- Answer the questions. They use only the rules above.
Glossary
- Webhook
- An https URL that receives a POST when another system has an event.
- Polling
- Asking an API on a timer whether anything new exists.
- Payload
- The body of that POST. Here it is JSON.
- HMAC
- A hash of the body mixed with a shared secret. This lab uses SHA-256.
- Replay
- Sending a captured request again. A short timestamp window makes an old capture useless.
- Idempotency
- Doing the side effect once even if the delivery arrives twice. The delivery id is the key.
- Backoff
- Waiting longer between retries so a broken receiver is not hammered.
- Dead letter
- A delivery the sender stopped retrying. A person, or a later job, can replay it.
- Raw body
- The exact bytes the sender signed, before JSON parsing.
- SSRF
- Tricking a server into calling an address it should not, such as localhost or a cloud metadata IP. The optional URL on this page is checked to refuse those.
Recommended next
Why these picksReading path transitions…