Express webhook handler
An endpoint that survives retries, replays, slow work and malformed bodies.
A webhook endpoint is called by a machine, retried when it is slow, called twice for the same event, and reachable by anyone who finds its URL. This recipe builds one in Express that handles all four; the reasoning is in Accept a payment.
The four things it has to get right
| Requirement | What goes wrong without it |
|---|---|
| Verify the signature | Anyone who finds the URL can mark orders paid |
| Answer within ten seconds | The delivery is marked failed and retried for up to 72 hours |
| Deduplicate | The same event delivered twice fulfils the same order twice |
| Never throw | One malformed payload takes the whole endpoint down |
1. Setup
npm install express @wajub/nodeThe only configuration is the signing secret of the endpoint you registered in the Dashboard. It
starts with whsec_ in live mode and whsec_test_ in sandbox, and the two are different values.
import { Wajub } from '@wajub/node';
export const wajub = new Wajub({
apiKey: process.env.WAJUB_API_KEY!,
webhookSecret: process.env.WAJUB_WEBHOOK_SECRET!,
});2. The raw body, before anything else
Signature verification runs against the exact bytes Wajub signed. A JSON parser reorders nothing but does re-serialise, and that is enough to change the hash.
So mount express.raw() on the webhook route and express.json() on everything else, in that order.
Getting this wrong produces an endpoint that rejects every genuine delivery while looking correct.
import express from 'express';
import { webhookRouter } from './webhooks';
const app = express();
app.use('/webhooks/wajub', express.raw({ type: '*/*' }), webhookRouter);
app.use(express.json());
app.listen(3001, () => console.log('listening on 3001'));3. The handler
Three things happen in order, and the order is the design. Verify, acknowledge, then work.
constructEvent does the verifying; once the signature holds, the handler parses the body
(id, event, data) as a WajubEvent.
import { Router } from 'express';
import { wajub } from './wajub';
import { events } from './events';
import { queue } from './queue';
export type WajubEvent = {
id: string;
event: string;
data: Record<string, any>;
};
export const webhookRouter = Router();
webhookRouter.post('/', async (req, res) => {
try {
wajub.webhooks.constructEvent(
req.body,
req.headers['x-wajub-signature'] as string,
req.headers['x-wajub-timestamp'] as string,
);
} catch {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString('utf8')) as WajubEvent;
res.sendStatus(200);
try {
const isNew = await events.record(event.id, event.event);
if (!isNew) return;
await queue.add('wajub-event', event);
} catch (err) {
console.error('failed to enqueue', event.id, err);
}
});constructEvent also rejects a timestamp more than 300 seconds from now, which stops a captured
request being replayed at you later.
The res.sendStatus(200) sits before the work on purpose. Wajub has already been told the
delivery landed, so nothing that happens afterwards can cause a retry.
Ten seconds, then it counts as failed
A delivery gets ten seconds to return a 2xx. After that it counts as failed and is redelivered
for up to 72 hours. If your handler talks to a payment
provider, sends an email or renders a PDF, that work belongs in the queue, not in the request.
4. Deduplication that survives a restart
Wajub retries a failed delivery, and a retry carries the same event. Your handler will therefore run twice for the same thing, and the only question is whether it notices.
Deduplicate in your database, not in memory. An in-process Set is empty after every deploy, which
is exactly when a backlog of retries arrives.
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);The primary key does the work. Insert first and let the database refuse the duplicate, rather than checking then inserting, which races with itself under concurrent deliveries.
import { db } from './db';
export const events = {
async record(eventId: string, eventType: string): Promise<boolean> {
const { rowCount } = await db.query(
`INSERT INTO processed_events (event_id, event_type)
VALUES ($1, $2)
ON CONFLICT (event_id) DO NOTHING`,
[eventId, eventType],
);
return rowCount === 1;
},
};rowCount === 1 means this event is new and you should process it. Zero means it has been seen, and
returning quietly is the correct response.
Two identifiers, and they answer different questions
event.id in the body identifies the event: the thing that happened. The
X-Wajub-Delivery-Id header identifies the delivery attempt of that event to your endpoint.
Both stay stable across retries. Deduplicate on event.id, and log the delivery id alongside it so
support can tell a retry from a genuine second event.
5. Routing the event
Now that the request is over, the worker decides what the event means. The envelope is fixed: event
is the name and data is the object it is about.
So data.id is the payment, refund or transfer id, depending on the event. A failure carries
data.failure_reason as well: see Failure reasons.
import { wajub } from './wajub';
import { deliver, orders } from './orders';
import { payouts } from './payouts';
import { support } from './support';
import type { WajubEvent } from './webhooks';
export async function handleEvent(event: WajubEvent) {
switch (event.event) {
case 'payment.succeeded':
return fulfillPayment(event.data.id);
case 'payment.failed':
case 'payment.expired':
case 'payment.cancelled':
return orders.markFailed(event.data.id, event.data.failure_reason);
case 'refund.succeeded':
return orders.markRefunded(event.data.transaction, event.data.amount);
case 'refund.failed':
return support.reopen(event.data.transaction, event.data.failure_reason);
case 'transfer.succeeded':
return payouts.markComplete(event.data.id);
default:
console.log('unhandled event', event.event);
}
}
async function fulfillPayment(paymentId: string) {
const payment = await wajub.payments.retrieve(paymentId);
if (payment.status !== 'succeeded') return;
const order = await orders.findByPaymentId(paymentId);
if (!order || Number(payment.amount) !== Number(order.amount)) return;
if (await orders.claim(order.id)) await deliver(order);
}Notice the refund branches read data.transaction, not data.payment. A refund names the payment it
belongs to under transaction, which is one of the few places a field name is not the obvious one.
And notice fulfillPayment re-reads the payment from the API. The event body is signed and
trustworthy, but re-reading also covers the case where something changed between the event being
emitted and your worker getting to it.
6. If your stack has no SDK
constructEvent exists in Node, PHP, Python, Go and C#. If you are on something else, here is the
same check, written out.
import crypto from 'crypto';
const TOLERANCE_SECONDS = 300;
export function verify(rawBody: Buffer, signature: string, timestamp: string, secret: string) {
if (!signature?.startsWith('v1=')) return false;
const drift = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(drift) || drift > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody.toString('utf8')}`)
.digest('hex');
const received = signature.slice(3);
if (expected.length !== received.length) return false;
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}`timingSafeEqual` throws on a length mismatch
It raises a RangeError rather than returning false when the two buffers differ in length, which
turns a malformed signature into a 500 instead of a 400. The explicit length check above is not
optional, and it leaks nothing: the length of a hex digest is public.
The headers involved are these four.
| Header | What it carries |
|---|---|
X-Wajub-Signature | v1= followed by the HMAC-SHA256 hex digest |
X-Wajub-Timestamp | The Unix second the event was signed |
X-Wajub-Event | The event name, also present in the body |
X-Wajub-Delivery-Id | This delivery attempt, stable across retries |
Signature verification is the authoritative page if the algorithm ever changes.
7. Run it against real deliveries
You do not need to deploy to test this. The CLI forwards your account's events to a local port.
wajub listen --forward-to localhost:3001/webhooks/wajub
wajub trigger payment.succeededwajub trigger emits a real event on your sandbox account, so everything downstream is genuine:
a real signature, a real event.id. What wajub listen does not do is retry: it forwards each
event once and prints what your handler answered.
Three things are worth proving before you ship.
| Test | How | What should happen |
|---|---|---|
| Bad signature | curl -X POST the endpoint with no headers | 400, nothing recorded |
| Duplicate delivery | Resend the same event with wajub events resend <id>: wajub listen receives it again | Second one inserts nothing and does nothing |
| Slow work | Add a ten second sleep in the worker | The response is still immediate |
Related pages
- Signature verificationThe authoritative algorithm and its parameters.
- Webhook eventsEvery event Wajub sends, product by product.
- Webhook retriesWhat counts as a failed delivery, and how long it is redelivered.
- Accept a paymentWhat the fulfilment on the other side of this queue looks like.
- wajub listenForward live events to a port on your machine.