# 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`](https://api.podzone.fm/api/v1/docs#tag/webhooks/operation/create_webhook):

```bash
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](https://api.podzone.fm/api/v1/docs#tag/webhooks/operation/rotate_webhook_secret) 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:

```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}`](https://api.podzone.fm/api/v1/docs#tag/alerts/operation/get_match) returns for that match; the [event's entry in the API reference](https://api.podzone.fm/api/v1/docs#tag/webhook-events/operation/alert_match_created) 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](https://www.standardwebhooks.com) 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 (after `whsec_`), 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:

```python
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:

```js
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:

```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:

```js
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`](https://api.podzone.fm/api/v1/docs#tag/alerts/operation/list_alert_matches) still has them.

[`GET /api/v1/webhooks/{webhook_id}`](https://api.podzone.fm/api/v1/docs#tag/webhooks/operation/get_webhook) 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`](https://api.podzone.fm/api/v1/docs#tag/webhooks/operation/update_webhook) once your endpoint is answering again.

## Test events

The Webhooks page in the app, or [`POST /api/v1/webhooks/{webhook_id}/test`](https://api.podzone.fm/api/v1/docs#tag/webhooks/operation/test_webhook), 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](https://api.podzone.fm/api/v1/docs#tag/webhooks/operation/list_webhook_deliveries), 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.
