Webhooks
Events, payloads, verifying the signature and how deliveries are retried.
A webhook tells your system when something changes, so it does not have to poll. Create one in the portal under API and MCP (/developers): an https:// address, the events it wants, and a signing secret shown once. Send a test delivers a ping.
Events
| Event | Sent when | data |
|---|---|---|
version.published | A week is published or revised | {"summary":"1 added, 1 removed"} |
audio.arrived | Audio that was pending arrives | {"creatives":[{"id":"trhd","isci":"SR_TRHD0921060"}]} |
deadline.approaching | Affidavits are due in two days | {"due":"Fri, Oct 2, 5:00 PM","dueAt":1790978400} |
week.reopened | The network reopens a signed week | {"reason":"A unit was entered on the wrong day","epoch":2} |
ping | You press Send a test (10 per webhook per hour) | {} |
The payload
Every event has the same envelope, and only data differs. A payload carries metadata, never the order itself: fetch the week with your key when you need its content. event_id is the same for every webhook of one event, so use it to ignore a repeat.
{
"event_id": "evt_0f3a9c1d2e4b5a6978c0d1e2f3a4b5c6",
"type": "version.published",
"created": 1790259120,
"network": "Summit Radio Network",
"affiliation_id": "srn-ksmt",
"station": "KSUMT-FM",
"week": "2026-09-21",
"version": 3,
"data": {
"summary": "1 added, 1 removed"
}
}Each delivery is a POST with Content-Type: application/json, User-Agent: AffiliateWebhooks/1, X-Aff-Event (the event), X-Aff-Delivery (this attempt's id) and X-Aff-Signature.
Verifying the signature
X-Aff-Signature is t=<unix seconds>,v1=<signature>, where the signature is the lowercase hex HMAC-SHA256 of the string <t>.<body>, keyed with the webhook's secret (the whole whsec_ value, as text). Compute it over the body exactly as it arrived, before parsing it, compare it in constant time, and refuse a t more than 5 minutes from your clock, so a captured delivery cannot be replayed later.
import { createHmac, timingSafeEqual } from "node:crypto";
// secret: the whsec_ value shown once when the webhook was created.
// header: the X-Aff-Signature header.
// rawBody: the request body exactly as it arrived, before any JSON parsing.
export function verifyWebhook(secret, header, rawBody, toleranceSeconds = 300) {
const parts = {};
for (const part of header.split(",")) {
const at = part.indexOf("=");
if (at > 0) parts[part.slice(0, at)] = part.slice(at + 1);
}
if (!/^[0-9]+$/.test(parts.t ?? "") || !/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(parts.t + "." + rawBody).digest();
return timingSafeEqual(expected, Buffer.from(parts.v1, "hex"));
}import hashlib
import hmac
import time
def verify_webhook(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
"""secret: the whsec_ value; header: X-Aff-Signature; raw_body: the body exactly as it arrived."""
parts = dict(part.split("=", 1) for part in header.split(",") if "=" in part)
timestamp, signature = parts.get("t", ""), parts.get("v1", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance:
return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)A worked example
| Input | Value |
|---|---|
| Secret | whsec_0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a |
t | 1790000300 |
| Body | {"event_id":"evt_1","type":"version.published"} |
| Signed string | 1790000300.{"event_id":"evt_1","type":"version.published"} |
X-Aff-Signature | t=1790000300,v1=97125e644692830bc2a93e42da3592b3e3be48e5edfb3c3709f4d910ab536447 |
Your code is right when it accepts this header for this body and secret (with the clock check set aside), and refuses it when one byte of the body changes.
Answering and retries
- Answer with any 2xx status within 10 seconds; the body is ignored. Do the work after answering.
- Anything else (another status, a redirect, a timeout, a refused connection) is retried with a growing delay, up to 12 attempts in all.
- After 20 failed deliveries in a row the webhook is disabled; turn it back on by saving it again in the portal.
- Deliveries go only to public
https://addresses on port 443 or 8443.
Who owns a webhook
A webhook belongs to the person who created it, and each affiliation has at most 5. It keeps working while its owner can report for that station; when the owner's access ends or the affiliation ends, it is disabled and its pending deliveries are cancelled.