Webhooks
Check X-Pozy-Signature, a hex HMAC-SHA256 of the raw request body keyed with your endpoint secret, so you only act on requests that came from Pozy.
Anyone who learns your webhook URL can send it a request. The signature proves a delivery came from Pozy and was not changed on the way. Check it on every request, before you parse or trust anything in the body.
#How the signature is made
When an endpoint has a secret, every delivery carries:
X-Pozy-Signature: 9c1f0e3b5d7a2c4e6f8b0d1a3c5e7f9b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1cThe value is the HMAC-SHA256 of the raw request body, keyed with your secret, written as lowercase hexadecimal (64 characters). There is no timestamp or version prefix to strip.
To verify: compute the same HMAC over the bytes you received, then compare it with the header using a constant-time comparison.
#Verify in Node
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.POZY_WEBHOOK_SECRET!;
export function isFromPozy(rawBody: Buffer, header: string | undefined): boolean {
if (!header) return false;
const expected = crypto.createHmac("sha256", SECRET).update(rawBody).digest("hex");
const a = Buffer.from(header, "utf8");
const b = Buffer.from(expected, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
// express.raw keeps req.body as a Buffer. Do not put express.json() in front of this route.
app.post("/webhooks/pozy", express.raw({ type: "application/json" }), (req, res) => {
if (!isFromPozy(req.body, req.get("X-Pozy-Signature"))) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
res.sendStatus(200);
void handle(event);
});// app/api/webhooks/pozy/route.ts
import crypto from "node:crypto";
export async function POST(req: Request) {
const raw = await req.text(); // read the body once, as text
const header = req.headers.get("x-pozy-signature") ?? "";
const expected = crypto.createHmac("sha256", process.env.POZY_WEBHOOK_SECRET!).update(raw).digest("hex");
const ok = header.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected));
if (!ok) return new Response("bad signature", { status: 401 });
const event = JSON.parse(raw);
await saveForLater(event); // keep this fast
return new Response(null, { status: 200 });
}crypto.timingSafeEqual throws when the two buffers differ in length, so compare lengths first, as above.
#Verify in Python
import hashlib
import hmac
import os
from flask import Flask, abort, request
SECRET = os.environ["POZY_WEBHOOK_SECRET"].encode()
app = Flask(__name__)
def is_from_pozy(raw: bytes, header: str | None) -> bool:
if not header:
return False
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
@app.post("/webhooks/pozy")
def pozy_webhook():
raw = request.get_data()
if not is_from_pozy(raw, request.headers.get("X-Pozy-Signature")):
abort(401)
event = request.get_json()
save_for_later(event)
return "", 200import hashlib
import hmac
import json
import os
from fastapi import FastAPI, Request, Response
SECRET = os.environ["POZY_WEBHOOK_SECRET"].encode()
app = FastAPI()
@app.post("/webhooks/pozy")
async def pozy_webhook(request: Request):
raw = await request.body()
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("x-pozy-signature", "")):
return Response(status_code=401)
event = json.loads(raw)
await save_for_later(event)
return Response(status_code=200)#Mistakes that break verification
| Symptom | Cause | Fix |
|---|---|---|
| Every signature fails | The framework parsed the JSON and you hashed JSON.stringify(req.body). Re-serialising changes spacing, key order and escaping. |
Hash the raw bytes. In Express use express.raw() on this route; in Next.js read req.text(). |
| Every signature fails | The secret in your environment is not the one saved on the endpoint, or has a trailing newline. | Compare with GET /v1/webhooks/settings, which returns the stored secret. Trim the value when you load it. |
| Some signatures fail | A proxy or middleware rewrites the body (compression, charset conversion, pretty-printing). | Verify before any middleware touches the body, or exclude the webhook route from it. |
| The header is missing | The endpoint was created without a secret. |
Set one with PUT /v1/webhooks/settings. |
| Fails only in tests | You signed a different string than you sent. | Sign and send the exact same bytes. |
Do not compare with === or ==. A plain string comparison stops at the first different character, which leaks timing information an attacker can use to guess a valid signature.
#Rotate the secret
The secret is yours, so you rotate it by updating the endpoint. Each endpoint holds one secret at a time, so plan a short overlap:
#Accept both secrets
Deploy your receiver so it accepts a signature made with either the current secret or the new one.
const SECRETS = [process.env.POZY_WEBHOOK_SECRET!, process.env.POZY_WEBHOOK_SECRET_NEXT].filter(Boolean) as string[];
export const isFromPozy = (raw: Buffer, header = "") =>
SECRETS.some((s) => {
const expected = crypto.createHmac("sha256", s).update(raw).digest("hex");
return header.length === expected.length && crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected));
});#Switch the endpoint
curl -X PUT https://api.pozy.ai/v1/webhooks/settings \
-H "Authorization: Bearer $POZY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "webhookId": "whk_4Tq9Lm2Vx8Rb6Nc1Pz7K", "secret": "'"$POZY_WEBHOOK_SECRET_NEXT"'" }'Retries of events created before the switch are signed with the new secret too, because the signature is computed at each attempt.
#Remove the old secret
Once the switch is live, make the new secret the only one and redeploy.
Rotate when someone who knew the secret leaves, when it may have been logged or committed, and on a regular schedule if your security policy asks for one.
#Extra protection
The signature is enough to trust a delivery. If your infrastructure needs more, two options:
- Custom headers. Add a static header with
customHeaders, such as{ "X-Gateway-Token": "..." }, and have your API gateway reject requests without it before they reach your code. - Replay window. Every body has a
timestamp. You can reject events older than you are willing to accept, but remember that retries arrive with the original timestamp up to about 33 hours after the first attempt, and a manual redelivery can come later still. Deduplicating on the event id is the more reliable defence: see Idempotency and ordering.
#Endpoints
| Endpoint | What it does |
|---|---|
POST /v1/webhooks/settings |
Create webhook |
PUT /v1/webhooks/settings |
Update webhook |
GET /v1/webhooks/settings |
List webhooks |