Webhooks

Verify signatures

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:

HTTP
X-Pozy-Signature: 9c1f0e3b5d7a2c4e6f8b0d1a3c5e7f9b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c

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

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 "", 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.

verify.ts
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

Terminal
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