Authentication
Verify that a delivery came from Onetap before you trust a byte of it.
ProposedNot yet callable
This documents a design under review. Endpoints, payloads and field names may change before release.
The signature header
Every request carries a signature header.
X-Onetap-Signature: t=1752915322,v1=5d41402abc4b2a76b9719d911017c592t is the Unix timestamp the delivery was signed at. v1 is an HMAC-SHA256 of the signed payload, hex encoded.
An unverified webhook endpoint is an open write to your attendance records. Anyone who learns the URL could mark a hall full of students present. Verification is not optional.
Verifying a delivery
The signed payload is the timestamp, a full stop, and the raw request body. Compute the HMAC with your endpoint secret and compare in constant time.
import crypto from "node:crypto";
export function verify(rawBody: string, header: string, secret: string) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=") as [string, string]),
);
const expected = crypto
.createHmac("sha256", secret)
.update(parts.t + "." + rawBody)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}Verify against the raw body, before any JSON parsing or middleware reserialises it. Re-encoded JSON produces a different signature and every delivery will fail verification. In Express that means express.raw() on the webhook route, not express.json().
Replay protection
A valid signature stays valid forever, so a captured request could be replayed. Reject deliveries whose timestamp is too old.
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (age > 300) return reject(); // older than five minutesFive minutes is generous enough to survive clock skew and a slow retry, and short enough that a captured request is worthless by the time it could be replayed.
Rolling a secret
Rolling issues a second secret while the first keeps working, so you can deploy without dropping deliveries.
POST /v1/webhook_endpoints/whe_8f21c0/roll_secret
{ "expires_old_in": "24h" }During the overlap, accept a delivery if it verifies against either secret. Remove the old one once the window closes.