A webhook is an HTTP request that one system sends to your URL when something happens - a payment succeeds, a deploy finishes, a form is submitted - so you don't have to keep polling for changes. Receiving one safely takes three things: verify the signature so you know who sent it, respond quickly so the sender doesn't retry unnecessarily, and handle duplicates because every serious webhook sender will, at some point, deliver the same event twice. This guide explains each with working Node.js code.
How webhooks work
- You give the sending service a URL, such as
https://api.example.co.za/webhooks/payments, and it gives you a signing secret. - When an event happens, the sender makes a
POSTrequest to your URL with a JSON body describing the event. - The sender signs the request, usually with an HMAC of the body using the shared secret, and puts the signature in a header.
- Your endpoint verifies the signature, records the event and returns a
2xxstatus. - If your endpoint times out or returns an error, the sender retries later, often several times with increasing delays.
A typical payload looks like this:
{
"id": "evt_7Hq2Lk9",
"type": "deployment.succeeded",
"createdAt": "2026-06-02T08:15:00Z",
"data": { "appId": "app_123", "commit": "a1b2c3d" }
}
Webhooks vs polling vs APIs
| Polling | Webhooks | |
|---|---|---|
| Who starts the request | You, on a timer | The sender, when something happens |
| Delay | Up to your polling interval | Usually seconds |
| Wasted requests | Many, most returning "nothing new" | None |
| You need | An API client | A public HTTPS endpoint |
Many integrations use both: webhooks for speed, plus an occasional reconciliation job that polls the API to catch anything missed. See cron jobs for Node.js for scheduling that job.
Step 1 - Verify the signature
Anyone can send a POST to your URL. The signature proves the request came from someone holding the secret and that the body wasn't altered.
The most common scheme is HMAC-SHA256 over the raw request body. The key detail: you must compute the HMAC over the exact bytes you received. If you let express.json() parse the body first and then re-serialise it, whitespace or key order may differ, and valid signatures will fail.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.WEBHOOK_SECRET;
function verifySignature(rawBody, signatureHeader) {
if (!signatureHeader) return false;
const expected = crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
const received = signatureHeader.replace(/^sha256=/, '');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(received, 'hex');
// timingSafeEqual throws if lengths differ, so check first
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post(
'/webhooks/payments',
express.raw({ type: 'application/json', limit: '1mb' }),
(req, res) => {
if (!verifySignature(req.body, req.get('x-signature'))) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// ...handle the event (see below)
res.sendStatus(200);
}
);
app.listen(process.env.PORT ?? 3000);
Why crypto.timingSafeEqual rather than ===? A normal string comparison stops at the first different character, so the time it takes leaks how much of a guessed signature was correct. A constant-time comparison removes that signal.
Header names and formats vary between providers (hex or base64, with or without a sha256= prefix), so follow the sender's documentation exactly. NewHost's own webhooks (such as deployment.succeeded) use the timestamped style described next: the X-NewHost-Signature header looks like t=1727337600,v1=<hex>, where v1 is the HMAC-SHA256 of ${t}.${rawBody} with your endpoint's secret, and X-NewHost-Delivery carries a unique ID for de-duplication. Full details are in the developer docs.
Step 2 - Stop replay attacks with a timestamp
A valid signed request captured in transit could be sent again later. Many providers include a timestamp in the signed content, for example signing ${timestamp}.${body}, and send the timestamp in a header. Reject anything too old:
function verifyWithTimestamp(rawBody, timestampHeader, signatureHeader, toleranceSeconds = 300) {
const timestamp = Number(timestampHeader);
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const signed = Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]);
return verifySignature(signed, signatureHeader);
}
Keep your server clock accurate - hosted platforms normally sync time automatically.
NewHost puts the timestamp inside the signature header (t=...,v1=...), so split it first and reuse the function above:
// X-NewHost-Signature: t=1727337600,v1=5f0c...
function verifyNewHostWebhook(rawBody, header) {
const parts = Object.fromEntries(
String(header || '').split(',').map((kv) => kv.split('=', 2))
);
return verifyWithTimestamp(rawBody, parts.t, parts.v1);
}
Step 3 - Respond fast, process later
Senders wait only a limited time for your response before treating the delivery as failed. If your handler sends emails, calls other APIs or runs slow queries before responding, you risk timeouts and duplicate retries.
The robust pattern:
- Verify the signature.
- Store the event (for example in a
webhook_eventstable). - Return
200immediately. - Process the stored event in the background, or with a scheduled worker.
Return a 2xx only once the event is safely stored. Return 4xx for invalid signatures, and 5xx if you genuinely failed to record the event, so the sender retries.
Step 4 - Make handling idempotent
Retries mean duplicates. Network timeouts mean you might process an event successfully while the sender never sees your 200, and sends it again. Your handler must produce the same result whether an event arrives once or five times.
Use the event ID with a unique constraint. With Prisma:
model WebhookEvent {
id String @id
type String
payload Json
receivedAt DateTime @default(now())
processedAt DateTime?
}
try {
await prisma.webhookEvent.create({
data: { id: event.id, type: event.type, payload: event },
});
} catch (err) {
if (err.code === 'P2002') return res.sendStatus(200); // duplicate - already stored
throw err;
}
The unique primary key guarantees each event is recorded once, even when two deliveries arrive at the same moment. When processing, also make the business action itself idempotent - for example, only mark an order paid if it is not already paid. See Node.js with MySQL and Prisma for setting up the database.
Step 5 - Don't trust the payload blindly
Even a signed webhook is a notification, not a command. For high-value events such as payments:
- Check that amounts, currencies and references match your own records.
- Optionally fetch the object from the sender's API to confirm its current status.
- Handle events arriving out of order: a "refunded" event might arrive before "paid" is processed. Compare timestamps or states rather than assuming order.
Security checklist for webhook endpoints
- HTTPS only
- Signature verified over the raw body with
crypto.timingSafeEqual - Timestamp tolerance to prevent replays, where the sender supports it
- Secret stored in an environment variable and rotatable
- Body size limit on the endpoint
- Event IDs stored with a unique constraint
- Fast
2xxresponse, heavy work done asynchronously - No personal data or secrets written to logs
Our Node.js security checklist covers the rest of your app.
Frequently asked questions
What is the difference between a webhook and an API?
With an API, your code sends a request and asks for data. With a webhook, the other system sends a request to your code when something happens. Many services offer both, using webhooks for notifications and the API for details.
Why does my webhook signature verification keep failing?
The usual cause is computing the HMAC over a parsed and re-serialised body instead of the raw bytes. Use express.raw() on the webhook route, and check that you use the right secret, encoding (hex or base64) and header format.
How do I test webhooks locally?
Use a tunnelling tool to expose your local server over HTTPS, or send test events with a script that signs them using your secret. Many providers also let you resend past events from their dashboard.
What happens if my endpoint is down?
Most senders retry failed deliveries several times over a period, then give up and may disable the endpoint. Check your provider's retry policy, and run a reconciliation job to catch any events that were never delivered.
NewHost's REST API sends webhooks for events like deployment.succeeded, alongside API keys and scheduled tasks. See automation on NewHost and the developer docs.