How to Create and Test a Webhook
Create a webhook endpoint, verify its signature, and test it with curl or a tunnel. Plus the retry, duplicate, and timeout tests most guides skip.

To create a webhook, write an HTTP endpoint that accepts POST. Verify the signature. Return 200 right away. Do the real work after. To test it, point the sender at a capture URL. Or tunnel your localhost to the internet. Or replay a saved payload with curl. All three take minutes.
The part that takes longer is testing the things that actually break. Most tutorials stop once a payload shows up in the console. Delivery is the easy half. This guide builds the endpoint, then tests duplicate deliveries, slow handlers, and bad signatures. That's where webhooks fail in production.
How do you create a webhook endpoint?
A webhook endpoint is a normal POST route. Nothing is special about it except what you do inside.
Here's the whole thing in Express, minus the parts worth explaining separately:
import express from 'express';
import crypto from 'crypto';
const app = express();
// keep the raw body: signature checks run on bytes, not parsed JSON
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
if (!isValidSignature(req)) return res.sendStatus(401);
const event = JSON.parse(req.body);
res.sendStatus(200); // acknowledge first
void handleEvent(event); // then work
});
app.listen(3000);
Three details in there matter more than the route itself.
Keep the raw body
Signature checks hash the exact bytes the sender signed. Parse the JSON first and you re-serialize it. Different bytes, so every signature fails. Key order and whitespace shift.
This is the most common webhook bug. express.json() breaks your signature check. The error looks like a wrong secret.
Return 200 before you process
Senders time out. Most give you a few seconds, then call the delivery failed and retry. Say your handler writes to three services before responding. You'll get retried while the first try is still running.
Acknowledge receipt, then process. The 200 means "I have it," not "I finished."
Fail loudly, but only on your side
Return 401 for a bad signature. Return 400 for JSON you can't parse. Return 500 only when you want a retry. A 200 on a request you couldn't handle drops the event for good.
How do you verify a webhook signature?
HMAC, almost always. The sender shares a secret with you. It hashes the request body with that secret and puts the result in a header. You do the same, then compare.
function isValidSignature(req) {
const received = req.get('x-webhook-signature');
if (!received) return false;
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(req.body) // raw bytes
.digest('hex');
// constant-time compare, not ===
return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}
Use timingSafeEqual, not ===. A plain string compare stops at the first wrong character. That leaks the right signature a byte at a time. AWS's own webhook tutorial uses hmac.compare_digest in Python for the same reason.
Is your handler public and unsigned? Then anyone who finds the URL can post to it. Treat an unverified webhook as untrusted input. It is one.
How do you test a webhook without deploying?
Three methods, in the order you'd reach for them.
Capture services
Open a site like webhook.site or Beeceptor. Get a unique URL. Paste it into the sender's config. Every request shows up with headers, body, and timing.
Best for one question: what does this payload really look like? Get that before you write parsing code. Vendor docs and vendor payloads disagree more often than they should.
Tunnels
Run your handler locally, then expose it. ngrok http 3000 gives you a public HTTPS URL pointing at localhost. Now you can set breakpoints in the real code path.
The catch on free tiers: the URL changes on every restart. So you re-paste it into the sender each time. Paid plans and Cloudflare Tunnel give stable URLs. Localtunnel is a free zero-config option if you don't need one.
curl replay
Once you've caught a real payload, save it. Now you can replay it forever. No network, no sender, no waiting.
BODY='{"type":"entry.published","entryId":"abc123"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | sed 's/^.* //')
curl -X POST http://localhost:3000/webhooks \
-H "Content-Type: application/json" \
-H "x-webhook-signature: $SIG" \
-d "$BODY"
This is the one to automate. It's the basis of every test below.
The webhook tests most tutorials skip
In a tutorial, delivery works on the first try. Here's what to test instead.
Test a duplicate delivery
Webhook delivery is at-least-once, not exactly-once. Senders retry on timeout. A retry can land after the first try already worked. Your handler will see the same event twice.
Run the curl command above twice, back to back. Then check. Did you send two emails? Charge twice? Create two entries?
If yes, you have a bug that will appear in production and be blamed on the sender.
Test a slow handler
Add a deliberate await sleep(30000) before your response. Send an event. Watch what the sender does. Most time out and retry. Now you can watch the duplicate problem happen for real.
Then move the work after the 200 and run it again. That's the fix, demonstrated.
Test a bad signature
Send the same payload with a garbage signature header. You should get 401 and no side effects.
Also send one with the signature omitted entirely. Plenty of handlers check "does the signature match" and skip "is there a signature at all." A request with no header then sails straight through.
Test out-of-order events
Events don't arrive in the order they happened. An entry.updated can land before the entry.created it followed.
Replay two payloads in reverse order. Does your handler crash on an update for something it hasn't created? Decide now: ignore it, or fetch current state from the API.
How do you make a webhook handler idempotent?
Idempotent means running it twice does what running it once does. This is the fix for duplicate delivery. It's mostly bookkeeping.
Every decent sender includes a unique event id. Store the ones you've processed and check before doing work:
async function handleEvent(event) {
// unique index on eventId makes this atomic
const fresh = await db.processedEvents.insertIfAbsent(event.id);
if (!fresh) return; // already handled, drop it
await doTheActualWork(event);
}
Two rules make this work. Put a unique constraint on the id column, so two retries can't both pass the check. And keep the records longer than the sender's retry window. That's often days.
Is your work already idempotent, like setting a field to a fixed value? Skip the table. Anything that increments, appends, or sends a message needs it.
What goes wrong in production?
Four failures, roughly in order of how often they show up.
The endpoint went down and you lost events. Check whether your sender retries, and for how long. Most do, on a backoff, for hours or days. That's your recovery window. Learn the number before you need it.
Silent 200s. A handler that swallows every error and returns 200 looks healthy while dropping events. Log the failures. Return 500 when you want the retry.
Secret rotation. Swapping a webhook secret breaks in-flight deliveries. Accept both the old and new secret during the changeover. Then drop the old one.
No visibility. You can't debug what you didn't record. Log the event id, type, and signature result for every request, before parsing. Then "did we get that event" has a real answer.
Where to go from here
Building a webhook endpoint takes ten minutes flat. Making it survive retries, duplicates, and reordering is the real work. That's mostly three things. Keep the raw body. Return 200 before processing. Make the handler idempotent with a unique event id.
Draftbase fires webhooks on every entry event. So you can test all of this against real ones. Create an entry, publish it, roll it back, and watch what your endpoint receives. Hobby is free with no card, Startup is $49/mo. New to the model? Read what a webhook is for the delivery guarantees behind these tests. Still deciding? Compare webhooks against polling APIs.
How to
- 1Write a POST route that keeps the raw body
Create an HTTP endpoint accepting POST. Configure your framework to hand you the raw request bytes, not parsed JSON, because signature checks hash the exact bytes the sender signed.
- 2Verify the signature with a constant-time compare
Hash the raw body with your shared secret using HMAC SHA-256, then compare against the signature header using timingSafeEqual or compare_digest. Reject with 401 when it fails or is missing.
- 3Return 200 before doing any work
Acknowledge receipt immediately, then process the event asynchronously. Senders time out after a few seconds and retry, so a slow handler creates duplicate deliveries.
- 4Capture a real payload
Point the sender at a capture URL such as webhook.site to see the actual headers and body. Vendor docs and vendor payloads often disagree.
- 5Tunnel to localhost for live debugging
Run ngrok http 3000 or Cloudflare Tunnel to expose your local handler over HTTPS. Now you can set breakpoints in the real code path.
- 6Replay the payload with curl
Save the captured body, compute its signature with openssl, and POST it to your endpoint. This runs offline and becomes the basis of every other test.
- 7Test duplicates, slow handlers, and bad signatures
Send the same payload twice and check for double side effects. Add a deliberate delay before responding to watch the sender retry. Send a garbage signature and one with no signature header at all.
- 8Make the handler idempotent
Store each processed event id behind a unique database constraint and drop events you have already handled. Keep those records longer than the sender's retry window.
Ship content that's built to be found
Draftbase generates schema, structured data, and a fast MDX editor for every post.
Frequently asked questions
How do I create a webhook endpoint?
Write a POST route, verify the signature on the raw body, return 200 right away, then process the event. Returning 200 before you do the work is what stops the sender from timing out and retrying.
How do I test a webhook without deploying?
Three ways. Paste a capture URL from webhook.site into the sender. Or tunnel localhost with ngrok. Or save a real payload and replay it with curl, which is the one worth automating.
Why does my webhook signature check keep failing?
Because the signature is a hash of the exact bytes the sender signed. Parsing JSON and re-serializing it changes key order and whitespace, so the hash no longer matches. Keep the raw body.
How do I make a webhook handler idempotent?
Store every event id you have processed, with a unique constraint on the column. Check it before doing any work and drop the ones you have seen. Keep those records longer than the sender retry window.
Why would the same webhook event arrive twice?
Delivery is at-least-once. A sender that times out will retry, and the retry can land after the first attempt already succeeded. Send the same payload twice and check for double side effects.