Pipes.bot

Webhook Security

Verify webhook signatures to ensure payloads are from Pipes.bot.

Pipes.bot signs webhook deliveries so you can verify that a request really came from us. The scheme depends on where the webhook is configured:

  • App webhooks (BYON) are signed with the Standard Webhooks scheme, using a per-App secret that starts with whsec_.
  • Pool number webhooks keep the original X-Pipes-Signature HMAC header. See Pool number webhooks.

Every delivery to an App webhook URL is signed, including message, template status and number.onboarded events.

App webhooks: Standard Webhooks

Every delivery to an App webhook URL carries three headers:

HeaderDescription
webhook-idUnique message ID. Identical across all retries of the same delivery
webhook-timestampUnix timestamp in seconds. Refreshed on every attempt
webhook-signaturev1,<base64>: base64-encoded HMAC-SHA256 signature
webhook-id: evt_3f9c1a7b2d4e4f6a8b0c1d2e3f4a5b6c
webhook-timestamp: 1760000000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2/d+Yyj7eJ0uVkE=

The webhook-id depends on the API version of your App:

  • API v2: the webhook-id equals the envelope's id (evt_...). See BYON API v2 migration.
  • Legacy: the webhook-id is msg_... and is also sent in the payload as pipes.webhookId.

Your signing secret

Each App has a signing secret in the format whsec_<base64>. Reveal it in the dashboard under your App's settings, where you can also rotate it. The secret is created the first time it is needed.

How the signature is computed

  1. Build the signed content as {webhook-id}.{webhook-timestamp}.{raw body}.
  2. Take the part of your secret after whsec_ and base64-decode it. The decoded bytes are the HMAC key.
  3. Compute HMAC-SHA256 of the signed content with that key and base64-encode the digest.
  4. The header value is v1, followed by that digest.

Verify with the standardwebhooks package

npm install standardwebhooks
import express from "express";
import { Webhook } from "standardwebhooks";

const app = express();
const wh = new Webhook(process.env.PIPES_WEBHOOK_SECRET!); // whsec_...

// Use the raw body: verifying a re-serialized body fails
app.post("/webhook", express.text({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = wh.verify(req.body, {
      "webhook-id": req.header("webhook-id")!,
      "webhook-timestamp": req.header("webhook-timestamp")!,
      "webhook-signature": req.header("webhook-signature")!,
    });
  } catch {
    return res.status(401).send("Invalid signature");
  }

  // Process verified event...
  res.sendStatus(200);
});

wh.verify also rejects timestamps more than five minutes away from your clock, which protects against replays.

Verify with plain Node crypto

import crypto from "crypto";

const TOLERANCE_SECONDS = 5 * 60;

function verifyPipesWebhook(
  rawBody: string,
  headers: { id: string; timestamp: string; signature: string },
  secret: string, // whsec_...
): boolean {
  const age = Math.abs(Date.now() / 1000 - Number(headers.timestamp));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${headers.id}.${headers.timestamp}.${rawBody}`)
    .digest("base64");

  // The header may hold several space-separated signatures: "v1,<sig> v1,<sig>"
  return headers.signature.split(" ").some((candidate) => {
    const [version, signature] = candidate.split(",");
    if (version !== "v1" || !signature) return false;
    const a = Buffer.from(signature);
    const b = Buffer.from(expected);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
}

app.post("/webhook", express.text({ type: "application/json" }), (req, res) => {
  const ok = verifyPipesWebhook(
    req.body,
    {
      id: req.header("webhook-id") ?? "",
      timestamp: req.header("webhook-timestamp") ?? "",
      signature: req.header("webhook-signature") ?? "",
    },
    WEBHOOK_SECRET,
  );
  if (!ok) return res.status(401).send("Invalid signature");

  const payload = JSON.parse(req.body);
  res.sendStatus(200);
});

Deduplicate with webhook-id

Failed deliveries are retried (see Retries). Every attempt of one delivery reuses the same webhook-id with a fresh webhook-timestamp and signature, so store the IDs you have processed and skip repeats.

Pool number webhooks

Webhooks on pool numbers (pk_ keys) are unchanged. When you configure a webhook secret, Pipes.bot signs every payload with HMAC-SHA256 and sends it in the X-Pipes-Signature header as a hex-encoded digest of the raw request body.

X-Pipes-Signature: a1b2c3d4e5f6...

Compute the HMAC-SHA256 digest of the raw request body using your webhook secret, then compare it to the header value using a timing-safe comparison.

Node.js / Express

import crypto from "crypto";

function verifySignature(rawBody: string | Buffer, signature: string, secret: string): boolean {
  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");

  return crypto.timingSafeEqual(Buffer.from(signature, "hex"), Buffer.from(expected, "hex"));
}

// Express middleware
app.post("/webhook", express.text({ type: "application/json" }), (req, res) => {
  const signature = req.headers["x-pipes-signature"] as string;

  if (!signature || !verifySignature(req.body, signature, WEBHOOK_SECRET)) {
    return res.status(401).send("Invalid signature");
  }

  const payload = JSON.parse(req.body);
  // Process verified payload...
  res.sendStatus(200);
});

Python / Flask

import hmac
import hashlib

def verify_signature(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, expected)

@app.route("/webhook", methods=["POST"])
def webhook():
    signature = request.headers.get("X-Pipes-Signature", "")

    if not verify_signature(request.data, signature, WEBHOOK_SECRET):
        return "Invalid signature", 401

    payload = request.get_json()
    # Process verified payload...
    return "", 200

Best practices

  • Always verify signatures in production. Without verification, anyone who discovers your endpoint URL can send fake payloads.
  • Verify the raw body. Verify the signature against the raw request body before parsing JSON to avoid discrepancies from re-serialization.
  • Use timing-safe comparison. Standard string equality (===, ==) is vulnerable to timing attacks. Use crypto.timingSafeEqual (Node.js) or hmac.compare_digest (Python).
  • Check the timestamp. For Standard Webhooks, reject deliveries whose webhook-timestamp is more than five minutes old.
  • Keep the secret private. Rotate the App signing secret from the dashboard if it leaks.

On this page