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

EventSent whendata
version.publishedA week is published or revised{"summary":"1 added, 1 removed"}
audio.arrivedAudio that was pending arrives{"creatives":[{"id":"trhd","isci":"SR_TRHD0921060"}]}
deadline.approachingAffidavits are due in two days{"due":"Fri, Oct 2, 5:00 PM","dueAt":1790978400}
week.reopenedThe network reopens a signed week{"reason":"A unit was entered on the wrong day","epoch":2}
pingYou 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.

json
{
  "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.

JavaScript (Node.js)
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"));
}
Python
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

InputValue
Secretwhsec_0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a
t1790000300
Body{"event_id":"evt_1","type":"version.published"}
Signed string1790000300.{"event_id":"evt_1","type":"version.published"}
X-Aff-Signaturet=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.