← blog

Receiving webhooks without deploying a server

A webhook is just an HTTP POST that some other service sends you when something happens. The annoyance is that to receive one you need a public URL, which usually means a deploy, a tunnel binary, or an account somewhere — all before you have even seen the shape of the payload. You can skip that part while you are still figuring out what the bytes look like.

Why testing webhooks is awkward

The sender lives on the public internet; your code lives on localhost or in a CI job behind NAT. There is no route between them. Sender dashboards rarely show the exact bytes they will POST, and signatures depend on those exact bytes, so guessing the shape from docs and discovering the difference in production is a bad loop. You want one real request in front of you, then write code against it.

Two patterns cover almost everything. A sink gives you a public URL that captures requests and shows them to you — you read the payload by hand. A forward relays requests from a public URL straight to a port on your machine, so your real handler runs and replies. Use a sink to inspect; use a forward to actually exercise your code.

Step 1 — see one real payload

Start with a request bin. You get a capture URL; paste it into the sender's webhook settings and trigger one event. The bin records the method, every header, and the raw body. Now you know the real content type, whether the signature lives in Stripe-Signature or X-Hub-Signature-256, and exactly which bytes you will be hashing. The bin is ephemeral and rate-limited — it is for inspection, not for running a service.

When you want your own handler in the loop, open a forward tunnel to your local port. Requests hit the public URL and arrive at your process; your response goes back to the sender — the same flow you would have in production, minus the deploy.

curl -fsSL https://exl.ink/api/tunnel/client -o exlink.js && node exlink.js 3000

Step 2 — verify the signature, or trust nothing

A public URL accepts POSTs from anyone, so anyone who learns it can forge events. The defense is an HMAC signature: the sender shares a secret with you, hashes the request body with it, and sends the digest in a header. You recompute the same HMAC and compare. Stripe and GitHub both use HMAC-SHA256; the details differ but the shape is identical.

Three things people get wrong here. First, hash the raw body bytes — if a JSON middleware parses and re-serializes the body before you hash it, the bytes change and verification fails (the classic express.json() footgun, which is why Stripe's own docs tell you to register the webhook route before the body parser). Second, compare in constant time: never use == on the two digests, because string comparison short-circuits and leaks timing — GitHub's docs point you at crypto.timingSafeEqual for exactly this reason. Third, do the work server-side with a real secret. In the browser or an edge runtime, SubtleCrypto gives you both HMAC and a verify() that returns a boolean, so you skip the hand-rolled compare entirely.

const ok = await crypto.subtle.verify("HMAC", key, sigBytes, bodyBytes);

Step 3 — replay protection and idempotency

A valid signature only proves the body was not altered. It does not stop someone from capturing a real signed request and sending it again. Most senders include a timestamp in or alongside the signature; the Standard Webhooks spec tells you to reject anything outside an allowable tolerance of the current time, and leaves the exact window to you — a few minutes is a common choice. For that to mean anything, the timestamp must be inside the signed bytes, so it cannot be tampered with.

Separately, you will receive the same event more than once. Senders retry on timeout or a non-2xx, and networks duplicate. So your handler must be idempotent: record each event's unique id (Stripe's event.id, the Standard Webhooks webhook-id) and make processing the same id twice a no-op. Reply 2xx only after you have durably accepted the event; do the slow work afterward. If you 2xx and then crash, the sender thinks it succeeded and never retries.

Acknowledge fast, process idempotently. A 2xx is a promise that the event will not be lost.

Step 4 — testing the other direction

Sometimes you are the one who has to call back — a job finishes and you POST a result to a URL the caller gave you. To test the receiving side without standing up the producer, fire a one-shot request on a delay with a scheduled callback. Point it at your endpoint, set a body, pick a delay, and it sends once at that time even if your laptop has slept. It is a single deferred HTTP call, not a queue, and it is best-effort — fine for exercising a handler, not for production delivery guarantees.

On the sending side that you do control, mirror what good senders do: retry failed deliveries with exponential backoff and jitter so a brief outage does not turn every retry into a synchronized stampede, and cap the total attempts so a dead endpoint does not get hammered forever.

Limits worth knowing

Our tools are deliberately small. The tunnel and bin have short lifetimes, per-IP caps, and body-size limits, and they drop when you disconnect — nothing camps. The tunnel relays plain HTTP request and response, not the tunneled app's own WebSocket upgrades. The scheduled callback fires once and is best-effort. None of this is a substitute for a real webhook gateway in production; it is for the part before production — open a page, catch the request, read the bytes, close the tab.

Further reading