Skip to main content
Verify webhook signatures to ensure incoming events are authentic.

Prerequisites

You will need:
  • A webhook subscription secret from Subscriptions
  • Access to the raw, unmodified request body in your webhook handler
  • The X-Hook0-Signature request header

Verify signatures

Verify webhook signatures to ensure that requests are authentic. This protects your application from forged webhooks and potential security threats.

How signature verification works

When you create a webhook subscription, the response includes a secret. This secret is used to verify that incoming webhooks are authentic. Each webhook request includes an X-Hook0-Signature header containing:
  • t field - the timestamp
  • h field - list of headers included in the signature
  • v1 field - the signature

Verification implementation

Here’s an example of how to verify webhook signatures:
import crypto from "crypto";

type HeadersMap = Record<string, string>;

function verifyWebhookSignature(
  payload: string,
  signatureHeader: string,
  secret: string,
  headers: HeadersMap,
  maxAgeMinutes = 5
): boolean {
  try {
    const elements = signatureHeader.split(",");
    const timestamp = elements.find((e) => e.startsWith("t="))?.split("=")[1] ?? "";
    const headerNames = elements.find((e) => e.startsWith("h="))?.split("=")[1] ?? "";
    const providedSignature = elements.find((e) => e.startsWith("v1="))?.split("=")[1] ?? "";

    const headerNameList = headerNames.split(" ");
    const headerValues = headerNameList.map((name) => headers[name] ?? "").join(".");

    const signedPayload = `${timestamp}.${headerNames}.${headerValues}.${payload}`;
    const expectedSignature = crypto
      .createHmac("sha256", secret)
      .update(signedPayload, "utf8")
      .digest("hex");

    const signaturesMatch = crypto.timingSafeEqual(
      Buffer.from(expectedSignature, "hex"),
      Buffer.from(providedSignature, "hex")
    );

    const webhookTime = parseInt(timestamp, 10) * 1000;
    const currentTime = Date.now();
    const ageMinutes = (currentTime - webhookTime) / (1000 * 60);

    if (ageMinutes > maxAgeMinutes) {
      console.error(
        `Webhook timestamp exceeds maximum age: ${ageMinutes.toFixed(1)} minutes > ${maxAgeMinutes} minutes`
      );
      return false;
    }

    return signaturesMatch;
  } catch (error) {
    console.error("Webhook verification error:", error);
    return false;
  }
}
And in your application:
import express from "express";

const app = express();

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

app.post("/webhook", (req, res) => {
  const payload = req.body.toString();
  const signature = req.headers["x-hook0-signature"];
  const secret = process.env.WEBHOOK_SECRET ?? "";

  if (verifyWebhookSignature(payload, String(signature || ""), secret, req.headers as Record<string, string>)) {
    const event = JSON.parse(payload);

    // Handle your transfer event here

    res.status(200).send("OK");
    return;
  }

  res.status(400).send("Invalid signature");
});