> ## Documentation Index
> Fetch the complete documentation index at: https://docs.paj.cash/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook signatures

> Telling a real delivery from anything else that found your URL.

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](/api-reference/update-webhooks).

You will find it on the key in the dashboard, and in the response to
[`PATCH /pub/v2/webhook`](/api-reference/update-webhooks).

<Warning>
  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.
</Warning>

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:

| Header | Example | What it is |
| - | - | - |
| `X-PAJ-Timestamp` | `1788224623` | When the delivery was signed, in Unix seconds |
| `X-PAJ-Signature` | `v1=798f601d45a4…` | The version, then the hex HMAC-SHA256 digest |

The signature covers the timestamp and the body joined by a single dot:

```
{X-PAJ-Timestamp}.{raw request body}
```

So a delivery timestamped `1788224623` carrying `{"id":"pay_1","amount":25.5}`
is a digest over exactly this string:

```
1788224623.{"id":"pay_1","amount":25.5}
```

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

<Steps>
  <Step title="Read the raw body">
    Capture the request body as the exact bytes that arrived, before any JSON
    parsing.
  </Step>

  <Step title="Check the timestamp is recent">
    Reject anything older than about five minutes. This is what stops a captured
    delivery being replayed at you later.
  </Step>

  <Step title="Recompute the digest">
    HMAC-SHA256 over `{timestamp}.{raw body}` using your `webhookSecret`, hex
    encoded.
  </Step>

  <Step title="Compare in constant time">
    Use your language's timing-safe comparison, not `==`.
  </Step>
</Steps>

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from 'crypto';

  function verifyPajWebhook(rawBody, headers, secret) {
    const timestamp = headers['x-paj-timestamp'];
    const received = (headers['x-paj-signature'] ?? '').replace('v1=', '');

    // Reject stale deliveries before doing any crypto.
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
      return false;
    }

    const expected = crypto
      .createHmac('sha256', secret)
      .update(`${timestamp}.${rawBody}`)
      .digest('hex');

    // Both are hex of the same length, so this cannot throw on length mismatch.
    if (received.length !== expected.length) return false;

    return crypto.timingSafeEqual(
      Buffer.from(received),
      Buffer.from(expected),
    );
  }
  ```

  ```python Python theme={null}
  import hashlib, hmac, time

  def verify_paj_webhook(raw_body: bytes, headers, secret: str) -> bool:
      timestamp = headers.get("X-PAJ-Timestamp", "")
      received = headers.get("X-PAJ-Signature", "").removeprefix("v1=")

      if abs(time.time() - int(timestamp)) > 300:
          return False

      expected = hmac.new(
          secret.encode(),
          f"{timestamp}.".encode() + raw_body,
          hashlib.sha256,
      ).hexdigest()

      return hmac.compare_digest(received, expected)
  ```
</CodeGroup>

## 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`.

<Warning>
  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.
</Warning>

## 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](/api-reference/test-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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.