Webhooks
Receive every alert match as a signed POST: the payload, verifying the signature, retries and testing.
A webhook is a URL of yours that Podzone POSTs to whenever one of a team's alerts finds a match: one request per match, signed so you can prove it came from Podzone, seconds after the match is found. It is how to act on mentions as they happen rather than by reading the hourly email digest or polling for matches.
Adding a webhook #
A team can have one webhook. An account admin adds it on the app's Webhooks page, or an integration adds it with POST /api/v1/webhooks:
curl https://api.podzone.fm/api/v1/webhooks \
-H "Authorization: Bearer $PODZONE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/podzone"}'
The URL must be https, on a host that resolves to a public address. The response carries the webhook's signing secret, which starts whsec_. It is returned only this once: store it with your other secrets. If it is lost or leaked, rotate it for a new one. The old secret stops working immediately, retries included, so update your receiver at the same time.
What is sent #
Each new match from any of the team's alerts is POSTed as JSON:
{
"type": "alert.match.created",
"timestamp": "2026-10-08T09:00:00.123Z",
"test": false,
"data": { "id": "...", "alert_id": "...", "alert_name": "...", "...": "..." }
}
data is exactly what GET /api/v1/alerts/matches/{match_id} returns for that match; the event's entry in the API reference lists every field. Transcripts are not included: fetch one with the episode's id when you need it.
Deliveries do not count against your rate limits.
Verifying a delivery #
Every request is signed following the Standard Webhooks specification. Three headers carry the signature:
webhook-id: the delivery's id, the same on every retry. Use it to ignore a delivery you have already processed.webhook-timestamp: when this attempt was sent, in Unix seconds. Reject one more than five minutes from your own clock, so a captured request cannot be replayed later.webhook-signature:v1,followed by the base64 HMAC-SHA256 of{webhook-id}.{webhook-timestamp}.{body}, keyed with your secret's base64 part (afterwhsec_), decoded.
Verify against the raw body, before parsing it. A framework that parses JSON first and hands you an object has already changed the bytes that were signed.
The Standard Webhooks libraries do all of this, timestamp check included. In Python, with the standardwebhooks package:
from standardwebhooks.webhooks import Webhook
webhook = Webhook(secret.removeprefix("whsec_"))
def receive(raw_body: bytes, headers: dict[str, str]) -> dict:
# Raises if the signature does not match or the timestamp is too old.
return webhook.verify(raw_body, headers)
In Node, with the standardwebhooks package from npm:
import { Webhook } from "standardwebhooks";
const webhook = new Webhook(secret.replace(/^whsec_/, ""));
// rawBody is the request body as a string, before any JSON parsing.
const event = webhook.verify(rawBody, headers);
Or by hand, in Python:
import base64, hashlib, hmac, time
def verify(secret: str, headers, body: bytes) -> bool:
if abs(time.time() - int(headers["webhook-timestamp"])) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{headers['webhook-id']}.{headers['webhook-timestamp']}.".encode() + body
digest = hmac.new(key, signed, hashlib.sha256).digest()
expected = "v1," + base64.b64encode(digest).decode()
return any(
hmac.compare_digest(expected, signature)
for signature in headers["webhook-signature"].split()
)
And by hand, in Node:
import crypto from "node:crypto";
function verify(secret, headers, rawBody) {
if (Math.abs(Date.now() / 1000 - Number(headers["webhook-timestamp"])) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const signed = `${headers["webhook-id"]}.${headers["webhook-timestamp"]}.${rawBody}`;
const expected = "v1," + crypto.createHmac("sha256", key).update(signed).digest("base64");
return headers["webhook-signature"]
.split(" ")
.some(
(signature) =>
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)),
);
}
Responding and retries #
Acknowledge first and do the work afterwards: put the delivery on a queue and reply, rather than processing it before you answer.
- Reply within 5 seconds with any
2xx. Any other status, a timeout or a redirect is a failed attempt. - A failed delivery is retried 7 more times, after 5 s, 25 s, 2 min, 10 min, 52 min, 4.3 h and 21.7 h: 8 attempts over about 27 hours.
- Test events are limited to 50 an hour per team.
A delivery can arrive more than once, and deliveries can arrive out of order. Use webhook-id to skip one you have already handled, and the match's own timestamps rather than arrival order.
When a webhook is switched off #
When a delivery has failed every attempt and the endpoint has accepted nothing since that delivery was created, Podzone switches the webhook off and emails the account's admins. Matches found while it is off are not sent later, but GET /api/v1/alerts/{alert_id}/matches still has them.
GET /api/v1/webhooks/{webhook_id} shows is_active and, for one Podzone switched off, disabled_reason: failing. Switch it back on from the app or by sending is_active: true once your endpoint is answering again.
Test events #
The Webhooks page in the app, or POST /api/v1/webhooks/{webhook_id}/test, sends a sample match with "test": true, signed with the webhook's secret. Pass a url to send it somewhere else, such as a tunnel to your machine. A test event is sent once and never retried. Ignore "test": true deliveries in production.
Troubleshooting #
Every delivery and its outcome is in the webhook's delivery log, also on the app's Webhooks page: its status, the HTTP status your endpoint answered with, and last_error when there was no answer.
- The signature never matches. Check that you verify the raw body rather than re-serialised JSON, that you decode the part of the secret after
whsec_from base64, and that you are using the current secret if it has been rotated. - Every timestamp is rejected. Your server's clock is probably wrong; the check allows five minutes either way.
- Deliveries time out. Reply before doing the work. A slow handler fails even when it eventually succeeds, and the retry delivers the same match again.
- A redirect counts as a failure. Podzone does not follow redirects, so give the final URL, including any trailing slash your server insists on.
- Nothing arrives. Check that the webhook
is_active, and that the team's alerts are active and finding matches.