Skip to content

Webhooks

Nothing arrives, the signature never verifies, or the same event lands three times.

Webhook problems are unusually easy to diagnose, because Wajub records every delivery attempt and the response your endpoint gave. Open Konsole, Webhook Tester first: it answers "did Wajub try" and "what did my server say" in one screen, and that pair decides which half of this page you need.

Nothing arrives at all

If Konsole shows no attempt, the event never targeted your endpoint. If it shows attempts that failed, the problem is on your side.

Konsole showsCauseFix
No attemptNo endpoint registered for this environmentRegister it, sandbox and live are separate
Attempt, connection refusedThe URL is not reachable from the internetlocalhost is not, use wajub listen
Attempt, 404 or 405The route exists for GET, not POSTWebhooks are always POST
Attempt, 5xxYour handler threwSee the last section of this page

Local development needs a tunnel, and the CLI is it.

Forward live deliveries to a local route
wajub listen --forward-to localhost:3000/webhooks/wajub

Listen covers the rest, and Triggers lets you fire a specific event without making a real payment.

The signature never verifies

Four causes, and the first is responsible for most of them.

Your framework parsed the body before you hashed it. The signature covers the exact bytes Wajub sent. Once a JSON middleware has parsed and re-serialised them, a reordered key or a changed space is enough to break the hash, even though the object is identical. Capture the raw body on the webhook route specifically, before any parser runs.

Raw bytes on the webhook route only
app.post('/webhooks/wajub', express.raw({ type: '*/*' }), (req, res) => {
  const event = wajub.webhooks.constructEvent(
    req.body,
    req.headers['x-wajub-signature'],
    req.headers['x-wajub-timestamp'],
  );
  res.sendStatus(200);
});

app.use(express.json());

The order of those two lines matters. express.json() mounted first consumes the body for every route, and your raw handler receives an empty buffer.

You passed the secret as the fourth argument. constructEvent takes the payload, the signature, the timestamp, and optionally a tolerance in seconds. The signing secret comes from the client you built, with webhookSecret in its constructor. Passing it fourth does not raise: it replaces the tolerance with a string, the drift check is skipped, and replay protection silently disappears.

You hashed the wrong string. The signed payload is {timestamp}.{raw_body}, joined by a literal dot, and the header carries a v1= prefix you must strip before comparing.

Your comparison threw instead of returning false. crypto.timingSafeEqual raises a RangeError when the two buffers differ in length, which is exactly what a truncated or malformed signature produces. Compare lengths first, then compare in constant time. Signature verification has the manual implementation.

event.type is undefined

The event name is on event, not on type.

What actually arrives
{
"id": "evt_9xh0XQ5wu2lxCSUGajfv",
"event": "payment.succeeded",
"data": {
"id": "trx_CSUGajfv9xh0XQ5wu2lx",
"status": "succeeded"
},
"livemode": true,
"pending_webhooks": 0,
"api_version": "2026-08-01",
"request": null,
"created": 1748083260
}

Branch on event.event. If you came from another platform where it was type, that is the habit firing. The Node and PHP SDKs currently type the parsed event as { type, data }, which is wrong against the wire and will be corrected. Trust the JSON above.

The same event arrives several times

By design. A delivery that does not answer 2xx inside 10 seconds is retried five times, spaced 30 seconds, 1 minute, 5 minutes, 10 minutes and 1 hour. A deploy in the middle of that window, or a handler that was briefly slow, and you will see duplicates.

Deduplicate on id, which is stable across every retry of the same delivery, and store it somewhere that survives a restart. An in-process Set empties on the deploy that caused the backlog you are about to receive.

The insert decides, not a lookup
CREATE TABLE processed_events (
  event_id     TEXT PRIMARY KEY,
  processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

INSERT INTO processed_events (event_id)
VALUES ($1)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;

A row comes back the first time and nothing on a replay, with no race between checking and writing.

My handler is slow, or throws

The budget is 10 seconds from connection to response. Anything your fulfilment does, a database write, a PDF, an email, belongs after the acknowledgement.

Verify the signature, record the event id, answer 200, then hand the event to a queue. If the queue work fails, retry the queue. Answering 500 because your fulfilment threw asks Wajub to redeliver, your handler throws again, and one bug becomes five copies of it.

The one case that should answer non 2xx is a signature that does not verify. Return 400: that request did not come from Wajub, and there is nothing worth redelivering.

An event I expected never fired

Check the name against Events before assuming a bug. Two frequent misses: there is no invoice.paid, and a refund's outcome arrives as refund.succeeded rather than on the payment.

If the event exists and you are subscribed, Konsole shows whether it was generated at all. An event that was never generated is a payment that never reached that state, which sends you back to Payments.

What did you think of this content?