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

# Verifying a webhook signature

> Confirm a webhook really came from Pruva before you act on it.

Anyone can POST to a public URL. Before your server trusts a webhook, verify its signature so you know it came from Pruva and was not tampered with in transit. This guide is the end to end version of the check summarized on the [Webhooks](/widgets/webhooks) page.

## What Pruva sends

Every delivery is a POST with two headers and a JSON body:

| Header              | Value                                                   |
| ------------------- | ------------------------------------------------------- |
| `X-Pruva-Signature` | HMAC SHA-256 of `"{timestamp}.{rawBody}"`, hex encoded. |
| `X-Pruva-Timestamp` | The Unix timestamp the event was signed.                |

The signing secret is the `whsec_...` value shown once when you created the webhook in Settings.

## The steps

<Steps>
  <Step title="Read the raw body">
    Capture the request body as raw bytes, before any JSON parsing. The signature is over the exact bytes sent; re-serialized JSON may differ and will fail to verify.
  </Step>

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

  <Step title="Compare in constant time">
    Compare your value to `X-Pruva-Signature` with a constant time comparison, not `==`, to avoid leaking timing information.
  </Step>

  <Step title="Check the timestamp is recent">
    Reject deliveries whose timestamp is far from now, for example more than five minutes old, so a captured request cannot be replayed later.
  </Step>

  <Step title="Then parse and act">
    Only after the signature and timestamp pass, parse the JSON and handle the event.
  </Step>
</Steps>

## A complete handler

<CodeGroup>
  ```javascript Node (Express) theme={null}
  import crypto from "crypto";
  import express from "express";

  const app = express();
  const SECRET = process.env.PRUVA_WEBHOOK_SECRET;

  // Capture the RAW body; do not use express.json() before verifying.
  app.post("/pruva/webhook", express.raw({ type: "*/*" }), (req, res) => {
    const rawBody = req.body.toString("utf8");
    const timestamp = req.header("X-Pruva-Timestamp");
    const signature = req.header("X-Pruva-Signature");

    // Reject stale deliveries (replay protection).
    const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
    if (!timestamp || ageSeconds > 300) {
      return res.status(400).send("stale or missing timestamp");
    }

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

    const ok =
      signature &&
      signature.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));

    if (!ok) return res.status(401).send("bad signature");

    // Acknowledge first, then do the work.
    res.sendStatus(200);

    const event = JSON.parse(rawBody);
    handleEvent(event);
  });
  ```

  ```python Python (Flask) theme={null}
  import hmac, hashlib, time, os
  from flask import Flask, request, abort

  app = Flask(__name__)
  SECRET = os.environ["PRUVA_WEBHOOK_SECRET"]

  @app.post("/pruva/webhook")
  def webhook():
      raw_body = request.get_data()  # raw bytes, unparsed
      timestamp = request.headers.get("X-Pruva-Timestamp", "")
      signature = request.headers.get("X-Pruva-Signature", "")

      # Replay protection.
      if not timestamp or abs(time.time() - int(timestamp)) > 300:
          abort(400)

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

      if not hmac.compare_digest(signature, expected):
          abort(401)

      # Acknowledge, then handle.
      event = request.get_json()
      handle_event(event)
      return "", 200
  ```

  ```php PHP theme={null}
  <?php
  $secret    = getenv('PRUVA_WEBHOOK_SECRET');
  $rawBody   = file_get_contents('php://input');
  $timestamp = $_SERVER['HTTP_X_PRUVA_TIMESTAMP'] ?? '';
  $signature = $_SERVER['HTTP_X_PRUVA_SIGNATURE'] ?? '';

  // Replay protection.
  if ($timestamp === '' || abs(time() - (int)$timestamp) > 300) {
      http_response_code(400);
      exit;
  }

  $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

  if (!hash_equals($expected, $signature)) {
      http_response_code(401);
      exit;
  }

  http_response_code(200);

  $event = json_decode($rawBody, true);
  handleEvent($event);
  ```
</CodeGroup>

<Warning>
  The two most common reasons a valid webhook fails to verify: parsing the JSON before reading the raw body, and comparing signatures with `==` instead of a constant time compare. The code above avoids both.
</Warning>
