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

# Verify signatures

> Confirm that a webhook came from Pazy and was not changed in transit, with code for Node.js, Python and Go

Pazy signs every delivery with your endpoint's signing secret. Verify the signature on every request, before you parse or act on the body. Anyone who knows your URL can send it a request, and the signature is the only proof that Pazy sent it.

## How the signature works

```
signature = HMAC-SHA256(signing_secret, "<timestamp>.<raw_body>")
```

* `signing_secret` is the secret we gave you for this endpoint.
* `timestamp` is the value of the `X-Pazy-Webhook-Timestamp` header, exactly as sent.
* `raw_body` is the request body bytes exactly as received, before any parsing.
* The result is hex-encoded and sent in `X-Pazy-Webhook-Signature` as `sha256=<64 hex characters>`.

## Verification steps

<Steps>
  <Step title="Read the raw body">
    Capture the body as a string before your framework parses it. Re-serialized JSON can differ in key order or whitespace, and then the signature won't match.
  </Step>

  <Step title="Check the timestamp">
    Parse `X-Pazy-Webhook-Timestamp` as an integer. If it differs from your current time by more than **5 minutes** in either direction, reject the request with `401`. This stops an attacker replaying a captured request.
  </Step>

  <Step title="Recompute the signature">
    Compute `HMAC-SHA256(secret, timestamp + "." + rawBody)` and hex-encode it.
  </Step>

  <Step title="Compare in constant time">
    Strip the `sha256=` prefix from the header and compare the two values with a timing-safe function. Return `401` on a mismatch.
  </Step>

  <Step title="Parse and process">
    Only now parse the JSON and handle the event.
  </Step>
</Steps>

<Warning>
  Use a constant-time comparison: `crypto.timingSafeEqual` in Node.js, `hmac.compare_digest` in Python, `hmac.Equal` in Go. A plain `==` leaks timing information.
</Warning>

## Code examples

<CodeGroup>
  ```javascript Node.js (Express) theme={null}
  import { createHmac, timingSafeEqual } from 'node:crypto';
  import express from 'express';

  const REPLAY_WINDOW_SECONDS = 300;

  function verifySignature(rawBody, secret, timestampHeader, signatureHeader) {
    const timestamp = Number.parseInt(timestampHeader, 10);
    if (Number.isNaN(timestamp)) return false;

    const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
    if (age > REPLAY_WINDOW_SECONDS) return false;

    const receivedHex = (signatureHeader ?? '').replace(/^sha256=/, '');
    if (!/^[0-9a-fA-F]{64}$/.test(receivedHex)) return false;

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

    return timingSafeEqual(
      Buffer.from(expectedHex, 'hex'),
      Buffer.from(receivedHex, 'hex'),
    );
  }

  const app = express();

  // express.raw keeps the body as a Buffer so it is not re-serialized
  app.post('/webhooks/pazy', express.raw({ type: 'application/json' }), (req, res) => {
    const rawBody = req.body.toString('utf-8');

    const valid = verifySignature(
      rawBody,
      process.env.PAZY_WEBHOOK_SECRET,
      req.headers['x-pazy-webhook-timestamp'],
      req.headers['x-pazy-webhook-signature'],
    );
    if (!valid) return res.sendStatus(401);

    const event = JSON.parse(rawBody);
    // Save the event or enqueue it, then acknowledge
    res.sendStatus(200);
  });
  ```

  ```python Python (Flask) theme={null}
  import hashlib
  import hmac
  import json
  import os
  import re
  import time

  from flask import Flask, abort, request

  REPLAY_WINDOW_SECONDS = 300


  def verify_signature(raw_body: bytes, secret: str, timestamp_header: str, signature_header: str) -> bool:
      if not (timestamp_header.isascii() and timestamp_header.isdigit()):
          return False

      age = abs(int(time.time()) - int(timestamp_header))
      if age > REPLAY_WINDOW_SECONDS:
          return False

      received = signature_header.removeprefix("sha256=")
      if not re.fullmatch(r"[0-9a-fA-F]{64}", received):
          return False

      signed_payload = timestamp_header.encode() + b"." + raw_body
      expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()

      return hmac.compare_digest(expected, received.lower())


  app = Flask(__name__)


  @app.post("/webhooks/pazy")
  def handle_webhook():
      # get_data() returns the raw bytes, before any JSON parsing
      raw_body = request.get_data()

      valid = verify_signature(
          raw_body,
          os.environ["PAZY_WEBHOOK_SECRET"],
          request.headers.get("X-Pazy-Webhook-Timestamp", ""),
          request.headers.get("X-Pazy-Webhook-Signature", ""),
      )
      if not valid:
          abort(401)

      event = json.loads(raw_body)
      # Save the event or enqueue it, then acknowledge
      return "", 200
  ```

  ```go Go theme={null}
  package main

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  	"io"
  	"net/http"
  	"os"
  	"strconv"
  	"strings"
  	"time"
  )

  const replayWindowSeconds = 300

  func verifySignature(rawBody []byte, secret, timestampHeader, signatureHeader string) bool {
  	timestamp, err := strconv.ParseInt(timestampHeader, 10, 64)
  	if err != nil {
  		return false
  	}

  	age := time.Now().Unix() - timestamp
  	if age < 0 {
  		age = -age
  	}
  	if age > replayWindowSeconds {
  		return false
  	}

  	receivedHex := strings.TrimPrefix(signatureHeader, "sha256=")
  	received, err := hex.DecodeString(receivedHex)
  	if err != nil || len(received) != sha256.Size {
  		return false
  	}

  	mac := hmac.New(sha256.New, []byte(secret))
  	mac.Write([]byte(timestampHeader + "."))
  	mac.Write(rawBody)

  	return hmac.Equal(mac.Sum(nil), received)
  }

  func webhookHandler(w http.ResponseWriter, r *http.Request) {
  	rawBody, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
  	if err != nil {
  		http.Error(w, "bad request", http.StatusBadRequest)
  		return
  	}

  	if !verifySignature(
  		rawBody,
  		os.Getenv("PAZY_WEBHOOK_SECRET"),
  		r.Header.Get("X-Pazy-Webhook-Timestamp"),
  		r.Header.Get("X-Pazy-Webhook-Signature"),
  	) {
  		http.Error(w, "unauthorized", http.StatusUnauthorized)
  		return
  	}

  	// Save the event or enqueue it, then acknowledge
  	w.WriteHeader(http.StatusOK)
  }

  func main() {
  	http.HandleFunc("/webhooks/pazy", webhookHandler)
  	http.ListenAndServe(":8080", nil)
  }
  ```
</CodeGroup>

## Test your implementation

Use this fixed example to check your code. It computes the signature for a known secret, timestamp and body.

| Input | Value |
| - | - |
| Secret | `whsec_test_secret` |
| `X-Pazy-Webhook-Timestamp` | `1746873600` |
| Body | the JSON below, on one line with no trailing newline |

```json theme={null}
{"id":"018f1a2b-3c4d-7e8f-9a0b-1c2d3e4f5a6d","type":"payment.invoice.completed","version":"1","createdAt":"2026-08-10T09:14:21.000Z","data":{"paymentId":"8841203","transactionId":"7c9e6679-7425-40de-944b-e07fc1f90ae7","source":"PLATFORM","resource":{"id":"inv_8fk21ma","identifier":"INV-4471"},"amount":"1234.50","currency":"INR","utr":"UTR123456789","paymentDate":"2026-08-10T09:14:20.000Z","isFinalSettlement":true,"paymentRequestId":"pr_2kd9","originalTransactionId":null}}
```

Expected signature:

```
sha256=9f67d39dcd1132a50c4d57d9770b32c31ce8e76268f9eb6dd4e73af7f44b9c59
```

That timestamp is in the past, so your full handler rejects it at the timestamp check. Test the HMAC step on its own, or temporarily widen the window.

## Accept two secrets during a rotation

While a [secret rotation](/webhooks/setup#rotate-your-signing-secret) is in progress, accept a request if it verifies against either secret:

```javascript theme={null}
const secrets = [process.env.PAZY_WEBHOOK_SECRET, process.env.PAZY_WEBHOOK_SECRET_OLD]
  .filter(Boolean);

const valid = secrets.some((secret) =>
  verifySignature(rawBody, secret, timestampHeader, signatureHeader),
);
```

## Common mistakes

| Mistake | Effect |
| - | - |
| Parsing the JSON before verifying | The re-serialized body differs from the original, so the signature fails |
| Trimming or modifying the raw body | Changes the signed input |
| Comparing with `==` or `===` | Leaks timing information |
| Skipping the timestamp check | Allows replay of captured requests |
| Using the wrong secret | Each endpoint has its own secret, and sandbox and production differ |
| Middleware that decodes or re-encodes the body | Frameworks that auto-parse JSON must be told to keep the raw bytes, as `express.raw` does above |


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