Skip to content

Express webhook handler

An endpoint that survives retries, replays, slow work and malformed bodies.

A webhook endpoint looks like the smallest route in your application and is usually the one that breaks production. It is called by a machine, it is retried when it is slow, it is called twice for the same event, and it is a public URL anyone can post to.

This recipe builds one in Express that handles all four. The reasoning behind the design is in Accept a payment; here it is as a running server.

The four things it has to get right

RequirementWhat goes wrong without it
Verify the signatureAnyone who finds the URL can mark orders paid
Answer within ten secondsThe delivery is marked failed and retried for up to 72 hours
DeduplicateThe same event delivered twice fulfils the same order twice
Never throwOne malformed payload takes the whole endpoint down

1. Setup

npm install express @wajub/node

The 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.

src/wajub.ts
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.

src/server.ts
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. Before 2.0.0 the Node SDK typed its result as { type, data }, while the body Wajub sends carries id, event and data, so the handler parses that body with its own WajubEvent type once the signature holds, which works on every release.

src/webhooks.ts
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 does three checks at once: it recomputes the HMAC over timestamp.body, compares it in constant time, and rejects a timestamp more than 300 seconds from now. That last one is what stops a request captured off the wire being replayed at you tomorrow.

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.

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.

The table
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.

src/events.ts
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.

A verified payment.succeeded
{
"id": "evt_9Lq5RtVb2Kd8",
"event": "payment.succeeded",
"livemode": true,
"api_version": "2026-10-11",
"created": "2026-09-13T10:31:02+00:00",
"data": {
"id": "trx_CSUGajfv9xh0XQ5wu2lx",
"reference": "order-4172",
"amount": 5000,
"currency": "XAF",
"status": "succeeded",
"channel": "cm.mtn"
}
}

So data.id is the payment, refund or transfer id, depending on the event. A failure carries data.failure_reason as well, which is a diagnostic drawn from the provider and not a fixed enumeration.

src/worker.ts
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.

Verification by hand
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));
}

The headers involved are these four.

HeaderWhat it carries
X-Wajub-Signaturev1= followed by the HMAC-SHA256 hex digest
X-Wajub-TimestampThe Unix second the event was signed
X-Wajub-EventThe event name, also present in the body
X-Wajub-Delivery-IdThis 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.

Two terminals
wajub listen --forward-to localhost:3001/webhooks/wajub

wajub trigger payment.succeeded

wajub 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.

TestHowWhat should happen
Bad signaturecurl -X POST the endpoint with no headers400, nothing recorded
Duplicate deliveryResend the same event with wajub events resend <id>: wajub listen receives it againSecond one inserts nothing and does nothing
Slow workAdd a ten second sleep in the workerThe response is still immediate

What did you think of this content?