Skip to main content

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:
"X-Inbox-Signature": "t=1705312200,v1=a1b2c3d4e5f6..."
The signature is computed over the timestamp and raw request body joined by a dot:
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:
export INBOX_WEBHOOK_SECRET="ibt_wh_your_signing_secret_here"

Verification steps

1
Extract the timestamp and signature
2
Parse the X-Inbox-Signature header to get the t (timestamp) and v1 (signature) values.
3
Reconstruct the signed payload
4
Concatenate the timestamp, a . character, and the raw request body (before any JSON parsing): {timestamp}.{raw_body}
5
Compute the expected signature
6
Generate an HMAC-SHA256 hash of the signed payload using your signing secret.
7
Compare signatures
8
Use a constant-time comparison to check if the computed signature matches the v1 value from the header.
9
Validate the timestamp
10
Check that the timestamp is within an acceptable tolerance (e.g., 5 minutes) to prevent replay attacks.

Code examples

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 };
}

Full handler example

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);
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.

Common mistakes

MistakeFix
Parsing JSON before verifyingUse the raw body string for verification, then parse after
Using non-constant-time comparisonAlways use timingSafeEqual (Node.js) or hmac.compare_digest (Python)
Not checking the timestampAlways validate that t is within your tolerance window
Hardcoding the secretStore 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