Skip to main content
Your webhook URL is a public endpoint. Anything on the internet can post to it, and a handler that credits an order because a request arrived saying so will credit one for whoever sends it. Every delivery Paj makes is therefore signed: it carries an HMAC over its own body, computed with a secret only you and Paj hold, so you can confirm the payload is ours and has not been altered. Verifying is a few lines and it is the difference between trusting a webhook and merely receiving one.

Your signing secret

Each API key has its own webhookSecret, issued with the key and looking like whsec_ followed by 64 hex characters. It signs every delivery to the webhooks on that key — both ramp updates and payment settlements. You will find it on the key in the dashboard, and in the response to PATCH /pub/v2/webhook.
The signing secret is as sensitive as the API key itself. Anyone holding it can forge deliveries that your handler will accept as genuine. Keep it in your server’s environment or secret manager, and never in a browser bundle, a mobile app, or version control.
Keys issued before signing existed did not have a secret. One is created for those automatically the first time the key is read or used, so if a key shows no secret, open it in the dashboard once and it will have one.

What arrives with a delivery

Two headers: The signature covers the timestamp and the body joined by a single dot:
So a delivery timestamped 1788224623 carrying {"id":"pay_1","amount":25.5} is a digest over exactly this string:
The v1= prefix names the scheme. If we ever introduce a second one, both will be sent together for a transition period, so match on the prefix rather than assuming the whole header is a digest.

Verifying a delivery

1

Read the raw body

Capture the request body as the exact bytes that arrived, before any JSON parsing.
2

Check the timestamp is recent

Reject anything older than about five minutes. This is what stops a captured delivery being replayed at you later.
3

Recompute the digest

HMAC-SHA256 over {timestamp}.{raw body} using your webhookSecret, hex encoded.
4

Compare in constant time

Use your language’s timing-safe comparison, not ==.

Verify against the raw body

The most common reason a signature will not verify is parsing the body first, then re-serialising it to check. Those are not the same bytes: key order, whitespace, and how unicode is escaped all differ between our serialiser and yours, and any of them changes the digest. Capture the raw body and verify that, then parse. In Express this means express.raw({ type: 'application/json' }) on the webhook route specifically, or express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }) if the route also needs a parsed body. Most frameworks have an equivalent — in NestJS, rawBody: true on NestFactory.create and then req.rawBody.
A body that has passed through a JSON parser and been re-serialised will fail verification intermittently rather than consistently — it depends on what the payload happens to contain. A verifier that works in testing and fails in production is almost always this.

Why the timestamp is signed

The timestamp sits inside the signed string rather than merely beside it, which means it cannot be edited in transit. That is what makes it usable as a replay window: someone who captures a valid delivery cannot move its timestamp forward without invalidating the signature, so once your five minutes are up the capture is worthless. This matters for retries. A failed delivery is retried with a fresh signature and a fresh timestamp, never a replay of the original, so a retry that arrives half an hour later still verifies against a five-minute window.

Redirects are not followed

Deliveries are posted to your URL and no further. If your endpoint answers with a redirect, the delivery is not followed to the new location and counts as failed — following it would hand a valid signature to a host you never configured. Point the webhook at its final URL directly. Watch for this if your URL redirects http to https, or a bare domain to www. Configure the destination itself.

Deliveries that arrive unsigned

Ramp orders opened outside a business have no API key behind them and so no secret to sign with. If you create orders that way, those deliveries arrive without the signature headers. Everything authenticated with an API key — every payment settlement, and every ramp order opened with x-api-key — is signed. If you only use the v2 API, treat a missing signature as a failed verification.

Checking your implementation

Test your webhook sends a sample payload signed with the same secret production traffic uses, so a verifier that accepts the test delivery accepts real ones. It is the cheapest way to confirm your raw-body handling is right before a real settlement depends on it.