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 shows | Cause | Fix |
|---|---|---|
| No attempt | No endpoint registered for this environment | Register it, sandbox and live are separate |
| Attempt, connection refused | The URL is not reachable from the internet | localhost is not, use wajub listen |
Attempt, 404 or 405 | The route exists for GET, not POST | Webhooks are always POST |
Attempt, 5xx | Your handler threw | See the last section of this page |
Local development needs a tunnel, and the CLI is it.
wajub listen --forward-to localhost:3000/webhooks/wajubListen 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.
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.
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.
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.