wherever.audio

Wherever Webhooks

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.

Contents

Receiving in five minutes

  1. Expose an HTTPS endpoint that accepts POST.
  2. Get a secret: the sender generates one (whsec_…), or you generate one and paste it into the sender.
  3. Verify each request with a Standard Webhooks library over the raw body.
  4. If the sender runs in a browser, answer the CORS preflight.
  5. Store each event, deduplicating on its 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]
    ...

Requests

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

Verifying signatures

Signing follows Standard Webhooks exactly, so its libraries and existing middleware work unchanged.

Verify against the raw body bytes. Parsing and re-serializing the JSON first changes the bytes and breaks the signature.

CORS for browser senders

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.

Delivery modes

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.

Responses and retries

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.

The event envelope

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.

Common fields

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.

References

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 segments

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.

These fields map directly onto the gPodder episode actions API: started = segmentStart, position = position, total = mediaDuration.

Event catalogue

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.

Schemas and versioning

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:

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.

Sender requirements

A sender:

Receiver requirements

A receiver:

Appendix: implementations

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>.