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

# Verifying webhook signatures

> Validate that webhook events are authentically sent by Inbox

## Why verify signatures

Without verification, any server that discovers your webhook URL could send fake events. Inbox signs every webhook delivery with your signing secret so you can confirm it's authentic before processing.

## How it works

Every webhook request includes an `X-Inbox-Signature` header with a timestamp and HMAC-SHA256 signature:

```ts theme={null}
"X-Inbox-Signature": "t=1705312200,v1=a1b2c3d4e5f6..."
```

The signature is computed over the timestamp and raw request body joined by a dot:

```ts theme={null}
HMAC_SHA256(secret, "{timestamp}.{raw_json_body}")
```

This format prevents replay attacks — the timestamp is part of the signed payload, so an attacker can't reuse a captured signature with a different body or at a different time.

## Getting your signing secret

1. Go to **Settings → Webhooks** in your Inbox dashboard
2. Click on a webhook configuration
3. Copy the **Signing secret**

Store it securely as an environment variable:

```bash theme={null}
export INBOX_WEBHOOK_SECRET="ibt_wh_your_signing_secret_here"
```

## Verification steps

<Steps>
  ### Extract the timestamp and signature

  Parse the `X-Inbox-Signature` header to get the `t` (timestamp) and `v1` (signature) values.

  ### Reconstruct the signed payload

  Concatenate the timestamp, a `.` character, and the **raw request body** (before any JSON parsing): `{timestamp}.{raw_body}`

  ### Compute the expected signature

  Generate an HMAC-SHA256 hash of the signed payload using your signing secret.

  ### Compare signatures

  Use a constant-time comparison to check if the computed signature matches the `v1` value from the header.

  ### Validate the timestamp

  Check that the timestamp is within an acceptable tolerance (e.g., 5 minutes) to prevent replay attacks.
</Steps>

## Code examples

<CodeGroup>
  ```typescript verify.ts theme={null}
  import { createHmac, timingSafeEqual } from "crypto";

  const TIMESTAMP_TOLERANCE_SECONDS = 300; // 5 minutes

  interface VerificationResult {
    valid: boolean;
    reason?: string;
  }

  function verifyWebhookSignature(
    rawBody: string,
    signatureHeader: string,
    secret: string,
  ): VerificationResult {
    // 1. Parse the signature header
    const parts = Object.fromEntries(
      signatureHeader.split(",").map((part) => {
        const [key, ...rest] = part.split("=");
        return [key, rest.join("=")];
      }),
    );

    const timestamp = parts["t"];
    const signature = parts["v1"];

    if (!timestamp || !signature) {
      return { valid: false, reason: "Missing timestamp or signature" };
    }

    // 2. Validate the timestamp
    const eventTime = parseInt(timestamp, 10);
    const currentTime = Math.floor(Date.now() / 1000);

    if (Math.abs(currentTime - eventTime) > TIMESTAMP_TOLERANCE_SECONDS) {
      return { valid: false, reason: "Timestamp outside tolerance" };
    }

    // 3. Compute the expected signature
    const signedPayload = `${timestamp}.${rawBody}`;
    const expected = createHmac("sha256", secret)
      .update(signedPayload)
      .digest("hex");

    // 4. Compare using constant-time comparison
    const expectedBuffer = Buffer.from(expected, "hex");
    const receivedBuffer = Buffer.from(signature, "hex");

    if (expectedBuffer.length !== receivedBuffer.length) {
      return { valid: false, reason: "Invalid signature" };
    }

    if (!timingSafeEqual(expectedBuffer, receivedBuffer)) {
      return { valid: false, reason: "Invalid signature" };
    }

    return { valid: true };
  }
  ```

  ```javascript verify.js theme={null}
  const { createHmac, timingSafeEqual } = require("crypto");

  const TIMESTAMP_TOLERANCE_SECONDS = 300; // 5 minutes

  function verifyWebhookSignature(rawBody, signatureHeader, secret) {
    // 1. Parse the signature header
    const parts = Object.fromEntries(
      signatureHeader.split(",").map((part) => {
        const [key, ...rest] = part.split("=");
        return [key, rest.join("=")];
      }),
    );

    const timestamp = parts["t"];
    const signature = parts["v1"];

    if (!timestamp || !signature) return false;

    // 2. Validate the timestamp
    const eventTime = parseInt(timestamp, 10);
    const currentTime = Math.floor(Date.now() / 1000);

    if (Math.abs(currentTime - eventTime) > TIMESTAMP_TOLERANCE_SECONDS) {
      return false;
    }

    // 3. Compute the expected signature
    const signedPayload = `${timestamp}.${rawBody}`;
    const expected = createHmac("sha256", secret)
      .update(signedPayload)
      .digest("hex");

    // 4. Compare using constant-time comparison
    const expectedBuffer = Buffer.from(expected, "hex");
    const receivedBuffer = Buffer.from(signature, "hex");

    if (expectedBuffer.length !== receivedBuffer.length) return false;

    return timingSafeEqual(expectedBuffer, receivedBuffer);
  }
  ```

  ```python verify.py theme={null}
  import hmac
  import hashlib
  import time

  TIMESTAMP_TOLERANCE_SECONDS = 300  # 5 minutes

  def verify_webhook_signature(raw_body: str, signature_header: str, secret: str) -> bool:
      # 1. Parse the signature header
      parts = dict(
          part.split("=", 1) for part in signature_header.split(",")
      )

      timestamp = parts.get("t")
      signature = parts.get("v1")

      if not timestamp or not signature:
          return False

      # 2. Validate the timestamp
      event_time = int(timestamp)
      current_time = int(time.time())

      if abs(current_time - event_time) > TIMESTAMP_TOLERANCE_SECONDS:
          return False

      # 3. Compute the expected signature
      signed_payload = f"{timestamp}.{raw_body}"
      expected = hmac.new(
          secret.encode("utf-8"),
          signed_payload.encode("utf-8"),
          hashlib.sha256,
      ).hexdigest()

      # 4. Compare using constant-time comparison
      return hmac.compare_digest(expected, signature)
  ```
</CodeGroup>

## Full handler example

<CodeGroup>
  ```typescript handler.ts theme={null}
  import express from "express";

  const app = express();

  // Important: use raw body for signature verification
  app.use("/webhooks/inbox", express.raw({ type: "application/json" }));

  app.post("/webhooks/inbox", (req, res) => {
    const signature = req.headers["x-inbox-signature"] as string;
    const rawBody = req.body.toString();

    if (!signature) {
      return res.status(401).json({ error: "Missing signature" });
    }

    const result = verifyWebhookSignature(
      rawBody,
      signature,
      process.env.INBOX_WEBHOOK_SECRET!,
    );

    if (!result.valid) {
      return res.status(401).json({ error: result.reason });
    }

    const event = JSON.parse(rawBody);

    // Process the verified event
    console.log("Verified event:", event.type, event.id);

    res.sendStatus(200);
  });

  app.listen(3000);
  ```

  ```python handler.py theme={null}
  from flask import Flask, request, jsonify
  import json
  import os

  app = Flask(__name__)

  @app.route("/webhooks/inbox", methods=["POST"])
  def handle_webhook():
      signature = request.headers.get("X-Inbox-Signature")
      raw_body = request.get_data(as_text=True)

      if not signature:
          return jsonify({"error": "Missing signature"}), 401

      if not verify_webhook_signature(
          raw_body,
          signature,
          os.environ["INBOX_WEBHOOK_SECRET"],
      ):
          return jsonify({"error": "Invalid signature"}), 401

      event = json.loads(raw_body)

      # Process the verified event
      print(f"Verified event: {event['type']} {event['id']}")

      return "", 200
  ```
</CodeGroup>

<Note>
  Make sure you verify against the **raw request body** string, not a
  re-serialized version. Parsing the JSON and re-serializing it may change
  whitespace or key ordering, which will produce a different signature.
</Note>

## Common mistakes

| Mistake                            | Fix                                                                      |
| ---------------------------------- | ------------------------------------------------------------------------ |
| Parsing JSON before verifying      | Use the raw body string for verification, then parse after               |
| Using non-constant-time comparison | Always use `timingSafeEqual` (Node.js) or `hmac.compare_digest` (Python) |
| Not checking the timestamp         | Always validate that `t` is within your tolerance window                 |
| Hardcoding the secret              | Store it in an environment variable or secret manager                    |

## Rotating your signing secret

You can rotate your signing secret at any time from **Settings → Webhooks**. When you rotate:

1. The old secret is immediately invalidated
2. All subsequent deliveries use the new secret
3. Update your verification code with the new secret before rotating, or accept a brief window of failed verifications
