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-SignatureHMAC header. See Pool number webhooks.
Every delivery to an App webhook URL is signed, including message, template status and
number.onboardedevents.
App webhooks: Standard Webhooks
Every delivery to an App webhook URL carries three headers:
| Header | Description |
|---|---|
webhook-id | Unique message ID. Identical across all retries of the same delivery |
webhook-timestamp | Unix timestamp in seconds. Refreshed on every attempt |
webhook-signature | v1,<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-idequals the envelope'sid(evt_...). See BYON API v2 migration. - Legacy: the
webhook-idismsg_...and is also sent in the payload aspipes.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
- Build the signed content as
{webhook-id}.{webhook-timestamp}.{raw body}. - Take the part of your secret after
whsec_and base64-decode it. The decoded bytes are the HMAC key. - Compute HMAC-SHA256 of the signed content with that key and base64-encode the digest.
- The header value is
v1,followed by that digest.
Verify with the standardwebhooks package
npm install standardwebhooksimport 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 "", 200Best 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. Usecrypto.timingSafeEqual(Node.js) orhmac.compare_digest(Python). - Check the timestamp. For Standard Webhooks, reject deliveries whose
webhook-timestampis more than five minutes old. - Keep the secret private. Rotate the App signing secret from the dashboard if it leaks.