# Podzone API reference

Generated from the API's OpenAPI schema. Every path below is on `https://api.podzone.fm`. The same reference, browsable: https://api.podzone.fm/api/v1/docs. The schema itself: https://api.podzone.fm/api/v1/openapi.json.

The Podzone public API, on `https://api.podzone.fm`. Authenticate with an API key created in the Podzone app, sent as a bearer token: `Authorization: Bearer pzk_live_...`.

This is the reference: every endpoint, field and error. The guides at [podzone.fm/docs](https://podzone.fm/docs) explain the rest -- a [quickstart](https://podzone.fm/docs/quickstart), [team and account keys](https://podzone.fm/docs/authentication), [verifying webhooks](https://podzone.fm/docs/webhooks) and connecting an [AI assistant over MCP](https://podzone.fm/docs/mcp).

## Rate limits

Requests are limited per **account**, not per key or per user. Everyone on an account shares one budget, and so does every API key they hold -- a key does not get an allowance of its own.

| Plan | Requests per minute | Requests per month | Transcript downloads per month |
| --- | --- | --- | --- |
| Free | 10 | 1,000 | 20 |
| Starter | 60 | 10,000 | 1,000 |
| Pro | 120 | 90,000 | 5,000 |

A transcript download is also counted against the plan's monthly transcript downloads, which are shared with downloads made in the app.

The monthly allowances reset at **00:00 UTC** on the same day each month: the day your account subscribed to its plan, or the day it was created if it has never subscribed. In a month without that day -- the 31st in April, say -- they reset on the month's last day.

Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` for the per-minute window, and the `-Month` suffixed equivalents for the monthly one. `X-RateLimit-Reset` and `X-RateLimit-Reset-Month` are Unix timestamps in seconds; read the latter rather than working out your reset day.

Exceeding a limit returns `429` with a `Retry-After` header and an error body whose `scope` is `per_minute`, `per_month` or `transcript_downloads_per_month`. Branch on it: `per_minute` means slow down and retry; the monthly scopes mean retrying before the reset will fail too. Denied requests still count against your monthly request allowance; a refused download does not count as a download.

## Pagination

A route that returns a page at a time takes `limit` and `cursor`, and answers with `next_cursor`. Pass `next_cursor` back as `cursor`, with the other parameters unchanged, for the next page; `null` means there are no more. `limit` sets the page size, up to the maximum each route states. Cursors are opaque -- do not build or parse them -- and no list reports a total count.

A cursor refused with `stale_cursor` was issued for a different query; one refused with `invalid_cursor` could not be read. Either way, start again from the first page.

## Errors

Every error has the same shape. Branch on `code`; `message` is for people and may be reworded. Some codes carry more fields, described under each.

```json
{"error": {"code": "episode_not_found", "message": "No such episode."}}
```

| Status | Code | Meaning |
| --- | --- | --- |
| 401 | `invalid_credentials` | The API key is missing, malformed or revoked. |
| 403 | `plan_limit_reached` | The plan has no room for another one. `limit_key` and `limit` say which limit; only a plan with more room lifts it. |
| 404 | `podcast_not_found` | No podcast has this id. |
| 404 | `episode_not_found` | No episode has this id. |
| 404 | `transcript_not_available` | The episode exists but has no transcript yet. |
| 404 | `alert_not_found` | No alert has this id in this team. |
| 404 | `alert_match_not_found` | No match has this id in this team. |
| 404 | `webhook_not_found` | No webhook has this id in this team. |
| 404 | `team_not_found` | No team this key can reach has this id. |
| 422 | `invalid_input` | A parameter, header or body field was invalid. `fields` says what was wrong with each. |
| 422 | `stale_cursor` | The cursor was issued for a different query. Start again from the first page. |
| 422 | `invalid_cursor` | The cursor could not be read. Start again from the first page. |
| 429 | `rate_limited` | A limit was reached. `scope` says which, `reset` when it lifts, and the `Retry-After` header how many seconds to wait. |
| 503 | `search_unavailable` | Search is briefly unavailable. Retry after the `Retry-After` header's seconds. |
| 503 | `storage_unavailable` | Transcripts are briefly unavailable. Retry after the `Retry-After` header's seconds. |

Each route lists the errors it can return. Every route can also return `401` and `429`.

## Identity

The key you are calling with: its account and team, and the teams and members it can reach.

### The team and account this API key acts as

`GET /api/v1/me` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/identity/operation/me))

Returns the key, the team whose data it reaches, and the account whose quota it spends. Useful for verifying that a key is live and which team it acts as.

`scope` is either `team` or `account`. A `team` key reaches one workspace and `team` names it. An `account` key reaches every team in the account, `team` is `null`, and each request that touches a team's data must name it in the `X-Podzone-Team` header — use `GET /v1/teams` to discover which ids it may send.

**Response `200`**: OK

- `account` (object (AccountOut), required)
  - `id` (string (uuid), required)
  - `name` (string, required)
- `id` (string (uuid), required)
- `name` (string, required)
- `prefix` (string, required)
- `scope` (string, required)
- `team` (object (TeamOut), nullable, required)
  - `id` (string (uuid), required)
  - `name` (string, required)

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `429` A limit was reached: `rate_limited`

### The teams this API key can reach

`GET /api/v1/teams` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/identity/operation/list_teams))

Every team this key may act for.

For a `team`-scoped key this is exactly one row — the team on the credential. For an `account`-scoped key it is every team in the account, and the `id` values here are what the `X-Podzone-Team` header expects.

The shape is the same for both, so a client never has to know its own scope to parse the response.

**Response `200`**: OK

- `next_cursor` (string, nullable)
- `results` (array of object (TeamOut), required)
  - `id` (string (uuid), required)
  - `name` (string, required)

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `429` A limit was reached: `rate_limited`

### Who is in a team this API key can reach

`GET /api/v1/teams/{team_id}/members` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/identity/operation/list_team_members))

Everybody in the named team.

The `id` values here are what `recipient_user_ids` on an alert expects — this is how an integration discovers who it may notify.

A team this key cannot reach answers `404`, whether it belongs to another account or is simply one a team-scoped key does not act for.

Email addresses are deliberately not published. An API key reaches a team's data, not its colleagues' contact details.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `team_id` | path | string (uuid) | yes |  |

**Response `200`**: OK

- `next_cursor` (string, nullable)
- `results` (array of object (TeamMemberOut), required)
  - `full_name` (string, required)
  - `id` (string (uuid), required)

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `404` Not found: `team_not_found`
- `422` Invalid request: `invalid_input`
- `429` A limit was reached: `rate_limited`

## Podcasts

The podcast catalogue: search it by title or description, read a podcast, and list its episodes.

### Search podcasts

`GET /api/v1/podcasts/search` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/podcasts/operation/search_podcasts))

Search the catalogue by podcast title, description, or both.

`q` is one search term. It matches anywhere inside a show's name, so `tech` finds *Mostly Technical* and `radio` finds *Radiolab*, and it is full-text search over the show's description.

Bare words are combined with AND, so `true crime` requires both. Use `"quoted phrases"` for adjacent words, `|` to widen, and a leading `-` to exclude — for example `kubernetes | docker -beginner`. Malformed syntax is interpreted as best it can be rather than rejected.

`fields` chooses where to look, and repeats: `?q=technical&fields=title&fields=description`. Omit it to search both. Terms must be at least three characters.

**One term, searched across the fields you choose.** A single request cannot bind a different term to each field — `title=technical` *and* `description=kubernetes` together is not expressible. Narrow with `fields` and the query syntax instead.

Results can be ordered with `order_by` — `best_match` (default) or `last_episode_at` — and `order_dir` (`asc`/`desc`). `best_match` always ranks most-relevant first and ignores `order_dir`.

Results are paged with an opaque cursor. Pass the `next_cursor` from a response as `cursor` to get the next page; it is null on the last one. A cursor is tied to the search that issued it: change `q`, `fields` or the ordering and start again from the first page.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `q` | query | string, 3 to 200 characters | yes | What to search for, at least three characters. Matches anywhere in a show's name. Bare words must all appear; `"quoted phrases"` match adjacent words, `\|` means OR and a leading `-` excludes. |
| `fields` | query | array of one of `title`, `description` | no | Where to look: `title`, `description`, or both (the default). Repeat to give both. |
| `order_by` | query | one of `best_match`, `last_episode_at`, default `best_match` | no | `best_match`, or `last_episode_at` for the date of the show's newest episode. |
| `order_dir` | query | one of `asc`, `desc`, default `desc` | no | `desc` or `asc`. Ignored when `order_by` is `best_match`. |
| `limit` | query | integer, 1 to 50, default `25` | no | How many results to return per page. |
| `cursor` | query | string, nullable | no | The `next_cursor` from the previous page. Omit it for the first page. Cursors are opaque: do not construct or parse them. |

**Response `200`**: OK

- `next_cursor` (string, nullable, required)
- `results` (array of object (PodcastOut), required)
  - `author` (string, required)
  - `description` (string, required)
  - `explicit` (boolean, required)
  - `feed_url` (string, required)
  - `id` (string (uuid), required)
  - `image_url` (string, required)
  - `itunes_id` (integer, nullable, required)
  - `language` (string, required)
  - `last_episode_at` (string (date-time), nullable, required)
  - `title` (string, required)

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `422` Invalid request: `invalid_input`, `stale_cursor`, `invalid_cursor`
- `429` A limit was reached: `rate_limited`
- `503` Temporarily unavailable: `search_unavailable`

### One podcast

`GET /api/v1/podcasts/{podcast_id}` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/podcasts/operation/get_podcast))

Everything a search result carries, plus the show's categories and `available_episode_count`: how many of its episodes Podzone holds, which is exactly what `/podcasts/{podcast_id}/episodes` pages through.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `podcast_id` | path | string (uuid) | yes |  |

**Response `200`**: OK

- `author` (string, required)
- `available_episode_count` (integer, required)
- `categories` (array of object (CategoryOut), required)
  - `name` (string, required)
  - `slug` (string, required)
- `description` (string, required)
- `explicit` (boolean, required)
- `feed_url` (string, required)
- `id` (string (uuid), required)
- `image_url` (string, required)
- `itunes_id` (integer, nullable, required)
- `language` (string, required)
- `last_episode_at` (string (date-time), nullable, required)
- `title` (string, required)

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `404` Not found: `podcast_not_found`
- `422` Invalid request: `invalid_input`
- `429` A limit was reached: `rate_limited`

### One podcast's episodes

`GET /api/v1/podcasts/{podcast_id}/episodes` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/podcasts/operation/list_podcast_episodes))

A podcast's episodes, most recently published first, with undated episodes last.

Paged with an opaque cursor: pass the `next_cursor` from a response as `cursor` to get the next page, and stop when it is null. Cursors encode a position in this ordering and must not be constructed or parsed.

Podzone follows a feed from the day it is admitted and does not fetch its back catalogue, so this lists the episodes we hold rather than everything the show has ever published.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `podcast_id` | path | string (uuid) | yes |  |
| `limit` | query | integer, 1 to 50, default `10` | no | How many results to return per page. |
| `cursor` | query | string, nullable | no | The `next_cursor` from the previous page. Omit it for the first page. Cursors are opaque: do not construct or parse them. |

**Response `200`**: OK

- `next_cursor` (string, nullable, required)
- `results` (array of object (EpisodeOut), required)
  - `audio_url` (string, default `""`)
  - `description` (string, required)
  - `duration_seconds` (integer, nullable, required)
  - `episode_number` (integer, nullable, required)
  - `has_transcript` (boolean, required)
  - `id` (string (uuid), required)
  - `published_at` (string (date-time), nullable, required)
  - `season_number` (integer, nullable, required)
  - `title` (string, required)

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `404` Not found: `podcast_not_found`
- `422` Invalid request: `invalid_input`, `invalid_cursor`
- `429` A limit was reached: `rate_limited`

## Episodes

Episodes and their transcripts: search what was said, read an episode, find words in its transcript, and read the transcript a page at a time or download it whole.

### Search episodes

`GET /api/v1/episodes/search` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/episodes/operation/search_episodes))

Find episodes by what was said in them, or by their title and description.

`scope` chooses which, and is required: `transcript` searches the spoken words, `metadata` searches titles and descriptions. **One scope per request.**

`q` uses the same syntax as `/podcasts/search`. Bare words are combined with AND, `"quoted phrases"` match adjacent words, `|` is OR, and a leading `-` excludes — for example `"brand safety" -podcast`. Malformed syntax is interpreted as best it can be rather than rejected. Terms must be at least three characters.

`podcast_id` narrows the search to one show.

**Searches cover the last 30 days by default.** `since` and `until` are inclusive dates. `until` defaults to today, and `since` defaults to 30 days before `until`. Episodes are searchable for eighteen months: an earlier `since` is moved forward to that limit rather than refused. Every response reports the days it actually covered in `window`, with `window.earliest` as the limit, so an empty page can be told apart from an empty archive.

The eighteen months limit search, not what Podzone holds. An older episode is still listed by `/podcasts/{podcast_id}/episodes`, and its transcript can still be searched with `/episodes/{episode_id}/transcript/excerpts` and read with `/episodes/{episode_id}/transcript`, one episode at a time.

Results can be ordered with `order_by`: `best_match` (default) or `published_at`, which is newest first unless `order_dir=asc`. `best_match` ignores `order_dir`. Use `published_at` to watch for new mentions.

Each result carries up to two `excerpts`, each an object whose `text` is a short plain-text passage around the match, taken from the transcript or, for `metadata`, the description. They can be empty. They carry no position in the audio: ask `/episodes/{episode_id}/transcript/excerpts` with the same words for timed passages. The full transcript is not included; read it from `/episodes/{episode_id}/transcript`.

Results are paged with an opaque cursor. Pass `next_cursor` back as `cursor`; it is null on the last page. A cursor is tied to the search that issued it: change `q`, `scope`, the dates or the ordering and start again from the first page. A default-window cursor also expires when the day changes (UTC), because the window has moved.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `q` | query | string, 3 to 200 characters | yes | What to search for, at least three characters. Bare words must all appear; `"quoted phrases"` match adjacent words, `\|` means OR and a leading `-` excludes. |
| `scope` | query | one of `transcript`, `metadata` | yes | `transcript` searches what was said; `metadata` searches episode titles and descriptions. |
| `podcast_id` | query | string (uuid), nullable | no | Only search this podcast's episodes. |
| `since` | query | string (date), nullable | no | First day to search, inclusive, as an ISO 8601 date: `YYYY-MM-DD` (UTC). Defaults to 30 days before `until`. An earlier date than 18 months ago is moved forward to 18 months before today. |
| `until` | query | string (date), nullable | no | Last day to search, inclusive, as an ISO 8601 date: `YYYY-MM-DD` (UTC). Defaults to today. |
| `order_by` | query | one of `best_match`, `published_at`, default `best_match` | no | `best_match`, or `published_at` to see new mentions first. |
| `order_dir` | query | one of `asc`, `desc`, default `desc` | no | `desc` or `asc`. Ignored when `order_by` is `best_match`. |
| `limit` | query | integer, 1 to 50, default `25` | no | How many results to return per page. |
| `cursor` | query | string, nullable | no | The `next_cursor` from the previous page. Omit it for the first page. Cursors are opaque: do not construct or parse them. |

**Response `200`**: OK

- `next_cursor` (string, nullable, required)
- `results` (array of object (EpisodeSearchHitOut), required)
  - `audio_url` (string, default `""`)
  - `description` (string, required)
  - `duration_seconds` (integer, nullable, required)
  - `excerpts` (array of object (EpisodeSearchExcerptOut), required)
    - `text` (string, required): The passage, as plain text.
  - `has_transcript` (boolean, required)
  - `id` (string (uuid), required)
  - `podcast` (object (EpisodePodcastOut), required)
    - `id` (string (uuid), required)
    - `image_url` (string, required)
    - `title` (string, required)
  - `published_at` (string (date-time), nullable, required)
  - `title` (string, required)
- `window` (object (EpisodeSearchWindowOut), required)
  - `earliest` (string (date), required)
  - `since` (string (date), required)
  - `until` (string (date), required)

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `422` Invalid request: `invalid_input`, `stale_cursor`, `invalid_cursor`
- `429` A limit was reached: `rate_limited`
- `503` Temporarily unavailable: `search_unavailable`

### One episode

`GET /api/v1/episodes/{episode_id}` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/episodes/operation/get_episode))

One episode, the show it belongs to, and a summary of its transcript.

`transcript` is null when the episode has none — uncommon, but check it (or `has_transcript`) before reading it. `transcript.duration_seconds` is measured from the audio and is more reliable than `duration_seconds`, which is the publisher's claim.

The transcript itself is not included at any size. Find passages in it with `/episodes/{episode_id}/transcript/excerpts`, read it a page at a time from `/episodes/{episode_id}/transcript`, or download it whole from `/episodes/{episode_id}/transcript/download`.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `episode_id` | path | string (uuid) | yes |  |

**Response `200`**: OK

- `audio_url` (string, default `""`)
- `description` (string, required)
- `duration_seconds` (integer, nullable, required)
- `episode_number` (integer, nullable, required)
- `has_transcript` (boolean, required)
- `id` (string (uuid), required)
- `podcast` (object (EpisodePodcastOut), required)
  - `id` (string (uuid), required)
  - `image_url` (string, required)
  - `title` (string, required)
- `published_at` (string (date-time), nullable, required)
- `season_number` (integer, nullable, required)
- `title` (string, required)
- `transcript` (object (TranscriptSummaryOut), nullable)
  - `duration_seconds` (number, nullable, required)
  - `language` (string, required)
  - `word_count` (integer, required)

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `404` Not found: `episode_not_found`
- `422` Invalid request: `invalid_input`
- `429` A limit was reached: `rate_limited`

### Read one episode's transcript

`GET /api/v1/episodes/{episode_id}/transcript` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/episodes/operation/read_episode_transcript))

The transcript as timed paragraphs, a page at a time: the same paragraphs as the `txt` download, each with its start and end time in seconds. It works on any transcribed episode, including one published before search's eighteen-month window.

**The first page counts as one transcript download**, the same as `/episodes/{episode_id}/transcript/download`, and is refused with `429` and `scope` `transcript_downloads_per_month` once the month's are used. Following `next_cursor` to the later pages counts only as API requests.

A cursor works only for the account that read the first page, and only until the monthly allowances reset. After that it is refused with `422` and code `stale_cursor`: read again from the first page.

Returns `404` with code `transcript_not_available` when the episode has no transcript, which costs no download.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `episode_id` | path | string (uuid) | yes |  |
| `cursor` | query | string, nullable | no | The `next_cursor` from the previous page. Omit it to start from the beginning, which counts as a transcript download. A cursor works only for the API key's account, and only until the monthly allowance resets. |

**Response `200`**: OK

- `duration_seconds` (number, nullable, required)
- `language` (string, required)
- `next_cursor` (string, nullable, required): Pass as `cursor` for the next page. Null on the last page.
- `paragraphs` (array of object (TranscriptParagraphOut), required)
  - `end` (number, required): Seconds from the start of the episode.
  - `start` (number, required): Seconds from the start of the episode.
  - `text` (string, required)

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `404` Not found: `episode_not_found`, `transcript_not_available`
- `422` Invalid request: `invalid_input`, `stale_cursor`, `invalid_cursor`
- `429` A limit was reached: `rate_limited`
- `503` Temporarily unavailable: `storage_unavailable`

### Download one episode's transcript

`GET /api/v1/episodes/{episode_id}/transcript/download` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/episodes/operation/download_episode_transcript))

The episode's transcript as a file, in one of four formats.

| `format` | Content-Type | Contains |
| --- | --- | --- |
| `json` *(default)* | `application/json` | timed segments |
| `txt` | `text/plain` | the transcript as paragraphs, no timestamps |
| `srt` | `application/x-subrip` | SubRip subtitles |
| `vtt` | `text/vtt` | WebVTT subtitles |

`json` carries the transcript as timed `segments` and does not repeat it as a single flat string; for the plain words, ask for `txt`, or join the segment texts with a space for exactly that string. `txt` breaks the words into paragraphs at pauses, at most 30 seconds each, separated by a blank line; `json`, `srt` and `vtt` stay at one sentence per segment.

The body is the file itself — not an archive to unpack — and carries a `Content-Disposition` filename, so the response can be written straight to disk and opened. (Ordinary HTTP transport compression still applies, and every HTTP client handles that for you.)

Timestamps are seconds from the start of the episode, so they deep-link into the publisher's audio directly.

Returns `404` with code `transcript_not_available` when the episode exists but has no transcript, which is distinct from `episode_not_found`.

Each download counts against your plan's monthly transcript downloads, shared with downloads made in the app, as well as being an API request. Once they are used, this returns `429` with code `rate_limited` and `scope` `transcript_downloads_per_month`, with `reset` and `Retry-After` saying when the allowance renews. A `404` costs no download.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `episode_id` | path | string (uuid) | yes |  |
| `format` | query | one of `json`, `txt`, `srt`, `vtt`, default `json` | no |  |

**Response `200`**: The transcript, as a file.

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `404` Not found: `episode_not_found`, `transcript_not_available`
- `422` Invalid request: `invalid_input`
- `429` A limit was reached: `rate_limited`
- `503` Temporarily unavailable: `storage_unavailable`

### Find words in one episode's transcript

`GET /api/v1/episodes/{episode_id}/transcript/excerpts` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/episodes/operation/find_transcript_excerpts))

The passages of one episode's transcript where any of the words in `q` is said, each with its start and end time in seconds.

Use it after a search to see where, and in what context, something was said, without reading the whole transcript. It works on any transcribed episode, including one published before search's eighteen-month window.

**Terms match as written.** Each is matched as a whole word or phrase, ignoring case, with none of search's query syntax. Send the words themselves rather than a search's `q`, one term per word or phrase: `q=brand safety` here is the phrase, where search reads two words that need not be adjacent. Variants such as `podcast` and `podcasts` are different words, so send both if both count.

**The earliest matches only.** At most `limit` passages come back, each `context` sentences either side of a match, and there is no next page. `total_hits` says how many times the words are said in all, so you can tell when there is more to read. To read the rest, use `/episodes/{episode_id}/transcript`.

Counts as one API request and never as a transcript download. Returns `404` with code `transcript_not_available` when the episode has no transcript.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `episode_id` | path | string (uuid) | yes |  |
| `q` | query | array of string, 3 to 200 characters, 1 to 5 items | yes | Words or phrases to find, matched as written: whole words, ignoring case, with no search syntax. Repeat for up to five, variants included (`podcast`, `podcasts`); a passage is returned where any of them is said. |
| `context` | query | integer, 0 to 5, default `2` | no | Sentences to include before and after each match. |
| `limit` | query | integer, 1 to 5, default `5` | no | The most passages to return. The earliest matches come first. |

**Response `200`**: OK

- `excerpts` (array of object (TranscriptExcerptOut), required)
  - `end` (number, required): Seconds from the start of the episode.
  - `start` (number, required): Seconds from the start of the episode.
  - `text` (string, required)
- `total_hits` (integer, required): How many times the words are said in the episode, including matches not returned in `excerpts`.

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `404` Not found: `episode_not_found`, `transcript_not_available`
- `422` Invalid request: `invalid_input`
- `429` A limit was reached: `rate_limited`
- `503` Temporarily unavailable: `storage_unavailable`

## Alerts

A team's keyword alerts, and the matches they find in new episodes.

### List alerts

`GET /api/v1/alerts` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/alerts/operation/list_alerts))

Every alert belonging to the team this API key acts for, newest first.

Paged with an opaque `cursor`: pass the `next_cursor` from one response as `cursor` on the next request, and stop when it comes back `null`.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | integer, 1 to 100, default `50` | no | How many results to return per page. |
| `cursor` | query | string, nullable | no | The `next_cursor` from the previous page. Omit it for the first page. Cursors are opaque: do not construct or parse them. |
| `X-Podzone-Team` | header | string (uuid) | no | The team id of the team to act for. **Required for an account-scoped key** on this route, which reaches every team and so cannot infer one; omitting it returns 422. Ignored by a team-scoped key, which only reaches one team. |

**Response `200`**: OK

- `next_cursor` (string, nullable, required)
- `results` (array of object (AlertOut), required)
  - `created_at` (string (date-time), required)
  - `created_by` (object (AlertAuthorOut), nullable, required)
    - `email` (string, required)
    - `full_name` (string, required)
    - `id` (string (uuid), required)
  - `email_enabled` (boolean, required)
  - `id` (string (uuid), required)
  - `is_active` (boolean, required)
  - `keywords` (array of string, required)
  - `name` (string, required)
  - `paused_at` (string (date-time), nullable, required)
  - `paused_reason` (one of `match_limit`, `plan_limit`, nullable, required)
  - `question` (string, required)
  - `recipients` (array of object (AlertRecipientOut), required)
    - `email` (string, required)
    - `full_name` (string, required)
    - `user_id` (string (uuid), nullable, required)
  - `team` (object (AlertTeamOut), required)
    - `id` (string (uuid), required)
    - `name` (string, required)
  - `updated_at` (string (date-time), required)
  - `use_classifier` (boolean, required)

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `422` Invalid request: `invalid_input`, `invalid_cursor`
- `429` A limit was reached: `rate_limited`

### Create an alert

`POST /api/v1/alerts` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/alerts/operation/create_alert))

Creates an alert for the team this API key acts for.

`keywords` is a list of up to 10 terms to watch for; a transcript matching any of them produces a match.

Optionally supply `question` with `use_classifier: true` to have each keyword judged using intelligent matching. The question should be phrased to return a yes or no answer.

**An alert emails nobody unless you say otherwise.** Pass `recipient_user_ids` with ids from `/teams/{team_id}/members` and `email_enabled: true` to have matches delivered by email; omit this and the alert will still record matches without emailing anyone.

**Keywords too broad to run are refused** with a 422 naming `keywords`: before an alert is switched on, its keywords are counted against the last week of transcripts, and an alert that would match too many episodes a day is not created. A running alert found in too many episodes in one day is switched off, with `paused_reason: match_limit`. Matches are kept for 60 days.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `X-Podzone-Team` | header | string (uuid) | no | The team id of the team to act for. **Required for an account-scoped key** on this route, which reaches every team and so cannot infer one; omitting it returns 422. Ignored by a team-scoped key, which only reaches one team. |

**Request body** (JSON)

- `email_enabled` (boolean, default `true`): Whether recipients get the hourly email digest. False keeps recording matches without emailing.
- `is_active` (boolean, default `true`): Whether the alert looks for matches. False pauses it.
- `keywords` (array of string, 1 to 200 characters, required): Words or phrases to watch for, at most 10. A transcript that says any of them, as a whole word and ignoring case, is a match.
- `name` (string, 1 to 200 characters, required): What to call the alert.
- `question` (string, at least 10 characters or `""`, nullable): A yes-or-no question for intelligent matching to ask about each match, such as "Is our product discussed in detail?". Required when `use_classifier` is true. Send `""` for none.
- `recipient_user_ids` (array of string (uuid), nullable): Who receives the email digest, as user ids from `GET /v1/teams/{team_id}/members`. Replaces the whole list. Empty or omitted on a new alert means nobody is emailed.
- `use_classifier` (boolean, default `false`): Keep only the matches for which the answer to `question` is yes. Off by default, because it also drops passing mentions.

**Response `201`**: Created

- `created_at` (string (date-time), required)
- `created_by` (object (AlertAuthorOut), nullable, required)
  - `email` (string, required)
  - `full_name` (string, required)
  - `id` (string (uuid), required)
- `email_enabled` (boolean, required)
- `id` (string (uuid), required)
- `is_active` (boolean, required)
- `keywords` (array of string, required)
- `name` (string, required)
- `paused_at` (string (date-time), nullable, required)
- `paused_reason` (one of `match_limit`, `plan_limit`, nullable, required)
- `question` (string, required)
- `recipients` (array of object (AlertRecipientOut), required)
  - `email` (string, required)
  - `full_name` (string, required)
  - `user_id` (string (uuid), nullable, required)
- `team` (object (AlertTeamOut), required)
  - `id` (string (uuid), required)
  - `name` (string, required)
- `updated_at` (string (date-time), required)
- `use_classifier` (boolean, required)

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `403` Not allowed on this plan: `plan_limit_reached`
- `422` Invalid request: `invalid_input`
- `429` A limit was reached: `rate_limited`

### Read one match

`GET /api/v1/alerts/matches/{match_id}` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/alerts/operation/get_match))

One match, by id, from any of this team's alerts.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `match_id` | path | string (uuid) | yes |  |
| `X-Podzone-Team` | header | string (uuid) | no | The team id of the team to act for. **Required for an account-scoped key** on this route, which reaches every team and so cannot infer one; omitting it returns 422. Ignored by a team-scoped key, which only reaches one team. |

**Response `200`**: OK

- `alert_id` (string (uuid), required)
- `alert_name` (string, required)
- `created_at` (string (date-time), required)
- `episode` (object (MatchEpisodeOut), required)
  - `duration_seconds` (integer, nullable, required)
  - `id` (string (uuid), required)
  - `podcast` (object (MatchPodcastOut), required)
    - `id` (string (uuid), required)
    - `image_url` (string, required)
    - `title` (string, required)
  - `published_at` (string (date-time), nullable, required)
  - `title` (string, required)
- `excerpt` (string, required)
- `first_offset_seconds` (number, nullable, required)
- `hit_offsets_seconds` (array of number, required)
- `id` (string (uuid), required)
- `keywords_matched` (array of string, required)

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `404` Not found: `alert_match_not_found`
- `422` Invalid request: `invalid_input`
- `429` A limit was reached: `rate_limited`

### Delete an alert

`DELETE /api/v1/alerts/{alert_id}` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/alerts/operation/delete_alert))

Deletes the alert and every match it has recorded.

Alternatively to stop the alert, send `is_active: false` instead.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `alert_id` | path | string (uuid) | yes |  |
| `X-Podzone-Team` | header | string (uuid) | no | The team id of the team to act for. **Required for an account-scoped key** on this route, which reaches every team and so cannot infer one; omitting it returns 422. Ignored by a team-scoped key, which only reaches one team. |

**Response `204`**: No Content

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `404` Not found: `alert_not_found`
- `422` Invalid request: `invalid_input`
- `429` A limit was reached: `rate_limited`

### Read one alert

`GET /api/v1/alerts/{alert_id}` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/alerts/operation/get_alert))

One alert, by id, from any of this team's alerts.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `alert_id` | path | string (uuid) | yes |  |
| `X-Podzone-Team` | header | string (uuid) | no | The team id of the team to act for. **Required for an account-scoped key** on this route, which reaches every team and so cannot infer one; omitting it returns 422. Ignored by a team-scoped key, which only reaches one team. |

**Response `200`**: OK

- `created_at` (string (date-time), required)
- `created_by` (object (AlertAuthorOut), nullable, required)
  - `email` (string, required)
  - `full_name` (string, required)
  - `id` (string (uuid), required)
- `email_enabled` (boolean, required)
- `id` (string (uuid), required)
- `is_active` (boolean, required)
- `keywords` (array of string, required)
- `name` (string, required)
- `paused_at` (string (date-time), nullable, required)
- `paused_reason` (one of `match_limit`, `plan_limit`, nullable, required)
- `question` (string, required)
- `recipients` (array of object (AlertRecipientOut), required)
  - `email` (string, required)
  - `full_name` (string, required)
  - `user_id` (string (uuid), nullable, required)
- `team` (object (AlertTeamOut), required)
  - `id` (string (uuid), required)
  - `name` (string, required)
- `updated_at` (string (date-time), required)
- `use_classifier` (boolean, required)

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `404` Not found: `alert_not_found`
- `422` Invalid request: `invalid_input`
- `429` A limit was reached: `rate_limited`

### Update an alert

`PATCH /api/v1/alerts/{alert_id}` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/alerts/operation/update_alert))

Partial update: send only the fields to change, and the rest are left alone.

Send `is_active: false` to pause matching entirely, or `email_enabled: false` to keep recording matches while stopping the email digest. Sending `is_active: true`, or new `keywords` for a running alert, re-runs the breadth check a new alert passes, and clears any `paused_reason`.

`keywords` and `recipient_user_ids` each replace the whole list rather than adding to it. An alert holds at most 10 keywords.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `alert_id` | path | string (uuid) | yes |  |
| `X-Podzone-Team` | header | string (uuid) | no | The team id of the team to act for. **Required for an account-scoped key** on this route, which reaches every team and so cannot infer one; omitting it returns 422. Ignored by a team-scoped key, which only reaches one team. |

**Request body** (JSON)

- `email_enabled` (boolean, nullable): Whether recipients get the hourly email digest. False keeps recording matches without emailing.
- `is_active` (boolean, nullable): Whether the alert looks for matches. False pauses it.
- `keywords` (array of string, 1 to 200 characters, nullable): Words or phrases to watch for, at most 10. A transcript that says any of them, as a whole word and ignoring case, is a match.
- `name` (string, 1 to 200 characters, nullable): What to call the alert.
- `question` (string, at least 10 characters or `""`, nullable): A yes-or-no question for intelligent matching to ask about each match, such as "Is our product discussed in detail?". Required when `use_classifier` is true. Send `""` for none.
- `recipient_user_ids` (array of string (uuid), nullable): Who receives the email digest, as user ids from `GET /v1/teams/{team_id}/members`. Replaces the whole list. Empty or omitted on a new alert means nobody is emailed.
- `use_classifier` (boolean, nullable): Keep only the matches for which the answer to `question` is yes. Off by default, because it also drops passing mentions.

**Response `200`**: OK

- `created_at` (string (date-time), required)
- `created_by` (object (AlertAuthorOut), nullable, required)
  - `email` (string, required)
  - `full_name` (string, required)
  - `id` (string (uuid), required)
- `email_enabled` (boolean, required)
- `id` (string (uuid), required)
- `is_active` (boolean, required)
- `keywords` (array of string, required)
- `name` (string, required)
- `paused_at` (string (date-time), nullable, required)
- `paused_reason` (one of `match_limit`, `plan_limit`, nullable, required)
- `question` (string, required)
- `recipients` (array of object (AlertRecipientOut), required)
  - `email` (string, required)
  - `full_name` (string, required)
  - `user_id` (string (uuid), nullable, required)
- `team` (object (AlertTeamOut), required)
  - `id` (string (uuid), required)
  - `name` (string, required)
- `updated_at` (string (date-time), required)
- `use_classifier` (boolean, required)

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `403` Not allowed on this plan: `plan_limit_reached`
- `404` Not found: `alert_not_found`
- `422` Invalid request: `invalid_input`
- `429` A limit was reached: `rate_limited`

### List an alert's matches

`GET /api/v1/alerts/{alert_id}/matches` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/alerts/operation/list_alert_matches))

Every episode this alert has matched, newest first.

Paged with an opaque `cursor`: pass the `next_cursor` from one response as `cursor` on the next request, and stop when it comes back `null`.

`first_offset_seconds` and `hit_offsets_seconds` are positions in the episode's audio, in seconds, suitable for deep-linking. `excerpt` is the surrounding transcript text, captured when the match was recorded.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `alert_id` | path | string (uuid) | yes |  |
| `limit` | query | integer, 1 to 100, default `25` | no | How many results to return per page. |
| `cursor` | query | string, nullable | no | The `next_cursor` from the previous page. Omit it for the first page. Cursors are opaque: do not construct or parse them. |
| `X-Podzone-Team` | header | string (uuid) | no | The team id of the team to act for. **Required for an account-scoped key** on this route, which reaches every team and so cannot infer one; omitting it returns 422. Ignored by a team-scoped key, which only reaches one team. |

**Response `200`**: OK

- `next_cursor` (string, nullable, required)
- `results` (array of object (MatchOut), required)
  - `alert_id` (string (uuid), required)
  - `alert_name` (string, required)
  - `created_at` (string (date-time), required)
  - `episode` (object (MatchEpisodeOut), required)
    - `duration_seconds` (integer, nullable, required)
    - `id` (string (uuid), required)
    - `podcast` (object (MatchPodcastOut), required)
      - `id` (string (uuid), required)
      - `image_url` (string, required)
      - `title` (string, required)
    - `published_at` (string (date-time), nullable, required)
    - `title` (string, required)
  - `excerpt` (string, required)
  - `first_offset_seconds` (number, nullable, required)
  - `hit_offsets_seconds` (array of number, required)
  - `id` (string (uuid), required)
  - `keywords_matched` (array of string, required)

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `404` Not found: `alert_not_found`
- `422` Invalid request: `invalid_input`, `invalid_cursor`
- `429` A limit was reached: `rate_limited`

## Webhooks

A team's webhook: the endpoint each new alert match is POSTed to, its signing secret, and its delivery log.

### List webhooks

`GET /api/v1/webhooks` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/webhooks/operation/list_webhooks))

The webhook of the team this API key acts for, in a list: a team has at most one today.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `X-Podzone-Team` | header | string (uuid) | no | The team id of the team to act for. **Required for an account-scoped key** on this route, which reaches every team and so cannot infer one; omitting it returns 422. Ignored by a team-scoped key, which only reaches one team. |

**Response `200`**: OK

- `next_cursor` (string, nullable, required): Always null today: a team has at most one webhook.
- `results` (array of object (WebhookOut), required)
  - `created_at` (string (date-time), required)
  - `disabled_at` (string (date-time), nullable, required)
  - `disabled_reason` (`failing`, nullable, required): Why Podzone switched it off, or null if it is on or was switched off by you. `failing`: a delivery failed every attempt for about a day and nothing was accepted since. Sending `is_active: true` switches it back on.
  - `id` (string (uuid), required)
  - `is_active` (boolean, required): Whether matches are being sent to it.
  - `last_success_at` (string (date-time), nullable, required): When it last answered a delivery with a 2xx. Test sends are not counted.
  - `url` (string, required): Where this team's alert matches are POSTed.

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `422` Invalid request: `invalid_input`
- `429` A limit was reached: `rate_limited`

### Add a webhook

`POST /api/v1/webhooks` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/webhooks/operation/create_webhook))

Adds the team's webhook. Podzone then POSTs each new match from the team's alerts to `url`, signed -- [the webhooks guide](https://podzone.fm/docs/webhooks) explains the payload and how to verify it.

The response carries the signing `secret`. It is not returned again: save it now, or rotate it later for a new one.

A team can have one webhook; a second is refused with `403 plan_limit_reached`. A URL that is not https, or whose host does not resolve to a public address, is refused with a `422` naming `url`.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `X-Podzone-Team` | header | string (uuid) | no | The team id of the team to act for. **Required for an account-scoped key** on this route, which reaches every team and so cannot infer one; omitting it returns 422. Ignored by a team-scoped key, which only reaches one team. |

**Request body** (JSON)

- `url` (string, 1 to 2000 characters, required): An https URL on a public host.

**Response `201`**: Created

- `created_at` (string (date-time), required)
- `disabled_at` (string (date-time), nullable, required)
- `disabled_reason` (`failing`, nullable, required): Why Podzone switched it off, or null if it is on or was switched off by you. `failing`: a delivery failed every attempt for about a day and nothing was accepted since. Sending `is_active: true` switches it back on.
- `id` (string (uuid), required)
- `is_active` (boolean, required): Whether matches are being sent to it.
- `last_success_at` (string (date-time), nullable, required): When it last answered a delivery with a 2xx. Test sends are not counted.
- `secret` (string, required): The signing secret. Returned now, and again only by rotating it.
- `url` (string, required): Where this team's alert matches are POSTed.

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `403` Not allowed on this plan: `plan_limit_reached`
- `422` Invalid request: `invalid_input`
- `429` A limit was reached: `rate_limited`

### Delete a webhook

`DELETE /api/v1/webhooks/{webhook_id}` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/webhooks/operation/delete_webhook))

Deletes the webhook and its delivery history.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `webhook_id` | path | string (uuid) | yes |  |
| `X-Podzone-Team` | header | string (uuid) | no | The team id of the team to act for. **Required for an account-scoped key** on this route, which reaches every team and so cannot infer one; omitting it returns 422. Ignored by a team-scoped key, which only reaches one team. |

**Response `204`**: No Content

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `404` Not found: `webhook_not_found`
- `422` Invalid request: `invalid_input`
- `429` A limit was reached: `rate_limited`

### Read a webhook

`GET /api/v1/webhooks/{webhook_id}` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/webhooks/operation/get_webhook))

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `webhook_id` | path | string (uuid) | yes |  |
| `X-Podzone-Team` | header | string (uuid) | no | The team id of the team to act for. **Required for an account-scoped key** on this route, which reaches every team and so cannot infer one; omitting it returns 422. Ignored by a team-scoped key, which only reaches one team. |

**Response `200`**: OK

- `created_at` (string (date-time), required)
- `disabled_at` (string (date-time), nullable, required)
- `disabled_reason` (`failing`, nullable, required): Why Podzone switched it off, or null if it is on or was switched off by you. `failing`: a delivery failed every attempt for about a day and nothing was accepted since. Sending `is_active: true` switches it back on.
- `id` (string (uuid), required)
- `is_active` (boolean, required): Whether matches are being sent to it.
- `last_success_at` (string (date-time), nullable, required): When it last answered a delivery with a 2xx. Test sends are not counted.
- `url` (string, required): Where this team's alert matches are POSTed.

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `404` Not found: `webhook_not_found`
- `422` Invalid request: `invalid_input`
- `429` A limit was reached: `rate_limited`

### Change a webhook

`PATCH /api/v1/webhooks/{webhook_id}` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/webhooks/operation/update_webhook))

Send `url` to change where matches go; deliveries still being retried go to the new URL.

Send `is_active: false` to stop sending -- deliveries waiting to be retried are cancelled, and matches found while it is off are never sent. `is_active: true` switches it back on, including after Podzone switched it off for failing.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `webhook_id` | path | string (uuid) | yes |  |
| `X-Podzone-Team` | header | string (uuid) | no | The team id of the team to act for. **Required for an account-scoped key** on this route, which reaches every team and so cannot infer one; omitting it returns 422. Ignored by a team-scoped key, which only reaches one team. |

**Request body** (JSON)

- `is_active` (boolean, nullable)
- `url` (string, 1 to 2000 characters, nullable)

**Response `200`**: OK

- `created_at` (string (date-time), required)
- `disabled_at` (string (date-time), nullable, required)
- `disabled_reason` (`failing`, nullable, required): Why Podzone switched it off, or null if it is on or was switched off by you. `failing`: a delivery failed every attempt for about a day and nothing was accepted since. Sending `is_active: true` switches it back on.
- `id` (string (uuid), required)
- `is_active` (boolean, required): Whether matches are being sent to it.
- `last_success_at` (string (date-time), nullable, required): When it last answered a delivery with a 2xx. Test sends are not counted.
- `url` (string, required): Where this team's alert matches are POSTed.

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `403` Not allowed on this plan: `plan_limit_reached`
- `404` Not found: `webhook_not_found`
- `422` Invalid request: `invalid_input`
- `429` A limit was reached: `rate_limited`

### List a webhook's deliveries

`GET /api/v1/webhooks/{webhook_id}/deliveries` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/webhooks/operation/list_webhook_deliveries))

What was sent and how the webhook answered, newest first, kept for 30 days.

Paged with an opaque `cursor`: pass the `next_cursor` from one response as `cursor` on the next request, and stop when it comes back `null`.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `webhook_id` | path | string (uuid) | yes |  |
| `limit` | query | integer, 1 to 100, default `50` | no |  |
| `cursor` | query | string, nullable | no |  |
| `X-Podzone-Team` | header | string (uuid) | no | The team id of the team to act for. **Required for an account-scoped key** on this route, which reaches every team and so cannot infer one; omitting it returns 422. Ignored by a team-scoped key, which only reaches one team. |

**Response `200`**: OK

- `next_cursor` (string, nullable, required)
- `results` (array of object (WebhookDeliveryOut), required)
  - `attempts` (integer, required)
  - `created_at` (string (date-time), required)
  - `delivered_at` (string (date-time), nullable, required)
  - `event_type` (string, required)
  - `id` (string (uuid), required): The `webhook-id` header the delivery carried.
  - `is_test` (boolean, required)
  - `last_attempt_at` (string (date-time), nullable, required)
  - `last_error` (string, required): Why the last attempt failed, e.g. "HTTP 500" or "Timed out."
  - `last_status_code` (integer, nullable, required): The HTTP status of the last answer, or null if none came back.
  - `next_attempt_at` (string (date-time), nullable, required): When the next attempt is due, while pending.
  - `status` (one of `pending`, `succeeded`, `failed`, `cancelled`, required)
  - `target_url` (string, required): Where a test send went when it was not the webhook's URL; empty otherwise.

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `404` Not found: `webhook_not_found`
- `422` Invalid request: `invalid_input`, `invalid_cursor`
- `429` A limit was reached: `rate_limited`

### Replace a webhook's signing secret

`POST /api/v1/webhooks/{webhook_id}/rotate-secret` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/webhooks/operation/rotate_webhook_secret))

Returns a new signing secret. The old one stops working immediately: every delivery from now on, retries included, is signed with the new one.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `webhook_id` | path | string (uuid) | yes |  |
| `X-Podzone-Team` | header | string (uuid) | no | The team id of the team to act for. **Required for an account-scoped key** on this route, which reaches every team and so cannot infer one; omitting it returns 422. Ignored by a team-scoped key, which only reaches one team. |

**Response `200`**: OK

- `secret` (string, required): The new signing secret. The old one stops working now.

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `404` Not found: `webhook_not_found`
- `422` Invalid request: `invalid_input`
- `429` A limit was reached: `rate_limited`

### Send a test event

`POST /api/v1/webhooks/{webhook_id}/test` ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/webhooks/operation/test_webhook))

Queues a sample `alert.match.created` delivery with `"test": true`, signed with the webhook's secret, to its URL or to `url` instead. It is sent once and never retried; read its result from the deliveries list.

A team may send 50 an hour. Past that, `429 rate_limited` with `scope: "webhook_test_sends_per_hour"` and a `Retry-After` header.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `webhook_id` | path | string (uuid) | yes |  |
| `X-Podzone-Team` | header | string (uuid) | no | The team id of the team to act for. **Required for an account-scoped key** on this route, which reaches every team and so cannot infer one; omitting it returns 422. Ignored by a team-scoped key, which only reaches one team. |

**Request body** (JSON)

- `url` (string, 1 to 2000 characters, nullable): Send the test here instead of to the webhook's own URL.

**Response `202`**: Accepted

- `attempts` (integer, required)
- `created_at` (string (date-time), required)
- `delivered_at` (string (date-time), nullable, required)
- `event_type` (string, required)
- `id` (string (uuid), required): The `webhook-id` header the delivery carried.
- `is_test` (boolean, required)
- `last_attempt_at` (string (date-time), nullable, required)
- `last_error` (string, required): Why the last attempt failed, e.g. "HTTP 500" or "Timed out."
- `last_status_code` (integer, nullable, required): The HTTP status of the last answer, or null if none came back.
- `next_attempt_at` (string (date-time), nullable, required): When the next attempt is due, while pending.
- `status` (one of `pending`, `succeeded`, `failed`, `cancelled`, required)
- `target_url` (string, required): Where a test send went when it was not the webhook's URL; empty otherwise.

**Errors**

- `401` Not authenticated: `invalid_credentials`
- `404` Not found: `webhook_not_found`
- `422` Invalid request: `invalid_input`
- `429` A limit was reached: `rate_limited`

## Webhook events

What Podzone POSTs to a team's webhook. Verify each delivery's signature before trusting it.

### `alert.match.created`: An alert found a match

Sent as a `POST` to the team's webhook URL. ([in the browsable reference](https://api.podzone.fm/api/v1/docs#tag/webhook-events/operation/alert_match_created))

Sent to the team's webhook once for each new match. Verify `webhook-signature` before trusting it: [the webhooks guide](https://podzone.fm/docs/webhooks) shows how.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `webhook-id` | header | string | yes | The delivery's id, the same on every retry. |
| `webhook-timestamp` | header | string | yes | When this attempt was sent, in Unix seconds. |
| `webhook-signature` | header | string | yes | `v1,` and the base64 HMAC-SHA256 signature. |

**Request body** (JSON)

- `data` (object (MatchOut), required): Exactly what `GET /v1/alerts/matches/{match_id}` returns for this match.
  - `alert_id` (string (uuid), required)
  - `alert_name` (string, required)
  - `created_at` (string (date-time), required)
  - `episode` (object (MatchEpisodeOut), required)
    - `duration_seconds` (integer, nullable, required)
    - `id` (string (uuid), required)
    - `podcast` (object (MatchPodcastOut), required)
      - `id` (string (uuid), required)
      - `image_url` (string, required)
      - `title` (string, required)
    - `published_at` (string (date-time), nullable, required)
    - `title` (string, required)
  - `excerpt` (string, required)
  - `first_offset_seconds` (number, nullable, required)
  - `hit_offsets_seconds` (array of number, required)
  - `id` (string (uuid), required)
  - `keywords_matched` (array of string, required)
- `test` (boolean, required): True for a test event, whose `data` is a fixed example rather than a real match.
- `timestamp` (string (date-time), required): When the match was found.
- `type` (`alert.match.created`, required)

**Response `2XX`**: Accepted. Any 2xx within 5 seconds; anything else is retried.
