Version 1.0 (draft) · Overview · Schema reference
Wherever Webhooks is an open format for a podcast player to report listening activity to an endpoint its user chooses: playback, follows, downloads, the queue, and library changes. Any player can send these events, and any service can receive them. It is built on two existing standards:
Field names follow the Podcast Index API.
In this document, a sender is the podcast player that emits events, a receiver is the service at the registered endpoint, and a registration is one endpoint configured in a sender with its URL, secret, chosen events, and delivery mode. “Must” and “should” state requirements; everything else is explanation.
POST.whsec_…), or you generate one and paste it into
the sender.id, and return 2xx.// Node, with the `standardwebhooks` package
import { Webhook } from 'standardwebhooks';
const webhook = new Webhook(process.env.WEBHOOK_SECRET); // "whsec_…"
export async function handle(request) {
const body = await request.text(); // raw body, before any JSON parsing
try {
webhook.verify(body, Object.fromEntries(request.headers));
} catch {
return new Response('bad signature', { status: 401 });
}
const parsed = JSON.parse(body);
const events = Array.isArray(parsed) ? parsed : [parsed]; // batch or single
for (const event of events) await save(event); // ignore duplicate event.id
return new Response(null, { status: 204 });
}
# Python, with the `standardwebhooks` package
from standardwebhooks.webhooks import Webhook
webhook = Webhook(os.environ["WEBHOOK_SECRET"])
def handle(raw_body: bytes, headers: dict):
webhook.verify(raw_body, headers) # raises on a bad signature
parsed = json.loads(raw_body)
events = parsed if isinstance(parsed, list) else [parsed]
...
Every delivery is an HTTPS POST to the registered URL.
| Header | Value |
|---|---|
Content-Type |
application/cloudevents-batch+json (batch) or application/cloudevents+json (single event) |
webhook-id |
Identifies this delivery; the same on every retry of it |
webhook-timestamp |
Unix seconds when the request was sent |
webhook-signature |
v1,<base64 HMAC-SHA256>; may hold several space-separated signatures |
Signing follows Standard Webhooks exactly, so its libraries and existing middleware work unchanged.
whsec_ followed by base64 of 24 to 64 random bytes. The HMAC key is the
decoded bytes.<webhook-id>.<webhook-timestamp>.<raw body>.v1, followed by the base64 HMAC-SHA256 of the signed content. Accept the
request if any v1 signature in the header matches.Verify against the raw body bytes. Parsing and re-serializing the JSON first changes the bytes and breaks the signature.
A sender may run in a web browser, such as a podcast web app. Browsers send a CORS
preflight before every delivery, because the webhook-* headers are custom headers. If
you want to receive from browser-based senders, answering the preflight is required.
Without it, the browser blocks the delivery before your endpoint sees it, and no sender-side
setting can work around that. Senders that run natively or on a server send no preflight;
if you only serve those, skip this section.
Answer OPTIONS with:
Access-Control-Allow-Origin: <the sender's origin, e.g. https://player.example>
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Content-Type, webhook-id, webhook-timestamp, webhook-signature
Access-Control-Max-Age: 86400
and include Access-Control-Allow-Origin on the POST response too. Max-Age lets the
browser reuse the preflight instead of repeating it for every delivery.
The person registering the endpoint picks a mode. Prefer batch whenever your receiver can accept it.
Batch (default). The body is a JSON array of events in the order they happened, per the
CloudEvents JSON batch format.
A batch may hold one event. webhook-id names the batch; a retry of the same batch reuses
it.
Single event. The body is one event in CloudEvents structured mode, and webhook-id
equals the event’s id. This suits handlers built to take one event per request. It costs
one request per event, and a browser sender closing its page can fit fewer events into its
last requests, so more are delayed until the app is next opened.
In either mode, deduplicate on the event id: it is the reliable idempotency key. A
sender may resend an event inside a new batch with a new webhook-id.
| Your response | Sender does |
|---|---|
2xx |
Done |
408, 429, 5xx, network error, timeout |
Retries the same delivery later |
410 Gone |
Stops sending to this endpoint (disables it) |
Any other 4xx |
Drops the delivery; does not retry |
Delivery is best effort. Browser and mobile senders can only send while the app is running, so they retry over minutes or until the next launch, not over days, and events can be lost if the app is closed for good before it reconnects. Design receivers to tolerate gaps: the library snapshot restates the full follow list periodically, and playback segments are cumulative, so the latest update repairs earlier losses.
Events in one batch are in occurrence order. Order across batches is not guaranteed: a
retried batch can arrive after a newer one. Order events by their time, and apply
segment updates by updatedAt, not by arrival.
Every event is a CloudEvents 1.0 event (schema):
{
"specversion": "1.0",
"id": "4b9a7a0e-6c1d-4a8e-9f61-2f7f0a3c8d21",
"type": "audio.wherever.playback.progress",
"source": "/player.example/3f2a9c1e-0e4b-4c7a-9b61-0d6c2f1e8a77",
"time": "2026-11-01T03:41:31.931Z",
"datacontenttype": "application/json",
"dataschema": "https://docs.wherever.audio/schemas/webhooks/playback-progress/v1.json",
"data": {
"origin": "user",
"episode": {
"guid": "ep-42",
"podcastGuid": "9b024349-ccf0-5f69-a609-6b82873eab3c",
"feedUrl": "https://example.com/feed.xml",
"feedTitle": "Example Podcast",
"enclosureUrl": "https://example.com/episode42.mp3",
"title": "Episode 42",
"duration": 3600
},
"segment": {
"segmentId": "7c1e0b52-3a9d-4f0e-8a51-6b2d9c4e1f30",
"segmentStart": 1520,
"position": 1820,
"mediaDuration": 3612,
"playbackRate": 1.5,
"listenTimeReal": 200,
"listenTimePlayback": 300,
"startedAt": "2026-11-01T03:38:11.000Z",
"updatedAt": "2026-11-01T03:41:31.000Z",
"closed": false
},
"recovered": false
}
}
| Field | Meaning |
|---|---|
id |
Unique per event and per endpoint. The idempotency key. |
type |
audio.wherever. plus the event name, e.g. audio.wherever.show.followed. |
source |
Identifies the sending install. Treat it as opaque; it tells devices apart but is not a user identity. |
time |
When the event happened. Can be earlier than the request by up to a flush interval. |
dataschema |
URL of the JSON Schema for data. Its path carries the schema version. |
Every data object carries:
| Field | Meaning |
|---|---|
origin |
Who caused the event: user, agent, link (opening a shared link), system (scheduled or automatic), or sync. |
actionId |
Present when one action produced several events (unfollowing several shows at once); shared by all of them. |
test |
Present, as true, only on synthetic events from a sender’s “send test”. Test events carry the install’s real source, so a receiver that stores them mixes sample data into that install’s history; see receiver requirements. |
Shows and episodes are embedded as references, never as internal IDs.
Show (schema): url (feed URL), and when
known podcastGuid, title, link (website).
Episode (schema): feedUrl,
enclosureUrl (the media URL as the feed publishes it), and when known guid,
podcastGuid, feedTitle, title, duration (seconds, from the feed; best effort).
Identity. An episode is identified by feedUrl plus guid, falling back to
enclosureUrl when there is no guid. A show is identified by its url; podcastGuid is
helpful but may be derived (a UUIDv5 of the feed URL, per Podcasting 2.0) when the feed
has none.
Private feeds. A reference carries "private": true when the user marked the feed
private. Paid and subscriber feeds often embed an access token in the URL. Do not log,
display, or forward those URLs, and expect guid to be missing or unstable.
Playback is reported as segments (schema). A segment is one stretch of continuous listening. It opens on play, resume, or after a seek, and closes on pause, seek, or end of media.
segmentId: the update with the greatest updatedAt, and ignore an
update older than the one you hold, since updates can arrive out of order. When two
updates share an updatedAt, the one with closed: true wins.segmentStart..position is a range that was actually heard.listenTimeReal (wall clock) or listenTimePlayback
(adjusted for speed) over segments.position of the most recently updated segment for an episode.recovered: true marks an update resent at the next launch.These fields map directly onto the
gPodder episode actions API:
started = segmentStart, position = position, total = mediaDuration.
Stability: stable events change only under the versioning rules; experimental events may change shape while they settle. The schema reference has a page per event, and index.json is the machine-readable list.
| Event | data beyond the common fields |
Stability |
|---|---|---|
app.started |
version, platform, startedAt |
stable |
app.foregrounded, app.backgrounded |
none | stable |
playback.started |
episode, segment (just opened); surface (where the play began), preview |
stable (surface experimental) |
playback.progress |
episode, segment, recovered |
stable |
playback.paused |
episode, segment (closed), reason: user, sleepTimer, interrupted |
stable |
playback.seeked |
episode, segment (closed), from, to (seconds), method: skip, scrub, chapter, auto |
stable |
playback.ended |
episode, segment (closed); fires only at true end of media |
stable |
show.followed, show.unfollowed |
show |
stable |
show.refreshed |
show, newEpisodeCount, ok |
stable |
show.episodesFound |
show, episodes (newest first) |
stable |
show.viewed |
show, surface: page, preview |
experimental |
episode.downloaded |
episode, bytes |
stable |
episode.downloadFailed |
episode, error (short code such as http_404, network) |
stable |
episode.downloadDeleted |
episode |
stable |
episode.markedDone |
episode; marked done without finishing, not a listen |
stable |
episode.viewed |
episode |
experimental |
episode.shown |
episode, surface, index |
experimental |
episode.dismissed |
episode |
experimental |
queue.added |
episode, index (position in that queue), queueId |
stable |
queue.removed |
episode, queueId; left the queue without being played through |
stable |
library.snapshot |
subscriptions: the complete follow list |
stable |
library.refreshed |
trigger, feeds, succeeded, failed, newEpisodes, durationMs, failedShows (at most 20), cancelled |
stable |
search.performed |
query, resultCount. Carries what the user typed; senders send it only on explicit opt-in |
experimental |
Each event’s schema is at /schemas/webhooks/<event>/v1.json, where <event> is the name
in kebab case, with a readable page beside it at v1.html: playback.progress →
playback-progress/v1.json,
readable.
Every event’s data, the shared references, and the envelope have JSON Schemas (draft
2020-12) under https://docs.wherever.audio/schemas/webhooks/. An event’s dataschema is
the URL of its schema, <event>/v<major>.json.
The schemas are deliberately open, and receivers should be too:
reason or surface can grow. Only origin is
closed.A breaking change (removing or renaming a field, changing its type or meaning, making an
optional field required) publishes a new major version at <event>/v2.json. The old schema
stays published, and dataschema tells you which version each event follows.
A sender:
type
audio.wherever.<event>, a dataschema naming the schema the data follows, and a
source that stays stable for one install.id, and keep it on every resend.updatedAt from one update to the next.webhook-id that stays the same when a delivery is retried.data that validates against the published schema for its event.test: true, and only those.4xx, and stop sending to an endpoint that answers 410.search.performed unless they opted in to it specifically.whsec_ secret in the sender or paste one
their receiver issued.library.snapshot periodically so receivers can repair missed follow
changes.A receiver:
id.test: true as activity: acknowledge them, and keep
them out of any state derived from real events. A sample library.snapshot stored as
real replaces the install’s library with one example show.updatedAt, keeping the newest per segmentId
(segments).origin), and unknown
event types.2xx once events are stored, a retryable code when it cannot store
them yet, and 410 when it no longer wants deliveries.private.This appendix is informative, not part of the specification.
Wherever (web). The Wherever web app is a sender. Webhooks are
under Settings › Webhooks, which appears when Debug mode is on (Settings › Advanced). It
supports both delivery modes and both ways of setting the secret. Its Send Test sends one
sample event of each chosen type, marked test: true. It sends from the browser, so
receivers must allow the origin https://wherever.audio in their CORS preflight response.
Its source has the form /wherever/web/<install id>.