Getting started
Verifying webhooks
Anyone can send a request to your URL. Check the RoundPlan-Signature header before you trust a call.
The signature
The header looks like t=1790500000,v1=5257a8…. t is when it was sent, in seconds. Each v1 is the HMAC-SHA256, in hex, of t, a dot and the raw body, with your webhook's signing secret as the key. After you create a new secret, calls carry two v1 values for 24 hours: one per secret. Accept the call if either matches.
Use the raw body exactly as received: parsing the JSON and serialising it again changes the bytes, and the signature no longer matches. Reject a t more than five minutes old, so a recorded call cannot be replayed later.
Node
import crypto from "node:crypto";
// rawBody: the request body exactly as received (a string, not parsed JSON).
function verifyRoundPlan(rawBody, header, secret, toleranceSec = 300) {
const parts = header.split(",").map((p) => p.split("="));
const t = Number(parts.find(([k]) => k === "t")?.[1]);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac("sha256", secret).update(t + "." + rawBody).digest("hex");
return parts.some(([k, v]) =>
k === "v1" && v && v.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(v, "hex"), Buffer.from(expected, "hex")));
}Python
import hashlib, hmac, time
# raw_body: the request body exactly as received (bytes, not parsed JSON).
def verify_roundplan(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = [p.split("=", 1) for p in header.split(",")]
t = next((v for k, v in parts if k == "t"), None)
if t is None or not t.isdigit() or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(k == "v1" and hmac.compare_digest(v, expected) for k, v in parts)Testing it
In Settings → API, Send test posts a ping event, signed the same way, and tells you what your server answered.