Skip to main content

Signature verification

Every webhook delivery carries a Permissio-Signature header containing an HMAC-SHA256 of the raw request body, signed with the endpoint's whsec_… secret.

The header format is:

Permissio-Signature: t=1714742400,v1=4f1d…b2a7

Verify the signature by recomputing HMAC_SHA256(secret, "{t}.{body}") and comparing the result to v1 with a constant-time compare. Then check that t is within a freshness window (we recommend 5 minutes) so a captured payload cannot be replayed indefinitely.

The timestamp t is generated fresh for every send, including retries — so a retried delivery carries a new t and a new signature, and a 5-minute freshness window will still accept it. Use Permissio-Delivery-Id as your dedup key to recognise replays of the same delivery attempt.

How secrets are stored

We show your whsec_… secret exactly once, at the moment you create the endpoint, so you can copy it into your verifier. After that, Permissio stores the secret encrypted at rest (AES-256-GCM, key held outside the database). We deliberately do not hash the secret: a one-way hash would prevent us from recomputing the signature when sending you a delivery. If you lose the secret, rotate the endpoint to issue a new one — we cannot recover the old value for you.

Verify in TypeScript

import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

export function verifyPermissioSignature(
rawBody: string,
header: string,
secret: string,
): boolean {
const parts = Object.fromEntries(
header.split(",").map((p) => {
const [k, ...v] = p.split("=");
return [k.trim(), v.join("=").trim()];
}),
);
const t = parts.t;
const v1 = parts.v1;
if (!t || !v1) return false;

// Reject deliveries whose timestamp is outside the freshness window
// before doing any HMAC work — protects against replay of an old
// signed body even if the signing key were briefly exposed.
const ts = Number.parseInt(t, 10);
if (!Number.isFinite(ts)) return false;
const nowSec = Math.floor(Date.now() / 1000);
if (Math.abs(nowSec - ts) > TOLERANCE_SECONDS) return false;

const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");

const a = Buffer.from(v1, "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}

Verify in Python

import hmac
import hashlib
import time

TOLERANCE_SECONDS = 5 * 60

def verify_permissio_signature(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(","))
t = parts.get("t")
v1 = parts.get("v1")
if not t or not v1:
return False

# Freshness check before HMAC work — rejects replays of old payloads.
try:
ts = int(t)
except ValueError:
return False
if abs(int(time.time()) - ts) > TOLERANCE_SECONDS:
return False

expected = hmac.new(
secret.encode("utf-8"),
f"{t}.".encode("utf-8") + raw_body,
hashlib.sha256,
).hexdigest()

return hmac.compare_digest(expected, v1)

Both snippets use the same algorithm the Permissio API server uses to sign deliveries. Pass the raw request body bytes — re-serialised JSON will not match.