# Accounts & workspaces > How Listener creators, networks, subnetworks and team members map onto the API. Every request runs inside exactly one **workspace**. The credential decides which one; there is no account or workspace parameter to pass. ## Two products, one API | | Listener | Listener Networks | | --- | --- | --- | | App | [listener.com](https://listener.com) | [network.listener.com](https://network.listener.com) | | Who | Individual creators and single shows | Podcast networks, agencies and publishers | | Workspace kind | `solo` | `network` or `subnetwork` | | Create keys at | [listener.com/app/developers](https://listener.com/app/developers) | [network.listener.com/app/developers](https://network.listener.com/app/developers) | Both apps sit on the same data and the same API host, `https://api.listener.com`. A key minted in either app works against every `/v1` endpoint; what it can see depends on the workspace it belongs to. ## Workspace kinds `GET /v1/me` reports the kind in `kind`: - **`network`**: a Listener Networks account with many shows. It owns campaigns, brands, reports, subnetworks and a team. - **`subnetwork`**: a network nested under a parent network (`parent_network_uid`). It sees its own shows and its own campaigns; the API never crosses into the parent or sibling subnetworks. - **`solo`**: a single show on Listener. Analytics, episodes and clusters work as usual. The workspace id (`effective_uid` on `GET /v1/me`) is the account owner’s id. Show ids, campaign ids and cluster ids are the same ids the apps use, so you can deep-link between your system and the app. ## Team members When a team member creates a key or authorizes an OAuth app, the credential resolves to the **network owner’s workspace**, the same workspace the team member sees in the app. Two people on the same team reading `/v1/analytics/reach` get the same numbers. Usage logs record which credential made each call, so you can still tell them apart. ## Network-only resources on solo workspaces Some resources only exist for networks: campaigns, subnetworks and the team. On a `solo` workspace these endpoints succeed with an empty list and a `meta.note` explaining why, instead of failing. That keeps one code path working for every kind of account: ```json { "object": "list", "url": "/v1/campaigns", "data": [], "has_more": false, "next_cursor": null, "meta": { "note": "Campaigns are a Listener Networks feature; this workspace is a single show." } } ``` ## Shows that no longer exist A network can still point at a show that was deleted. Those ids appear in `missing_show_ids` on `GET /v1/me` and are excluded from every list and total, so they never distort a sum. ## Plans Some features depend on the workspace’s plan. When a plan does not include something, the API answers `403` with `plan_required` rather than returning partial data. `GET /v1/plan` tells you what the current plan unlocks. ## Public URLs Listener pages are shareable, and many objects carry their public URL. The shapes are stable: | Object | Public URL | | --- | --- | | Show | `https://listener.com/{handle}` | | Network | `https://network.listener.com/{handle}` | | Cluster | `https://listener.com/cluster/{showId}_{clusterId}` | | Campaign | `https://network.listener.com/{handle}/campaigns/{id}` | | Report | `https://network.listener.com/report/{workspaceId}/{id}` | `GET /v1/urls` builds them for you. --- # Authentication > API keys, how to send them, scopes and presets, rotation, and keeping keys out of browsers. Every `/v1` request authenticates with a bearer credential in the `Authorization` header. There are two kinds: | Credential | Looks like | Use it when | | --- | --- | --- | | API key | `lsk_` + id + secret | Your own scripts and servers act on **your** workspace. | | OAuth access token | `lat_` + id + secret | Your app acts on behalf of **other** Listener users who connected it. See [OAuth 2.0](https://developers.listener.com/docs/oauth). | A few endpoints need no credential at all: the API root `GET /v1`, the spec at `GET /v1/openapi.json` and the event catalog at `GET /v1/event-types`. ## Sending the key Send the key as a bearer token: ```bash curl https://api.listener.com/v1/me \ -H "Authorization: Bearer lsk_…" ``` HTTP Basic auth with the key as the username and an empty password also works, which is handy with curl: ```bash curl -u "lsk_…:" https://api.listener.com/v1/me ``` > **Warning:** Keys in the query string (`?key=`) are rejected on `/v1`: URLs end up in logs, browser history and proxies. The query-string form exists only on the MCP endpoint, for AI clients that cannot set headers. ## Creating keys Create keys in **Settings → Developers → API keys** in either app: [network.listener.com/app/developers](https://network.listener.com/app/developers) or [listener.com/app/developers](https://listener.com/app/developers). The page still answers at `/app/mcp` for older links. When you create a key you choose: - **A name**, shown in usage logs and in `GET /v1/me` as `credential.label`. - **Access**: a preset or a set of fine-grained scopes (below). - **An expiry** (optional), in days. Expired keys answer `401 credential_expired`. The full key is shown once. Listener stores only a hash, so a lost key cannot be recovered; create a new one. A workspace can have up to 10 active keys. ## Presets and scopes Two presets cover most needs: | Preset | Scopes | Good for | | --- | --- | --- | | **Read** | `workspace:read` | Reporting, dashboards, exports, AI assistants that should only look. | | **Read & write** | `workspace:read` `workspace:write` | Automation that creates campaigns, edits clusters or manages webhooks. | For anything that runs unattended, prefer fine-grained scopes: a reporting job needs only `analytics:read` and `shows:read`; a campaign sync needs `campaigns:read` and `campaigns:write`. Each endpoint’s required scope is shown in the [API reference](https://developers.listener.com/api), and a call without it fails with `403 scope_missing`, naming the `required_scope`. The full list is in [Scopes](https://developers.listener.com/docs/scopes). ## Rotating keys Rotate a key from the same page. Rotation issues a new secret immediately and keeps the old one working for **24 hours**, so you can deploy the new value without downtime: 1. Rotate the key and copy the new value. 2. Update the secret wherever it is deployed. 3. Watch the key’s **Last used** time; within 24 hours the old value stops working on its own. Revoking a key is immediate. Do that if a key leaks, then create a replacement. ## Keep keys out of browsers API keys carry the full authority of their scopes over a whole workspace and do not expire unless you set an expiry. Never embed one in front-end code, a mobile app, a browser extension or a public repository. - **Server-side only.** Keep keys in environment variables or a secrets manager and call the API from your backend. - **Building a browser or mobile app?** Use [OAuth 2.0](https://developers.listener.com/docs/oauth) with PKCE. Each user signs in with their own Listener account and you get short-lived, revocable tokens. - **Leaked a key?** Revoke it in Settings → Developers right away; revocation takes effect immediately. The API allows cross-origin requests from any origin so that OAuth apps running in a browser can use their access tokens. That is not an invitation to put `lsk_` keys in client code. ## Request logs Settings → Developers → **Logs** lists the last 14 days of requests per workspace (method, path, status, duration, which credential, request id). Bodies are never logged. **Usage** shows daily totals and errors per key for the last 30 days. --- # Data sources, freshness & retention > Where every number comes from, how often it refreshes, what is stored per post, how long data is kept, and what is not built yet. Every metric in the API traces back to a platform. This page explains, platform by platform, how Listener gets the data, what it stores, when it refreshes and how long it keeps it. Where something is not built yet, it says so. ## How Listener gets each platform’s data There are four ways data reaches Listener. A show can mix them. | Method | What it means | Platforms | | --- | --- | --- | | **Authorized API (OAuth)** | The show owner signs in to the platform and grants read access. Listener calls the platform’s official API with that grant. | YouTube (YouTube Data and YouTube Analytics APIs), Instagram professional accounts (Instagram API with Instagram Login), Blubrry, Eventbrite | | **Public profile** | The show adds a public profile URL or handle. Listener reads the public counters through third-party data providers. No sign-in, so no private insights. | Instagram (accounts not connected with Instagram Login), TikTok, Facebook pages and groups, X, Threads, LinkedIn | | **API key** | The show pastes a key or token from the hosting or newsletter platform. Listener calls that platform’s API. | Simplecast, Transistor, Omny Studio, Art19, Captivate, Beehiiv, Substack, Facebook page insights through a Sprout Social token | | **Listener Sync (browser extension)** | The show connects Spotify for Creators, Apple Podcasts Connect or Megaphone through the Listener Sync extension. Listener stores the resulting session encrypted and reads the same dashboards the show sees. | Spotify, Apple Podcasts, Megaphone | When a show has not connected Sync, Spotify and Apple Podcasts numbers can be requested from the Listener team (`POST /v1/data-requests`); a person pulls them, so they arrive later than automated data. Substack podcast downloads can also be imported from Substack’s stats export. X is read through a data provider first; when that returns nothing recent, Listener falls back to the official X API with its own credentials (not the show’s). > **Public-profile data is public data:** For platforms read from a public profile, Listener only sees what the platform shows publicly: views, likes, comments and shares where the platform exposes them. Private insights such as reach, saves or audience demographics need the authorized connection, which today exists for YouTube and Instagram professional accounts. ## Posts added by URL Anyone with access to a cluster can paste a post URL to add it. Listener fetches the post right away and stores the numbers on the cluster node. | URL | Fetched through | Captured when added | | --- | --- | --- | | YouTube video or Short | Data provider; on refresh, live YouTube API counters when the channel is connected | views, likes, comments | | YouTube community post | Data provider | likes, comments where available | | Instagram post or reel | Data provider | views (Instagram’s play count) for reels and videos, likes, comments, shares. Image posts have no views. | | Instagram story | Entered by hand | views | | Archived Instagram or Facebook post | Entered by hand | views (reels) or impressions (feed posts) | | TikTok | Data provider | views, likes, comments, shares | | Facebook | Data provider | views, likes (reactions), comments, shares | | X | Data provider | views (stored as impressions), likes, reposts, replies, quotes, bookmarks | | LinkedIn (including `lnkd.in` links) | Data provider | reactions, comments, reposts. No impressions. | | Threads | Data provider | likes and total engagement. No views. | | Spotify episode | Data provider | episode details only; plays come from the show’s connected Spotify data | | Apple Podcasts episode | Apple’s public lookup API | episode details only; plays come from the show’s connected Apple data | | Reddit | Data provider | upvotes, comments | | Any other web page | Page metadata and a traffic data provider | the site’s monthly visits and rank, not per-article views | Megaphone URLs can’t be dropped; add Megaphone episodes from the show’s connected Megaphone library. The creator app (listener.com) recognises the same URLs, plus Substack posts. Added posts refresh with their cluster, as described in [Refresh schedule](#refresh-schedule). ## What is stored per post Each cluster node stores the latest value of each metric, not a history per post. | Platform | Fields stored | Counted as reach | | --- | --- | --- | | YouTube | views, likes, comments; engaged views when the channel is connected | views. Engaged views are reported separately and never added to reach. Long-form and Shorts are split by the show’s duration rule or a format set by hand. | | Instagram | views (from Instagram’s play count), likes, comments, shares | views. Image posts have no views, so their engagement stands in. | | TikTok | views (TikTok’s play count), likes, comments, shares | views | | Facebook | views (play count), likes (reactions), comments, shares | views | | X | impressions (X’s view count), likes, reposts, replies | impressions | | LinkedIn | reactions, comments, reposts | LinkedIn does not expose impressions to Listener, so reactions stand in | | Threads | likes, total engagement | Threads views are not captured today, so engagement stands in | | Beehiiv, Substack posts | unique opens, delivered | opens | | Spotify episode | plays, streams, audience size (Spotify app only) | plays | | Apple Podcasts episode | plays, listeners, engaged listeners | listeners, then plays | How a platform defines a view is the platform’s own definition: Listener stores the counter the platform reports and does not recompute it. Instagram now calls its play count “Views”; X reports views as impressions; Spotify numbers cover the Spotify app only. For account-level LinkedIn analytics, where LinkedIn reports no impressions, Listener estimates impressions from reactions and flags the figure as estimated (`impressions_estimated`). Cluster reach never uses that estimate. ## Refresh schedule Account data (a show’s channel or profile) and cluster posts refresh in different ways. All times are UTC. ### Account data | Trigger | What refreshes | | --- | --- | | **Opening Listener** | Opening the network dashboard or the public network page refreshes connected platforms for shows whose data is older than 4 hours, a limited number of shows per visit on large networks. | | **Refresh button or API** | Refreshes one show and platform now. In the API: `POST /v1/shows/{show_id}/platforms/{platform}/refresh`. Public-profile platforms (TikTok, X, Facebook, Instagram, Threads, LinkedIn) spend data-provider credits and need `confirm: true`. | | **Before a scheduled report** | 07:00 daily, Listener refreshes the data that reports due in the next two hours will use. Reports send at 08:00. | | **Listener Sync** | Spotify, Apple Podcasts and Megaphone accounts connected through Sync refresh automatically once a day (the job works through shows hourly at :20). | | **Daily history capture** | Hourly at :40, Listener folds stored data into daily history (no platform calls). | There is no fixed timer that refreshes every social account every day. Account data moves when someone opens Listener, presses Refresh, calls the refresh endpoint, or when a scheduled report is due. Spotify and Apple data without Sync arrives when the Listener team fulfils a data request. ### Cluster posts | Trigger | Rule | | --- | --- | | **Viewing a public campaign page** | Clusters whose last refresh is more than 5 hours old refresh in the background, up to 40 per page view. | | **Refresh button or API** | The cluster’s Refresh action, or `POST /v1/shows/{show_id}/clusters/{cluster_id}/refresh` with `confirm: true`, refreshes every post in the cluster now. Merged clusters refresh through their source clusters. | | **Nightly job** | Not running. A nightly refresh of recent clusters is built but switched off, so clusters refresh on view and on demand only. Use the refresh endpoint when you need fresh numbers. | Cluster posts therefore refresh only when a public page that shows them is viewed, or when someone refreshes them. ## History and retention | Data | What is kept | How long | | --- | --- | --- | | **Daily history** | One value per platform per day (reach and engagement), stored per show in yearly shards. Written whenever platform data is saved and re-folded daily. | No expiry. Coverage starts when Listener first saw the platform; some platforms backfill further (for example up to 400 days for Spotify, the full series for Simplecast and Transistor). | | **Recent detail** | Each platform’s recent daily series and recent posts on the show record, about the last 30 days. | Rolling; older days live on in daily history. | | **Episode lists** | Audio connectors keep at least the 30 most recent episodes; trimming only happens when a show’s record nears its size limit. | Rolling | | **Cluster posts** | The latest value of each metric per post. | Until the cluster is deleted | | **Cluster reach per day** | A daily total per cluster (all, long-form, short-form), captured once a day and after refreshes. | No expiry | | **Shared reports** | A snapshot frozen when shared, or a live report rebuilt at most hourly. | Until deleted | | **API events and webhook deliveries** | Event payloads and delivery attempts. | 30 days | | **API request logs** | Method, path, status, timing, credential; never bodies. | 14 days | | **Idempotency records** | The first response for each key. | 24 hours | Windows longer than about 30 days read from daily history. Responses say so in `coverage.history_used` and explain gaps in `coverage.warnings` (see [Time windows](https://developers.listener.com/docs/time-windows)). ### Disconnecting, deleting and leaving - **Disconnecting a platform** in Listener Networks removes that platform’s current data from the show. In the Listener creator app the data is kept and marked disconnected. In both apps, daily history for that platform is kept, and any Sync session is revoked first. - **Deleting a cluster, campaign or report** deletes that document right away. Daily cluster reach entries already captured are not removed. - **Deleting a show** removes the show record. Its daily history, clusters and other stored sub-records are not removed automatically today. - **Deleting an account** is not self-serve yet. Email [tech@listener.com](mailto:tech@listener.com) to have an account and all of its data deleted. ## Storage and security - **Where data lives:** Google Cloud Firestore in the `nam5` location, a United States multi-region. Google encrypts Firestore data at rest by default. - **API keys and OAuth tokens issued by Listener** are stored only as SHA-256 hashes and compared in constant time. A leaked database copy can’t be used to call the API. - **Webhook signing secrets** are encrypted with AES-256-GCM before they are stored. - **Listener Sync sessions** (Spotify, Apple Podcasts, Megaphone) are encrypted with AES-256-GCM and kept apart from the show record. - **Request logs** never contain request or response bodies. ## Restatements, deleted posts and shared accounts **Restatements.** When a platform revises a post’s numbers, the next refresh stores the new value, with two guards: - A metric that comes back as `0` after being positive keeps its previous value, because a zero is far more often a provider hiccup than a real reset. - Views, impressions and reach on a cluster post never move down. A platform lowering one of these (for example after removing invalid traffic) is not reflected on the post. Engagement counts such as likes can move down. Daily history is re-folded from the latest stored series, so a revised day replaces the old value there. **Deleted or private posts.** When a post can no longer be fetched (deleted, made private or blocked), its last numbers stay on the cluster and the cluster records a refresh error for that post. Deleted Instagram and Facebook posts are kept as archived posts with their last known numbers. Remove a post from a cluster with `POST /v1/shows/{show_id}/clusters/{cluster_id}/nodes/remove` if it should no longer count. **One account on several shows.** A network can link the same Instagram, TikTok, Facebook or YouTube account to several shows. By default each show counts its own accounts. With `social_count_mode=unique`, network totals, timelines and platform splits count each shared account once, attributed to one show (`suppressed_platforms_by_show` says which copies were skipped). Per-show rankings and comparisons always use each show’s own accounts. Cluster reach does not deduplicate accounts. See [Metrics glossary](https://developers.listener.com/docs/metrics#unique-account-mode). ## Platform limits and how staleness shows up Platforms and data providers limit how often Listener can call them. Listener spaces its calls, retries after `429` responses using the platform’s `Retry-After`, waits for YouTube’s daily quota to reset when it runs out, and budgets data-provider credits. When a limit or an outage stops a refresh, the previous numbers stay and the data simply ages. The API reports that age instead of hiding it: - **`freshness`**: for each platform, `last_updated`, `age_hours` and `stale`. A platform is stale when its data is more than 36 hours old or has never been updated. For several shows, the summary reports the oldest. - **`stale_platforms`**: the platforms that are stale in the result. - **`coverage.warnings`**: plain-language notes when growth was left out (fewer than half the previous window’s days have data), or when a window reaches before the data Listener holds. - **Cluster freshness**: when each cluster was last refreshed, plus its refresh errors. The [Data freshness](https://developers.listener.com/docs/freshness) guide shows how to read these, and the `analytics.refreshed` and `integration.stale` [events](https://developers.listener.com/docs/webhooks/event-types) tell you when data moves or stops moving. The Listener API’s own limits are in [Rate limits](https://developers.listener.com/docs/rate-limits). --- # Errors > The error object, every error type and code, and how to handle each. The API uses HTTP status codes for the broad outcome and a typed error object for the detail. `2xx` means success, `4xx` means the request needs to change, `5xx` means something failed on our side. ```json { "error": { "type": "invalid_request_error", "code": "parameter_invalid", "message": "Unknown show id: abc. Valid show ids in this workspace: 8sQe1v0x2kTq, k2LmP0aZ9qWe", "param": "show_id", "doc_url": "https://developers.listener.com/docs/errors#parameter_invalid", "request_id": "req_8f2LkQ0aZ1" } } ``` | Field | | | --- | --- | | `type` | The broad category. Branch on this first. | | `code` | A stable, machine-readable reason. Branch on this for specific handling. | | `message` | A human-readable explanation. Safe to log; wording may change, so do not parse it. | | `param` | The parameter at fault, when there is one. | | `required_scope` | Present on `scope_missing`. | | `doc_url` | A link to this page, anchored at the code. | | `request_id` | The same value as the `Request-Id` header. Include it when you contact support. | ## Error types | Type | HTTP | Meaning | | --- | --- | --- | | `invalid_request_error` | 400, 405, 413 | The request is malformed or a parameter is wrong. Fix the request before retrying. | | `authentication_error` | 401 | No credential, or one the API cannot accept. | | `permission_error` | 403 | The credential is valid but may not do this. | | `not_found_error` | 404 | The resource or route does not exist in this workspace. | | `conflict_error` | 409 | The write needs explicit confirmation. | | `idempotency_error` | 400, 409 | The `Idempotency-Key` conflicts with an earlier or in-flight request. | | `rate_limit_error` | 429 | Too many requests for this credential. Wait `Retry-After` seconds. | | `api_error` | 500, 502, 503 | Something failed on our side or upstream. Safe to retry with backoff. | ## Error codes ### parameter_missing `400` · `invalid_request_error` A required parameter was not sent. `param` names it. **Fix:** Add the parameter. The reference marks required ones. ### parameter_invalid `400` · `invalid_request_error` A parameter has the wrong type or value, or names something that is not in this workspace (an unknown show id, for example). The message lists valid values when it can. **Fix:** Correct the value named in `param`. For ids, list the resource first. ### parameter_unknown `400` · `invalid_request_error` The request sent a parameter this endpoint does not accept. **Fix:** Remove it, or check the spelling against the reference. ### body_invalid_json `400` · `invalid_request_error` The body is not valid JSON, or the `Content-Type` is not `application/json`. **Fix:** Send a JSON object with `Content-Type: application/json`. ### version_invalid `400` · `invalid_request_error` The `Listener-Version` header names a version that does not exist. **Fix:** Use a dated version from the changelog, or omit the header. ### oauth_required `400` · `invalid_request_error` This platform can only be connected by a person signing in (Google or Meta sign-in, or the Listener browser extension). The error carries `connect_url`, the show’s Integrations page. **Fix:** Send the creator to `error.connect_url` to connect it, then listen for `integration.connected`. ### method_not_allowed `405` · `invalid_request_error` The path exists but not with this HTTP method. **Fix:** Check the method in the reference. ### body_too_large `413` · `invalid_request_error` The body is larger than 1 MB. **Fix:** Send less per request, or split it into several writes. ### credential_missing `401` · `authentication_error` No `Authorization` header was sent. **Fix:** Send `Authorization: Bearer lsk_…`. Keys in the query string are not accepted on `/v1`. ### credential_invalid `401` · `authentication_error` The key or token is malformed or unknown. **Fix:** Copy the key again from Settings → Developers. Check for whitespace. ### credential_expired `401` · `authentication_error` The key passed its expiry date, or the OAuth access token is older than one hour. **Fix:** Create a new key, or refresh the OAuth token. ### credential_revoked `401` · `authentication_error` The key was revoked, or the user removed your app’s access. **Fix:** Create a new key, or send the user through OAuth again. ### scope_missing `403` · `permission_error` The credential lacks the scope this endpoint needs. The error carries `required_scope`. **Fix:** Create a key with that scope, or request it in your OAuth authorization URL. ### workspace_mismatch `403` · `permission_error` The resource belongs to a different workspace than the credential. **Fix:** Use a credential for the workspace that owns the resource. ### plan_required `403` · `permission_error` The workspace’s plan does not include this feature. **Fix:** Upgrade the plan in the app, or skip this call for that workspace. ### writes_disabled `403` · `permission_error` Writes are temporarily switched off for maintenance. **Fix:** Retry later. Reads keep working. ### resource_not_found `404` · `not_found_error` No resource with that id in this workspace. **Fix:** List the resource to find valid ids. ### route_not_found `404` · `not_found_error` No endpoint at this path. **Fix:** Check the path and the `/v1` prefix. ### confirmation_required `409` · `conflict_error` A destructive or metered write (deletes, publishing reports, metered refreshes, data requests) was sent without `confirm: true`. The error carries a `preview` of exactly what would happen. **Fix:** Show the `preview` (or run with `dry_run`), then repeat with `confirm: true`. ### idempotency_key_in_use `409` · `idempotency_error` Another request with the same `Idempotency-Key` is still running. **Fix:** Wait for it to finish, then retry to receive its stored response. ### idempotency_key_reused `400` · `idempotency_error` The `Idempotency-Key` was used before with a different body. **Fix:** Use a new key for a different request. ### rate_limited `429` · `rate_limit_error` The credential exceeded its rate limit. `Retry-After` says how many seconds to wait. **Fix:** Back off for `Retry-After` seconds. See Rate limits. ### internal_error `500` · `api_error` An unexpected error. Details never leak; the request id lets us find it. **Fix:** Retry with backoff. If it persists, contact support with the `request_id`. ### upstream_error `502` · `api_error` A platform or service we depend on failed. **Fix:** Retry with backoff. ### service_unavailable `503` · `api_error` The API is starting up or briefly overloaded, or a feature this endpoint depends on is not configured on the server. **Fix:** Retry with backoff. If it persists for one endpoint only, contact support. ## Handling errors well ```javascript async function listener(path, init = {}) { for (let attempt = 0; ; attempt++) { const res = await fetch(`https://api.listener.com${path}`, { ...init, headers: { Authorization: `Bearer ${process.env.LISTENER_API_KEY}`, ...init.headers }, }); if (res.ok) return res.status === 204 ? null : res.json(); const { error } = await res.json(); const retryable = res.status === 429 || res.status >= 500; if (retryable && attempt < 4) { const wait = Number(res.headers.get('Retry-After')) || 2 ** attempt; await new Promise((r) => setTimeout(r, wait * 1000)); continue; } throw Object.assign(new Error(`${error.code}: ${error.message}`), { error, status: res.status }); } } ``` ```python import os, time import requests def listener(method, path, **kwargs): for attempt in range(5): res = requests.request( method, f"https://api.listener.com{path}", headers={"Authorization": f"Bearer {os.environ['LISTENER_API_KEY']}"}, **kwargs, ) if res.ok: return res.json() if res.content else None retryable = res.status_code == 429 or res.status_code >= 500 if retryable and attempt < 4: time.sleep(float(res.headers.get("Retry-After") or 2 ** attempt)) continue error = res.json()["error"] raise RuntimeError(f"{error['code']}: {error['message']} ({error['request_id']})") ``` - **Retry** `429` and `5xx` with backoff, honouring `Retry-After`. Send an [`Idempotency-Key`](https://developers.listener.com/docs/idempotency) on writes so a retry never creates a duplicate. - **Do not retry** other `4xx` errors unchanged; they will fail the same way. - **Treat `401 credential_revoked`** on an OAuth token as the user disconnecting your app. - **Log `request_id`** with every failure. --- # Events API > List and fetch the events behind webhooks, for polling, backfills and reconciliation. Every change that produces a webhook also produces an **event** you can read through the API, whether or not you have an endpoint. Events are kept for **30 days**. | Request | | | --- | --- | | `GET /v1/events` | List events, newest first. | | `GET /v1/events/{event_id}` | One event. | | `GET /v1/event-types` | The catalog of event types with descriptions and examples. No authentication. | Reading events needs the `webhooks:read` scope. ## Filters | Parameter | | | --- | --- | | `type` | One type (`campaign.updated`) or a whole domain (`campaign.*`). | | `created_gte` | Created at or after this time (ISO 8601 or Unix seconds). | | `created_lte` | Created at or before this time (ISO 8601 or Unix seconds). | | `limit`, `cursor` | Standard [pagination](https://developers.listener.com/docs/pagination). | ## Polling instead of webhooks If you cannot receive webhooks (a desktop tool, a locked-down network, a notebook), poll the Events API. Remember the newest `created` you processed and ask for everything since: ```javascript let since = await loadCheckpoint(); // e.g. '2026-10-14T09:00:00Z' const qs = new URLSearchParams({ created_gte: since, limit: '100' }); const res = await fetch(`https://api.listener.com/v1/events?${qs}`, { headers: { Authorization: `Bearer ${process.env.LISTENER_API_KEY}` }, }); const { data } = await res.json(); // Newest first: process oldest first, skip ids you have already seen. for (const event of data.reverse()) { if (await alreadyProcessed(event.id)) continue; await handle(event); since = event.created; } await saveCheckpoint(since); ``` ```python since = load_checkpoint() # e.g. "2026-10-14T09:00:00Z" res = requests.get( "https://api.listener.com/v1/events", headers={"Authorization": f"Bearer {os.environ['LISTENER_API_KEY']}"}, params={"created_gte": since, "limit": 100}, ) events = res.json()["data"] # Newest first: process oldest first, skip ids you have already seen. for event in reversed(events): if already_processed(event["id"]): continue handle(event) since = event["created"] save_checkpoint(since) ``` Use `created_gte` (inclusive) together with id deduplication, so an event created in the same second as your checkpoint is never skipped. Page through with `next_cursor` when `has_more` is `true`. Polling every minute or two is plenty: changes made in the apps are detected every five minutes. ## Reconciliation Even with webhooks, run the same loop on a schedule (for example hourly over the last day). It catches anything your endpoint missed while it was down or disabled. --- # Data freshness > How current each platform’s numbers are, how to detect stale data, and how to refresh it. Listener collects data from each connected platform on its own schedule, so different platforms can be a few hours apart. The API never hides that: every result built from platform data says how fresh it is. ## Reading freshness ```json { "freshness": { "youtube": { "last_updated": "2026-10-14T06:12:00Z", "age_hours": 3.3, "stale": false }, "spotify": { "last_updated": "2026-10-12T04:40:00Z", "age_hours": 52.8, "stale": true } }, "stale_platforms": ["spotify"] } ``` A platform is **stale** when its data has not advanced for more than **36 hours**. > **Zero is usually stale, not silent:** When a number looks too low or reads zero, check `freshness` first. A stale or disconnected connection is a far more common cause than an audience that disappeared. Show the freshness next to the number so your users can tell too. ## Where to look | Need | Endpoint | | --- | --- | | Freshness for the whole workspace | `GET /v1/me` (`data_freshness`, `stale_platforms`) | | Connection state per show and platform | `GET /v1/integrations` | | What is stale and how it can be refreshed | `GET /v1/integrations/status` | | Freshness behind a specific number | the `freshness` field of that response | `GET /v1/integrations` lists each show and platform as `connected`, `pending` or `disconnected`, with the last update, whether it is stale, how it can be refreshed (`server`, `manual_request` or `none`) and the last error the connection reported. ## Getting notified Three [events](https://developers.listener.com/docs/webhooks/event-types) cover freshness, so you do not have to poll: - `analytics.refreshed`: a show’s platform data advanced. Re-read the analytics you care about. - `integration.stale`: a connected platform has not advanced for more than 36 hours. - `integration.disconnected`: a connection stopped working and needs someone to reconnect it in the app. ## Refreshing data Most platforms can be refreshed on demand with `POST /v1/shows/{show_id}/platforms/{platform}/refresh` and the `refresh:write` scope, one platform per call: - **Metered platforms** (TikTok, X, Facebook, Instagram, Threads, LinkedIn) spend scraping credits, so they also need `confirm: true`. - **Spotify** refreshes asynchronously; the response says `requested`. - **Apple Podcasts** cannot be refreshed on demand. File a data request instead. ```bash curl -X POST https://api.listener.com/v1/shows/8sQe1v0x2kTq/platforms/instagram/refresh \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"confirm": true}' ``` For Spotify or Apple Podcasts numbers that are missing or stale, `POST /v1/data-requests` (with `confirm: true`) asks the Listener team to pull them; it creates work for a person, so a request already pending for the same show and platform is refused. Use `dry_run: true` first to see what would be refreshed or requested. Reconnecting a platform account (for example after a password change) happens in the app, under Integrations. The API cannot connect platform accounts. --- # Idempotency > Retry writes safely with the Idempotency-Key header. Networks fail in the middle of requests. If a `POST` times out, you cannot tell whether the campaign was created. An idempotency key makes the retry safe: the API runs the write once and replays the stored response for every repeat. ## Sending a key Add an `Idempotency-Key` header (up to 255 characters) to any `POST`, `PATCH`, `PUT` or `DELETE`. Use a value that is unique per operation, such as a UUID or your own record id. ```bash curl https://api.listener.com/v1/campaigns \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Idempotency-Key: io-2026-0142" \ -H "Content-Type: application/json" \ -d '{"title": "Acme Coffee Q4 host reads", "brand": "Acme Coffee"}' ``` ## What happens on a repeat | Situation | Result | | --- | --- | | Same key, same body, first request finished | The stored response is returned with `Idempotent-Replayed: true`. Nothing is written again. | | Same key, first request still running | `409 idempotency_key_in_use`. Wait and retry. | | Same key, different body | `400 idempotency_key_reused`. Use a new key for a new operation. | | Key older than 24 hours | Treated as new. | Keys are scoped to the credential that sent them, so two API keys or two OAuth connections never collide. Responses are stored whatever their status, including errors, so a retry of a request that failed validation fails the same way. ## Good practice - Generate the key **before** the first attempt and reuse it for every retry of that operation. - Derive it from your own data when you can (`invoice-842-create`), so a crash-and-restart also replays instead of duplicating. - Retry with backoff on network errors, `429` and `5xx`. See [Errors](https://developers.listener.com/docs/errors#handling-errors-well). --- # Introduction > What the Listener API is, who it is for, and how its pieces fit together. The Listener API gives your code the same view of a podcast business that the Listener apps show: shows, reach across YouTube, audio and social, episodes, episode clusters, sponsorship campaigns and shared reports. It is one API for every Listener account, whether that is a single creator on [listener.com](https://listener.com) or a network on [Listener Networks](https://network.listener.com). The API, the MCP server, the in-app agent and the apps themselves all read through the same metric code, so a reach number you fetch here matches the number on the Analytics page for the same window. ## What you can build - [Reporting pipelines](https://developers.listener.com/docs/quickstart): Pull reach, rankings and campaign delivery into a warehouse, a spreadsheet or a client portal. - [Ad-ops automation](https://developers.listener.com/api/campaigns): Create campaigns from insertion orders, link episode clusters to slots and track delivered CPM. - [Integrations for Listener users](https://developers.listener.com/docs/oauth): Let any Listener workspace connect your product with “Sign in with Listener”. - [Event-driven workflows](https://developers.listener.com/docs/webhooks): React when a campaign goes live, a cluster gains posts or a platform connection breaks. ## The pieces | Surface | Where | Use it for | | --- | --- | --- | | REST API | `https://api.listener.com/v1` | Reading and changing workspace data from your servers and scripts. | | OAuth 2.0 | `https://api.listener.com/oauth` | Acting on behalf of Listener users who connect your app. | | Webhooks and events | `/v1/webhook-endpoints`, `/v1/events` | Hearing about changes without polling everything. | | MCP server | `https://mcp.listener.com/mcp` | Giving Claude, ChatGPT or Cursor access to a workspace. | | OpenAPI document | `https://api.listener.com/v1/openapi.json` | Generating clients, importing into Postman or Insomnia, feeding LLM tools. | ## Design principles - **Resource-oriented and predictable.** Every payload has an `object` and an `id`, lists share one envelope, errors share one shape. See [Requests & responses](https://developers.listener.com/docs/requests). - **Honest numbers.** Windowed analytics say which window they resolved, how much of it has data and how fresh each platform is. Growth is `null` rather than a misleading percentage when the baseline is thin. See [Time windows](https://developers.listener.com/docs/time-windows) and [Data freshness](https://developers.listener.com/docs/freshness). - **Safe writes.** Every write accepts `dry_run`, every write accepts an `Idempotency-Key`, and destructive or metered writes need `confirm: true`. See [Testing with dry_run](https://developers.listener.com/docs/testing). - **Least privilege.** Keys and OAuth grants carry fine-grained scopes, and every endpoint declares the one scope it needs. See [Scopes](https://developers.listener.com/docs/scopes). ## What the API does not do Some actions stay in the apps on purpose: billing and plan changes, adding or removing team members and subnetworks, connecting platform accounts (YouTube, Instagram and so on), and brand invites. You can read most of that state through the API; you change it in the app. > **Using an AI assistant?:** Every page has a **Copy page** button and the whole site is available as [llms.txt](https://developers.listener.com/llms.txt) and [llms-full.txt](https://developers.listener.com/llms-full.txt). If you want an assistant to work with your workspace directly, connect it to the [MCP server](https://developers.listener.com/docs/mcp) instead. --- # Connect an AI assistant > Give Claude, ChatGPT or Cursor access to a Listener workspace through the MCP server. Listener runs a [Model Context Protocol](https://modelcontextprotocol.io) server, so AI assistants can read a workspace and make guarded changes in plain language: “Which shows grew fastest last quarter?”, “Build clusters for this week’s episodes”, “Create a campaign from this insertion order”. The server exposes more than 80 tools over the same metric code as the API and the apps, so an assistant quotes the same numbers your dashboard shows, with the window, coverage and freshness behind them. | | | | --- | --- | | Server URL | `https://mcp.listener.com/mcp` | | Transport | Streamable HTTP | | Authentication | Sign in with Listener (OAuth), or an API key | `https://api.listener.com/mcp` serves the same server. ## Sign in with Listener (recommended) Clients that support MCP authorization discover Listener’s OAuth server automatically: add the server URL **without** a key, and the client opens a Listener sign-in and consent screen. You choose the workspace and whether the assistant may only read (“Full read access”) or also write (“Full write access”). Revoke it any time under **Settings → Developers → Authorized apps**. ### Claude (claude.ai and Claude Desktop) 1. Open **Settings → Connectors → Add custom connector**. 2. Name it **Listener** and paste `https://mcp.listener.com/mcp`. 3. Choose **Connect** and sign in with your Listener account. ### ChatGPT 1. Turn on **Developer mode** under **Settings → Connectors → Advanced**. 2. Create a connector with the URL `https://mcp.listener.com/mcp` and OAuth authentication. 3. Sign in with Listener when prompted. ### Claude Code ```bash claude mcp add --transport http listener https://mcp.listener.com/mcp ``` Then run `/mcp` inside Claude Code and choose **Authenticate** for `listener`. ## Connect with an API key For clients without OAuth support, or for automation, use an API key from **Settings → Developers**. Pick the **Read** preset unless you want the assistant to make changes. ```bash claude mcp add --transport http listener https://mcp.listener.com/mcp \ --header "Authorization: Bearer lsk_…" ``` ```json { "mcpServers": { "listener": { "url": "https://mcp.listener.com/mcp", "headers": { "Authorization": "Bearer lsk_…" } } } } ``` ```text https://mcp.listener.com/mcp?key=lsk_… ``` The Cursor entry goes in `~/.cursor/mcp.json` (all projects) or `.cursor/mcp.json` (one project). The **URL form** puts the key in the connector URL, for clients that can neither do OAuth nor send headers. It is accepted on the MCP endpoint only, and the key is stripped from the URL before anything is logged. Prefer OAuth or the header form wherever you can, and never share a URL that contains a key. ## What the assistant can do | Area | Examples | | --- | --- | | Workspace | List shows, plan, team, integrations and their freshness. | | Analytics | Reach summaries, timelines, rankings, comparisons, platform splits, dashboard KPIs. | | Episodes and clusters | Search episodes and transcripts, build clusters with AI, add posts, review suggestions. | | Campaigns | Create and update campaigns and slots, link clusters, check delivered CPM and air checks. | | Reports and sponsors | Publish shared reports, match sponsors, find bundle opportunities. | Write tools follow the same rules as the API: they preview with `dry_run`, and deletes, publishing and metered refreshes need explicit confirmation. A **Read** key or a read-only grant cannot change anything. ## Scopes An assistant signed in with OAuth needs `workspace:read`, plus `workspace:write` for write tools. With an API key, the key’s preset decides. ## Troubleshooting - **The client never shows a sign-in screen.** It may not support MCP authorization yet. Use the header or URL form with an API key. - **“Unauthorized” after it worked before.** The key was rotated or revoked, or the grant was removed under Authorized apps. Reconnect. - **Numbers look low.** Ask the assistant for the freshness of each platform. See [Data freshness](https://developers.listener.com/docs/freshness). --- # Metrics glossary > Reach categories, unique-account mode, EMV, CPM and the other numbers the API returns. The API reports the same numbers as the Listener apps, computed by the same code. This page defines them. ## Reach **Reach** is the number of views, plays, downloads or impressions content received in a window, depending on what each platform reports. | Category | Platforms | | --- | --- | | `youtube` | YouTube views, long-form and Shorts. | | `audio` | Spotify, Apple Podcasts, Simplecast, Transistor, Megaphone, Art19, Omny Studio, Captivate, Acast, and Substack podcast downloads or plays. | | `social` | Instagram, TikTok, Facebook, X (Twitter), LinkedIn, Threads, Beehiiv and Substack posts. | **Total reach** = `youtube` + `audio` + `social`. Reach summaries also report a **newsletter** figure: Beehiiv and Substack newsletter opens. Those platforms already count in `social` and `total`; the separate figure lets you show newsletters on their own. ### Account-level and cluster-level reach There are two kinds of reach in Listener, and they answer different questions. Say which one you are quoting. - **Account-level** reach reads each platform account’s own daily series. It is what the Analytics page shows, and what `/v1/analytics/*` returns. - **Cluster-level** reach sums the reach of the clips, posts and episodes attached to an **episode cluster**. It is what the Clusters and Campaign pages show, and what cluster and campaign endpoints return. ### Clusters An **episode cluster** groups an episode with every clip and post made from it across platforms. A **merged** cluster combines several episode clusters; a merged cluster and its source clusters are never both counted in a total. A **manual** cluster has no source episode. ## Unique-account mode In a network, one Instagram or TikTok account can belong to several shows. By default (`social_count_mode=all`) those accounts count once per show. With `social_count_mode=unique`, shared accounts count **once** in network totals, and `suppressed_platforms_by_show` says which show’s copy was skipped. Unique mode applies to overview totals, timelines and platform distributions. It never applies to per-show rankings or comparisons, where each show keeps its own accounts. ## EMV (earned media value) EMV estimates what the attention an ad or integration earned would cost to buy: ```text EMV = long-form views × long-form CPM ÷ 1000 + short-form views × short-form CPM ÷ 1000 + likes × like rate + comments × comment rate ``` | Rate | Default | | --- | --- | | Long-form CPM | $25 | | Short-form CPM | $20 | | Per like | $0.25 | | Per comment | $1.25 | When several rate settings apply, the most specific wins: 1. The campaign’s EMV settings 2. Show overrides 3. The cluster’s EMV rates 4. The network’s default EMV (`PUT /v1/network/default-emv`) 5. The defaults above ## CPM - **Expected CPM** = budget ÷ expected impressions × 1000. - **Delivered CPM** is only computed once a campaign has delivered at least **25%** of its expected impressions. Before that it is `null`, because early delivered CPM swings wildly and misleads. ## Freshness Results built from platform data carry `freshness`: for each platform, when its data last advanced (`last_updated`), how many hours ago (`age_hours`) and whether that is more than 36 hours (`stale`). `stale_platforms` lists the stale ones. See [Data freshness](https://developers.listener.com/docs/freshness). ## Computed at Analytics responses include `computed_at` and `cache_age_seconds`. Reads are cached for up to two minutes, so two calls a few seconds apart return identical numbers. --- # OAuth 2.0 > Let Listener users connect your app with “Sign in with Listener”, using the authorization code flow with PKCE. Use OAuth when your product acts for **other people’s** Listener workspaces. Each user signs in with their own account, sees exactly what your app is asking for, and can revoke it at any time. You receive a short-lived access token scoped to their workspace, never their password or an API key. Listener runs an OAuth 2.1 authorization server at `https://api.listener.com`. It follows the parts of the spec that matter for security: authorization code only, PKCE required for every client, refresh-token rotation, and exact redirect URI matching. ## When to use OAuth | Situation | Use | | --- | --- | | A script or backend that reads your own workspace | An [API key](https://developers.listener.com/docs/authentication) | | A SaaS product many Listener customers connect to | OAuth, confidential client | | A browser, desktop, mobile or CLI app | OAuth, public client (PKCE, no secret) | | An AI assistant connecting through MCP | OAuth via [discovery](#discovery-and-dynamic-registration), handled by the client | ## Endpoints | Endpoint | Purpose | | --- | --- | | `GET /oauth/authorize` | Start the flow in the user’s browser. | | `POST /oauth/token` | Exchange a code, or refresh a token. | | `POST /oauth/revoke` | Revoke an access or refresh token ([RFC 7009](https://www.rfc-editor.org/rfc/rfc7009)). | | `POST /oauth/introspect` | Check a token’s state; confidential clients, own tokens only ([RFC 7662](https://www.rfc-editor.org/rfc/rfc7662)). | | `GET /oauth/userinfo` | The user and workspace behind an access token. | | `POST /oauth/register` | Dynamic client registration ([RFC 7591](https://www.rfc-editor.org/rfc/rfc7591)). | | `GET /.well-known/oauth-authorization-server` | Server metadata ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)). Also at `/.well-known/openid-configuration`. | ## Register your app Create an OAuth app in **Settings → Developers → OAuth apps** in either app. You provide: - **Name, logo and homepage**: shown on the consent screen. Add privacy policy and terms URLs if you have them. - **Redirect URIs**: exact string matches, no fragments. Use `https://` URIs; for local development, loopback URIs on `localhost`, `127.0.0.1` or `[::1]` are allowed, and for those any port matches (RFC 8252), so a CLI can pick a free port. - **Client type**: *confidential* if you can keep a secret on a server, *public* for browser, mobile, desktop and CLI apps. You get a client id (`lcid_…`) and, for confidential clients, a client secret (`lcsk_…`). The secret is shown once; rotating it later keeps the previous secret valid for 24 hours. A workspace can have up to 25 OAuth apps. Deleting an app revokes every grant and token issued to it. ## Authorization code with PKCE ### Create a code verifier and challenge For every sign-in, generate a random `code_verifier` and derive the `code_challenge` from it with SHA-256. Only `S256` is accepted, and PKCE is required even for confidential clients. Also generate a random `state` to protect against CSRF. Store both in the user’s session. ```javascript import crypto from 'node:crypto'; const codeVerifier = crypto.randomBytes(32).toString('base64url'); const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url'); const state = crypto.randomBytes(16).toString('base64url'); // Save codeVerifier and state in the user's session. ``` ```python import base64, hashlib, secrets code_verifier = secrets.token_urlsafe(32) code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).rstrip(b"=").decode() state = secrets.token_urlsafe(16) # Save code_verifier and state in the user's session. ``` ### Send the user to Listener Redirect the browser to the authorization endpoint: ```http GET https://api.listener.com/oauth/authorize ?response_type=code &client_id=lcid_… &redirect_uri=https%3A%2F%2Fexample.com%2Fcallback &scope=analytics%3Aread%20shows%3Aread%20offline_access &state=… &code_challenge=… &code_challenge_method=S256 ``` | Parameter | | | --- | --- | | `response_type` | Always `code`. | | `client_id` | Your client id, or a [client ID metadata document](#client-id-metadata-documents) URL. | | `redirect_uri` | One of your registered redirect URIs, exactly. | | `scope` | Space-separated [scopes](https://developers.listener.com/docs/scopes). Add `offline_access` to receive a refresh token. If omitted, it defaults to `workspace:read workspace:write offline_access`, so always send it. | | `state` | Your random value (up to 2048 characters); it comes back unchanged. | | `code_challenge` | The S256 challenge from step 1. Required. | | `code_challenge_method` | `S256`. Required. | | `resource` | Optional ([RFC 8707](https://www.rfc-editor.org/rfc/rfc8707)): `https://api.listener.com`, `https://api.listener.com/v1` or `https://api.listener.com/mcp`. Anything else fails with `invalid_target`. | | `prompt` | Optional. `consent` shows the consent screen even if the user approved before. `none` is not supported and returns `interaction_required`. | | `login_hint` / `account` | Optional. `account=network` or `account=creator` skips the product chooser. | The user picks **Listener** (for podcasters and creators) or **Listener Networks** (for networks, agencies and brands), which is remembered for next time, signs in if needed, and sees a consent screen in that app naming your app, the workspace being shared and each scope you asked for. ### Handle the callback Listener redirects back to your `redirect_uri` with `code`, `state` and `iss` (the issuer, `https://api.listener.com`, per [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207)). Check that `state` matches the one you stored and that `iss` is what you expect. If the user declined, you get `error=access_denied` instead. The code is single-use and expires after 60 seconds; presenting it twice revokes the tokens it produced. ### Exchange the code for tokens ```bash curl https://api.listener.com/oauth/token \ -u "lcid_…:lcsk_…" \ -d grant_type=authorization_code \ -d code="$CODE" \ -d redirect_uri=https://example.com/callback \ -d code_verifier="$CODE_VERIFIER" ``` ```javascript const res = await fetch('https://api.listener.com/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: 'Basic ' + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64'), }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: 'https://example.com/callback', code_verifier: codeVerifier, }), }); const tokens = await res.json(); ``` ```python res = requests.post( "https://api.listener.com/oauth/token", auth=(CLIENT_ID, CLIENT_SECRET), data={ "grant_type": "authorization_code", "code": code, "redirect_uri": "https://example.com/callback", "code_verifier": code_verifier, }, ) tokens = res.json() ``` Confidential clients authenticate with HTTP Basic (`client_secret_basic`) or by sending `client_id` and `client_secret` in the form body (`client_secret_post`). Public clients send only `client_id`. ```json { "access_token": "lat_…", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "lrt_…", "scope": "analytics:read shows:read offline_access", "workspace": { "id": "nW7aQ2kL…", "name": "Northwind Audio", "kind": "network" } } ``` `refresh_token` is present only when `offline_access` was granted. Store tokens per workspace: the same user can connect more than one. ### Call the API Use the access token exactly like an API key: ```bash curl https://api.listener.com/v1/analytics/reach?time_range=30d \ -H "Authorization: Bearer lat_…" ``` ## Scopes Ask for the least you need; users are more likely to approve a short list. The consent screen shows each scope’s title and description, and users can see what they granted later under **Authorized apps**. | You want to | Request | | --- | --- | | Read reach and rankings | `analytics:read shows:read` | | Sync campaigns both ways | `campaigns:read campaigns:write` | | Build a reporting product | `analytics:read shows:read reports:read` | | Everything, read-only | `workspace:read` (shown as “Full read access”) | | Keep working after the user leaves | add `offline_access` | The full catalog is in [Scopes](https://developers.listener.com/docs/scopes). ## Refreshing tokens Access tokens last one hour. Exchange a refresh token for a new pair before or after expiry: ```bash curl https://api.listener.com/oauth/token \ -u "lcid_…:lcsk_…" \ -d grant_type=refresh_token \ -d refresh_token=lrt_… ``` Refresh tokens **rotate**: every refresh returns a new refresh token and invalidates the one you sent. Always store the newest. If an old, already-rotated refresh token is ever used again, Listener treats it as a leak and revokes the whole token family, so the user has to connect again. Refresh tokens are valid for 60 days from their last use, up to one year in total. > **Tip:** If several workers share one connection, refresh behind a lock. Two workers refreshing with the same token at once means the second one presents a rotated token and revokes the family. ## Revoking tokens Revoke a token when a user disconnects your app. The endpoint always answers `200`, even for unknown tokens. ```bash curl https://api.listener.com/oauth/revoke \ -u "lcid_…:lcsk_…" \ -d token=lrt_… ``` Users can also revoke your app themselves under **Settings → Developers → Authorized apps**, which kills every token of that grant. Revocation reaches the API within 30 seconds; after that, calls return `401 credential_revoked`. Treat that as “the user disconnected” and stop retrying. ## Introspection and userinfo Confidential clients can ask about their own tokens with `POST /oauth/introspect` (`token=…`). The response follows RFC 7662: `active`, and for active tokens fields such as `scope`, `client_id` and `exp`. `GET /oauth/userinfo` with an access token returns the signed-in user and the workspace they shared. Use it to show “Connected to Northwind Audio” in your settings page, or as the identity step of “Sign in with Listener”. ## Discovery and dynamic registration Clients that discover servers at runtime, such as AI assistants connecting to MCP, do not need to be pre-registered. - **Metadata.** `GET /.well-known/oauth-authorization-server` lists every endpoint and supported value. Resource metadata ([RFC 9728](https://www.rfc-editor.org/rfc/rfc9728)) is at `/.well-known/oauth-protected-resource`, `/.well-known/oauth-protected-resource/v1` and `/.well-known/oauth-protected-resource/mcp`. The MCP endpoint’s `401` points to the last one in its `WWW-Authenticate` header. Both `api.listener.com` and `mcp.listener.com` serve the same documents, each naming its own host as the issuer. - **Dynamic client registration.** `POST /oauth/register` with `client_name` and `redirect_uris` creates a **public** client (no secret, `token_endpoint_auth_method: none`) unless you ask for `client_secret_basic`. Redirect URIs must be `https://` or loopback. Dynamically registered clients are marked unverified. ### Client ID metadata documents Instead of registering, a client can use an `https://` URL as its `client_id`. Listener fetches that URL (5-second timeout, 64 KB limit, no redirects, cached for an hour), checks that the document’s `client_id` equals the URL and that your `redirect_uri` is listed in its `redirect_uris`, and treats you as a public client (`token_endpoint_auth_method` must be `none`). ```json { "client_id": "https://example.com/oauth/client.json", "client_name": "Example Analytics", "redirect_uris": ["https://example.com/callback"], "logo_uri": "https://example.com/logo.png" } ``` ## “Sign in with Listener” guidelines - **Say where they are going.** Label the button “Sign in with Listener” or “Connect Listener”, with the Listener mark. Do not imply Listener endorses your product. - **Ask late, ask small.** Start the flow when the user reaches a feature that needs Listener data, and request only the scopes that feature uses. Ask for more later with a new authorization request. - **Show the result.** After connecting, show which workspace is connected (from `workspace.name` in the token response) and a **Disconnect** action that calls `/oauth/revoke`. - **Handle “no”.** `access_denied` is a normal outcome. Return the user to where they were with a short explanation, not an error page. ## Errors Errors in the authorization request are sent back to your `redirect_uri` as `error` and `error_description` query parameters, with your `state`: | `error` | Meaning | | --- | --- | | `access_denied` | The user declined. | | `invalid_request` | A required parameter is missing or malformed, often `code_challenge`. | | `invalid_scope` | A requested scope does not exist or is not allowed for this client. | | `invalid_target` | `resource` is not one of the Listener resources. | | `interaction_required` | You sent `prompt=none`; Listener always shows its consent screen. | | `unauthorized_client` | The client may not use this flow. | | `unsupported_response_type` | `response_type` was not `code`. | | `server_error` | Something failed on our side. Try again. | If the `client_id` or `redirect_uri` itself is wrong, Listener cannot safely redirect, so it shows an error page instead. The token, revoke and introspect endpoints are rate limited per IP address (60 requests per minute together; dynamic registration 20 per hour), answering `429` with `Retry-After`. The token endpoint returns standard OAuth JSON errors (`{"error": "invalid_grant", "error_description": "…"}`). `invalid_grant` means the code expired or was used, the `code_verifier` does not match, or the refresh token was rotated or revoked. `invalid_client` means client authentication failed. --- # Pagination > Cursor-based lists with limit, cursor, has_more and next_cursor. Every endpoint that returns many items uses the same list envelope: ```json { "object": "list", "url": "/v1/campaigns", "data": [ { "object": "campaign", "id": "cmpA7Hq2LmX9vRt4BzKe" } ], "has_more": true, "next_cursor": "eyJvIjoyNX0", "meta": { "computed_at": "2026-10-14T09:30:00Z", "cache_age_seconds": 12 } } ``` | Field | | | --- | --- | | `data` | This page of results. | | `has_more` | `true` when another page exists. | | `next_cursor` | Pass it as `cursor` to get the next page. `null` on the last page. | | `meta` | Everything about the list that is not an item: totals, the resolved window, coverage, freshness, or a `note` explaining an empty list. | ## Parameters | Parameter | | | --- | --- | | `limit` | Items per page, 1 to 100. Default 25, or less where an endpoint says so (sponsor matches default to 5). | | `cursor` | The `next_cursor` from the previous page. | A few lists are ranked results rather than pages: search, suggestions and candidate lists take their own `limit` and never return a `next_cursor`. Cursors are opaque. Do not build or edit them; they may change format at any time. Keep the other query parameters the same across pages, or the cursor may point somewhere that no longer makes sense. ## Reading every page ```javascript async function* listAll(path, params = {}) { let cursor; do { const qs = new URLSearchParams({ ...params, limit: '100', ...(cursor ? { cursor } : {}) }); const res = await fetch(`https://api.listener.com${path}?${qs}`, { headers: { Authorization: `Bearer ${process.env.LISTENER_API_KEY}` }, }); const page = await res.json(); yield* page.data; cursor = page.has_more ? page.next_cursor : null; } while (cursor); } for await (const campaign of listAll('/v1/campaigns', { status: 'active' })) { console.log(campaign.id, campaign.title); } ``` ```python def list_all(path, **params): cursor = None while True: query = {**params, "limit": 100, **({"cursor": cursor} if cursor else {})} page = requests.get( f"https://api.listener.com{path}", headers={"Authorization": f"Bearer {os.environ['LISTENER_API_KEY']}"}, params=query, ).json() yield from page["data"] if not page["has_more"]: break cursor = page["next_cursor"] for campaign in list_all("/v1/campaigns", status="active"): print(campaign["id"], campaign["title"]) ``` Paging through a large workspace counts every page against your [rate limit](https://developers.listener.com/docs/rate-limits). Use `limit=100` and filters to keep the number of requests down. --- # Partner integrations > Connect your platform to Listener end to end, for example a podcast host. OAuth sign-in, show identity, social accounts, automatic clusters, webhooks, metrics, co-branded pages and ad-server delivery. This guide walks your platform through a complete Listener integration, for example a podcast host that wants its creators to see how every episode travels across YouTube, Instagram, TikTok and the rest. Each step uses a real endpoint; you can stop after any of them. 1. Creator → Your platform: Clicks “Connect Listener” 2. Your platform → Listener: GET /oauth/authorize (PKCE) 3. Creator → Listener: Signs in or signs up, approves 4. Listener → Your platform: Redirect with code 5. Your platform → Listener: POST /oauth/token 6. Your platform → Listener: PUT /v1/shows/{id}/podcast 7. Your platform → Listener: POST + GET account-discovery 8. Your platform → Listener: PUT integrations/{platform} 9. Listener → Your platform: 400 oauth_required + connect_url 10. Your platform → Creator: Sends creator to connect_url 11. Your platform → Listener: PUT auto-build · POST /v1/cluster-builds 12. Your platform → Listener: POST /v1/webhook-endpoints 13. Listener → Your platform: cluster.* · integration.* webhooks 14. Your platform → Listener: GET episodes/lookup · GET cluster 15. Your platform → Listener: PATCH cluster co_brand 16. Your platform → Listener: POST campaigns/{id}/external-delivery ## Before you start - **An OAuth app.** Create one under **Settings → Developers → OAuth apps** with your redirect URI. See [OAuth 2.0](https://developers.listener.com/docs/oauth#register-your-app). - **Scopes.** This guide uses `shows:write episodes:read clusters:write campaigns:write webhooks:write offline_access`. Each `:write` scope includes its `:read` twin. Add `analytics:read` if you also show account-level analytics. Ask only for what you use; see [Scopes](https://developers.listener.com/docs/scopes). - **One connection per workspace.** A token belongs to one Listener workspace: a creator’s show, or a network with many shows. Store tokens per workspace, keyed by `workspace.id` from the token response. ## 1. Add a “Connect Listener” button When the creator clicks it, send them to Listener’s authorization endpoint with PKCE. Use `account=creator` to open the Listener creator app directly; leave it out to let people choose between Listener and Listener Networks. ```javascript import crypto from 'node:crypto'; export function connectListenerUrl(session) { const verifier = crypto.randomBytes(32).toString('base64url'); const state = crypto.randomBytes(16).toString('base64url'); session.listener = { verifier, state }; const params = new URLSearchParams({ response_type: 'code', client_id: process.env.LISTENER_CLIENT_ID, redirect_uri: 'https://your-platform.example/integrations/listener/callback', scope: 'shows:write episodes:read clusters:write campaigns:write webhooks:write offline_access', state, code_challenge: crypto.createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256', account: 'creator', }); return `https://api.listener.com/oauth/authorize?${params}`; } ``` ## 2. Sign in with Listener The creator signs in to Listener. Someone without an account can sign up on the same screen and comes straight back to the consent screen, which names your app, the workspace being shared and every scope. After they approve, Listener redirects to your callback with `code` and `state`. Exchange the code: ```javascript const res = await fetch('https://api.listener.com/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: 'Basic ' + Buffer.from(`${process.env.LISTENER_CLIENT_ID}:${process.env.LISTENER_CLIENT_SECRET}`).toString('base64'), }, body: new URLSearchParams({ grant_type: 'authorization_code', code: query.code, redirect_uri: 'https://your-platform.example/integrations/listener/callback', code_verifier: session.listener.verifier, }), }); const tokens = await res.json(); // tokens.workspace = { id, name, kind } ; store access_token, refresh_token and expiry per workspace ``` Then find the show. A creator workspace has one; a network can have many, so let the person pick when there is more than one. ```bash curl https://api.listener.com/v1/shows \ -H "Authorization: Bearer $LAT" ``` Every request below sends `Authorization: Bearer lat_…`. Refresh the access token before it expires after one hour (see [Refreshing tokens](https://developers.listener.com/docs/oauth#refreshing-tokens)). ## 3. Set the podcast identity Tell Listener which podcast the show is, so episodes and analytics line up with yours. Send the ids you know; only the fields you send change, and platform URLs are accepted too. ```bash curl -X PUT https://api.listener.com/v1/shows/$SHOW_ID/podcast \ -H "Authorization: Bearer $LAT" \ -H "Content-Type: application/json" \ -d '{ "rss_url": "https://feeds.your-platform.example/the-long-game.xml", "apple_podcasts_id": "https://podcasts.apple.com/us/podcast/the-long-game/id1580012345", "spotify_id": "https://open.spotify.com/show/5aBcDeFgHiJkLmNoPqRsTu" }' ``` These ids are references for matching. They don’t connect Spotify or Apple Podcasts analytics; those need the creator (step 4). ## 4. Find and connect social accounts Run account discovery to find the show’s public accounts on YouTube, Instagram, TikTok, X, Facebook, Threads and LinkedIn. Each run is metered and capped per show, so it needs `confirm: true`. ```bash curl -X POST https://api.listener.com/v1/shows/$SHOW_ID/account-discovery \ -H "Authorization: Bearer $LAT" \ -H "Content-Type: application/json" \ -d '{"confirm": true}' ``` If the response says `running`, read the result a minute later with `GET /v1/shows/{show_id}/account-discovery`. Each candidate has a handle, profile URL, follower count, a `confidence` (`high`, `medium`, `low`), a reason, its current connection state and `connect_via`: - **`api`**: you can connect it yourself. Show the candidate to the creator, then confirm it with `PUT /v1/shows/{show_id}/integrations/{platform}`. - **`app_sign_in`**: a person has to sign in to the platform. Send the creator to `verify_url`. ```javascript async function connectAccount(showId, platform, account) { const res = await fetch(`https://api.listener.com/v1/shows/${showId}/integrations/${platform}`, { method: 'PUT', headers: { Authorization: `Bearer ${lat}`, 'Content-Type': 'application/json' }, body: JSON.stringify(account), // { handle: 'thelonggame' } or { url: 'https://www.linkedin.com/company/…' } }); const body = await res.json(); if (res.status === 400 && body.error.code === 'oauth_required') { return { needsCreator: true, url: body.error.connect_url }; } if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`); return body; } ``` | Platform | How it connects | | --- | --- | | `instagram`, `tiktok`, `twitter`, `facebook`, `threads` | `handle` or profile `url` | | `linkedin` | profile `url` | | `simplecast` | `api_key`, optionally `podcast_id`. The key is never returned. | | `youtube`, `instagramBusiness`, `spotify`, `applePodcasts` | Sign-in only. The API answers `400 oauth_required` with `connect_url`, the show’s Integrations page in Listener; send the creator there. | A successful connection seeds the account, runs its first refresh and emits `integration.connected`. Connecting a platform that is already connected to another account replaces it, and the response warns you. Disconnect with `DELETE /v1/shows/{show_id}/integrations/{platform}?confirm=true`. ## 5. Build clusters automatically An episode cluster groups an episode with every clip and post made from it. Turn on auto-build and Listener’s hourly run builds clusters for new episodes from the last 14 days: ```bash curl -X PUT https://api.listener.com/v1/shows/$SHOW_ID/auto-build \ -H "Authorization: Bearer $LAT" \ -H "Content-Type: application/json" \ -d '{"mode": "suggest"}' ``` `suggest` records found posts for the creator to review; `auto` adds the posts Listener is sure about straight to the cluster. `GET /v1/shows/{show_id}/auto-build` shows the mode and recent per-episode statuses. To build the moment you publish instead of waiting for the hourly run, start a build for that episode: ```bash curl https://api.listener.com/v1/cluster-builds \ -H "Authorization: Bearer $LAT" \ -H "Content-Type: application/json" \ -d '{"show_id": "'$SHOW_ID'", "scope": "episodes", "episode_ids": ["'$EPISODE_ID'"], "mode": "suggest"}' ``` Builds run in the background. Follow one with `GET /v1/cluster-builds/{job_id}`, or wait for the webhook in the next step. ## 6. Subscribe to webhooks Create an endpoint in the creator’s workspace with their token. List event types explicitly; `*` also works but includes types you may not handle. ```bash curl https://api.listener.com/v1/webhook-endpoints \ -H "Authorization: Bearer $LAT" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-platform.example/webhooks/listener", "enabled_events": [ "cluster.created", "cluster.updated", "cluster.nodes_added", "cluster_build.completed", "integration.connected", "integration.disconnected", "integration.stale" ], "description": "Your platform" }' ``` Store the returned `secret` per workspace and [verify every delivery](https://developers.listener.com/docs/webhooks/signatures). Changes made in the Listener apps reach you within about five minutes; changes made through the API arrive within seconds. See [Webhooks](https://developers.listener.com/docs/webhooks). ## 7. Map your episode ids Your platform has its own episode ids. Look up the Listener episode behind them with any identifier you have: the RSS item `guid`, `apple_episode_id`, `spotify_episode_id`, `simplecast_episode_id`, or `title` plus `published_on`. ```bash curl -G https://api.listener.com/v1/shows/$SHOW_ID/episodes/lookup \ -H "Authorization: Bearer $LAT" \ --data-urlencode "guid=your-platform-episode-8a1f3c2e" \ --data-urlencode "published_on=2026-10-14" ``` `best` is set only for an unambiguous `exact` (an id or guid) or `high` (same title within a day) match; otherwise read `matches` and their `confidence`. Store the Listener `episode_id` and `cluster_id` next to your episode. ## 8. Read cluster metrics ```bash curl https://api.listener.com/v1/shows/$SHOW_ID/clusters/$CLUSTER_ID \ -H "Authorization: Bearer $LAT" ``` The cluster carries reach by category (YouTube, audio, social) and by format, engagement, every post with its platform numbers, and its public URL. Show freshness next to the numbers: when the cluster was last refreshed and any posts that failed to refresh. Cluster posts refresh when their public page is viewed and when you ask; `POST /v1/shows/{show_id}/clusters/{cluster_id}/refresh` with `confirm: true` refreshes every post now (it needs the `refresh:write` scope and spends data-provider credits). See [Data sources, freshness & retention](https://developers.listener.com/docs/data-sources#refresh-schedule) and [Metrics glossary](https://developers.listener.com/docs/metrics). ## 9. Co-brand the share page Every cluster has a public page the creator can share. Add your brand next to the show’s: ```bash curl -X PATCH https://api.listener.com/v1/shows/$SHOW_ID/clusters/$CLUSTER_ID \ -H "Authorization: Bearer $LAT" \ -H "Content-Type: application/json" \ -d '{"customization": {"co_brand": {"name": "Your Platform", "logo_url": "https://your-platform.example/logo.png", "href": "https://your-platform.example"}}}' ``` `name` is required (1 to 80 characters); `logo_url` and `href` must be `https://`. Send `"co_brand": null` to remove it. Other customization fields are kept. ## 10. Push ad-server delivery onto campaigns If your platform serves ads, record delivered impressions (and optionally listeners) on the creator’s Listener campaigns, per day or per period, per slot or show: ```bash curl https://api.listener.com/v1/campaigns/$CAMPAIGN_ID/external-delivery \ -H "Authorization: Bearer $LAT" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: delivery-$CAMPAIGN_ID-2026-10-14" \ -d '{ "rows": [ { "source": "other", "label": "The Long Game mid-roll", "slot_id": "'$SLOT_ID'", "date": "2026-10-14", "impressions": 18250, "listeners": 11900 } ] }' ``` - `source` is one of the values the API accepts: `adswizz`, `audioserve` or `other`. - Send either `date` or `start_date` and `end_date`. - Re-sending the same source, slot, show and day (or period) updates that row instead of adding a duplicate, so a daily sync can safely resend. - Up to 500 rows per request and 2,000 per campaign. - Read them back with `GET /v1/campaigns/{campaign_id}/external-delivery`, which includes totals per source. Ad-server counts stay separate: they are shown next to Listener’s numbers and never added to Listener reach, impressions or EMV. ## What data is aggregate-only Listener works with aggregate counts: per platform, per day, per post or per episode. The API never returns listener-level data: no individual plays or listen events, IP addresses, device ids or user agents. External delivery takes aggregates too (impressions and listeners per day or period); don’t send per-listener records. ## Limits and versioning - **Rate limits** apply per OAuth connection, so every creator who connects gets their own budget: 120 requests a minute, 30 writes a minute, 10 in flight; analytics calls count double. See [Rate limits](https://developers.listener.com/docs/rate-limits). - **Metered calls:** account discovery and platform or cluster refreshes spend credits, need `confirm: true` and may be capped. Don’t run them on a timer for every show. - **Retries:** send an `Idempotency-Key` on writes so a retried request never applies twice. See [Idempotency](https://developers.listener.com/docs/idempotency). - **Version:** send `Listener-Version: 2026-10-14` on every request so new versions never change behaviour under you. See [Versioning](https://developers.listener.com/docs/versioning). - **Test safely:** every write accepts `dry_run: true`. See [Testing with dry_run](https://developers.listener.com/docs/testing). - **Disconnects:** when a creator removes your app under **Authorized apps**, calls start failing with `401 credential_revoked`. Treat that as a disconnect and stop syncing that workspace. --- # Quickstart > Create a key, make your first request and read your reach, in about two minutes. You need a Listener account with at least one show. Everything below works the same for a Listener creator account and a Listener Networks workspace. ### Create an API key Open **Settings → Developers** in the app you use: - Listener Networks: [network.listener.com/app/developers](https://network.listener.com/app/developers) - Listener: [listener.com/app/developers](https://listener.com/app/developers) Choose **Create key**, give it a name you will recognise later (for example “Weekly reporting script”) and pick the **Read** preset. Copy the key when it is shown: it starts with `lsk_` and is only displayed once. > **Warning:** Treat the key like a password. Keep it on your server or in a secrets manager, never in a browser, a mobile app or a public repository. See [Authentication](https://developers.listener.com/docs/authentication). ### Make your first request Export the key, then ask the API who you are. `GET /v1/me` returns the workspace the key belongs to, its shows, data freshness and the key itself. ```bash export LISTENER_API_KEY="lsk_…" ``` ```bash curl https://api.listener.com/v1/me \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ```javascript // Node 18+ (built-in fetch). Save as me.mjs and run: node me.mjs const res = await fetch('https://api.listener.com/v1/me', { headers: { Authorization: `Bearer ${process.env.LISTENER_API_KEY}` }, }); const me = await res.json(); if (!res.ok) throw new Error(`${me.error.code}: ${me.error.message}`); console.log(me.network_name, me.kind, me.shows.length, 'shows'); ``` ```python # pip install requests import os import requests res = requests.get( "https://api.listener.com/v1/me", headers={"Authorization": f"Bearer {os.environ['LISTENER_API_KEY']}"}, ) res.raise_for_status() me = res.json() print(me["network_name"], me["kind"], len(me["shows"]), "shows") ``` A successful response looks like this (trimmed): ```json { "object": "workspace", "kind": "network", "network_name": "Northwind Audio", "plan": "network", "show_count": 12, "shows": [ { "id": "8sQe1v0x2kTq", "name": "Dugout Daily", "handle": "dugoutdaily", "connected_platforms": ["youtube", "spotify", "instagram"] } ], "stale_platforms": [], "credential": { "object": "credential", "type": "api_key", "label": "Weekly reporting script", "scopes": ["workspace:read"] } } ``` If you see `401 credential_invalid`, the key was copied incompletely. Every error links to its explanation in [Errors](https://developers.listener.com/docs/errors). ### List your shows Show ids are what most other endpoints take. Lists come back in a standard envelope with `data`, `has_more` and `next_cursor` (see [Pagination](https://developers.listener.com/docs/pagination)). ```bash curl -G https://api.listener.com/v1/shows \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -d limit=10 ``` ```javascript const res = await fetch('https://api.listener.com/v1/shows?limit=10', { headers: { Authorization: `Bearer ${process.env.LISTENER_API_KEY}` }, }); const { data: shows, has_more } = await res.json(); for (const show of shows) console.log(show.id, show.name, show.connected_platforms.join(', ')); ``` ```python res = requests.get( "https://api.listener.com/v1/shows", headers={"Authorization": f"Bearer {os.environ['LISTENER_API_KEY']}"}, params={"limit": 10}, ) for show in res.json()["data"]: print(show["id"], show["name"], ", ".join(show["connected_platforms"])) ``` ### Read your reach `GET /v1/analytics/reach` returns the KPI cards from the Analytics page: `total`, `youtube`, `audio`, `social` and `newsletter` reach for a window, each with `current`, `previous` and `growth`. Leave out `show_ids` for the whole workspace, or pass a subset (repeat the parameter or comma-separate the ids). ```bash curl -G https://api.listener.com/v1/analytics/reach \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -d time_range=30d ``` ```javascript const params = new URLSearchParams({ time_range: '30d' }); const res = await fetch(`https://api.listener.com/v1/analytics/reach?${params}`, { headers: { Authorization: `Bearer ${process.env.LISTENER_API_KEY}` }, }); const reach = await res.json(); const { total } = reach; console.log(`Reach ${reach.window.start} to ${reach.window.end}:`, total.current); console.log('Growth:', total.growth === null ? '—' : `${total.growth}%`); ``` ```python res = requests.get( "https://api.listener.com/v1/analytics/reach", headers={"Authorization": f"Bearer {os.environ['LISTENER_API_KEY']}"}, params={"time_range": "30d"}, ) reach = res.json() total = reach["total"] print(f"Reach {reach['window']['start']} to {reach['window']['end']}:", total["current"]) print("Growth:", "—" if total["growth"] is None else f"{total['growth']}%") ``` Two fields deserve attention from day one: - `growth` is `null` when the previous window has less than half its days covered by data. Show it as a dash (`—`), never as 0. See [Time windows](https://developers.listener.com/docs/time-windows). - `freshness` and `stale_platforms` tell you when a platform has not updated for more than 36 hours. A suspiciously low number is usually a stale connection, not a lost audience. See [Data freshness](https://developers.listener.com/docs/freshness). ## Next steps - [Authentication](https://developers.listener.com/docs/authentication): Scopes, key rotation and keeping credentials safe. - [API reference](https://developers.listener.com/api): Every endpoint, with a Try it console. - [Webhooks](https://developers.listener.com/docs/webhooks): Get notified instead of polling. - [Metrics glossary](https://developers.listener.com/docs/metrics): What reach, EMV and CPM mean here. --- # Rate limits > Per-credential request budgets, the headers that report them, and how to back off. Limits apply per credential: each API key, and each OAuth connection (one user’s grant to one app), has its own budget. | Limit | Budget | | --- | --- | | All requests | 120 per minute | | Writes (`POST`, `PATCH`, `PUT`, `DELETE`) | 30 per minute | | Concurrent requests | 10 in flight | Analytics endpoints (`/v1/analytics/*` and other computed reports) cost **two** requests each, because they do more work. ## Headers Every response reports where you stand, using the IETF `RateLimit-*` headers (with `X-RateLimit-*` aliases for older clients): ```http RateLimit-Limit: 120 RateLimit-Remaining: 87 RateLimit-Reset: 23 ``` `RateLimit-Reset` is the number of **seconds** until the budget refills, not a timestamp. ## When you hit a limit The API answers `429` with code `rate_limited` and a `Retry-After` header in seconds. Wait that long, then retry. Retrying sooner only fails again. ```json { "error": { "type": "rate_limit_error", "code": "rate_limited", "message": "Rate limit exceeded. Retry in 12 seconds.", "doc_url": "https://developers.listener.com/docs/errors#rate_limited", "request_id": "req_8f2LkQ0aZ1" } } ``` ## Staying under the limits - **Page big.** Use `limit=100` on lists. - **Ask once for many shows.** Analytics endpoints take `show_ids`; one request for ten shows beats ten requests. - **Cache.** Analytics are themselves cached for up to two minutes, so polling faster than that returns the same numbers. Use `If-None-Match` with the `ETag` to make unchanged polls cheap. - **Subscribe instead of polling.** [Webhooks](https://developers.listener.com/docs/webhooks) tell you when data changes; `analytics.refreshed` fires when a platform’s numbers advance. - **Limit concurrency** to a handful of parallel requests per credential. Need more headroom for a real workload? Email [tech@listener.com](mailto:tech@listener.com) with your use case and expected volume. --- # Requests & responses > Hosts, formats, ids, timestamps, headers, caching and CORS. The conventions every endpoint shares. ## Base URL All REST endpoints live under `https://api.listener.com/v1`. Every request must use HTTPS. ## Format - Request and response bodies are JSON, UTF-8, with `snake_case` field names. - Requests with a body send `Content-Type: application/json`. Bodies are limited to 1 MB (`413 body_too_large`). - `GET` parameters go in the query string. Lists of ids are comma-separated: `show_ids=8sQe1v0x2kTq,k2LmP0aZ9qWe`. ## Objects and ids Every resource carries an `object` field naming its type and an `id`: ```json { "object": "campaign", "id": "cmpA7Hq2LmX9vRt4BzKe", "title": "Acme Coffee Q4 host reads", "status": "active" } ``` Workspace resources (shows, campaigns, clusters, reports) keep the ids the apps use. Objects that only exist in the API use prefixed ids so you can tell them apart at a glance: | Prefix | Object | | --- | --- | | `we_` | Webhook endpoint | | `evt_` | Event | | `wd_` | Webhook delivery | | `req_` | Request | | `whsec_` | Webhook signing secret | | `lsk_` | API key | | `lcid_` / `lcsk_` | OAuth client id / client secret | | `lat_` / `lrt_` | OAuth access token / refresh token | ## Dates and times Timestamps are ISO 8601 strings in UTC: `2026-10-14T09:30:00Z`. Calendar dates are `YYYY-MM-DD`. Analytics days are UTC calendar days (see [Time windows](https://developers.listener.com/docs/time-windows)). ## Response headers | Header | | | --- | --- | | `Request-Id` | `req_…`, unique per request. Log it; support will ask for it. Also in every error body as `request_id`. | | `Listener-Version` | The API version that served the request. See [Versioning](https://developers.listener.com/docs/versioning). | | `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` | Your rate-limit budget. `X-RateLimit-*` aliases are sent too. See [Rate limits](https://developers.listener.com/docs/rate-limits). | | `ETag` | On `GET` responses, for conditional requests. Weak (`W/"…"`). | | `Cache-Control` | `private, max-age=0` on `GET`, `no-store` otherwise. | | `Idempotent-Replayed` | `true` when a response was replayed for a repeated `Idempotency-Key`. | ## Conditional requests Send the `ETag` you received as `If-None-Match` on the next `GET`. If nothing changed, the API answers `304 Not Modified` with no body, which saves bandwidth when you poll. The ETag ignores `computed_at` and `cache_age_seconds`, so a recomputed but identical result still answers `304`. ```bash curl -i https://api.listener.com/v1/shows \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H 'If-None-Match: "…etag from the last response…"' ``` ## Computed results and caching Analytics are computed from the same documents the apps read. Reads are cached for up to two minutes, and analytics responses say so with `computed_at` and `cache_age_seconds`. Writes made through the API clear the cache for what they touch, so a read right after your own write sees it. ## Writes | Method | Use | | --- | --- | | `POST` | Create something, or perform an action (`/status`, `/refresh`, `/roll-secret`). | | `PATCH` | Partial update: send only the fields you change. | | `PUT` | Replace a settable value (default EMV, episode goals, a slot’s cluster link). | | `DELETE` | Delete. Deletes that cannot be undone need `confirm=true`. | Every write supports [`dry_run`](https://developers.listener.com/docs/testing) and an [`Idempotency-Key`](https://developers.listener.com/docs/idempotency). Successful writes emit [events](https://developers.listener.com/docs/events). Most writes answer with a **write result** that says exactly what was stored: ```json { "object": "write_result", "resource": "campaign", "ok": true, "id": "cmpA7Hq2LmX9vRt4BzKe", "method": "add", "written": { "title": "Acme Coffee Q4 host reads", "status": "upcoming" }, "warnings": [], "urls": { "app": "https://network.listener.com/app/campaigns/cmpA7Hq2LmX9vRt4BzKe", "public": "https://network.listener.com/northwind-audio/campaigns/cmpA7Hq2LmX9vRt4BzKe" }, "data": {} } ``` `written` is the exact payload; server-side timestamps appear as `""`. `warnings` lists side effects worth knowing about (for example other campaigns that share a brand). Webhook endpoint writes are the exception: they return the `webhook_endpoint` or `webhook_delivery` object itself. ## Unknown routes and methods An unknown path answers `404 route_not_found`. A known path with the wrong method answers `405 method_not_allowed` with an `Allow` header listing the methods it supports. ## CORS `/v1`, `/oauth/token` and `/oauth/revoke` answer cross-origin requests from any origin (`Access-Control-Allow-Origin: *`) and expose the headers above. Authentication is by bearer token only; no cookies are involved. This exists for OAuth apps running in a browser. Do not put API keys in browser code (see [Authentication](https://developers.listener.com/docs/authentication#keep-keys-out-of-browsers)). --- # Scopes > Every scope an API key or OAuth grant can carry, and what each one unlocks. Each `/v1` endpoint requires exactly one scope, shown on its card in the [API reference](https://developers.listener.com/api). A credential without it gets `403 scope_missing`, and the error’s `required_scope` names what was missing. | Scope | Name | Grants | | --- | --- | --- | | `workspace:read` | Full read access | Every `*:read` scope. The key preset “Read”. | | `workspace:write` | Full write access | Every `*:read` and `*:write` scope. The key preset “Read & write”. | | `shows:read` | Shows | Workspace, shows, subnetworks, team, plan, integrations, public URLs. | | `shows:write` | Edit shows | Show and network profile edits, default EMV. | | `analytics:read` | Analytics | Analytics, content, categories, hosts, account dedup. | | `episodes:read` | Episodes | Episodes, search, transcripts. | | `episodes:write` | Edit episodes | Tags, goals, Substack downloads. | | `clusters:read` | Clusters | Clusters, cluster reports and timelines, cluster builder reads. | | `clusters:write` | Edit clusters | Create, update, merge, delete, nodes, builds, suggestions, sources. | | `campaigns:read` | Campaigns | Campaigns, brands, air checks, CPM insights. | | `campaigns:write` | Edit campaigns | Campaigns, slots, brands, cluster links. | | `reports:read` | Reports | Shared reports, templates, automations. | | `reports:write` | Edit reports | Create and delete reports and templates, disable automations. | | `sponsors:read` | Sponsors | Sponsor matching, network opportunities, sponsorship packages. | | `library:read` | Library & research | Library files, web research, podcast search, insertion-order parsing. | | `refresh:write` | Refresh data | Platform refresh, cluster refresh, Spotify and Apple data requests (metered). | | `webhooks:read` | Webhooks | Webhook endpoints, deliveries, events. | | `webhooks:write` | Manage webhooks | Create, update, test, roll secrets, delete endpoints. | | `offline_access` | Stay connected | A refresh token on the OAuth token response. | ## How scopes combine - **Umbrella scopes.** `workspace:read` includes every `*:read` scope. `workspace:write` includes every `*:read` and `*:write` scope. These are the **Read** and **Read & write** key presets. - **Write implies read.** `campaigns:write` also grants `campaigns:read`, and so on for each resource. - **`offline_access`** only matters for OAuth: it makes the token response include a refresh token. API keys ignore it. ## Choosing scopes | Job | Scopes | | --- | --- | | Nightly reach export | `analytics:read` `shows:read` | | Campaign delivery dashboard | `campaigns:read` `analytics:read` | | Create campaigns from insertion orders | `campaigns:write` `library:read` | | Episode cluster automation | `clusters:write` `episodes:read` | | Webhook management | `webhooks:write` | | Trigger data refreshes | `refresh:write` (metered, needs `confirm`) | ## MCP sessions AI assistants connected through [MCP](https://developers.listener.com/docs/mcp) with an OAuth token need `workspace:read`, plus `workspace:write` to use write tools. The consent screen shows those as “Full read access” and “Full write access”. --- # SDKs > Official TypeScript and Python clients with typed methods, auto-pagination, retries, idempotency, webhook verification and OAuth helpers. > **Publishing soon:** The SDKs are built and tested, but not on npm or PyPI yet. The install commands below will work once they are published, which the [changelog](https://developers.listener.com/changelog) will announce. Until then, call the API directly; every page shows `fetch` and `requests` examples. | Language | Package | Install | | --- | --- | --- | | TypeScript and JavaScript | `@listener/api` | `npm install @listener/api` | | Python 3.9+ | `listener-api` | `pip install listener-api` | Both SDKs are generated from the same [OpenAPI document](https://developers.listener.com/api) as this reference, so every endpoint has a method and every response field keeps the API’s own `snake_case` name. The reference shows each operation’s SDK method next to its path. - Zero-dependency TypeScript client on the platform `fetch` (Node 18+, Deno, Bun, Workers, edge runtimes, browsers for OAuth public clients). Sync and async Python clients on `httpx`. - Cursor auto-pagination, typed errors that mirror the [error object](https://developers.listener.com/docs/errors), and retries on `429`, `5xx` and network errors. - An `Idempotency-Key` on every write, reused across retries, so a retried write is applied at most once. - `dryRun` / `dry_run` on every write and `confirm` only on the methods that need it. - `Listener-Version: 2026-10-14` pinned on every request. ## Quickstart Both clients read `LISTENER_API_KEY` from the environment when you do not pass a key. ```typescript import Listener from '@listener/api'; const listener = new Listener({ apiKey: process.env.LISTENER_API_KEY }); const me = await listener.me.retrieve(); console.log(me.network_name, me.kind); // Every show, fetching pages as needed for await (const show of listener.shows.list()) { console.log(show.id, show.name, show.connected_platforms); } const reach = await listener.analytics.reach({ time_range: '30d' }); console.log(reach.total.current, reach.total.growth); ``` ```python from listener_api import Listener client = Listener() # or Listener(api_key="lsk_…") me = client.me.retrieve() print(me["network_name"], me["kind"]) # Every show, fetching pages as needed for show in client.shows.list(): print(show["id"], show["name"], show["connected_platforms"]) reach = client.analytics.reach(time_range="30d") print(reach["total"]["current"], reach["total"]["growth"]) ``` ## Naming Methods follow the API’s resources. Path parameters are positional; query and body parameters go in one object (TypeScript) or as keyword arguments (Python), with the API’s names. | Endpoint | TypeScript | Python | | --- | --- | --- | | `GET /v1/campaigns` | `listener.campaigns.list()` | `client.campaigns.list()` | | `POST /v1/campaigns` | `listener.campaigns.create({…})` | `client.campaigns.create(…)` | | `POST /v1/shows/{show_id}/clusters` | `listener.shows.clusters.create(showId, {…})` | `client.shows.clusters.create(show_id, …)` | | `PUT /v1/campaigns/{id}/slots/{slot_id}/cluster` | `listener.campaigns.slots.linkCluster(id, slotId, {…})` | `client.campaigns.slots.link_cluster(id, slot_id, …)` | | `POST /v1/webhook-endpoints` | `listener.webhookEndpoints.create({…})` | `client.webhook_endpoints.create(…)` | ## Dry runs, confirmations and errors ```typescript import { ConflictError, PermissionError } from '@listener/api'; const draft = { title: 'Acme Coffee Q4', start_date: '2026-11-01', end_date: '2026-12-31', brand: { name: 'Acme Coffee' } }; const preview = await listener.campaigns.create(draft, { dryRun: true }); console.log(preview.would_write); try { await listener.campaigns.delete(campaignId); } catch (err) { if (err instanceof ConflictError && err.code === 'confirmation_required') { await listener.campaigns.delete(campaignId, { confirm: true }); } else if (err instanceof PermissionError) { console.error(`This key needs ${err.requiredScope} (request ${err.requestId})`); } else throw err; } ``` ```python from listener_api import ConflictError, PermissionDeniedError draft = dict(title="Acme Coffee Q4", start_date="2026-11-01", end_date="2026-12-31", brand={"name": "Acme Coffee"}) preview = client.campaigns.create(**draft, dry_run=True) print(preview["would_write"]) try: client.campaigns.delete(campaign_id) except ConflictError as err: if err.code == "confirmation_required": client.campaigns.delete(campaign_id, confirm=True) except PermissionDeniedError as err: print(f"This key needs {err.required_scope} (request {err.request_id})") ``` ## Webhooks The SDKs verify the `Listener-Signature` header exactly as described in [Verify signatures](https://developers.listener.com/docs/webhooks/signatures), including both signatures during a secret rotation and a 300-second tolerance. Pass the raw body. ```typescript // Next.js route handler: app/api/webhooks/listener/route.ts import { webhooks } from '@listener/api'; export async function POST(request: Request) { const body = await request.text(); try { const event = webhooks.constructEvent(body, request.headers.get('listener-signature'), process.env.LISTENER_WEBHOOK_SECRET!); // handle event.type … return new Response(null, { status: 200 }); } catch { return new Response('invalid signature', { status: 400 }); } } ``` ```python # Flask from listener_api import SignatureVerificationError, webhooks @app.post("/webhooks/listener") def listener_webhook(): try: event = webhooks.construct_event(request.get_data(), request.headers.get("Listener-Signature"), SECRET) except SignatureVerificationError: return "invalid signature", 400 # handle event["type"] … return "", 200 ``` ## OAuth Helpers cover PKCE, the authorization URL, the code exchange and refresh-token rotation. Refreshes are single-flight, so concurrent requests never reuse a rotated refresh token. ```typescript import { Listener, createPkcePair, createState } from '@listener/api'; const listener = new Listener(); const pkce = await createPkcePair(); const state = await createState(); const url = listener.oauth.buildAuthorizeUrl({ clientId: process.env.LISTENER_CLIENT_ID!, redirectUri: 'https://yourapp.com/oauth/callback', scope: ['analytics:read', 'shows:read', 'offline_access'], state, codeChallenge: pkce.codeChallenge, }); // In the callback, after checking state: const tokens = await listener.oauth.exchangeCode({ code, redirectUri: 'https://yourapp.com/oauth/callback', codeVerifier: pkce.codeVerifier, clientId: process.env.LISTENER_CLIENT_ID!, clientSecret: process.env.LISTENER_CLIENT_SECRET, }); const userClient = new Listener({ accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, accessTokenExpiresAt: tokens.expiresAt, clientId: process.env.LISTENER_CLIENT_ID!, clientSecret: process.env.LISTENER_CLIENT_SECRET, onTokenRefresh: (next) => db.saveTokens(user.id, next), // always store the newest refresh token }); ``` ```python from listener_api import Listener, create_pkce_pair, create_state client = Listener() pkce = create_pkce_pair() state = create_state() url = client.oauth.build_authorize_url( client_id=CLIENT_ID, redirect_uri="https://yourapp.com/oauth/callback", scope=["analytics:read", "shows:read", "offline_access"], state=state, code_challenge=pkce.code_challenge, ) # In the callback, after checking state: tokens = client.oauth.exchange_code( code=request.args["code"], redirect_uri="https://yourapp.com/oauth/callback", code_verifier=pkce.code_verifier, client_id=CLIENT_ID, client_secret=CLIENT_SECRET, # omit for public clients ) ``` ## Other languages The OpenAPI 3.1 document at `https://api.listener.com/v1/openapi.json` works with standard generators, for example `openapi-generator` for Go, Java or Ruby, and imports into Postman, Insomnia or Bruno. --- # Testing with dry_run > There is no sandbox. Here is how to build and test safely against real workspaces. Listener does not have a separate sandbox or test mode environment. Your code talks to real workspaces with real data from day one. Instead of a sandbox, the API gives you tools that make that safe. ## dry_run Every write accepts `dry_run: true`, in the JSON body or as a query parameter. The API runs the same validation and the same preview logic as the real write, then returns what it **would** store, without changing anything and without emitting events. ```bash curl https://api.listener.com/v1/campaigns \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"title": "Acme Coffee Q4 host reads", "brand": "Acme Coffee", "dry_run": true}' ``` ```json { "object": "dry_run", "resource": "campaign", "would_write": { "title": "Acme Coffee Q4 host reads", "brand": "Acme Coffee", "status": "upcoming" } } ``` A dry run surfaces the same `400` errors a real write would (unknown show ids, bad dates, missing fields), so it is the right way to validate a payload in CI or before showing a confirmation step to a user. ## confirm Writes that cannot be undone or that cost money need an explicit `confirm: true` as well: - Deleting campaigns, clusters and shared reports. - Publishing a shared report (it becomes a public URL). - Refreshing cluster data or a metered platform (TikTok, X, Facebook, Instagram, Threads, LinkedIn), and filing Spotify or Apple data requests. - Changing the network’s default EMV, which changes EMV figures across the workspace. - Some edits only need it in certain cases: changing a show or network `handle` (the old public URL stops working) and editing a brand that several campaigns share. The reference marks these “Some changes need confirm”. Without `confirm`, these answer `409 confirmation_required` and change nothing. The error carries a `preview` of exactly what would happen, so you can show it to the user before repeating the request with `confirm: true`. ## A safe development setup - **Develop with a read-only key.** The **Read** preset cannot change anything, so you can explore every endpoint freely. - **Add write scopes narrowly** when you need them, for example only `campaigns:write`. - **Keep `dry_run: true` on by default** in development builds of anything that writes, and turn it off deliberately. - **Use the Try it console** in the [API reference](https://developers.listener.com/api). It defaults writes to `dry_run`. - **Test webhooks with the test event.** `POST /v1/webhook-endpoints/{id}/test` sends a `webhook_endpoint.test` event to your endpoint right away, without touching data. - **Use a test show for live writes.** If you need to exercise real writes, do it against a show or campaign you created for testing, and delete it afterwards. --- # Time windows > How analytics windows resolve, how growth is compared, and what coverage tells you. Every analytics endpoint answers for a window of days. You choose it with `time_range`, or with explicit dates. ## Choosing a window | Parameter | Values | | --- | --- | | `time_range` | `7d`, `14d`, `30d`, `90d`, `180d`, `365d`, `all`, a quarter like `2026-Q3`, or `Q3` for that quarter this year. | | `start_date` + `end_date` | `YYYY-MM-DD`, both inclusive. Send both. They override `time_range`. | Windows are **UTC calendar days** and include today. `30d` on 2026-10-14 means 2026-09-15 through 2026-10-14. The **previous period** is the same number of days immediately before the window. For quarter tokens it is the previous quarter. ## The window object Windowed responses echo what they resolved, so you never have to recompute it: ```json { "time_range": "30d", "kind": "rolling", "start": "2026-09-15", "end": "2026-10-14", "days": 30, "previous_start": "2026-08-16", "previous_end": "2026-09-14" } ``` ## Coverage Not every platform has data for every day. `coverage` says how much of the window is backed by data: ```json { "start": "2026-08-16", "embedded_boundary": "2026-09-15", "history_used": true, "current_window_coverage_days": 30, "previous_window_coverage_days": 11, "growth_basis": "like_for_like", "warnings": ["Previous window has data for 11 of 30 days; growth is not shown."] } ``` | Field | | | --- | --- | | `embedded_boundary` | Today minus 29 days. Recent days come from each platform’s live series; older days come from stored history. | | `history_used` | `true` when the window reached into stored history. | | `current_window_coverage_days`, `previous_window_coverage_days` | How many days of each window have data. | | `growth_basis` | How growth was compared (below). | | `warnings` | Plain-language reasons when something was left out. Show them to users. | ## Growth Growth compares the window with the previous period. It is **`null`** when the previous window has data for fewer than half of its days, because comparing against a mostly empty baseline produces numbers like +3,441% that mean nothing. The reason is in `coverage.warnings`. > **Show null growth as a dash, never as 0:** Never coerce `null` growth to `0`. Zero claims “no change”; `null` means “not comparable”. The Listener apps show a dash (`—`) in that case, and your users will expect the same. ### Like-for-like growth on long windows For windows longer than 30 days (`90d`, quarters, custom ranges), growth is compared **like for like**: only platforms whose data covers at least half of the previous window take part, and both windows are summed over exactly those platforms. `coverage.growth_basis` is `"like_for_like"` and `coverage.growth_platforms` lists the platforms compared. If no platform qualifies, growth is `null`. ## Endpoints with fixed windows The dashboard KPIs (`GET /v1/analytics/dashboard`) and per-show platform stats (`GET /v1/shows/{show_id}/platforms`) accept `7d`, `30d` or `90d`. On `90d`, impressions, downloads and engagement rate cover all 90 days from history, but likes, comments, posts, top content and follower changes stay on the last 30 days, because there is no long-range source for them. `metric_windows` (and each tile’s `window`) says which window every metric used. Those 30-day metrics, and EMV, have no growth on `90d`. ## Custom ranges Custom ranges are the most precise way to align with a reporting period: ```bash curl -G https://api.listener.com/v1/analytics/reach \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -d start_date=2026-07-01 \ -d end_date=2026-09-30 ``` That is equivalent to `time_range=2026-Q3`, and its previous period is 2026-Q2. --- # Versioning > Dated API versions, the Listener-Version header, and what counts as a breaking change. The API is versioned by date. The current and only version is **`2026-10-14`**. ## Pinning a version Send the `Listener-Version` header to pin the behaviour your code was written against: ```bash curl https://api.listener.com/v1/me \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Listener-Version: 2026-10-14" ``` Without the header you get the latest version. Every response echoes the version that served it in `Listener-Version`. An unknown version answers `400 version_invalid`. > **Tip:** Pin the version in production code. When a new version ships, your integration keeps working until you choose to move. ## What changes without a new version Additive changes ship continuously and do not get a new version. Write your code to tolerate them: - New endpoints. - New optional request parameters. - New fields in responses, and new values in `meta`. - New event types. Webhook endpoints subscribed to specific types never receive new ones; endpoints subscribed to `*` do, so ignore types you do not handle. - New error codes within an existing `type`. - Changes to human-readable `message` text. ## What gets a new version Breaking changes ship only as a new dated version, announced in the [changelog](https://developers.listener.com/changelog): removing or renaming a field, changing a field’s type or meaning, changing defaults, or making an optional parameter required. Webhook endpoints record the version they were created with (`api_version`), and every event carries `api_version` too, so you always know which version shaped a payload. The `/v1` path prefix changes only for a complete redesign, which is not planned. --- # Best practices > Build webhook handlers that are fast, safe and correct under retries. ## Respond fast, work later Return `2xx` as soon as you have verified the signature and stored the event. Put the real work on a queue. A handler that calls other APIs before responding will eventually hit the 10-second timeout and trigger retries for events you already processed. ## Verify every request Check the `Listener-Signature` on every request, against the raw body, with a timestamp tolerance. Reject anything that fails. See [Verify signatures](https://developers.listener.com/docs/webhooks/signatures). ## Handle duplicates Delivery is at least once. Store the `id` of each event you process (or use it as a unique key) and skip ids you have already handled. The same `id` is also in the `Listener-Event-Id` header, so you can deduplicate before parsing. ## Do not rely on order Retries mean an older event can arrive after a newer one. Compare `created` timestamps, or treat events as a signal to re-read the current state with a `GET`. For example, on `campaign.updated`, fetch `GET /v1/campaigns/{id}` rather than applying `previous_attributes` blindly. ## Subscribe to what you need List specific types in `enabled_events` instead of `*`. You get less traffic, and your handler is not surprised by new event types added later. If you do use `*`, ignore types you do not recognise and return `2xx` for them. ## Reconcile with the Events API Webhooks can be missed: your endpoint was disabled, a deploy dropped requests, a queue lost messages. Run a periodic job that lists recent [events](https://developers.listener.com/docs/events) with `created_gte` and processes any you have not seen. That turns webhooks from “usually right” into “eventually always right”. ## Keep secrets secret Store each endpoint’s signing secret in a secrets manager. Roll it with `POST /v1/webhook-endpoints/{id}/roll-secret` if it might have leaked; the previous secret stays valid for 24 hours while you deploy. ## Use separate endpoints per environment Register one endpoint for production and another for staging, each with its own secret, so a staging bug never processes production events twice. --- # Set up an endpoint > Register a webhook URL, choose its events, store the secret and send a test. You can manage endpoints in **Settings → Developers → Webhooks** in either app, or through the API with the `webhooks:write` scope. Both use the same endpoints, so anything you create in one shows up in the other. ## 1. Build a receiver Your endpoint must: - Accept `POST` requests with a JSON body over **HTTPS**. - Read the **raw** body (before JSON parsing) so you can [verify the signature](https://developers.listener.com/docs/webhooks/signatures). - Return a `2xx` status within **10 seconds**. Redirects are not followed and count as failures. Do the slow work after responding: put the event on a queue and return `200` straight away. ## 2. Register it ```bash curl https://api.listener.com/v1/webhook-endpoints \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/webhooks/listener", "enabled_events": ["campaign.created", "campaign.updated", "campaign.status_changed"], "description": "Campaign sync" }' ``` ```javascript const res = await fetch('https://api.listener.com/v1/webhook-endpoints', { method: 'POST', headers: { Authorization: `Bearer ${process.env.LISTENER_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ url: 'https://example.com/webhooks/listener', enabled_events: ['campaign.created', 'campaign.updated', 'campaign.status_changed'], description: 'Campaign sync', }), }); const endpoint = await res.json(); console.log(endpoint.id, endpoint.secret); // store the secret now ``` ```python res = requests.post( "https://api.listener.com/v1/webhook-endpoints", headers={"Authorization": f"Bearer {os.environ['LISTENER_API_KEY']}"}, json={ "url": "https://example.com/webhooks/listener", "enabled_events": ["campaign.created", "campaign.updated", "campaign.status_changed"], "description": "Campaign sync", }, ) endpoint = res.json() print(endpoint["id"], endpoint["secret"]) # store the secret now ``` - `enabled_events` is a list of [event types](https://developers.listener.com/docs/webhooks/event-types), or `["*"]` for every type, including types added later. - The response includes the signing **`secret`** (`whsec_…`). Store it in your secrets manager. Lists and reads show only its last four characters; `GET /v1/webhook-endpoints/{id}/secret` reveals it again and needs `webhooks:write`, because the secret lets its holder forge deliveries. - A workspace can have up to **16** endpoints, disabled ones included. ### URL rules URLs must use `https://`, carry no credentials or fragment, and must not resolve to private, loopback, link-local or other internal addresses. The check runs when you save the endpoint and again before every delivery. To receive webhooks on your laptop, expose your local server through a tunnel such as `ngrok` or `cloudflared` and register the tunnel’s HTTPS URL. ## 3. Send a test event ```bash curl -X POST https://api.listener.com/v1/webhook-endpoints/we_5Rk2LqP9xT1m/test \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"event_type": "campaign.created"}' ``` Listener sends a test event right away and returns the delivery, including your endpoint’s response status. Without `event_type` it sends `webhook_endpoint.test`; with one, it sends that type’s catalog example with `data.test: true`. Test deliveries are signed like real ones but are not retried and never count against the endpoint’s health. Use them to check signature verification end to end without changing any data. ## Managing endpoints | Action | Request | | --- | --- | | List endpoints | `GET /v1/webhook-endpoints` | | Change URL, events or description | `PATCH /v1/webhook-endpoints/{id}` | | Pause or resume | `PATCH /v1/webhook-endpoints/{id}` with `{"disabled": true}` or `false` | | Reveal the secret | `GET /v1/webhook-endpoints/{id}/secret` | | Rotate the secret | `POST /v1/webhook-endpoints/{id}/roll-secret` | | See recent deliveries | `GET /v1/webhook-endpoints/{id}/deliveries` | | Redeliver one now | `POST /v1/webhook-deliveries/{id}/retry` | | Delete | `DELETE /v1/webhook-endpoints/{id}` | The **Webhooks** tab in the app shows the same delivery log with the request and response of each attempt. ### Rotating the secret `roll-secret` returns a new secret. The previous secret keeps signing for **24 hours** by default (`expire_previous_in_hours`, 0 to 168; the response says when in `expires_previous_at`), and during that time every delivery carries two `v1` signatures, one per secret. Deploy the new secret, and verification keeps passing throughout. See [Verify signatures](https://developers.listener.com/docs/webhooks/signatures#during-secret-rotation). --- # Event types > Every event Listener sends, where it comes from, and an example payload for each. Subscribe an endpoint to the types below, or to `["*"]` for all of them. Every event shares the same envelope: | Field | | | --- | --- | | `id` | `evt_…`. Unique; use it to deduplicate. | | `type` | One of the types below. | | `api_version` | The API version that shaped `data`. | | `created` | When the change happened (ISO 8601, UTC). | | `workspace_id` | The workspace it happened in. | | `source` | `api`, `app` or `system`. See [where events come from](https://developers.listener.com/docs/webhooks#where-events-come-from). | | `data.object` | The resource after the change. For `*.deleted`, its last known state. | | `data.changed_fields` | For updates detected in the app: the names of the fields that changed. | | `data.previous_attributes` | For updates: old values, when they are known (`status`, `title`, `node_count`, `last_updated`, `connected`, `stale`). | | `request` | For `source: "api"`: the `id` and `idempotency_key` of the request that caused it. Otherwise `null`. | The labels next to each type show which sources can produce it. Expand a type to see an example. The same catalog, with these examples, is served as JSON at `GET /v1/event-types` (no authentication). Events detected from app changes carry a compact `data.object`; call the matching `GET` endpoint when you need the full resource. `webhook_endpoint.test` is sent only when you ask for it: a `*` subscription never receives it. ### campaign.created A campaign was created. Sources: `api`, `app` ```json { "object": "event", "id": "evt_1a2B3c4D5e6F7g8H9i0JkLmN", "type": "campaign.created", "api_version": "2026-10-14", "created": "2026-10-14T09:30:00Z", "workspace_id": "uJm8Fq1kT0bYwZ3xA9cD", "source": "app", "data": { "object": { "object": "campaign", "id": "Xb3kQ9LmT2vWc8Rz1PaE", "title": "Acme Coffee Q4 Fall Flight", "description": "Host-read mid-rolls plus two YouTube integrations per show.", "status": "active", "start_date": "2026-10-01", "end_date": "2026-12-15", "brand": { "name": "Acme Coffee", "slug": "acme-coffee", "domain": "acmecoffee.example", "logo": "https://cdn.example.com/acme-coffee.png" }, "goals": { "total_views": 2500000, "total_budget": 85000 }, "cluster_ids": [ "cluster-6vQ1jWjYcK2mWkQ0dGx3Zp" ], "total_slots": 12, "created_at": "2026-09-22T15:04:11Z", "updated_at": "2026-10-14T09:29:58Z", "public_url": "https://network.listener.com/northwind-audio/campaigns/Xb3kQ9LmT2vWc8Rz1PaE" } }, "request": null } ``` ### campaign.updated A campaign's title, description, dates, brand, goals, slots, linked clusters, exclusions or EMV settings changed. Sources: `api`, `app` ```json { "object": "event", "id": "evt_1a2B3c4D5e6F7g8H9i0JkLmN", "type": "campaign.updated", "api_version": "2026-10-14", "created": "2026-10-14T09:30:00Z", "workspace_id": "uJm8Fq1kT0bYwZ3xA9cD", "source": "app", "data": { "object": { "object": "campaign", "id": "Xb3kQ9LmT2vWc8Rz1PaE", "title": "Acme Coffee Q4 Fall Flight", "description": "Host-read mid-rolls plus two YouTube integrations per show.", "status": "active", "start_date": "2026-10-01", "end_date": "2026-12-15", "brand": { "name": "Acme Coffee", "slug": "acme-coffee", "domain": "acmecoffee.example", "logo": "https://cdn.example.com/acme-coffee.png" }, "goals": { "total_views": 2500000, "total_budget": 85000 }, "cluster_ids": [ "cluster-6vQ1jWjYcK2mWkQ0dGx3Zp" ], "total_slots": 12, "created_at": "2026-09-22T15:04:11Z", "updated_at": "2026-10-14T09:29:58Z", "public_url": "https://network.listener.com/northwind-audio/campaigns/Xb3kQ9LmT2vWc8Rz1PaE" }, "changed_fields": [ "goals", "slots" ], "previous_attributes": {} }, "request": null } ``` ### campaign.deleted A campaign was deleted. Sources: `api`, `app` ```json { "object": "event", "id": "evt_1a2B3c4D5e6F7g8H9i0JkLmN", "type": "campaign.deleted", "api_version": "2026-10-14", "created": "2026-10-14T09:30:00Z", "workspace_id": "uJm8Fq1kT0bYwZ3xA9cD", "source": "app", "data": { "object": { "object": "campaign", "id": "Xb3kQ9LmT2vWc8Rz1PaE", "title": "Acme Coffee Q4 Fall Flight", "deleted": true } }, "request": null } ``` ### campaign.status_changed A campaign's status (upcoming, active, completed) changed, either set by hand or because its flight started or ended. Sources: `api`, `app` ```json { "object": "event", "id": "evt_1a2B3c4D5e6F7g8H9i0JkLmN", "type": "campaign.status_changed", "api_version": "2026-10-14", "created": "2026-10-14T09:30:00Z", "workspace_id": "uJm8Fq1kT0bYwZ3xA9cD", "source": "app", "data": { "object": { "object": "campaign", "id": "Xb3kQ9LmT2vWc8Rz1PaE", "title": "Acme Coffee Q4 Fall Flight", "description": "Host-read mid-rolls plus two YouTube integrations per show.", "status": "active", "start_date": "2026-10-01", "end_date": "2026-12-15", "brand": { "name": "Acme Coffee", "slug": "acme-coffee", "domain": "acmecoffee.example", "logo": "https://cdn.example.com/acme-coffee.png" }, "goals": { "total_views": 2500000, "total_budget": 85000 }, "cluster_ids": [ "cluster-6vQ1jWjYcK2mWkQ0dGx3Zp" ], "total_slots": 12, "created_at": "2026-09-22T15:04:11Z", "updated_at": "2026-10-14T09:29:58Z", "public_url": "https://network.listener.com/northwind-audio/campaigns/Xb3kQ9LmT2vWc8Rz1PaE" }, "previous_attributes": { "status": "upcoming" } }, "request": null } ``` ### cluster.created An episode cluster was created (by hand, by the AI cluster builder or by a merge). Sources: `api`, `app` ```json { "object": "event", "id": "evt_1a2B3c4D5e6F7g8H9i0JkLmN", "type": "cluster.created", "api_version": "2026-10-14", "created": "2026-10-14T09:30:00Z", "workspace_id": "uJm8Fq1kT0bYwZ3xA9cD", "source": "app", "data": { "object": { "object": "cluster", "id": "cluster-6vQ1jWjYcK2mWkQ0dGx3Zp", "show_id": "pQ7sHt2Lw9XcV4bN1mRa", "name": "Episode 412: Deadline Day Fallout", "internal_title": null, "episode_ids": [ "6vQ1jWjYcK2mWkQ0dGx3Zp" ], "node_count": 18, "is_manual": false, "auto_generated": true, "created_at": "2026-10-13T18:22:40Z", "updated_at": "2026-10-14T09:12:03Z", "public_url": "https://listener.com/cluster/pQ7sHt2Lw9XcV4bN1mRa_cluster-6vQ1jWjYcK2mWkQ0dGx3Zp" } }, "request": null } ``` ### cluster.updated A cluster's name, internal title, linked episodes, brand collaboration or page customization changed, or nodes were removed. Sources: `api`, `app` ```json { "object": "event", "id": "evt_1a2B3c4D5e6F7g8H9i0JkLmN", "type": "cluster.updated", "api_version": "2026-10-14", "created": "2026-10-14T09:30:00Z", "workspace_id": "uJm8Fq1kT0bYwZ3xA9cD", "source": "app", "data": { "object": { "object": "cluster", "id": "cluster-6vQ1jWjYcK2mWkQ0dGx3Zp", "show_id": "pQ7sHt2Lw9XcV4bN1mRa", "name": "Episode 412: Deadline Day Fallout", "internal_title": null, "episode_ids": [ "6vQ1jWjYcK2mWkQ0dGx3Zp" ], "node_count": 18, "is_manual": false, "auto_generated": true, "created_at": "2026-10-13T18:22:40Z", "updated_at": "2026-10-14T09:12:03Z", "public_url": "https://listener.com/cluster/pQ7sHt2Lw9XcV4bN1mRa_cluster-6vQ1jWjYcK2mWkQ0dGx3Zp" }, "changed_fields": [ "name" ], "previous_attributes": {} }, "request": null } ``` ### cluster.deleted A cluster was deleted. Sources: `api`, `app` ```json { "object": "event", "id": "evt_1a2B3c4D5e6F7g8H9i0JkLmN", "type": "cluster.deleted", "api_version": "2026-10-14", "created": "2026-10-14T09:30:00Z", "workspace_id": "uJm8Fq1kT0bYwZ3xA9cD", "source": "app", "data": { "object": { "object": "cluster", "id": "cluster-6vQ1jWjYcK2mWkQ0dGx3Zp", "show_id": "pQ7sHt2Lw9XcV4bN1mRa", "deleted": true } }, "request": null } ``` ### cluster.nodes_added Content nodes (clips, posts, videos) were added to a cluster. Sources: `api`, `app` ```json { "object": "event", "id": "evt_1a2B3c4D5e6F7g8H9i0JkLmN", "type": "cluster.nodes_added", "api_version": "2026-10-14", "created": "2026-10-14T09:30:00Z", "workspace_id": "uJm8Fq1kT0bYwZ3xA9cD", "source": "app", "data": { "object": { "object": "cluster", "id": "cluster-6vQ1jWjYcK2mWkQ0dGx3Zp", "show_id": "pQ7sHt2Lw9XcV4bN1mRa", "name": "Episode 412: Deadline Day Fallout", "internal_title": null, "episode_ids": [ "6vQ1jWjYcK2mWkQ0dGx3Zp" ], "node_count": 18, "is_manual": false, "auto_generated": true, "created_at": "2026-10-13T18:22:40Z", "updated_at": "2026-10-14T09:12:03Z", "public_url": "https://listener.com/cluster/pQ7sHt2Lw9XcV4bN1mRa_cluster-6vQ1jWjYcK2mWkQ0dGx3Zp" }, "previous_attributes": { "node_count": 14 }, "nodes_added": 4 }, "request": null } ``` ### episode.created A new episode appeared in a show's feed data (stored Spotify, Apple or YouTube episodes). Sources: `app` ```json { "object": "event", "id": "evt_1a2B3c4D5e6F7g8H9i0JkLmN", "type": "episode.created", "api_version": "2026-10-14", "created": "2026-10-14T09:30:00Z", "workspace_id": "uJm8Fq1kT0bYwZ3xA9cD", "source": "app", "data": { "object": { "object": "episode", "id": "6vQ1jWjYcK2mWkQ0dGx3Zp", "show_id": "pQ7sHt2Lw9XcV4bN1mRa", "title": "Episode 412: Deadline Day Fallout", "published_at": "2026-10-14", "duration": "1:12:40", "duration_seconds": 4360, "episode_number": "412", "source_platform": "spotify", "cover": "https://i.scdn.co/image/ab6765630000ba8a1f0f3c6e2c0b6d1e2f3a4b5c", "share_url": "https://open.spotify.com/episode/6vQ1jWjYcK2mWkQ0dGx3Zp" } }, "request": null } ``` ### analytics.refreshed A show platform's analytics advanced (its last-updated stamp moved forward after a refresh or sync). Sources: `app` ```json { "object": "event", "id": "evt_1a2B3c4D5e6F7g8H9i0JkLmN", "type": "analytics.refreshed", "api_version": "2026-10-14", "created": "2026-10-14T09:30:00Z", "workspace_id": "uJm8Fq1kT0bYwZ3xA9cD", "source": "app", "data": { "object": { "object": "platform_status", "show_id": "pQ7sHt2Lw9XcV4bN1mRa", "show_name": "Dugout Daily", "platform": "youtube", "connected": true, "last_updated": "2026-10-14T06:02:31Z", "age_hours": 0.4, "stale": false }, "previous_attributes": { "last_updated": "2026-10-13T06:01:12Z" } }, "request": null } ``` ### integration.connected A platform was connected to a show through the API (by handle, profile URL or API key). The first data refresh may still be running. Sources: `api` ```json { "object": "event", "id": "evt_1a2B3c4D5e6F7g8H9i0JkLmN", "type": "integration.connected", "api_version": "2026-10-14", "created": "2026-10-14T09:30:00Z", "workspace_id": "uJm8Fq1kT0bYwZ3xA9cD", "source": "api", "data": { "object": { "object": "integration", "id": "instagram", "show_id": "pQ7sHt2Lw9XcV4bN1mRa", "path": "users/pQ7sHt2Lw9XcV4bN1mRa", "urls": { "app": "https://network.listener.com/app/integrations?show=pQ7sHt2Lw9XcV4bN1mRa", "public": null }, "written": { "uid": "pQ7sHt2Lw9XcV4bN1mRa", "platform": "instagram", "handle": "dugoutdaily", "url": "https://www.instagram.com/dugoutdaily/" }, "data": { "show_id": "pQ7sHt2Lw9XcV4bN1mRa", "platform": "instagram", "status": "connected", "connected": true, "username": "dugoutdaily", "profile_url": "https://www.instagram.com/dugoutdaily/", "refreshed": true, "previous_state": "disconnected", "previous_account": null } } }, "request": { "id": "req_8f2LkQ0wXv1Tz", "idempotency_key": null } } ``` ### integration.disconnected A show's platform connection was turned off (through the API or in the app) or lost. Sources: `api`, `app` ```json { "object": "event", "id": "evt_1a2B3c4D5e6F7g8H9i0JkLmN", "type": "integration.disconnected", "api_version": "2026-10-14", "created": "2026-10-14T09:30:00Z", "workspace_id": "uJm8Fq1kT0bYwZ3xA9cD", "source": "app", "data": { "object": { "object": "platform_status", "show_id": "pQ7sHt2Lw9XcV4bN1mRa", "show_name": "Dugout Daily", "platform": "youtube", "connected": false, "last_updated": "2026-10-14T06:02:31Z", "age_hours": 3.1, "stale": false }, "previous_attributes": { "connected": true } }, "request": null } ``` ### integration.stale A connected platform has not refreshed for more than 36 hours. Sources: `app` ```json { "object": "event", "id": "evt_1a2B3c4D5e6F7g8H9i0JkLmN", "type": "integration.stale", "api_version": "2026-10-14", "created": "2026-10-14T09:30:00Z", "workspace_id": "uJm8Fq1kT0bYwZ3xA9cD", "source": "app", "data": { "object": { "object": "platform_status", "show_id": "pQ7sHt2Lw9XcV4bN1mRa", "show_name": "Dugout Daily", "platform": "youtube", "connected": true, "last_updated": "2026-10-12T20:55:00Z", "age_hours": 36.6, "stale": true }, "previous_attributes": { "stale": false } }, "request": null } ``` ### report.created A shared report was published (by hand or by a report automation). Sources: `api`, `app` ```json { "object": "event", "id": "evt_1a2B3c4D5e6F7g8H9i0JkLmN", "type": "report.created", "api_version": "2026-10-14", "created": "2026-10-14T09:30:00Z", "workspace_id": "uJm8Fq1kT0bYwZ3xA9cD", "source": "app", "data": { "object": { "object": "report", "id": "rpt_2026_10_weekly_northwind", "title": "Weekly network scorecard — Oct 7–13", "network_name": "Northwind Audio", "time_range": "7d", "created_at": "2026-10-14T08:00:12Z", "is_automated": true, "urls": { "network": "https://network.listener.com/report/uJm8Fq1kT0bYwZ3xA9cD/rpt_2026_10_weekly_northwind", "public": "https://listener.com/report/uJm8Fq1kT0bYwZ3xA9cD/rpt_2026_10_weekly_northwind" } } }, "request": null } ``` ### report.deleted A shared report was deleted. Sources: `api`, `app` ```json { "object": "event", "id": "evt_1a2B3c4D5e6F7g8H9i0JkLmN", "type": "report.deleted", "api_version": "2026-10-14", "created": "2026-10-14T09:30:00Z", "workspace_id": "uJm8Fq1kT0bYwZ3xA9cD", "source": "app", "data": { "object": { "object": "report", "id": "rpt_2026_10_weekly_northwind", "deleted": true } }, "request": null } ``` ### cluster_build.completed An AI cluster build job finished (done, failed or cancelled). Sources: `api`, `system` ```json { "object": "event", "id": "evt_1a2B3c4D5e6F7g8H9i0JkLmN", "type": "cluster_build.completed", "api_version": "2026-10-14", "created": "2026-10-14T09:30:00Z", "workspace_id": "uJm8Fq1kT0bYwZ3xA9cD", "source": "system", "data": { "object": { "object": "cluster_build", "job_id": "bld_9Qe2Rk7Tz1", "status": "done", "scope": { "kind": "range", "from_date": "2026-10-01", "to_date": "2026-10-14", "episode_ids": [] }, "mode": "auto", "source": "mcp", "total": 9, "done": 9, "progress_pct": 100, "counts": { "built": 6, "ready": 2, "nothing": 1, "failed": 0, "skipped": 0 }, "credits_used": 41, "created_at": "2026-10-14T08:40:00Z" } }, "request": null } ``` ### brand.updated A brand profile (name, domain, logo, notes, contact) was created or updated. Sources: `api` ```json { "object": "event", "id": "evt_1a2B3c4D5e6F7g8H9i0JkLmN", "type": "brand.updated", "api_version": "2026-10-14", "created": "2026-10-14T09:30:00Z", "workspace_id": "uJm8Fq1kT0bYwZ3xA9cD", "source": "api", "data": { "object": { "object": "brand", "slug": "acme-coffee", "name": "Acme Coffee", "domain": "acmecoffee.example", "logo": "https://cdn.example.com/acme-coffee.png", "notes": "Prefers host reads; no competitor mentions in the same episode.", "has_contact": true, "campaign_count": 4, "public_url": "https://network.listener.com/northwind-audio/brands/acme-coffee" } }, "request": { "id": "req_8f2LkQ0wXv1Tz", "idempotency_key": null } } ``` ### show.updated A show's or the network's public profile was edited. Sources: `api` ```json { "object": "event", "id": "evt_1a2B3c4D5e6F7g8H9i0JkLmN", "type": "show.updated", "api_version": "2026-10-14", "created": "2026-10-14T09:30:00Z", "workspace_id": "uJm8Fq1kT0bYwZ3xA9cD", "source": "api", "data": { "object": { "object": "show", "id": "pQ7sHt2Lw9XcV4bN1mRa", "name": "Dugout Daily", "handle": "dugout-daily", "image": "https://firebasestorage.googleapis.com/v0/b/listener/o/dugout-daily.jpg", "public_url": "https://listener.com/dugout-daily", "categories": [ "Sports", "Baseball" ] } }, "request": { "id": "req_8f2LkQ0wXv1Tz", "idempotency_key": null } } ``` ### webhook_endpoint.test Sent on demand from the dashboard or POST /v1/webhook-endpoints/{id}/test. Never sent automatically. Sources: `api` (on demand only; `*` does not receive it) ```json { "object": "event", "id": "evt_1a2B3c4D5e6F7g8H9i0JkLmN", "type": "webhook_endpoint.test", "api_version": "2026-10-14", "created": "2026-10-14T09:30:00Z", "workspace_id": "uJm8Fq1kT0bYwZ3xA9cD", "source": "api", "data": { "object": { "object": "webhook_endpoint_test", "endpoint_id": "we_4mT8sKq2Zx7Lb1Nc9VfR3pYd", "message": "This is a test event from Listener. Respond with any 2xx status to acknowledge it." } }, "request": { "id": "req_8f2LkQ0wXv1Tz", "idempotency_key": null } } ``` --- # Webhooks > Get a signed HTTPS request the moment something changes in a workspace. Webhooks push [events](https://developers.listener.com/docs/events) to your server as they happen: a campaign is created or changes status, a cluster gains posts, a new episode appears, a platform’s numbers advance or a connection breaks. Instead of polling every resource, you subscribe once and react. ## How it works 1. You register an HTTPS **endpoint** and choose which event types it receives. 2. When something changes, Listener records an **event** and sends it to every matching endpoint as a `POST` with a JSON body. 3. Each request is **signed** with your endpoint’s secret. You verify the signature, return `2xx` quickly, and do the work. 4. If your endpoint fails, Listener **retries** with backoff for three days. ```http POST /webhooks/listener HTTP/1.1 Host: example.com Content-Type: application/json User-Agent: Listener-Webhooks/1.0 (+https://developers.listener.com/docs/webhooks) Listener-Signature: t=1792056600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd Listener-Event-Id: evt_1Nf8sKq2LmP0 Listener-Event-Type: campaign.status_changed Listener-Webhook-Id: we_5Rk2LqP9xT1m Listener-Delivery-Id: wd_8Hq1Zt0mPx4c { "object": "event", "id": "evt_1Nf8sKq2LmP0", "type": "campaign.status_changed", "api_version": "2026-10-14", "created": "2026-10-14T09:30:00Z", "workspace_id": "nW7aQ2kL…", "source": "app", "data": { "object": { "object": "campaign", "id": "cmpA7Hq2LmX9vRt4BzKe", "status": "active" }, "previous_attributes": { "status": "upcoming" } }, "request": null } ``` ## Where events come from Every event has a `source`: | `source` | Meaning | Latency | | --- | --- | --- | | `api` | A write made through the API, by you or another integration. `request` holds its request id and idempotency key. | Seconds | | `app` | A change someone made in the Listener apps, or new data arriving from a platform. Listener detects these by comparing each workspace’s state every five minutes. | Up to about 5 minutes | | `system` | Background jobs, such as an AI cluster build finishing. | Seconds | App-side detection starts when a workspace has its first enabled endpoint, and only watches the kinds of events some endpoint subscribes to. The first pass records a baseline and sends nothing, so you do not receive a flood of “created” events for existing data. These events carry a compact object (the fields used to detect the change, never computed analytics); fetch the full resource from the API when you need it. ## Start here - [Set up an endpoint](https://developers.listener.com/docs/webhooks/endpoints): Register a URL, choose events, send a test. - [Verify signatures](https://developers.listener.com/docs/webhooks/signatures): Check every request really came from Listener. - [Event types](https://developers.listener.com/docs/webhooks/event-types): Every event, with an example payload. - [Retries & auto-disable](https://developers.listener.com/docs/webhooks/retries): What happens when your endpoint is down. --- # Retries & auto-disable > What Listener does when your endpoint is slow, down or failing, and how to recover. ## What counts as success A delivery succeeds when your endpoint returns any `2xx` status within **10 seconds**. Everything else is a failure: `3xx` (redirects are not followed), `4xx`, `5xx`, timeouts, TLS errors and refused connections. ## Retry schedule Failed deliveries are retried with increasing delays after the first attempt: | Attempt | Delay after the previous attempt | | --- | --- | | 2 | 1 minute | | 3 | 5 minutes | | 4 | 30 minutes | | 5 | 2 hours | | 6 | 5 hours | | 7 and later | 10 hours | Retries continue while the next attempt still falls within **three days** of the event, up to 12 attempts in all. After that the delivery is marked `failed` and not tried again automatically. Because of retries, the same event can arrive more than once, and events can arrive out of order. See [Best practices](https://developers.listener.com/docs/webhooks/best-practices). ## Automatic disabling If an endpoint has had no successful delivery for three days while its deliveries keep failing, Listener disables it: its `status` becomes `disabled` with `disabled_reason: "failing"`, and nothing more is sent to it. A single success resets the clock. (Endpoints you pause yourself show `disabled_reason: "manual"`.) To recover: 1. Fix the endpoint, and confirm it with `POST /v1/webhook-endpoints/{id}/test`. 2. Re-enable it with `PATCH /v1/webhook-endpoints/{id}` and `{"disabled": false}`. 3. Catch up on what you missed with the [Events API](https://developers.listener.com/docs/events): list events since the outage with `created_gte`, and process the ones you have not seen. Events are kept for 30 days. ## Inspecting deliveries `GET /v1/webhook-endpoints/{id}/deliveries` lists recent deliveries with their status (`pending`, `succeeded` or `failed`), every attempt, your endpoint’s response code, the first 1 KB of its response body, the latency and, when it failed before getting a response, an `error` such as `timeout` or `connection_failed`. The Webhooks tab in the app shows the same log. To send one again right now, for example after a fix, call `POST /v1/webhook-deliveries/{id}/retry`. The attempt runs immediately; if it fails, a pending delivery keeps its original schedule. --- # Verify signatures > Check the Listener-Signature header so you only act on requests that really came from Listener. Anyone who learns your endpoint URL can send it requests. Every genuine delivery is signed with your endpoint’s secret, so verify the signature before you trust the body. ## The signature header ```http Listener-Signature: t=1792056600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd ``` - `t` is the Unix time (seconds) when Listener signed the request. - `v1` is the hex-encoded HMAC-SHA256 of the string `"{t}.{raw_body}"`, keyed with your endpoint’s signing secret. - During a [secret rotation](#during-secret-rotation) the header carries a second `v1` value, signed with the previous secret. ## How to verify 1. Read the **raw request body** exactly as received. Parsing and re-serialising JSON changes bytes and breaks the signature. 2. Split the header on `,` and collect `t` and every `v1` value. 3. Build the signed payload: the value of `t`, a literal `.`, then the raw body. 4. Compute HMAC-SHA256 of that payload with your secret (the whole `whsec_…` string, UTF-8 encoded) and hex-encode it. 5. Compare it with each `v1` using a **constant-time** comparison. Accept if any matches. 6. Reject the request if `t` is more than five minutes from your clock. This stops an attacker replaying an old delivery. ## Code The [SDKs](https://developers.listener.com/docs/sdks) do all of this in one call (`listener.webhooks.constructEvent` in TypeScript, `webhooks.construct_event` in Python). Without an SDK: ```javascript // Express. Mount with the raw body parser so you can verify the exact bytes: // app.post('/webhooks/listener', express.raw({ type: 'application/json' }), handler); import crypto from 'node:crypto'; const TOLERANCE_SECONDS = 300; export function verifyListenerSignature(rawBody, header, secret) { if (!header) return false; let timestamp = null; const signatures = []; for (const part of header.split(',')) { const [key, value] = part.split('=', 2); if (key === 't') timestamp = Number(value); if (key === 'v1' && value) signatures.push(value); } if (!Number.isFinite(timestamp) || signatures.length === 0) return false; if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false; const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.`) .update(rawBody) // a Buffer from express.raw() .digest(); return signatures.some((sig) => { const given = Buffer.from(sig, 'hex'); return given.length === expected.length && crypto.timingSafeEqual(given, expected); }); } export function handler(req, res) { const ok = verifyListenerSignature(req.body, req.get('Listener-Signature'), process.env.LISTENER_WEBHOOK_SECRET); if (!ok) return res.status(400).send('Invalid signature'); const event = JSON.parse(req.body.toString('utf8')); queue.add(event); // do the work later res.sendStatus(200); } ``` ```python # Flask. request.get_data() returns the raw bytes. import hashlib import hmac import json import os import time from typing import Optional from flask import Flask, abort, request TOLERANCE_SECONDS = 300 app = Flask(__name__) def verify_listener_signature(raw_body: bytes, header: Optional[str], secret: str) -> bool: if not header: return False timestamp, signatures = None, [] for part in header.split(","): key, _, value = part.partition("=") if key == "t": timestamp = value elif key == "v1" and value: signatures.append(value) if timestamp is None or not timestamp.isdigit() or not signatures: return False if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS: return False signed = timestamp.encode() + b"." + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, sig) for sig in signatures) @app.post("/webhooks/listener") def listener_webhook(): raw = request.get_data() if not verify_listener_signature(raw, request.headers.get("Listener-Signature"), os.environ["LISTENER_WEBHOOK_SECRET"]): abort(400) event = json.loads(raw) enqueue(event) # do the work later return "", 200 ``` ```go package webhooks import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "io" "math" "net/http" "os" "strconv" "strings" "time" ) const tolerance = 5 * time.Minute func VerifyListenerSignature(rawBody []byte, header, secret string) bool { var timestamp string var signatures []string for _, part := range strings.Split(header, ",") { key, value, _ := strings.Cut(part, "=") switch key { case "t": timestamp = value case "v1": if value != "" { signatures = append(signatures, value) } } } ts, err := strconv.ParseInt(timestamp, 10, 64) if err != nil || len(signatures) == 0 { return false } if math.Abs(time.Since(time.Unix(ts, 0)).Seconds()) > tolerance.Seconds() { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(timestamp + ".")) mac.Write(rawBody) expected := mac.Sum(nil) for _, sig := range signatures { given, err := hex.DecodeString(sig) if err == nil && hmac.Equal(given, expected) { return true } } return false } func Handler(w http.ResponseWriter, r *http.Request) { raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil || !VerifyListenerSignature(raw, r.Header.Get("Listener-Signature"), os.Getenv("LISTENER_WEBHOOK_SECRET")) { http.Error(w, "invalid signature", http.StatusBadRequest) return } enqueue(raw) // do the work later w.WriteHeader(http.StatusOK) } ``` ## During secret rotation After you call `POST /v1/webhook-endpoints/{id}/roll-secret`, deliveries for the next 24 hours carry two signatures: ```http Listener-Signature: t=1792056600,v1=,v1= ``` The code above accepts a request when **any** `v1` matches, so you can deploy the new secret whenever it suits you within those 24 hours. If you verify against several secrets yourself, check each one against each `v1`. ## Common mistakes | Symptom | Cause | | --- | --- | | Every signature fails | The body was parsed before verifying. Use the raw bytes. | | Fails only in production | A proxy or framework re-encodes the body, or the wrong secret is deployed for that endpoint. Each endpoint has its own secret. | | Fails after a while | Your server clock drifted beyond five minutes. Sync it with NTP. | | Fails right after rotating | You replaced the secret in code but kept comparing only one signature. Accept any `v1`. | --- # Changelog > Every change to the Listener API, newest first. Breaking changes always ship as a new dated version. ## 2026-10-14: Listener API v1 The first public version of the Listener API. API version `2026-10-14`. ### REST API - One API for Listener creators and Listener Networks workspaces at `https://api.listener.com/v1`. - Endpoints for the workspace and shows, episodes, analytics, clusters and the AI cluster builder, campaigns and brands, air checks, reports, sponsors, the library and research, data refreshes, webhooks and events. - Stripe-style conventions: `object` on every payload, a shared list envelope with cursors, typed errors with `doc_url`, `Request-Id` on every response. - `dry_run` on every write, `Idempotency-Key` on every write, `confirm` on destructive and metered writes. - IETF `RateLimit-*` headers, `ETag` and `If-None-Match`, CORS for browser OAuth apps. - An OpenAPI 3.1 document at `/v1/openapi.json`. ### Authentication - API keys with presets or fine-grained scopes, optional expiry and 24-hour rotation, created under Settings → Developers in both apps. - OAuth 2.1 with PKCE, refresh-token rotation, revocation, introspection, userinfo, dynamic client registration and client ID metadata documents. ### Webhooks and events - Signed webhooks (`Listener-Signature`, HMAC-SHA256) for campaign, cluster, episode, data, integration, brand, report and show changes, with retries for three days. - An Events API with 30 days of history. ### Developer console - Settings → Developers in both apps: API keys, OAuth apps, authorized apps, webhooks with a delivery log, request logs and usage. ### MCP - The MCP server accepts OAuth sign-in in addition to API keys. ## What is coming - Official SDKs on npm (`@listener/api`) and PyPI (`listener-api`). See [SDKs](https://developers.listener.com/docs/sdks). - The event catalog with live examples at `GET /v1/event-types`, mirrored on [Event types](https://developers.listener.com/docs/webhooks/event-types). --- # Support > How to get help with the Listener API, report a problem or disclose a security issue. ## Before you write Most problems are answered by the error itself. Every error has a `code` and a `doc_url` pointing at its entry in [Errors](https://developers.listener.com/docs/errors), and the request log under **Settings → Developers → Logs** shows the last 14 days of calls with their status and request id. ## Contact us Email [tech@listener.com](mailto:tech@listener.com). Include: - The **`request_id`** (`req_…`) from the error body or the `Request-Id` header. - The method and path, and roughly when you made the call (with time zone). - What you expected and what happened. Never include API keys, access tokens or webhook secrets. ## Community Join the [Listener community on Slack](https://join.slack.com/t/listenercom) to ask questions, share what you built and hear about changes early. ## Product help For how the apps work (connecting platforms, building reports, managing a network), see the [Listener Knowledge Base](https://help.listener.com). ## Service health `GET https://api.listener.com/health` returns the service status as JSON (`status`, `version`, `firestore`). There is no hosted status page yet. ## Security If you find a vulnerability, email [tech@listener.com](mailto:tech@listener.com) with the details and steps to reproduce. Please give us a reasonable time to fix it before disclosing it publicly. If you believe a key or token of yours has leaked, revoke it under Settings → Developers first. --- # API reference Base URL: `https://api.listener.com`. Authenticate with `Authorization: Bearer lsk_…`. OpenAPI: https://api.listener.com/v1/openapi.json ## Meta API root, the OpenAPI document and the calling credential's workspace. - `GET /v1`: [API root](https://developers.listener.com/api/meta#get-api-root) - `GET /v1/openapi.json`: [OpenAPI document](https://developers.listener.com/api/meta#get-openapi-document) - `GET /v1/me`: [Retrieve the workspace and credential](https://developers.listener.com/api/meta#get-me) ## Workspace & shows The workspace (network, subnetwork or solo show), its shows, plan, team, integrations and public profiles. - `GET /v1/shows`: [List shows](https://developers.listener.com/api/workspace-and-shows#list-shows) - `GET /v1/shows/{show_id}`: [Retrieve a show](https://developers.listener.com/api/workspace-and-shows#get-show) - `PATCH /v1/shows/{show_id}`: [Update a show's public profile](https://developers.listener.com/api/workspace-and-shows#update-show-profile) - `GET /v1/plan`: [Retrieve the plan](https://developers.listener.com/api/workspace-and-shows#get-plan) - `GET /v1/team`: [Retrieve the team](https://developers.listener.com/api/workspace-and-shows#get-team) - `GET /v1/subnetworks`: [List subnetworks](https://developers.listener.com/api/workspace-and-shows#list-subnetworks) - `GET /v1/urls`: [Build public and app URLs](https://developers.listener.com/api/workspace-and-shows#get-public-urls) - `PATCH /v1/network`: [Update the network's public profile](https://developers.listener.com/api/workspace-and-shows#update-network-profile) - `PUT /v1/network/default-emv`: [Set the network's default EMV rates](https://developers.listener.com/api/workspace-and-shows#set-network-default-emv) - `GET /v1/integrations`: [List platform integrations](https://developers.listener.com/api/workspace-and-shows#list-integrations) - `GET /v1/integrations/status`: [Retrieve data freshness and refresh status](https://developers.listener.com/api/workspace-and-shows#get-refresh-status) ## Episodes Episodes per show, search, transcripts, tags, goals and Substack download figures. - `GET /v1/shows/{show_id}/episodes`: [List a show's episodes](https://developers.listener.com/api/episodes#list-episodes) - `GET /v1/shows/{show_id}/episodes/{episode_id}`: [Retrieve an episode](https://developers.listener.com/api/episodes#get-episode) - `GET /v1/shows/{show_id}/episodes/{episode_id}/transcript`: [Retrieve an episode transcript](https://developers.listener.com/api/episodes#get-episode-transcript) - `GET /v1/episodes/search`: [Search episodes](https://developers.listener.com/api/episodes#search-episodes) - `POST /v1/shows/{show_id}/episodes/tags`: [Set episode tags](https://developers.listener.com/api/episodes#set-episode-tags) - `PUT /v1/shows/{show_id}/episode-goals`: [Set episode goals](https://developers.listener.com/api/episodes#set-episode-goals) - `PUT /v1/shows/{show_id}/substack/downloads`: [Set Substack podcast downloads for an episode node](https://developers.listener.com/api/episodes#set-substack-podcast-downloads) - `POST /v1/shows/{show_id}/substack/imports`: [Import Substack episode stats (CSV)](https://developers.listener.com/api/episodes#import-substack-episode-stats) ## Analytics Account-level reach, timelines, rankings, platform stats and recent content. These endpoints cost 2 rate-limit tokens. - `GET /v1/analytics/reach`: [Reach summary](https://developers.listener.com/api/analytics#get-reach-summary) - `GET /v1/analytics/timeline`: [Reach timeline](https://developers.listener.com/api/analytics#get-reach-timeline) - `GET /v1/analytics/shows`: [Rank shows](https://developers.listener.com/api/analytics#get-shows-ranking) - `GET /v1/analytics/compare`: [Compare two shows](https://developers.listener.com/api/analytics#compare-shows) - `GET /v1/analytics/distribution`: [Platform distribution](https://developers.listener.com/api/analytics#get-platform-distribution) - `GET /v1/analytics/dashboard`: [Dashboard KPIs](https://developers.listener.com/api/analytics#get-dashboard-kpis) - `GET /v1/analytics/hosts`: [Rank hosts](https://developers.listener.com/api/analytics#get-hosts-ranking) - `GET /v1/analytics/accounts`: [Shared-account (dedup) view](https://developers.listener.com/api/analytics#get-account-dedup-view) - `GET /v1/analytics/categories`: [List show categories](https://developers.listener.com/api/analytics#list-show-categories) - `GET /v1/shows/{show_id}/platforms`: [A show's platform stats](https://developers.listener.com/api/analytics#get-platform-stats) - `GET /v1/content`: [Recent posts and content](https://developers.listener.com/api/analytics#get-recent-posts) ## Clusters Episode clusters (an episode plus its clips and posts), cluster reports and timelines, and the AI cluster builder. - `GET /v1/clusters`: [List clusters](https://developers.listener.com/api/clusters#list-clusters) - `GET /v1/clusters/report`: [Cluster report](https://developers.listener.com/api/clusters#get-cluster-report) - `POST /v1/shows/{show_id}/clusters`: [Create a cluster](https://developers.listener.com/api/clusters#create-cluster) - `POST /v1/shows/{show_id}/clusters/merge`: [Merge episode clusters](https://developers.listener.com/api/clusters#merge-clusters) - `GET /v1/shows/{show_id}/clusters/{cluster_id}`: [Retrieve a cluster](https://developers.listener.com/api/clusters#get-cluster) - `PATCH /v1/shows/{show_id}/clusters/{cluster_id}`: [Update a cluster](https://developers.listener.com/api/clusters#update-cluster) - `DELETE /v1/shows/{show_id}/clusters/{cluster_id}`: [Delete a cluster](https://developers.listener.com/api/clusters#delete-cluster) - `GET /v1/shows/{show_id}/clusters/{cluster_id}/timeline`: [Cluster timeline](https://developers.listener.com/api/clusters#get-cluster-timeline) - `POST /v1/shows/{show_id}/episodes/{episode_id}/posts`: [Add posts to an episode's cluster](https://developers.listener.com/api/clusters#add-posts-to-cluster) - `POST /v1/shows/{show_id}/clusters/{cluster_id}/nodes/remove`: [Remove cluster nodes](https://developers.listener.com/api/clusters#remove-cluster-nodes) - `POST /v1/shows/{show_id}/clusters/{cluster_id}/nodes/tags`: [Set cluster node tags](https://developers.listener.com/api/clusters#set-cluster-node-tags) - `POST /v1/shows/{show_id}/clusters/{cluster_id}/refresh`: [Refresh a cluster's metrics](https://developers.listener.com/api/clusters#refresh-cluster) - `GET /v1/shows/{show_id}/cluster-sources`: [Accounts the cluster builder searches](https://developers.listener.com/api/clusters#get-cluster-sources) - `PATCH /v1/shows/{show_id}/cluster-sources`: [Update the cluster builder's accounts](https://developers.listener.com/api/clusters#update-cluster-sources) - `GET /v1/shows/{show_id}/cluster-candidates`: [Episodes to cluster](https://developers.listener.com/api/clusters#find-episodes-to-cluster) - `GET /v1/shows/{show_id}/episodes/{episode_id}/content-candidates`: [Find an episode's clips and posts](https://developers.listener.com/api/clusters#find-episode-content) - `POST /v1/cluster-builds`: [Start an AI cluster build](https://developers.listener.com/api/clusters#build-clusters) - `GET /v1/cluster-builds/latest`: [Retrieve a show's latest cluster build](https://developers.listener.com/api/clusters#get-latest-cluster-build) - `GET /v1/cluster-builds/{job_id}`: [Retrieve a cluster build](https://developers.listener.com/api/clusters#get-cluster-build) - `GET /v1/shows/{show_id}/cluster-suggestions`: [List cluster suggestions](https://developers.listener.com/api/clusters#list-cluster-suggestions) - `POST /v1/shows/{show_id}/cluster-suggestions/{episode_id}/review`: [Review cluster suggestions](https://developers.listener.com/api/clusters#review-cluster-suggestions) ## Campaigns Sponsorship campaigns, slots, cluster links, brand profiles and air checks. - `GET /v1/campaigns`: [List campaigns](https://developers.listener.com/api/campaigns#list-campaigns) - `POST /v1/campaigns`: [Create a campaign](https://developers.listener.com/api/campaigns#create-campaign) - `GET /v1/campaigns/{campaign_id}`: [Retrieve a campaign](https://developers.listener.com/api/campaigns#get-campaign) - `PATCH /v1/campaigns/{campaign_id}`: [Update a campaign](https://developers.listener.com/api/campaigns#update-campaign) - `DELETE /v1/campaigns/{campaign_id}`: [Delete a campaign](https://developers.listener.com/api/campaigns#delete-campaign) - `GET /v1/campaigns/{campaign_id}/timeline`: [Campaign timeline](https://developers.listener.com/api/campaigns#get-campaign-timeline) - `GET /v1/campaigns/{campaign_id}/cpm-insights`: [Campaign CPM insights](https://developers.listener.com/api/campaigns#get-campaign-cpm-insights) - `POST /v1/campaigns/{campaign_id}/status`: [Set a campaign's status](https://developers.listener.com/api/campaigns#set-campaign-status) - `POST /v1/campaigns/{campaign_id}/slots`: [Add campaign slots](https://developers.listener.com/api/campaigns#add-campaign-planned-episodes) - `PATCH /v1/campaigns/{campaign_id}/slots/{slot_id}`: [Update a campaign slot](https://developers.listener.com/api/campaigns#update-campaign-planned-episode) - `PUT /v1/campaigns/{campaign_id}/slots/{slot_id}/cluster`: [Link a cluster to a slot](https://developers.listener.com/api/campaigns#link-cluster-to-campaign) - `DELETE /v1/campaigns/{campaign_id}/slots/{slot_id}/cluster`: [Unlink a slot's cluster](https://developers.listener.com/api/campaigns#unlink-cluster-from-campaign) - `GET /v1/brands`: [List brands](https://developers.listener.com/api/campaigns#list-brands) - `GET /v1/brands/{brand_slug}`: [Retrieve a brand](https://developers.listener.com/api/campaigns#get-brand) - `PUT /v1/brands/{brand_slug}`: [Create or update a brand](https://developers.listener.com/api/campaigns#upsert-campaign-brand) - `GET /v1/air-checks`: [List air checks](https://developers.listener.com/api/campaigns#list-air-checks) ## Reports Shared reports, report templates and report automations. - `GET /v1/reports`: [List shared reports](https://developers.listener.com/api/reports#list-reports) - `POST /v1/reports`: [Publish a shared report](https://developers.listener.com/api/reports#create-shared-report) - `GET /v1/reports/{report_id}`: [Retrieve a report](https://developers.listener.com/api/reports#get-report) - `DELETE /v1/reports/{report_id}`: [Delete a shared report](https://developers.listener.com/api/reports#delete-shared-report) - `GET /v1/report-templates`: [List report templates](https://developers.listener.com/api/reports#list-report-templates) - `POST /v1/report-templates`: [Save a report template](https://developers.listener.com/api/reports#create-report-template) - `GET /v1/report-automations`: [List report automations](https://developers.listener.com/api/reports#list-report-automations) - `POST /v1/report-automations/{automation_id}/disable`: [Disable a report automation](https://developers.listener.com/api/reports#disable-report-automation) ## Sponsors Sponsor matching, network bundle opportunities and sponsorship packages. - `GET /v1/sponsors/matches`: [Match sponsors](https://developers.listener.com/api/sponsors#match-sponsors) - `GET /v1/sponsors/opportunities`: [Network bundle opportunities](https://developers.listener.com/api/sponsors#get-network-opportunities) - `GET /v1/shows/{show_id}/sponsorship-packages`: [List sponsorship packages](https://developers.listener.com/api/sponsors#list-sponsorship-packages) ## Library & research Workspace library files, web research, podcast search and insertion-order parsing. - `GET /v1/library/files`: [List library files](https://developers.listener.com/api/library-and-research#list-workspace-library-files) - `GET /v1/library/files/{file_id}`: [Retrieve a library file](https://developers.listener.com/api/library-and-research#get-workspace-library-file) - `GET /v1/research/web`: [Web research](https://developers.listener.com/api/library-and-research#web-research) - `GET /v1/podcasts/search`: [Search podcasts](https://developers.listener.com/api/library-and-research#search-podcasts) - `POST /v1/insertion-orders/parse`: [Parse an insertion order](https://developers.listener.com/api/library-and-research#parse-insertion-order) ## Refresh Metered actions: platform refreshes and Spotify / Apple data requests. - `POST /v1/shows/{show_id}/platforms/{platform}/refresh`: [Refresh a show's platform data](https://developers.listener.com/api/refresh#refresh-show-platform) - `POST /v1/data-requests`: [File a Spotify / Apple data request](https://developers.listener.com/api/refresh#request-platform-data) ## Partner integrations For hosting and ad-tech partners: discover and connect a show's accounts, set its podcast identity, look up episodes by your own ids, control auto-build, and record ad-server delivery on campaigns. - `GET /v1/shows/{show_id}/account-discovery`: [Retrieve a show's account discovery](https://developers.listener.com/api/partner-integrations#get-account-discovery) - `POST /v1/shows/{show_id}/account-discovery`: [Run account discovery for a show](https://developers.listener.com/api/partner-integrations#start-account-discovery) - `PUT /v1/shows/{show_id}/integrations/{platform}`: [Connect a platform to a show](https://developers.listener.com/api/partner-integrations#connect-integration) - `DELETE /v1/shows/{show_id}/integrations/{platform}`: [Disconnect a platform from a show](https://developers.listener.com/api/partner-integrations#disconnect-integration) - `PUT /v1/shows/{show_id}/podcast`: [Set a show's podcast identity](https://developers.listener.com/api/partner-integrations#set-podcast-identity) - `GET /v1/shows/{show_id}/episodes/lookup`: [Look up an episode by a partner's identifiers](https://developers.listener.com/api/partner-integrations#lookup-episode) - `GET /v1/shows/{show_id}/auto-build`: [Retrieve a show's auto-build setting and statuses](https://developers.listener.com/api/partner-integrations#get-auto-build) - `PUT /v1/shows/{show_id}/auto-build`: [Set a show's auto-build mode](https://developers.listener.com/api/partner-integrations#set-auto-build-mode) - `GET /v1/campaigns/{campaign_id}/external-delivery`: [List a campaign's external ad-server delivery](https://developers.listener.com/api/partner-integrations#list-campaign-external-delivery) - `POST /v1/campaigns/{campaign_id}/external-delivery`: [Record external ad-server delivery on a campaign](https://developers.listener.com/api/partner-integrations#add-campaign-external-delivery) - `DELETE /v1/campaigns/{campaign_id}/external-delivery/{row_id}`: [Delete an external delivery row](https://developers.listener.com/api/partner-integrations#delete-campaign-external-delivery) ## Webhooks & events Webhook endpoints, deliveries and the Events API. - `GET /v1/webhook-endpoints`: [List webhook endpoints](https://developers.listener.com/api/webhooks-and-events#list-webhook-endpoints) - `POST /v1/webhook-endpoints`: [Create a webhook endpoint](https://developers.listener.com/api/webhooks-and-events#create-webhook-endpoint) - `GET /v1/webhook-endpoints/{endpoint_id}`: [Retrieve a webhook endpoint](https://developers.listener.com/api/webhooks-and-events#get-webhook-endpoint) - `PATCH /v1/webhook-endpoints/{endpoint_id}`: [Update a webhook endpoint](https://developers.listener.com/api/webhooks-and-events#update-webhook-endpoint) - `DELETE /v1/webhook-endpoints/{endpoint_id}`: [Delete a webhook endpoint](https://developers.listener.com/api/webhooks-and-events#delete-webhook-endpoint) - `POST /v1/webhook-endpoints/{endpoint_id}/roll-secret`: [Roll a webhook signing secret](https://developers.listener.com/api/webhooks-and-events#roll-webhook-endpoint-secret) - `GET /v1/webhook-endpoints/{endpoint_id}/secret`: [Reveal a webhook signing secret](https://developers.listener.com/api/webhooks-and-events#reveal-webhook-endpoint-secret) - `POST /v1/webhook-endpoints/{endpoint_id}/test`: [Send a test event](https://developers.listener.com/api/webhooks-and-events#send-webhook-endpoint-test) - `GET /v1/webhook-endpoints/{endpoint_id}/deliveries`: [List recent deliveries](https://developers.listener.com/api/webhooks-and-events#list-webhook-deliveries) - `POST /v1/webhook-deliveries/{delivery_id}/retry`: [Retry a delivery](https://developers.listener.com/api/webhooks-and-events#retry-webhook-delivery) - `GET /v1/events`: [List events](https://developers.listener.com/api/webhooks-and-events#list-events) - `GET /v1/events/{event_id}`: [Retrieve an event](https://developers.listener.com/api/webhooks-and-events#get-event) - `GET /v1/event-types`: [List event types](https://developers.listener.com/api/webhooks-and-events#list-event-types) --- # Meta > API root, the OpenAPI document and the calling credential's workspace. Base URL: `https://api.listener.com` ## API root `GET /v1` No authentication required. Version, links and the docs URL. No authentication. ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | any | optional | | | `version` | string | optional | | | `docs_url` | string | optional | | | `openapi_url` | string | optional | | | `links` | object | optional | | ### Example request ```bash curl "https://api.listener.com/v1" ``` ### Example response ```json { "object": "api", "name": "Listener API", "version": "2026-10-14", "versions": [ "2026-10-14" ], "server_version": "2.5.0", "docs_url": "https://developers.listener.com", "openapi_url": "https://api.listener.com/v1/openapi.json", "links": { "me": "https://api.listener.com/v1/me", "shows": "https://api.listener.com/v1/shows", "campaigns": "https://api.listener.com/v1/campaigns", "event_types": "https://api.listener.com/v1/event-types", "oauth_metadata": "https://api.listener.com/baseline-banter/oauth-authorization-server", "mcp": "https://api.listener.com/mcp" } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## OpenAPI document `GET /v1/openapi.json` No authentication required. This API as an OpenAPI 3.1 document. No authentication. ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Example request ```bash curl "https://api.listener.com/v1/openapi.json" ``` ### Example response ```json {} ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Retrieve the workspace and credential `GET /v1/me` Scope: `shows:read` SDK: `client.me.retrieve()` (TypeScript) · `client.me.retrieve()` (Python) The workspace this credential resolves to: identity, kind (`network`, `subnetwork` or `solo`), plan, every show (id, name, handle, public URL, categories, connected platforms), `missing_show_ids` (shows the workspace points at that no longer exist; excluded everywhere), team and subnetwork counts, integrations summary and per-platform data freshness, plus the calling `credential` (type, id, label, scopes, OAuth client). Start here: the show ids every other endpoint accepts come from this response or `GET /v1/shows`. Solo workspaces return one show; subnetworks report `parent_network_uid`. - Required scope: `shows:read`. ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `effective_uid` | string | required | | | `authenticated_uid` | string | required | | | `auth_kind` | string | required | | | `key_label` | string | optional | Default `null`. Nullable. | | `scopes` | array of strings | required | | | `kind` | string | required | One of `network`, `subnetwork`, `solo`. | | `is_network` | boolean | required | | | `is_subnetwork` | boolean | required | | | `parent_network_uid` | string | optional | Default `null`. Nullable. | | `network_name` | string | required | | | `network_handle` | string | optional | Default `null`. Nullable. | | `network_logo` | string | optional | Default `null`. Nullable. | | `plan` | string | required | | | `shows` | array of objects | required | | | `shows[].id` | string | required | | | `shows[].name` | string | required | | | `shows[].handle` | string | optional | Default `""`. | | `shows[].image` | string | optional | Default `null`. Nullable. | | `shows[].public_url` | string | optional | Default `null`. Nullable. | | `shows[].categories` | array of strings | optional | | | `shows[].plan` | string | required | | | `shows[].connected_platforms` | array of strings | optional | | | `shows[].stale_platforms` | array of strings | optional | | | `shows[].episode_count` | integer | optional | Known episode total from Spotify/metadata; `GET /v1/shows/{show_id}/episodes` returns the exact indexed count. Default `null`. Nullable. | | `show_count` | integer | required | | | `missing_show_ids` | array of strings | optional | managedKits entries with no users/ doc; excluded from shows. | | `team_size` | integer | required | | | `subnetwork_count` | integer | required | | | `campaign_count` | integer | optional | Default `null`. Nullable. | | `integrations_summary` | map of integer | optional | platform → number of shows with that platform connected or holding data. | | `data_freshness` | map of object | optional | platform → oldest freshness across shows. | | `data_freshness.last_updated` | string | optional | Default `null`. Nullable. | | `data_freshness.age_hours` | number | optional | Default `null`. Nullable. | | `data_freshness.stale` | boolean | optional | True when older than 36 hours or unknown. Default `true`. | | `stale_platforms` | array of strings | optional | | | `network_default_emv` | object | optional | Default `null`. Nullable. | | `public_url` | string | optional | Default `null`. Nullable. | | `app_url` | string | required | | | `computed_at` | string | required | | | `cache_age_seconds` | number | required | | | `note` | string | optional | Default `null`. Nullable. | | `credential` | object | optional | | | `credential.object` | any | optional | | | `credential.type` | string | optional | One of `api_key`, `oauth_token`, `firebase_id_token`. | | `credential.id` | string | optional | | | `credential.label` | string | optional | Nullable. | | `credential.scopes` | array of strings | optional | | | `credential.client_id` | string | optional | Nullable. | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/me" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "workspace", "effective_uid": "XXfzcGxkLsQaKAvRy1SwLsZCcH78", "authenticated_uid": "XXfzcGxkLsQaKAvRy1SwLsZCcH78", "auth_kind": "api_key", "key_label": "revamp-dev", "scopes": [ "workspace:read", "workspace:write" ], "kind": "network", "is_network": true, "is_subnetwork": false, "parent_network_uid": null, "network_name": "Northwind Audio", "network_handle": "northwind-audio", "network_logo": "https://images.example.com/listener/fdacdc5d7150ed7b.jpg", "plan": "premium", "shows": [ { "id": "paAtKPJGo56md5VyoEQ9Las7qahL", "name": "Hard Court", "handle": "hard-court", "image": "https://images.example.com/listener/6cbd825d3f77ec48.jpg", "public_url": "https://listener.com/hard-court", "categories": [ "Sports", "Comedy" ], "plan": "premium", "connected_platforms": [ "applePodcasts", "instagram" ], "stale_platforms": [ "applePodcasts" ], "episode_count": 2242 }, { "id": "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H", "name": "The Bullpen", "handle": "the-bullpen", "image": "https://images.example.com/listener/8f6c3c4428259526.jpg", "public_url": "https://listener.com/the-bullpen", "categories": [ "Fantasy Sports", "Sports News" ], "plan": "premium", "connected_platforms": [ "applePodcasts", "instagram" ], "stale_platforms": [ "applePodcasts", "spotify" ], "episode_count": 1428 } ], "show_count": 12, "missing_show_ids": [ "0Iush8rT5eCcD4ePHroKFDlQvKhT", "nCJuUMzGC6EE3dfruqjRHtYdlCTr" ], "team_size": 1, "subnetwork_count": 0, "campaign_count": 21, "integrations_summary": { "applePodcasts": 10, "facebook": 1, "instagram": 11, "instagramBusiness": 1, "megaphone": 3, "omnystudio": 1, "spotify": 12, "threads": 3, "tiktok": 11, "twitter": 10, "youtube": 7 }, "data_freshness": { "applePodcasts": { "last_updated": "2026-04-13T16:00:26+00:00", "age_hours": 4417.56, "stale": true }, "facebook": { "last_updated": "2026-10-08T07:02:13.420000+00:00", "age_hours": 154.53, "stale": true }, "instagram": { "last_updated": "2026-10-13T09:35:13.282000+00:00", "age_hours": 31.98, "stale": false }, "megaphone": { "last_updated": "2026-10-14T09:22:51.743000+00:00", "age_hours": 8.19, "stale": false }, "omnystudio": { "last_updated": "2026-10-13T09:33:48.789000+00:00", "age_hours": 32.01, "stale": false }, "spotify": { "last_updated": "2026-08-06T08:49:48.390000+00:00", "age_hours": 1664.74, "stale": true }, "threads": { "last_updated": "2026-10-13T09:36:40.899000+00:00", "age_hours": 31.96, "stale": false }, "tiktok": { "last_updated": "2026-10-13T09:34:15.574000+00:00", "age_hours": 32, "stale": false }, "twitter": { "last_updated": "2026-10-10T07:02:33.014000+00:00", "age_hours": 106.53, "stale": true }, "youtube": { "last_updated": "2026-10-02T10:37:33.857000+00:00", "age_hours": 294.94, "stale": true } }, "stale_platforms": [ "applePodcasts", "facebook" ], "network_default_emv": { "likeRate": 0.25, "longFormCpm": 25, "shortFormCpm": 20, "commentRate": 1.25 }, "public_url": "https://network.listener.com/northwind-audio", "app_url": "https://network.listener.com/app/dashboard?uid=XXfzcGxkLsQaKAvRy1SwLsZCcH78", "computed_at": "2026-10-14T17:34:12.427723+00:00", "cache_age_seconds": 6.9, "note": "2 managedKits entries point at deleted shows; clean them in Listener Networks → Shows.", "credential": { "object": "credential", "type": "api_key", "id": "jSmUlIkAtT5p", "label": "revamp-dev", "scopes": [ "workspace:read", "workspace:write" ], "client_id": null } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. --- # Workspace & shows > The workspace (network, subnetwork or solo show), its shows, plan, team, integrations and public profiles. Base URL: `https://api.listener.com` ## List shows `GET /v1/shows` Scope: `shows:read` SDK: `client.shows.list()` (TypeScript) · `client.shows.list()` (Python) The workspace's shows with profile, plan, connected platforms and public URLs. `category` matches any of a show's categories (case-insensitive); `search` matches name, handle and description. Hidden shows (every category hidden on the profile) are left out unless `include_hidden=true`. - Required scope: `shows:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `category` | string | optional | | | `search` | string | optional | Case-insensitive text filter. | | `include_hidden` | boolean | optional | | | `limit` | integer | optional | Page size, 1–100 (default 25). | | `cursor` | string | optional | Opaque cursor from the previous page's next_cursor. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].id` | string | required | | | `data[].name` | string | required | | | `data[].handle` | string | optional | Default `""`. | | `data[].image` | string | optional | Default `null`. Nullable. | | `data[].public_url` | string | optional | Default `null`. Nullable. | | `data[].categories` | array of strings | optional | | | `data[].plan` | string | required | | | `data[].connected_platforms` | array of strings | optional | | | `data[].stale_platforms` | array of strings | optional | | | `data[].episode_count` | integer | optional | Known episode total from Spotify/metadata; `GET /v1/shows/{show_id}/episodes` returns the exact indexed count. Default `null`. Nullable. | | `data[].description` | string | optional | Default `""`. | | `data[].author` | string | optional | Default `""`. | | `data[].hidden_categories` | array of strings | optional | | | `data[].is_hidden` | boolean | optional | Default `false`. | | `data[].agency_managed` | boolean | optional | Default `false`. | | `data[].created_at` | string | optional | Default `null`. Nullable. | | `data[].last_cluster_update` | string | optional | Default `null`. Nullable. | | `data[].host_names` | array of strings | optional | | | `data[].app_url` | string | required | | | `data[].object` | string | required | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | | `meta.filters` | object | optional | | | `meta.missing_show_ids` | array of strings | optional | | | `meta.computed_at` | string | optional | | | `meta.cache_age_seconds` | number | optional | | | `meta.note` | string | optional | Default `null`. Nullable. | ### Example request ```bash curl "https://api.listener.com/v1/shows" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/shows", "data": [ { "object": "show", "id": "i3KlnFag2DgIpykraVxjrIZwJYcL", "name": "The Long Game", "handle": "the-long-game", "image": "https://images.example.com/listener/4b71276112f25fab.jpg", "public_url": "https://listener.com/the-long-game", "categories": [ "Sports", "Basketball" ], "plan": "premium", "connected_platforms": [ "applePodcasts", "instagram" ], "stale_platforms": [ "applePodcasts", "spotify" ], "episode_count": 2754, "description": "Highlights and analysis from the latest matchday.", "author": "Northwind", "hidden_categories": [], "is_hidden": false, "agency_managed": true, "created_at": "2025-09-01T17:22:29.493000+00:00", "last_cluster_update": null, "host_names": [ "Morgan Diaz", "Reese Carter" ], "app_url": "https://network.listener.com/app/dashboard?uid=i3KlnFag2DgIpykraVxjrIZwJYcL" }, { "object": "show", "id": "sLLP2LKo9LJHqRCtBjjWDsbyDImn", "name": "Dugout Daily", "handle": "dugout-daily", "image": "https://images.example.com/listener/09c3beb030eae9d0.jpg", "public_url": "https://listener.com/dugout-daily", "categories": [ "Technology", "Sports" ], "plan": "premium", "connected_platforms": [ "applePodcasts", "instagram" ], "stale_platforms": [ "applePodcasts", "spotify" ], "episode_count": 463, "description": "A deep dive into the numbers behind the season so far.", "author": "Alex Rivera", "hidden_categories": [], "is_hidden": false, "agency_managed": true, "created_at": "2025-09-01T17:33:30.709000+00:00", "last_cluster_update": "2026-01-14T20:25:31.494600+00:00", "host_names": [ "Alex Rivera" ], "app_url": "https://network.listener.com/app/dashboard?uid=sLLP2LKo9LJHqRCtBjjWDsbyDImn" } ], "has_more": true, "next_cursor": "eyJvIjoyfQ", "meta": { "filters": { "category": null, "search": null, "include_hidden": false }, "missing_show_ids": [ "0Iush8rT5eCcD4ePHroKFDlQvKhT", "nCJuUMzGC6EE3dfruqjRHtYdlCTr" ], "computed_at": "2026-10-14T17:34:12.430383+00:00", "cache_age_seconds": 6.9, "note": null, "total": 12 } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Retrieve a show `GET /v1/shows/{show_id}` Scope: `shows:read` SDK: `client.shows.retrieve()` (TypeScript) · `client.shows.retrieve()` (Python) One show's full profile: hosts, categories, podcast metadata (RSS, Spotify and Apple ids), Spotify rating, sponsorship packages, past guests, collaborations, per-platform connection state and freshness, and public URLs. - Required scope: `shows:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `id` | string | required | | | `name` | string | required | | | `handle` | string | optional | Default `""`. | | `image` | string | optional | Default `null`. Nullable. | | `description` | string | optional | Default `""`. | | `author` | string | optional | Default `""`. | | `website` | string | optional | Default `null`. Nullable. | | `categories` | array of strings | optional | | | `hidden_categories` | array of strings | optional | | | `main_platform` | string | optional | Default `null`. Nullable. | | `plan` | string | required | | | `agency_managed` | boolean | optional | Default `false`. | | `created_by` | string | optional | Default `null`. Nullable. | | `created_at` | string | optional | Default `null`. Nullable. | | `updated_at` | string | optional | Default `null`. Nullable. | | `hosts` | array of objects | optional | | | `hosts[].id` | string | optional | Default `null`. Nullable. | | `hosts[].name` | string | required | | | `hosts[].bio` | string | optional | Default `""`. | | `hosts[].photo` | string | optional | Default `null`. Nullable. | | `hosts[].social_links` | map of string | optional | | | `show_host_section` | boolean | optional | Default `false`. | | `talent` | array of objects | optional | | | `talent[].email` | string | optional | Masked: j***@domain Default `null`. Nullable. | | `talent[].uid` | string | optional | Default `null`. Nullable. | | `talent[].status` | string | optional | Default `null`. Nullable. | | `talent[].role` | string | optional | Default `"talent"`. | | `talent[].invited_at` | string | optional | Default `null`. Nullable. | | `talent[].accepted_at` | string | optional | Default `null`. Nullable. | | `podcast_metadata` | object | required | | | `podcast_metadata.provider` | string | optional | Default `null`. Nullable. | | `podcast_metadata.provider_id` | string | optional | Default `null`. Nullable. | | `podcast_metadata.provider_url` | string | optional | Default `null`. Nullable. | | `podcast_metadata.rss_url` | string | optional | Default `null`. Nullable. | | `podcast_metadata.spotify_id` | string | optional | Default `null`. Nullable. | | `podcast_metadata.apple_podcasts_id` | string | optional | Default `null`. Nullable. | | `podcast_metadata.total_episodes` | integer | optional | Default `null`. Nullable. | | `podcast_metadata.average_episode_length_sec` | integer | optional | Default `null`. Nullable. | | `podcast_metadata.imported_at` | string | optional | Default `null`. Nullable. | | `spotify_rating` | object | optional | Default `null`. Nullable. | | `spotify_rating.rating` | number | optional | Default `null`. Nullable. | | `spotify_rating.total_ratings` | integer | optional | Default `null`. Nullable. | | `spotify_rating.total_episodes` | integer | optional | Default `null`. Nullable. | | `platforms` | array of objects | optional | | | `platforms[].platform` | string | required | | | `platforms[].connected` | boolean | required | | | `platforms[].has_data` | boolean | required | | | `platforms[].last_updated` | string | optional | Default `null`. Nullable. | | `platforms[].importing_state` | any | optional | Default `null`. Nullable. | | `platforms[].freshness` | object | optional | Default `null`. Nullable. | | `platforms[].freshness.last_updated` | string | optional | Default `null`. Nullable. | | `platforms[].freshness.age_hours` | number | optional | Default `null`. Nullable. | | `platforms[].freshness.stale` | boolean | optional | True when older than 36 hours or unknown. Default `true`. | | `connected_platforms` | array of strings | optional | | | `freshness` | map of object | optional | | | `freshness.last_updated` | string | optional | Default `null`. Nullable. | | `freshness.age_hours` | number | optional | Default `null`. Nullable. | | `freshness.stale` | boolean | optional | True when older than 36 hours or unknown. Default `true`. | | `stale_platforms` | array of strings | optional | | | `sponsorship_packages` | array of objects | optional | | | `collaborations` | array of objects | optional | | | `collaborations[].id` | string | optional | Default `null`. Nullable. | | `collaborations[].name` | string | optional | Default `""`. | | `collaborations[].brand_url` | string | optional | Default `null`. Nullable. | | `collaborations[].date` | string | optional | Default `null`. Nullable. | | `collaborations[].content_link` | string | optional | Default `null`. Nullable. | | `collaborations[].description` | string | optional | Default `""`. | | `past_guests` | array of objects | optional | | | `past_guests[].id` | string | optional | Default `null`. Nullable. | | `past_guests[].name` | string | required | | | `past_guests[].title` | string | optional | Default `""`. | | `past_guests[].bio` | string | optional | Default `""`. | | `past_guests[].photo` | string | optional | Default `null`. Nullable. | | `past_guests[].featured` | boolean | optional | Default `false`. | | `past_guests[].episode_link` | string | optional | Default `null`. Nullable. | | `survey_data` | object | optional | Default `null`. Nullable. | | `survey_data.how_did_you_find` | string | optional | Default `null`. Nullable. | | `survey_data.what_are_you_looking_for` | array of strings | optional | | | `survey_data.completed_at` | string | optional | Default `null`. Nullable. | | `last_cluster_update` | string | optional | Default `null`. Nullable. | | `last_update_stats` | object | optional | Default `null`. Nullable. | | `episode_count` | integer | optional | Default `null`. Nullable. | | `public_url` | string | optional | Default `null`. Nullable. | | `app_url` | string | required | | | `computed_at` | string | required | | | `cache_age_seconds` | number | required | | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/shows/SHOW_ID" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "show", "id": "i3KlnFag2DgIpykraVxjrIZwJYcL", "name": "The Long Game", "handle": "the-long-game", "image": "https://images.example.com/listener/4b71276112f25fab.jpg", "description": "Highlights and analysis from the latest matchday.", "author": "Northwind", "website": null, "categories": [ "Sports", "Basketball" ], "hidden_categories": [], "main_platform": "apple", "plan": "premium", "agency_managed": true, "created_by": "XXfzcGxkLsQaKAvRy1SwLsZCcH78", "created_at": "2025-09-01T17:22:29.493000+00:00", "updated_at": "2026-04-13T17:28:59.370000+00:00", "hosts": [ { "id": null, "name": "Morgan Diaz", "bio": "Clip from this week's episode. Full episode on YouTube and Spotify.", "photo": "https://images.example.com/listener/09fdf85b7709cc3a.jpg", "social_links": { "linkedin": "https://www.linkedin.com/in/mkaowcqjd/", "tiktok": "https://www.tiktok.com/@matchday48", "instagram": "https://www.instagram.com/tefxrxwfochj/", "twitter": "https://x.com/tefxrxwfochj" } }, { "id": null, "name": "Reese Carter", "bio": "New episode out now. We break down the week's biggest storylines and answer your questions.", "photo": "https://images.example.com/listener/b60b8d133c43b54b.jpg", "social_links": { "linkedin": "https://www.linkedin.com/in/pautuls-jilygfhx-29282s7x/" } } ], "show_host_section": true, "talent": [], "podcast_metadata": { "provider": null, "provider_id": null, "provider_url": null, "rss_url": null, "spotify_id": "2pGhC7Mvqu6L2VzmndqWLq", "apple_podcasts_id": "0807847418", "total_episodes": null, "average_episode_length_sec": null, "imported_at": null }, "spotify_rating": { "rating": 4.65, "total_ratings": 954402, "total_episodes": 2754 }, "platforms": [ { "platform": "applePodcasts", "connected": true, "has_data": true, "last_updated": "2026-04-13T17:28:59+00:00", "importing_state": null, "freshness": { "last_updated": "2026-04-13T17:28:59+00:00", "age_hours": 4416.09, "stale": true } }, { "platform": "beehiiv", "connected": false, "has_data": false, "last_updated": null, "importing_state": null, "freshness": null } ], "connected_platforms": [ "applePodcasts", "instagram" ], "freshness": { "youtube": { "last_updated": "2026-10-13T09:34:22.044000+00:00", "age_hours": 32, "stale": false }, "spotify": { "last_updated": "2026-09-22T22:06:27.429000+00:00", "age_hours": 523.46, "stale": true }, "applePodcasts": { "last_updated": "2026-04-13T17:28:59+00:00", "age_hours": 4416.09, "stale": true }, "tiktok": { "last_updated": "2026-10-13T09:35:24.476000+00:00", "age_hours": 31.98, "stale": false }, "instagram": { "last_updated": "2026-10-13T09:35:13.282000+00:00", "age_hours": 31.98, "stale": false } }, "stale_platforms": [ "applePodcasts", "spotify" ], "sponsorship_packages": [], "collaborations": [], "past_guests": [ { "id": null, "name": "Rowan Blake", "title": "Power rankings, week 6", "bio": "A deep dive into the numbers behind the season so far.", "photo": "https://images.example.com/listener/c3b2579e6356fc07.jpg", "featured": false, "episode_link": "https://open.spotify.com/episode/74E9FEMUr1EzoWXQlDVN5C" }, { "id": null, "name": "Sage Turner", "title": "Playoff picture, explained", "bio": "New episode out now. We break down the week's biggest storylines and answer your questions.", "photo": "https://images.example.com/listener/d6d31a45da778191.jpg", "featured": false, "episode_link": "https://open.spotify.com/episode/0wstKw9APFDUvHLk1VmhfS" } ], "survey_data": null, "last_cluster_update": null, "last_update_stats": null, "episode_count": 2754, "public_url": "https://listener.com/the-long-game", "app_url": "https://network.listener.com/app/dashboard?uid=i3KlnFag2DgIpykraVxjrIZwJYcL", "computed_at": "2026-10-14T17:34:12.432340+00:00", "cache_age_seconds": 6.9 } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Update a show's public profile `PATCH /v1/shows/{show_id}` Scope: `shows:write` · some changes need `confirm=true` SDK: `client.shows.update()` (TypeScript) · `client.shows.update()` (Python) Update public-profile fields shown on `https://listener.com/{handle}`: name, author, logo, about, categories (up to 5), main platform, featured YouTube video (URL or 11-character id), highlighted Spotify episode (URL or 22-character id), publishing schedule, brand colours (`#hex`) and website. Only the fields you send change. Changing `handle` needs `"confirm": true`: it is slugified, checked for uniqueness, and the old public URL stops resolving (nothing redirects). Custom stats, display settings, hosts, collaborations and email are never touched. The response is the exact update map. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `shows:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `podcast_name` | string | optional | Default `null`. Nullable. | | `podcast_author` | string | optional | Default `null`. Nullable. | | `podcast_logo` | string | optional | Default `null`. Nullable. | | `about` | string | optional | Default `null`. Nullable. | | `categories` | array of strings | optional | Only shows whose categories contain one of these (case-insensitive substring). Default `null`. Nullable. | | `main_platform` | string | optional | Default `null`. Nullable. | | `youtube_video` | string | optional | Default `null`. Nullable. | | `highlighted_episode` | string | optional | Default `null`. Nullable. | | `publishing_schedule` | string | optional | Default `null`. Nullable. | | `custom_schedule` | string | optional | Default `null`. Nullable. | | `primary_color` | string | optional | Default `null`. Nullable. | | `secondary_color` | string | optional | Default `null`. Nullable. | | `website` | string | optional | Default `null`. Nullable. | | `handle` | string | optional | Public URL handle (lowercase, hyphens). Default `null`. Nullable. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | | `confirm` | boolean | optional | Must be true to apply destructive or metered changes; without it the API answers 409 with a preview. Default `false`. | ### Example request ```bash curl -X PATCH "https://api.listener.com/v1/shows/SHOW_ID" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "show", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/i3KlnFag2DgIpykraVxjrIZwJYcL", "id": "i3KlnFag2DgIpykraVxjrIZwJYcL", "method": "transaction", "written": { "profile.about": "API probe (dry run)", "updatedAt": "2026-10-14T17:34:12.766Z" }, "warnings": [], "urls": { "app": "https://network.listener.com/app/connect?uid=i3KlnFag2DgIpykraVxjrIZwJYcL", "public": "https://listener.com/the-long-game" }, "error": null, "data": { "show_id": "i3KlnFag2DgIpykraVxjrIZwJYcL" }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Retrieve the plan `GET /v1/plan` Scope: `shows:read` SDK: `client.plan.retrieve()` (TypeScript) · `client.plan.retrieve()` (Python) The resolved plan (explicit plan → subscription → network → free) and the premium platforms and features it unlocks. Check it before offering an integration the plan locks. - Required scope: `shows:read`. ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `plan` | string | required | | | `plan_source` | string | required | One of `explicit`, `subscription`, `is_network`, `agency_managed`, `default_free`. | | `is_paid` | boolean | required | | | `premium_platforms` | array of strings | required | Platforms the app marks isPremium (locked on the free plan). | | `locked_platforms` | array of strings | required | Premium platforms this plan cannot connect. | | `gated_features` | map of boolean | required | feature → available on this plan. | | `shows_plans` | map of string | optional | show_id → resolved plan for each managed show. | | `writes_enabled` | boolean | required | | | `note` | string | optional | Default `null`. Nullable. | | `computed_at` | string | required | | | `cache_age_seconds` | number | required | | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/plan" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "plan", "plan": "premium", "plan_source": "explicit", "is_paid": true, "premium_platforms": [ "spotify", "applePodcasts" ], "locked_platforms": [], "gated_features": { "premium_audio_platforms": true, "campaigns": true, "subnetworks": true, "team_members": true, "shared_reports": true, "clusters": true, "network_public_page": true, "mcp_writes": false }, "shows_plans": { "paAtKPJGo56md5VyoEQ9Las7qahL": "premium", "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H": "premium", "HTSVG2lSc1RmPELJNtCnvOEb4Z2u": "premium", "Xl2xMNVBZpyUA7MVJpqcKkFhEM2X": "premium", "i3KlnFag2DgIpykraVxjrIZwJYcL": "premium", "p3KfMe4Kfe1mY1eWjLLGFzS2wtUu": "premium", "sLLP2LKo9LJHqRCtBjjWDsbyDImn": "premium", "PXjDgD84U0pDobe25GOpKoNGTT1i": "premium", "jgDOYIsPG3c9Jk7mysodavhQxXhV": "premium", "Of9Els0RskCtlyZFPG8ujblH3NKI": "premium", "2RSfMoyvMja3bzIq9vdY0UeZ0Jp8": "premium", "h2DMGe9hfktytWWa0ZxVj1jMQbUb": "premium" }, "writes_enabled": true, "note": null, "computed_at": "2026-10-14T17:34:12.769014+00:00", "cache_age_seconds": 7.2 } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Retrieve the team `GET /v1/team` Scope: `shows:read` SDK: `client.team.retrieve()` (TypeScript) · `client.team.retrieve()` (Python) The network team: accepted members, pending invites and talent invites per show. Emails are masked (`j***@domain`). - Required scope: `shows:read`. ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `owner_uid` | string | required | | | `members` | array of objects | required | | | `members[].email` | string | optional | Masked: j***@domain Default `null`. Nullable. | | `members[].uid` | string | optional | Default `null`. Nullable. | | `members[].status` | string | optional | Default `"pending"`. | | `members[].role` | string | optional | Default `"member"`. | | `members[].invited_at` | string | optional | Default `null`. Nullable. | | `members[].accepted_at` | string | optional | Default `null`. Nullable. | | `pending_invites` | array of objects | required | | | `pending_invites[].email` | string | optional | Masked: j***@domain Default `null`. Nullable. | | `pending_invites[].uid` | string | optional | Default `null`. Nullable. | | `pending_invites[].status` | string | optional | Default `"pending"`. | | `pending_invites[].role` | string | optional | Default `"member"`. | | `pending_invites[].invited_at` | string | optional | Default `null`. Nullable. | | `pending_invites[].accepted_at` | string | optional | Default `null`. Nullable. | | `team_size` | integer | required | Owner + accepted members. | | `talent_by_show` | array of objects | optional | | | `talent_by_show[].show_id` | string | required | | | `talent_by_show[].show_name` | string | required | | | `talent_by_show[].talent` | array of objects | required | | | `talent_by_show[].talent[].email` | string | optional | Masked: j***@domain Default `null`. Nullable. | | `talent_by_show[].talent[].uid` | string | optional | Default `null`. Nullable. | | `talent_by_show[].talent[].status` | string | optional | Default `null`. Nullable. | | `talent_by_show[].talent[].role` | string | optional | Default `"talent"`. | | `talent_by_show[].talent[].invited_at` | string | optional | Default `null`. Nullable. | | `talent_by_show[].talent[].accepted_at` | string | optional | Default `null`. Nullable. | | `note` | string | optional | Default `null`. Nullable. | | `computed_at` | string | required | | | `cache_age_seconds` | number | required | | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/team" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "team", "owner_uid": "XXfzcGxkLsQaKAvRy1SwLsZCcH78", "members": [], "pending_invites": [], "team_size": 1, "talent_by_show": [ { "show_id": "p3KfMe4Kfe1mY1eWjLLGFzS2wtUu", "show_name": "Rink Rats", "talent": [ { "email": "d***@mxsxmlhkfvuhc15", "uid": "64NBXKv1myJyv2r2Mu9d7fE7SDF4", "status": "accepted", "role": "talent", "invited_at": "2025-11-26T13:59:41.004000+00:00", "accepted_at": "2025-11-26T16:06:39.392000+00:00" } ] }, { "show_id": "sLLP2LKo9LJHqRCtBjjWDsbyDImn", "show_name": "Dugout Daily", "talent": [ { "email": "b***@mxsxmlhkfvuhc15", "uid": null, "status": "pending", "role": "talent", "invited_at": "2025-11-26T13:59:22.108000+00:00", "accepted_at": null } ] } ], "note": null, "computed_at": "2026-10-14T17:34:12.770975+00:00", "cache_age_seconds": 7.2 } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## List subnetworks `GET /v1/subnetworks` Scope: `shows:read` SDK: `client.subnetworks.list()` (TypeScript) · `client.subnetworks.list()` (Python) The network's subnetworks (id, name, handle, show ids, activation state, public URL). Never returns credentials. Solo workspaces get an empty list with `meta.note`; a subnetwork reports `parent_network_uid` instead. - Required scope: `shows:read`. ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].id` | string | optional | Default `null`. Nullable. | | `data[].name` | string | required | | | `data[].handle` | string | optional | Default `null`. Nullable. | | `data[].logo` | string | optional | Default `null`. Nullable. | | `data[].show_ids` | array of strings | optional | | | `data[].show_count` | integer | optional | Default `0`. | | `data[].unknown_show_ids` | array of strings | optional | Show ids listed on the subnetwork but not in the parent's managedKits. | | `data[].is_activated` | boolean | optional | Default `false`. | | `data[].account_uid` | string | optional | Default `null`. Nullable. | | `data[].created_at` | string | optional | Default `null`. Nullable. | | `data[].public_url` | string | optional | Default `null`. Nullable. | | `data[].object` | string | required | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | | `meta.total` | integer | optional | | | `meta.is_subnetwork` | boolean | optional | Default `false`. | | `meta.parent_network_uid` | string | optional | Default `null`. Nullable. | | `meta.note` | string | optional | Default `null`. Nullable. | | `meta.computed_at` | string | optional | | | `meta.cache_age_seconds` | number | optional | | ### Example request ```bash curl "https://api.listener.com/v1/subnetworks" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/subnetworks", "data": [], "has_more": false, "next_cursor": null, "meta": { "total": 0, "is_subnetwork": false, "parent_network_uid": null, "note": "No subnetworks configured.", "computed_at": "2026-10-14T17:34:12.773152+00:00", "cache_age_seconds": 7.2 } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Build public and app URLs `GET /v1/urls` Scope: `shows:read` SDK: `client.urls.retrieve()` (TypeScript) · `client.urls.retrieve()` (Python) Build the public share URL and in-app URL for a `network`, `show`, `cluster`, `campaign`, `brand`, `report`, `chart` or `links` page. `id` is the entity id (cluster id, campaign id, brand slug, report id, chart id); `show_id` is required for show, cluster and links pages. The entity is not checked for existence. - Required scope: `shows:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `entity` | string | required | | | `id` | string | optional | | | `show_id` | string | optional | A show id in this workspace (see GET /v1/shows). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `entity` | string | required | One of `network`, `show`, `cluster`, `campaign`, `brand`, `report`, `chart`, `link`. | | `id` | string | optional | Default `null`. Nullable. | | `show_id` | string | optional | Default `null`. Nullable. | | `public_url` | string | optional | Default `null`. Nullable. | | `app_url` | string | optional | Default `null`. Nullable. | | `note` | string | optional | Default `null`. Nullable. | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/urls?entity=ENTITY" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "public_url", "entity": "network", "id": null, "show_id": null, "public_url": "https://network.listener.com/northwind-audio", "app_url": "https://network.listener.com/app/dashboard?uid=XXfzcGxkLsQaKAvRy1SwLsZCcH78", "note": null } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Update the network's public profile `PATCH /v1/network` Scope: `shows:write` · some changes need `confirm=true` SDK: `client.network.update()` (TypeScript) · `client.network.update()` (Python) Update the network's public profile shown on `https://network.listener.com/{handle}`: name, owner, website, contact email, about, focus, categories (up to 5), founded year, layout and the AI / reach-stats toggles. Only the fields you send change. Changing `handle` (`^[a-z0-9-]+$`) needs `"confirm": true`; the old public URL stops resolving. Privacy, custom domain, brand colours and sponsors are never touched. Solo workspaces have no network profile (400). - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `shows:write`. ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `network_name` | string | optional | Default `null`. Nullable. | | `network_owner` | string | optional | Default `null`. Nullable. | | `network_website` | string | optional | Default `null`. Nullable. | | `contact_email` | string | optional | Default `null`. Nullable. | | `about` | string | optional | Default `null`. Nullable. | | `main_focus` | string | optional | Default `null`. Nullable. | | `categories` | array of strings | optional | Only shows whose categories contain one of these (case-insensitive substring). Default `null`. Nullable. | | `founded_year` | string | optional | Default `null`. Nullable. | | `podcast_display_layout` | string | optional | One of `grid`, `list`. Default `null`. Nullable. | | `show_ai` | boolean | optional | Default `null`. Nullable. | | `show_reach_stats` | boolean | optional | Default `null`. Nullable. | | `handle` | string | optional | Public URL handle (lowercase, hyphens). Default `null`. Nullable. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | | `confirm` | boolean | optional | Must be true to apply destructive or metered changes; without it the API answers 409 with a preview. Default `false`. | ### Example request ```bash curl -X PATCH "https://api.listener.com/v1/network" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "network", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/XXfzcGxkLsQaKAvRy1SwLsZCcH78", "id": "XXfzcGxkLsQaKAvRy1SwLsZCcH78", "method": "transaction", "written": { "networkProfile.about": "API probe (dry run)", "updatedAt": "2026-10-14T17:34:12.936Z" }, "warnings": [], "urls": { "app": "https://network.listener.com/app/network-connect?uid=XXfzcGxkLsQaKAvRy1SwLsZCcH78", "public": "https://network.listener.com/northwind-audio" }, "error": null, "data": { "network_uid": "XXfzcGxkLsQaKAvRy1SwLsZCcH78" }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Set the network's default EMV rates `PUT /v1/network/default-emv` Scope: `shows:write` · some changes need `confirm=true` SDK: `client.network.setDefaultEmv()` (TypeScript) · `client.network.set_default_emv()` (Python) Set the network-wide default EMV rates inherited by every cluster and campaign without its own override. CPMs are dollars per 1,000 impressions; like and comment rates are dollars each; every value must be a finite number ≥ 0. Needs `"confirm": true` because it changes EMV figures across the whole workspace. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `shows:write`. ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `long_form_cpm` | number | required | | | `short_form_cpm` | number | required | | | `like_rate` | number | required | | | `comment_rate` | number | required | | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | | `confirm` | boolean | optional | Must be true to apply destructive or metered changes; without it the API answers 409 with a preview. Default `false`. | ### Example request ```bash curl -X PUT "https://api.listener.com/v1/network/default-emv" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"long_form_cpm": 1.0, "short_form_cpm": 1.0, "like_rate": 1.0, "comment_rate": 1.0, "dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "network_default_emv", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/XXfzcGxkLsQaKAvRy1SwLsZCcH78", "id": "XXfzcGxkLsQaKAvRy1SwLsZCcH78", "method": "update", "written": { "networkDefaultEmv": { "longFormCpm": 25, "shortFormCpm": 20, "likeRate": 0.25, "commentRate": 1.25 }, "updatedAt": "2026-10-14T17:34:13.090Z" }, "warnings": [], "urls": { "app": "https://network.listener.com/app/dashboard?uid=XXfzcGxkLsQaKAvRy1SwLsZCcH78", "public": null }, "error": null, "data": { "networkDefaultEmv": { "longFormCpm": 25, "shortFormCpm": 20, "likeRate": 0.25, "commentRate": 1.25 }, "confirm_required": true }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## List platform integrations `GET /v1/integrations` Scope: `shows:read` SDK: `client.integrations.list()` (TypeScript) · `client.integrations.list()` (Python) One row per show × platform (the 18 platforms of the Integrations page): connection state (`connected`, `pending`, `disconnected`), last update, staleness, how it can be refreshed (`server`, `manual_request`, `none`), whether a refresh needs `confirm`, and the last stored error. `platform` accepts aliases; `stale_only=true` keeps rows older than 36 h. For the per-show rollup with cluster freshness and pending data requests use `GET /v1/integrations/status`. - Required scope: `shows:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_ids` | array of strings | optional | Limit to these show ids (default: every show in the workspace). Repeat the parameter or comma-separate values. | | `platform` | string | optional | A platform name (e.g. youtube, spotify, applePodcasts, instagram). | | `state` | string | optional | | | `stale_only` | boolean | optional | | | `limit` | integer | optional | Page size, 1–100 (default 25). | | `cursor` | string | optional | Opaque cursor from the previous page's next_cursor. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].show` | object | required | The minimal show reference embedded in cross-show results. | | `data[].show.id` | string | required | | | `data[].show.name` | string | required | | | `data[].show.handle` | string | optional | Default `""`. | | `data[].show.image` | string | optional | Default `null`. Nullable. | | `data[].show.public_url` | string | optional | Default `null`. Nullable. | | `data[].platform` | string | required | | | `data[].label` | string | required | | | `data[].group` | string | required | One of `audio`, `content`. | | `data[].state` | string | required | One of `connected`, `pending`, `disconnected`. | | `data[].state_reason` | string | optional | Which rule of resolveIntegrationConnectionState fired. Default `""`. | | `data[].connected_flag` | boolean | optional | Raw `.connected` on the show doc. Default `null`. Nullable. | | `data[].platform_order_connected` | boolean | optional | Default `false`. | | `data[].has_data` | boolean | optional | Default `false`. | | `data[].importing_state` | any | optional | Default `null`. Nullable. | | `data[].status` | string | optional | Default `null`. Nullable. | | `data[].setup_step` | string | optional | Default `null`. Nullable. | | `data[].disconnected_at` | string | optional | Default `null`. Nullable. | | `data[].last_updated` | string | optional | Default `null`. Nullable. | | `data[].age_hours` | number | optional | Default `null`. Nullable. | | `data[].stale` | boolean | optional | Default `true`. | | `data[].refresh_kind` | string | required | One of `server`, `manual_request`, `none`. | | `data[].requires_confirm` | boolean | optional | Default `false`. | | `data[].premium` | boolean | optional | Default `false`. | | `data[].last_error` | string | optional | Default `null`. Nullable. | | `data[].object` | string | required | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | | `meta.summary` | object | optional | | | `meta.summary.rows` | integer | required | | | `meta.summary.connected` | integer | required | | | `meta.summary.pending` | integer | required | | | `meta.summary.disconnected` | integer | required | | | `meta.summary.stale_connected` | integer | required | Connected rows whose data is older than 36 h (or undated). | | `meta.summary.by_platform` | map of map of integer | optional | platform → {connected, pending, disconnected, stale}. | | `meta.summary.shows_with_any_connection` | integer | required | | | `meta.summary.shows_without_connections` | integer | required | | | `meta.platforms` | array of strings | optional | The 18 platform keys the Integrations page tracks, in its Audio → Content order. | | `meta.filters` | object | optional | | | `meta.show_ids` | array of strings | optional | | | `meta.missing_show_ids` | array of strings | optional | | | `meta.computed_at` | string | optional | | | `meta.cache_age_seconds` | number | optional | Default `0`. | | `meta.note` | string | optional | Default `null`. Nullable. | ### Example request ```bash curl "https://api.listener.com/v1/integrations" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/integrations", "data": [ { "object": "integration", "show": { "id": "paAtKPJGo56md5VyoEQ9Las7qahL", "name": "Hard Court", "handle": "hard-court", "image": "https://images.example.com/listener/6cbd825d3f77ec48.jpg", "public_url": "https://listener.com/hard-court" }, "platform": "spotify", "label": "Spotify", "group": "audio", "state": "connected", "state_reason": "platformOrder.connected", "connected_flag": false, "platform_order_connected": true, "has_data": true, "importing_state": null, "status": null, "setup_step": null, "disconnected_at": null, "last_updated": "2026-10-14T07:20:29.026000+00:00", "age_hours": 10.23, "stale": false, "refresh_kind": "manual_request", "requires_confirm": false, "premium": true, "last_error": null }, { "object": "integration", "show": { "id": "paAtKPJGo56md5VyoEQ9Las7qahL", "name": "Hard Court", "handle": "hard-court", "image": "https://images.example.com/listener/6cbd825d3f77ec48.jpg", "public_url": "https://listener.com/hard-court" }, "platform": "applePodcasts", "label": "Apple Podcasts", "group": "audio", "state": "connected", "state_reason": "platformOrder.connected", "connected_flag": false, "platform_order_connected": true, "has_data": true, "importing_state": null, "status": null, "setup_step": null, "disconnected_at": null, "last_updated": "2026-04-13T17:36:30+00:00", "age_hours": 4415.96, "stale": true, "refresh_kind": "manual_request", "requires_confirm": false, "premium": true, "last_error": null } ], "has_more": true, "next_cursor": "eyJvIjoyfQ", "meta": { "summary": { "rows": 216, "connected": 66, "pending": 0, "disconnected": 150, "stale_connected": 22, "by_platform": { "spotify": { "connected": 10, "pending": 0, "disconnected": 2, "stale": 9 }, "applePodcasts": { "connected": 9, "pending": 0, "disconnected": 3, "stale": 9 }, "simplecast": { "connected": 0, "pending": 0, "disconnected": 12, "stale": 0 }, "transistor": { "connected": 0, "pending": 0, "disconnected": 12, "stale": 0 }, "megaphone": { "connected": 3, "pending": 0, "disconnected": 9, "stale": 1 }, "art19": { "connected": 0, "pending": 0, "disconnected": 12, "stale": 0 }, "captivate": { "connected": 0, "pending": 0, "disconnected": 12, "stale": 0 }, "omnystudio": { "connected": 1, "pending": 0, "disconnected": 11, "stale": 0 }, "youtube": { "connected": 7, "pending": 0, "disconnected": 5, "stale": 1 }, "tiktok": { "connected": 11, "pending": 0, "disconnected": 1, "stale": 0 }, "instagram": { "connected": 11, "pending": 0, "disconnected": 1, "stale": 0 }, "linkedin": { "connected": 0, "pending": 0, "disconnected": 12, "stale": 0 }, "twitter": { "connected": 10, "pending": 0, "disconnected": 2, "stale": 1 }, "facebook": { "connected": 1, "pending": 0, "disconnected": 11, "stale": 1 }, "facebookGroups": { "connected": 0, "pending": 0, "disconnected": 12, "stale": 0 }, "beehiiv": { "connected": 0, "pending": 0, "disconnected": 12, "stale": 0 }, "substack": { "connected": 0, "pending": 0, "disconnected": 12, "stale": 0 }, "threads": { "connected": 3, "pending": 0, "disconnected": 9, "stale": 0 } }, "shows_with_any_connection": 12, "shows_without_connections": 0 }, "platforms": [ "spotify", "applePodcasts" ], "filters": { "show_ids": null, "platform": null, "state": "all", "stale_only": false }, "show_ids": [ "paAtKPJGo56md5VyoEQ9Las7qahL", "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H" ], "missing_show_ids": [ "0Iush8rT5eCcD4ePHroKFDlQvKhT", "nCJuUMzGC6EE3dfruqjRHtYdlCTr" ], "computed_at": "2026-10-14T17:34:13.092101+00:00", "cache_age_seconds": 7.6, "note": null, "total": 216 } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Retrieve data freshness and refresh status `GET /v1/integrations/status` Scope: `shows:read` SDK: `client.integrations.status()` (TypeScript) · `client.integrations.status()` (Python) Per show: the newest platform update, stale platforms grouped by how they can be refreshed, cluster refresh freshness (last run, failed nodes), pending Spotify / Apple data requests and the last cluster update stats. Check it before `POST /v1/shows/{show_id}/platforms/{platform}/refresh`, `POST /v1/data-requests` or a cluster refresh. `include_cluster_freshness=false` makes it cheaper. - Required scope: `shows:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_ids` | array of strings | optional | Limit to these show ids (default: every show in the workspace). Repeat the parameter or comma-separate values. | | `include_cluster_freshness` | boolean | optional | | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `shows` | array of objects | required | | | `shows[].show` | object | required | The minimal show reference embedded in cross-show results. | | `shows[].show.id` | string | required | | | `shows[].show.name` | string | required | | | `shows[].show.handle` | string | optional | Default `""`. | | `shows[].show.image` | string | optional | Default `null`. Nullable. | | `shows[].show.public_url` | string | optional | Default `null`. Nullable. | | `shows[].newest_platform_update` | string | optional | Default `null`. Nullable. | | `shows[].newest_platform` | string | optional | Default `null`. Nullable. | | `shows[].connected_platforms` | array of strings | optional | | | `shows[].platform_freshness` | map of object | optional | | | `shows[].platform_freshness.last_updated` | string | optional | Default `null`. Nullable. | | `shows[].platform_freshness.age_hours` | number | optional | Default `null`. Nullable. | | `shows[].platform_freshness.stale` | boolean | optional | True when older than 36 hours or unknown. Default `true`. | | `shows[].stale_platforms` | array of strings | optional | | | `shows[].refreshable_stale_platforms` | array of strings | optional | Stale platforms `POST /v1/shows/{show_id}/platforms/{platform}/refresh` can refresh (server kind). | | `shows[].request_only_stale_platforms` | array of strings | optional | Stale platforms only an ops request can refresh (Spotify/Apple). | | `shows[].last_cluster_update` | string | optional | Default `null`. Nullable. | | `shows[].last_update_stats` | object | optional | Default `null`. Nullable. | | `shows[].cluster_freshness` | object | optional | Default `null`. Nullable. | | `shows[].cluster_freshness.kind` | string | required | One of `refreshed`, `saved`, `unknown`. | | `shows[].cluster_freshness.at` | string | optional | Default `null`. Nullable. | | `shows[].cluster_freshness.last_refreshed_at` | string | optional | Default `null`. Nullable. | | `shows[].cluster_freshness.cluster_count` | integer | optional | Default `0`. | | `shows[].cluster_freshness.failed_node_count` | integer | optional | Default `0`. | | `shows[].cluster_freshness.clusters_with_errors` | integer | optional | Default `0`. | | `shows[].cluster_freshness.errors` | array of objects | optional | Per-node refresh errors (capped at 20). | | `shows[].pending_data_requests` | array of objects | optional | | | `shows[].pending_data_requests[].id` | string | required | | | `shows[].pending_data_requests[].platform` | string | required | | | `shows[].pending_data_requests[].status` | string | required | | | `shows[].pending_data_requests[].requested_at` | string | optional | Default `null`. Nullable. | | `shows[].pending_data_requests[].completed_at` | string | optional | Default `null`. Nullable. | | `shows[].pending_data_requests[].error` | string | optional | Default `null`. Nullable. | | `shows[].recent_data_requests` | array of objects | optional | Latest completed/failed request per platform. | | `shows[].recent_data_requests[].id` | string | required | | | `shows[].recent_data_requests[].platform` | string | required | | | `shows[].recent_data_requests[].status` | string | required | | | `shows[].recent_data_requests[].requested_at` | string | optional | Default `null`. Nullable. | | `shows[].recent_data_requests[].completed_at` | string | optional | Default `null`. Nullable. | | `shows[].recent_data_requests[].error` | string | optional | Default `null`. Nullable. | | `stale_platforms` | array of strings | optional | Platforms stale on at least one show. | | `freshness` | map of object | optional | platform → oldest freshness across the scoped shows. | | `freshness.last_updated` | string | optional | Default `null`. Nullable. | | `freshness.age_hours` | number | optional | Default `null`. Nullable. | | `freshness.stale` | boolean | optional | True when older than 36 hours or unknown. Default `true`. | | `pending_request_count` | integer | optional | Default `0`. | | `shows_with_stale_clusters` | integer | optional | Default `0`. | | `show_ids` | array of strings | required | | | `missing_show_ids` | array of strings | optional | | | `computed_at` | string | required | | | `cache_age_seconds` | number | optional | Default `0`. | | `note` | string | optional | Default `null`. Nullable. | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/integrations/status" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "refresh_status", "shows": [ { "show": { "id": "paAtKPJGo56md5VyoEQ9Las7qahL", "name": "Hard Court", "handle": "hard-court", "image": "https://images.example.com/listener/6cbd825d3f77ec48.jpg", "public_url": "https://listener.com/hard-court" }, "newest_platform_update": "2026-10-14T07:55:11.134000+00:00", "newest_platform": "twitter", "connected_platforms": [ "applePodcasts", "instagram" ], "platform_freshness": { "youtube": { "last_updated": "2026-10-14T07:52:53.974000+00:00", "age_hours": 9.69, "stale": false }, "spotify": { "last_updated": "2026-10-14T07:20:29.026000+00:00", "age_hours": 10.23, "stale": false }, "applePodcasts": { "last_updated": "2026-04-13T17:36:30+00:00", "age_hours": 4415.96, "stale": true }, "tiktok": { "last_updated": "2026-10-14T07:54:34.140000+00:00", "age_hours": 9.66, "stale": false }, "instagram": { "last_updated": "2026-10-14T07:54:18.265000+00:00", "age_hours": 9.67, "stale": false }, "twitter": { "last_updated": "2026-10-14T07:55:11.134000+00:00", "age_hours": 9.65, "stale": false } }, "stale_platforms": [ "applePodcasts" ], "refreshable_stale_platforms": [], "request_only_stale_platforms": [ "applePodcasts" ], "last_cluster_update": "2026-04-23T11:48:41.184246+00:00", "last_update_stats": { "totalClusters": 82, "successfulClusters": 82, "updateTime": "2026-04-23T11:48:41.184254", "totalNodes": 1181, "failedClusters": 0 }, "cluster_freshness": { "kind": "refreshed", "at": "2026-10-08T05:55:29.001Z", "last_refreshed_at": "2026-10-08T05:55:29.001Z", "cluster_count": 136, "failed_node_count": 0, "clusters_with_errors": 0, "errors": [] }, "pending_data_requests": [], "recent_data_requests": [ { "id": "JYTswSZxL67VDHhDwqiz", "platform": "spotify", "status": "completed", "requested_at": "2025-10-27T13:51:08.683000+00:00", "completed_at": null, "error": null }, { "id": "kMe9DTY65xpovACV2nUr", "platform": "applePodcasts", "status": "completed", "requested_at": "2025-10-23T15:29:50.389000+00:00", "completed_at": null, "error": null } ] }, { "show": { "id": "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H", "name": "The Bullpen", "handle": "the-bullpen", "image": "https://images.example.com/listener/8f6c3c4428259526.jpg", "public_url": "https://listener.com/the-bullpen" }, "newest_platform_update": "2026-10-14T09:22:51.743000+00:00", "newest_platform": "megaphone", "connected_platforms": [ "applePodcasts", "instagram" ], "platform_freshness": { "youtube": { "last_updated": "2026-10-02T10:37:33.857000+00:00", "age_hours": 294.94, "stale": true }, "spotify": { "last_updated": "2026-09-21T17:31:56.535000+00:00", "age_hours": 552.04, "stale": true }, "applePodcasts": { "last_updated": "2026-09-20T17:42:50.074000+00:00", "age_hours": 575.86, "stale": true }, "tiktok": { "last_updated": "2026-10-14T08:03:25.681000+00:00", "age_hours": 9.51, "stale": false }, "instagram": { "last_updated": "2026-10-14T08:02:55.492000+00:00", "age_hours": 9.52, "stale": false }, "twitter": { "last_updated": "2026-10-10T07:02:33.014000+00:00", "age_hours": 106.53, "stale": true }, "megaphone": { "last_updated": "2026-10-14T09:22:51.743000+00:00", "age_hours": 8.19, "stale": false }, "omnystudio": { "last_updated": "2026-10-13T09:33:48.789000+00:00", "age_hours": 32.01, "stale": false } }, "stale_platforms": [ "applePodcasts", "spotify" ], "refreshable_stale_platforms": [ "twitter", "youtube" ], "request_only_stale_platforms": [ "applePodcasts", "spotify" ], "last_cluster_update": "2026-10-13T04:03:27.050175+00:00", "last_update_stats": { "successfulClusters": 4, "totalClusters": 4, "updateTime": "2025-11-26T12:26:44.433295", "totalNodes": 7, "failedClusters": 0 }, "cluster_freshness": { "kind": "saved", "at": "2026-03-17T17:16:36.453Z", "last_refreshed_at": null, "cluster_count": 16, "failed_node_count": 0, "clusters_with_errors": 0, "errors": [] }, "pending_data_requests": [], "recent_data_requests": [ { "id": "PyGGBlSIoVnT4uQX20u7", "platform": "spotify", "status": "completed", "requested_at": "2025-10-23T15:17:55.781000+00:00", "completed_at": null, "error": null }, { "id": "cQdGsxQmbl1zY2rcg3fQ", "platform": "applePodcasts", "status": "completed", "requested_at": "2025-10-22T17:46:33.789000+00:00", "completed_at": null, "error": null } ] } ], "stale_platforms": [ "applePodcasts", "facebook" ], "freshness": { "applePodcasts": { "last_updated": "2026-04-13T16:00:26+00:00", "age_hours": 4417.56, "stale": true }, "facebook": { "last_updated": "2026-10-08T07:02:13.420000+00:00", "age_hours": 154.53, "stale": true }, "instagram": { "last_updated": "2026-10-13T09:35:13.282000+00:00", "age_hours": 31.98, "stale": false }, "megaphone": { "last_updated": "2026-10-14T09:22:51.743000+00:00", "age_hours": 8.19, "stale": false }, "omnystudio": { "last_updated": "2026-10-13T09:33:48.789000+00:00", "age_hours": 32.01, "stale": false }, "spotify": { "last_updated": "2026-08-06T08:49:48.390000+00:00", "age_hours": 1664.74, "stale": true }, "threads": { "last_updated": "2026-10-13T09:36:40.899000+00:00", "age_hours": 31.96, "stale": false }, "tiktok": { "last_updated": "2026-10-13T09:34:15.574000+00:00", "age_hours": 32, "stale": false }, "twitter": { "last_updated": "2026-10-10T07:02:33.014000+00:00", "age_hours": 106.53, "stale": true }, "youtube": { "last_updated": "2026-10-02T10:37:33.857000+00:00", "age_hours": 294.94, "stale": true } }, "pending_request_count": 0, "shows_with_stale_clusters": 3, "show_ids": [ "paAtKPJGo56md5VyoEQ9Las7qahL", "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H" ], "missing_show_ids": [ "0Iush8rT5eCcD4ePHroKFDlQvKhT", "nCJuUMzGC6EE3dfruqjRHtYdlCTr" ], "computed_at": "2026-10-14T17:34:13.108085+00:00", "cache_age_seconds": 7.6, "note": null } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. --- # Episodes > Episodes per show, search, transcripts, tags, goals and Substack download figures. Base URL: `https://api.listener.com` ## List a show's episodes `GET /v1/shows/{show_id}/episodes` Scope: `episodes:read` SDK: `client.shows.episodes.list()` (TypeScript) · `client.shows.episodes.list()` (Python) A show's episodes as the Episodes page lists them: every episode the app knows (stored feed, Spotify chart, Apple and cluster-derived, newest first) with its cluster's reach by category and form, clips and posts, engagement, tags, brands, freshness and the cluster's public URL. `meta.episode_count` is the full count. `time_range` (`all`, `30d`, `90d`, `quarter`, `year`; or `start_date` + `end_date`) filters on the publish date; undated episodes never fall inside an active window. `series`, `guests` and `format` match episode tags (any-of). `has_cluster=true` keeps episodes whose cluster exists or has reach. `view=goals` adds per-episode target deltas from the show's episode goals; `view=benchmark` adds deltas against same-format peer medians (minimum 3 peers) and the ≥ 3× breakout badge. - Required scope: `episodes:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `time_range` | string | optional | 7d \| 14d \| 30d \| 90d \| 180d \| 365d \| all, or a quarter like 2026-Q2. Windows are UTC days anchored to today. | | `start_date` | string | optional | First day, YYYY-MM-DD (inclusive). With end_date, overrides time_range. | | `end_date` | string | optional | Last day, YYYY-MM-DD (inclusive). With start_date, overrides time_range. | | `series` | array of strings | optional | Series tags. Repeat the parameter or comma-separate values. | | `guests` | array of strings | optional | Guest tags. Repeat the parameter or comma-separate values. | | `format` | array of strings | optional | Format tags. Repeat the parameter or comma-separate values. | | `brand` | string | optional | Brand name or slug filter. | | `search` | string | optional | Case-insensitive text filter. | | `has_cluster` | boolean | optional | | | `sort_by` | string | optional | Sort field. | | `sort_order` | string | optional | asc or desc. | | `view` | string | optional | | | `limit` | integer | optional | Page size, 1–100 (default 25). | | `cursor` | string | optional | Opaque cursor from the previous page's next_cursor. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].total_reach` | integer \| number | optional | Default `0`. | | `data[].youtube_reach` | integer \| number | optional | Default `0`. | | `data[].youtube_long_form_reach` | integer \| number | optional | Default `0`. | | `data[].youtube_shorts_reach` | integer \| number | optional | Default `0`. | | `data[].audio_reach` | integer \| number | optional | Default `0`. | | `data[].social_reach` | integer \| number | optional | Default `0`. | | `data[].long_form_reach` | integer \| number | optional | Default `0`. | | `data[].short_form_reach` | integer \| number | optional | Default `0`. | | `data[].long_form_count` | integer | optional | Default `0`. | | `data[].short_form_count` | integer | optional | Default `0`. | | `data[].clips` | integer | optional | Default `0`. | | `data[].posts` | integer | optional | Default `0`. | | `data[].total_engagement` | integer \| number | optional | Default `0`. | | `data[].engagement_rate` | integer \| number | optional | Percent (1.93 == 1.93 %). Default `0`. | | `data[].id` | string | required | | | `data[].title` | string | required | | | `data[].published_at` | string | optional | YYYY-MM-DD parsed with the app's date rules; null when the stored date is unreadable. Default `null`. Nullable. | | `data[].date_raw` | string | optional | Default `null`. Nullable. | | `data[].duration` | string | optional | Default `null`. Nullable. | | `data[].duration_seconds` | integer | optional | Default `null`. Nullable. | | `data[].episode_number` | string | optional | Default `null`. Nullable. | | `data[].source_platform` | string | required | 'spotify' \| 'apple' \| 'youtube' \| 'manual' \| 'cluster' \| provider key \| 'unknown'. | | `data[].platform_label` | string | optional | Default `null`. Nullable. | | `data[].is_manual` | boolean | optional | Default `false`. | | `data[].is_external` | boolean | optional | Default `false`. | | `data[].is_synthetic` | boolean | optional | Row invented by the app for a chart / Apple entry without a stored episode. Default `false`. | | `data[].explicit` | boolean | optional | Default `null`. Nullable. | | `data[].cover` | string | optional | Default `null`. Nullable. | | `data[].share_url` | string | optional | Default `null`. Nullable. | | `data[].description` | string | optional | Truncated in list rows; full text on `GET /v1/shows/{show_id}/episodes/{episode_id}`. Default `null`. Nullable. | | `data[].tags` | map of array of strings | optional | {series, format, guests}. | | `data[].brands` | array of strings | optional | Sponsored-node brands and linked campaign brands on the source cluster. | | `data[].cluster_id` | string | optional | Default `null`. Nullable. | | `data[].has_cluster` | boolean | optional | A source cluster exists or any reach / clips / posts > 0 (the page's rule). Default `false`. | | `data[].cluster_type` | string | optional | Default `null`. Nullable. | | `data[].metrics_source` | string | optional | Default `"none"`. | | `data[].summary_stale` | boolean | optional | Default `false`. | | `data[].freshness` | object | required | | | `data[].freshness.kind` | string | optional | 'refreshed' (a refresh run) \| 'saved' (newest node fetch stamp) \| 'unknown'. Default `"unknown"`. | | `data[].freshness.at` | string | optional | Default `null`. Nullable. | | `data[].freshness.source` | string | optional | Refresh source when known ('manual' \| 'scheduled' \| …). Default `null`. Nullable. | | `data[].last_refreshed_at` | string | optional | Default `null`. Nullable. | | `data[].refresh_error_count` | integer | optional | Default `0`. | | `data[].cluster_public_url` | string | optional | Default `null`. Nullable. | | `data[].cluster_app_url` | string | optional | Default `null`. Nullable. | | `data[].goals` | object | optional | Default `null`. Nullable. | | `data[].goals.comparable` | boolean | optional | The episode has a cluster with reach, so deltas are meaningful. Default `false`. | | `data[].goals.targets` | object | required | | | `data[].goals.targets.total_reach` | integer | optional | Default `null`. Nullable. | | `data[].goals.targets.long_form_reach` | integer | optional | Default `null`. Nullable. | | `data[].goals.targets.short_form_reach` | integer | optional | Default `null`. Nullable. | | `data[].goals.deltas` | object | required | Percent vs target ((value − target) / target × 100); null without a target. | | `data[].goals.deltas.total_reach` | number | optional | Default `null`. Nullable. | | `data[].goals.deltas.long_form_reach` | number | optional | Default `null`. Nullable. | | `data[].goals.deltas.short_form_reach` | number | optional | Default `null`. Nullable. | | `data[].benchmark` | object | optional | Default `null`. Nullable. | | `data[].benchmark.format` | string | required | 'long' \| 'short' — the episode's dominant reach format. | | `data[].benchmark.year` | integer | required | | | `data[].benchmark.quarter` | integer | required | | | `data[].benchmark.counts` | map of integer | optional | | | `data[].benchmark.periods` | map of object | optional | 'year' and 'quarter' peer comparisons. | | `data[].benchmark.periods.label` | string | optional | Default `""`. | | `data[].benchmark.periods.peer_count` | integer | optional | Default `0`. | | `data[].benchmark.periods.sufficient` | boolean | optional | At least 3 same-format peers in the period. Default `false`. | | `data[].benchmark.periods.medians` | map of number | optional | | | `data[].benchmark.periods.deltas` | map of number | optional | Percent vs the peer median. | | `data[].benchmark.periods.median_counts` | map of number | optional | | | `data[].benchmark.breakout` | object | optional | {multiplier, format} when the episode is ≥ 3× its format's median this year. Default `null`. Nullable. | | `data[].object` | string | required | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | | `meta.show` | object | optional | The minimal show reference embedded in cross-show results. | | `meta.show.id` | string | required | | | `meta.show.name` | string | required | | | `meta.show.handle` | string | optional | Default `""`. | | `meta.show.image` | string | optional | Default `null`. Nullable. | | `meta.show.public_url` | string | optional | Default `null`. Nullable. | | `meta.episode_count` | integer | optional | Size of the show's full episode universe before filters (stored episodes ∪ chart rows ∪ Apple ∪ cluster-derived). | | `meta.filtered_count` | integer | optional | Rows matching the filters (== page.total). | | `meta.with_cluster_count` | integer | optional | Episodes in the universe with a source cluster. | | `meta.view` | string | optional | One of `simple`, `goals`, `benchmark`. | | `meta.date_range` | object | optional | | | `meta.filters` | object | optional | | | `meta.tag_options` | map of array of objects | optional | | | `meta.tag_options.value` | string | required | | | `meta.tag_options.count` | integer | required | | | `meta.brand_options` | array of strings | optional | | | `meta.goals` | object | optional | Default `null`. Nullable. | | `meta.goals.targets` | object | required | | | `meta.goals.targets.total_reach` | integer | optional | Default `null`. Nullable. | | `meta.goals.targets.long_form_reach` | integer | optional | Default `null`. Nullable. | | `meta.goals.targets.short_form_reach` | integer | optional | Default `null`. Nullable. | | `meta.goals.sources` | map of string | optional | Per target: 'series' (override) \| 'default' \| null. | | `meta.goals.series` | string | optional | Default `null`. Nullable. | | `meta.goals.has_series_overrides` | boolean | optional | Default `false`. | | `meta.goals.has_any` | boolean | optional | Default `false`. | | `meta.goals.comparable_count` | integer | optional | Default `0`. | | `meta.benchmark` | object | optional | Default `null`. Nullable. | | `meta.benchmark.population_size` | integer | optional | Default `0`. | | `meta.benchmark.years` | array of integers | optional | | | `meta.benchmark.median_metrics` | map of map of number | optional | Default `null`. Nullable. | | `meta.benchmark.breakout_count` | integer | optional | Default `0`. | | `meta.cluster_freshness` | object | optional | ``getShowRefreshFreshness``: newest refresh across a show's clusters. | | `meta.cluster_freshness.kind` | string | optional | Default `"unknown"`. | | `meta.cluster_freshness.at` | string | optional | Default `null`. Nullable. | | `meta.cluster_freshness.last_refreshed_at` | string | optional | Default `null`. Nullable. | | `meta.cluster_freshness.failed_node_count` | integer | optional | Default `0`. | | `meta.cluster_freshness.clusters_with_errors` | integer | optional | Default `0`. | | `meta.freshness` | map of object | optional | | | `meta.freshness.last_updated` | string | optional | Default `null`. Nullable. | | `meta.freshness.age_hours` | number | optional | Default `null`. Nullable. | | `meta.freshness.stale` | boolean | optional | True when older than 36 hours or unknown. Default `true`. | | `meta.stale_platforms` | array of strings | optional | | | `meta.missing_show_ids` | array of strings | optional | | | `meta.computed_at` | string | optional | | | `meta.cache_age_seconds` | number | optional | | | `meta.note` | string | optional | Default `null`. Nullable. | ### Example request ```bash curl "https://api.listener.com/v1/shows/SHOW_ID/episodes" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/shows/i3KlnFag2DgIpykraVxjrIZwJYcL/episodes", "data": [ { "object": "episode", "total_reach": 1231, "youtube_reach": 0, "youtube_long_form_reach": 0, "youtube_shorts_reach": 0, "audio_reach": 1231, "social_reach": 0, "long_form_reach": 1231, "short_form_reach": 0, "long_form_count": 1, "short_form_count": 0, "clips": 1, "posts": 0, "total_engagement": 0, "engagement_rate": 0, "id": "2Uu4VPBZxmUSGm8pHYe9Cq", "title": "Midseason awards", "published_at": "2026-09-18", "date_raw": "2026-09-18", "duration": "01:10:12", "duration_seconds": 4212, "episode_number": null, "source_platform": "spotify", "platform_label": "Spotify", "is_manual": false, "is_external": true, "is_synthetic": false, "explicit": false, "cover": "https://images.example.com/listener/a3d3672df4e9ac70.jpg", "share_url": "https://open.spotify.com/episode/2Uu4VPBZxmUSGm8pHYe9Cq", "description": "The crew is back with rankings, hot takes and a listener mailbag.", "tags": { "series": [], "format": [], "guests": [] }, "brands": [], "cluster_id": "cluster-2Uu4VPBZxmUSGm8pHYe9Cq", "has_cluster": true, "cluster_type": "episode", "metrics_source": "nodes", "summary_stale": false, "freshness": { "kind": "unknown", "at": null, "source": null }, "last_refreshed_at": null, "refresh_error_count": 0, "cluster_public_url": "https://listener.com/cluster/i3KlnFag2DgIpykraVxjrIZwJYcL_cluster-2Uu4VPBZxmUSGm8pHYe9Cq", "cluster_app_url": "https://network.listener.com/app/cluster?id=cluster-2Uu4VPBZxmUSGm8pHYe9Cq&uid=i3KlnFag2DgIpykraVxjrIZwJYcL", "goals": null, "benchmark": null }, { "object": "episode", "total_reach": 0, "youtube_reach": 0, "youtube_long_form_reach": 0, "youtube_shorts_reach": 0, "audio_reach": 0, "social_reach": 0, "long_form_reach": 0, "short_form_reach": 0, "long_form_count": 0, "short_form_count": 0, "clips": 0, "posts": 0, "total_engagement": 0, "engagement_rate": 0, "id": "3yQkVZN18HajewTkZZtXqG", "title": "Our bold predictions for the second half", "published_at": "2026-09-17", "date_raw": "2026-09-17", "duration": null, "duration_seconds": null, "episode_number": null, "source_platform": "spotify", "platform_label": "Spotify", "is_manual": false, "is_external": false, "is_synthetic": false, "explicit": null, "cover": "https://images.example.com/listener/f7abb1fd9793951e.jpg", "share_url": "https://open.spotify.com/episode/3yQkVZN18HajewTkZZtXqG", "description": null, "tags": { "series": [], "format": [], "guests": [] }, "brands": [], "cluster_id": null, "has_cluster": false, "cluster_type": null, "metrics_source": "none", "summary_stale": false, "freshness": { "kind": "unknown", "at": null, "source": null }, "last_refreshed_at": null, "refresh_error_count": 0, "cluster_public_url": null, "cluster_app_url": null, "goals": null, "benchmark": null } ], "has_more": true, "next_cursor": "eyJvIjoyfQ", "meta": { "show": { "id": "i3KlnFag2DgIpykraVxjrIZwJYcL", "name": "The Long Game", "handle": "the-long-game", "image": "https://images.example.com/listener/4b71276112f25fab.jpg", "public_url": "https://listener.com/the-long-game" }, "episode_count": 349, "filtered_count": 349, "with_cluster_count": 1, "view": "simple", "date_range": { "preset": "all", "start": null, "end": null }, "filters": { "series": null, "guests": null, "format": null, "brand": null, "search": null, "has_cluster": null, "sort_by": "date", "sort_order": "desc" }, "tag_options": { "series": [], "guests": [], "format": [] }, "brand_options": [], "goals": null, "benchmark": null, "cluster_freshness": { "kind": "unknown", "at": null, "last_refreshed_at": null, "failed_node_count": 0, "clusters_with_errors": 0 }, "freshness": { "youtube": { "last_updated": "2026-10-13T09:34:22.044000+00:00", "age_hours": 32, "stale": false }, "spotify": { "last_updated": "2026-09-22T22:06:27.429000+00:00", "age_hours": 523.46, "stale": true }, "applePodcasts": { "last_updated": "2026-04-13T17:28:59+00:00", "age_hours": 4416.09, "stale": true }, "tiktok": { "last_updated": "2026-10-13T09:35:24.476000+00:00", "age_hours": 31.98, "stale": false }, "instagram": { "last_updated": "2026-10-13T09:35:13.282000+00:00", "age_hours": 31.98, "stale": false } }, "stale_platforms": [ "applePodcasts", "spotify" ], "missing_show_ids": [], "computed_at": "2026-10-14T17:34:13.311067+00:00", "cache_age_seconds": 7.8, "note": null, "total": 349 } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Retrieve an episode `GET /v1/shows/{show_id}/episodes/{episode_id}` Scope: `episodes:read` SDK: `client.shows.episodes.retrieve()` (TypeScript) · `client.shows.episodes.retrieve()` (Python) One episode: its episodes-table row (reach by category and form, clips and posts, engagement, tags, brands), stored Spotify / Apple / YouTube stats when present, a summary of its cluster (type, node counts, sponsor brands, linked campaigns, freshness), goal deltas and benchmark bucket. `episode_id` is any id `GET /v1/shows/{show_id}/episodes` or `GET /v1/episodes/search` returns (Spotify, `manual-`, Apple or synthetic ids). - Required scope: `episodes:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | | `episode_id` | string | required | Episode id (see GET /v1/shows/{show_id}/episodes). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `show` | object | required | The minimal show reference embedded in cross-show results. | | `show.id` | string | required | | | `show.name` | string | required | | | `show.handle` | string | optional | Default `""`. | | `show.image` | string | optional | Default `null`. Nullable. | | `show.public_url` | string | optional | Default `null`. Nullable. | | `episode` | object | required | One episodes-table row: the episode plus its source cluster's reach. | | `episode.total_reach` | integer \| number | optional | Default `0`. | | `episode.youtube_reach` | integer \| number | optional | Default `0`. | | `episode.youtube_long_form_reach` | integer \| number | optional | Default `0`. | | `episode.youtube_shorts_reach` | integer \| number | optional | Default `0`. | | `episode.audio_reach` | integer \| number | optional | Default `0`. | | `episode.social_reach` | integer \| number | optional | Default `0`. | | `episode.long_form_reach` | integer \| number | optional | Default `0`. | | `episode.short_form_reach` | integer \| number | optional | Default `0`. | | `episode.long_form_count` | integer | optional | Default `0`. | | `episode.short_form_count` | integer | optional | Default `0`. | | `episode.clips` | integer | optional | Default `0`. | | `episode.posts` | integer | optional | Default `0`. | | `episode.total_engagement` | integer \| number | optional | Default `0`. | | `episode.engagement_rate` | integer \| number | optional | Percent (1.93 == 1.93 %). Default `0`. | | `episode.id` | string | required | | | `episode.title` | string | required | | | `episode.published_at` | string | optional | YYYY-MM-DD parsed with the app's date rules; null when the stored date is unreadable. Default `null`. Nullable. | | `episode.date_raw` | string | optional | Default `null`. Nullable. | | `episode.duration` | string | optional | Default `null`. Nullable. | | `episode.duration_seconds` | integer | optional | Default `null`. Nullable. | | `episode.episode_number` | string | optional | Default `null`. Nullable. | | `episode.source_platform` | string | required | 'spotify' \| 'apple' \| 'youtube' \| 'manual' \| 'cluster' \| provider key \| 'unknown'. | | `episode.platform_label` | string | optional | Default `null`. Nullable. | | `episode.is_manual` | boolean | optional | Default `false`. | | `episode.is_external` | boolean | optional | Default `false`. | | `episode.is_synthetic` | boolean | optional | Row invented by the app for a chart / Apple entry without a stored episode. Default `false`. | | `episode.explicit` | boolean | optional | Default `null`. Nullable. | | `episode.cover` | string | optional | Default `null`. Nullable. | | `episode.share_url` | string | optional | Default `null`. Nullable. | | `episode.description` | string | optional | Truncated in list rows; full text on `GET /v1/shows/{show_id}/episodes/{episode_id}`. Default `null`. Nullable. | | `episode.tags` | map of array of strings | optional | {series, format, guests}. | | `episode.brands` | array of strings | optional | Sponsored-node brands and linked campaign brands on the source cluster. | | `episode.cluster_id` | string | optional | Default `null`. Nullable. | | `episode.has_cluster` | boolean | optional | A source cluster exists or any reach / clips / posts > 0 (the page's rule). Default `false`. | | `episode.cluster_type` | string | optional | Default `null`. Nullable. | | `episode.metrics_source` | string | optional | Default `"none"`. | | `episode.summary_stale` | boolean | optional | Default `false`. | | `episode.freshness` | object | required | | | `episode.freshness.kind` | string | optional | 'refreshed' (a refresh run) \| 'saved' (newest node fetch stamp) \| 'unknown'. Default `"unknown"`. | | `episode.freshness.at` | string | optional | Default `null`. Nullable. | | `episode.freshness.source` | string | optional | Refresh source when known ('manual' \| 'scheduled' \| …). Default `null`. Nullable. | | `episode.last_refreshed_at` | string | optional | Default `null`. Nullable. | | `episode.refresh_error_count` | integer | optional | Default `0`. | | `episode.cluster_public_url` | string | optional | Default `null`. Nullable. | | `episode.cluster_app_url` | string | optional | Default `null`. Nullable. | | `episode.goals` | object | optional | Default `null`. Nullable. | | `episode.goals.comparable` | boolean | optional | The episode has a cluster with reach, so deltas are meaningful. Default `false`. | | `episode.goals.targets` | object | required | | | `episode.goals.targets.total_reach` | integer | optional | Default `null`. Nullable. | | `episode.goals.targets.long_form_reach` | integer | optional | Default `null`. Nullable. | | `episode.goals.targets.short_form_reach` | integer | optional | Default `null`. Nullable. | | `episode.goals.deltas` | object | required | Percent vs target ((value − target) / target × 100); null without a target. | | `episode.goals.deltas.total_reach` | number | optional | Default `null`. Nullable. | | `episode.goals.deltas.long_form_reach` | number | optional | Default `null`. Nullable. | | `episode.goals.deltas.short_form_reach` | number | optional | Default `null`. Nullable. | | `episode.benchmark` | object | optional | Default `null`. Nullable. | | `episode.benchmark.format` | string | required | 'long' \| 'short' — the episode's dominant reach format. | | `episode.benchmark.year` | integer | required | | | `episode.benchmark.quarter` | integer | required | | | `episode.benchmark.counts` | map of integer | optional | | | `episode.benchmark.periods` | map of object | optional | 'year' and 'quarter' peer comparisons. | | `episode.benchmark.periods.label` | string | optional | Default `""`. | | `episode.benchmark.periods.peer_count` | integer | optional | Default `0`. | | `episode.benchmark.periods.sufficient` | boolean | optional | At least 3 same-format peers in the period. Default `false`. | | `episode.benchmark.periods.medians` | map of number | optional | | | `episode.benchmark.periods.deltas` | map of number | optional | Percent vs the peer median. | | `episode.benchmark.periods.median_counts` | map of number | optional | | | `episode.benchmark.breakout` | object | optional | {multiplier, format} when the episode is ≥ 3× its format's median this year. Default `null`. Nullable. | | `sources` | array of strings | optional | Index sources that produced the row (stored feed, chart, apple, cluster, provider). | | `spotify` | object | optional | Spotify chart / stored stats for the episode when present. Default `null`. Nullable. | | `apple` | object | optional | Default `null`. Nullable. | | `youtube` | object | optional | Default `null`. Nullable. | | `cluster` | object | optional | Default `null`. Nullable. | | `cluster.id` | string | required | | | `cluster.cluster_type` | string | optional | Default `null`. Nullable. | | `cluster.title` | string | optional | Default `null`. Nullable. | | `cluster.node_count` | integer | optional | Default `0`. | | `cluster.clips` | integer | optional | Default `0`. | | `cluster.posts` | integer | optional | Default `0`. | | `cluster.episode_ids` | array of strings | optional | | | `cluster.sponsor_brands` | array of strings | optional | | | `cluster.linked_campaign_ids` | array of strings | optional | | | `cluster.linked_campaigns` | array of objects | optional | | | `cluster.linked_campaigns[].campaign_id` | string | required | | | `cluster.linked_campaigns[].title` | string | required | | | `cluster.linked_campaigns[].brand_name` | string | optional | Default `null`. Nullable. | | `cluster.linked_campaigns[].display_brand_name` | string | required | | | `cluster.linked_campaigns[].is_brand_fallback` | boolean | optional | Default `false`. | | `cluster.linked_campaigns[].status` | string | optional | Default `null`. Nullable. | | `cluster.linked_campaigns[].excluded_node_count` | integer | optional | Nodes the campaign excludes across every cluster it links. Default `0`. | | `cluster.linked_campaigns[].excluded_node_ids_in_cluster` | array of strings | optional | This cluster's nodes the campaign excludes (known when node ids are loaded). Default `null`. Nullable. | | `cluster.linked_campaigns[].excluded_node_count_in_cluster` | integer | optional | Default `null`. Nullable. | | `cluster.linked_campaigns[].included_node_count_in_cluster` | integer | optional | Default `null`. Nullable. | | `cluster.linked_campaigns[].public_url` | string | optional | Default `null`. Nullable. | | `cluster.freshness` | object | required | | | `cluster.freshness.kind` | string | optional | 'refreshed' (a refresh run) \| 'saved' (newest node fetch stamp) \| 'unknown'. Default `"unknown"`. | | `cluster.freshness.at` | string | optional | Default `null`. Nullable. | | `cluster.freshness.source` | string | optional | Refresh source when known ('manual' \| 'scheduled' \| …). Default `null`. Nullable. | | `cluster.refresh_error_count` | integer | optional | Default `0`. | | `cluster.refresh_errors` | array of objects | optional | | | `cluster.refresh_errors[].node_id` | string | optional | Default `null`. Nullable. | | `cluster.refresh_errors[].platform` | string | optional | Default `null`. Nullable. | | `cluster.refresh_errors[].kind` | string | optional | Default `null`. Nullable. | | `cluster.refresh_errors[].message` | string | optional | Default `null`. Nullable. | | `cluster.refresh_errors[].status` | any | optional | Default `null`. Nullable. | | `cluster.refresh_errors[].at` | string | optional | Default `null`. Nullable. | | `cluster.refresh_errors[].url` | string | optional | Default `null`. Nullable. | | `cluster.metrics_source` | string | optional | Default `"none"`. | | `cluster.summary_stale` | boolean | optional | Default `false`. | | `cluster.public_url` | string | optional | Default `null`. Nullable. | | `cluster.app_url` | string | optional | Default `null`. Nullable. | | `goals` | object | optional | Default `null`. Nullable. | | `goals.targets` | object | required | | | `goals.targets.total_reach` | integer | optional | Default `null`. Nullable. | | `goals.targets.long_form_reach` | integer | optional | Default `null`. Nullable. | | `goals.targets.short_form_reach` | integer | optional | Default `null`. Nullable. | | `goals.sources` | map of string | optional | Per target: 'series' (override) \| 'default' \| null. | | `goals.series` | string | optional | Default `null`. Nullable. | | `goals.has_series_overrides` | boolean | optional | Default `false`. | | `goals.has_any` | boolean | optional | Default `false`. | | `goals.comparable_count` | integer | optional | Default `0`. | | `benchmark` | object | optional | Default `null`. Nullable. | | `benchmark.population_size` | integer | optional | Default `0`. | | `benchmark.years` | array of integers | optional | | | `benchmark.median_metrics` | map of map of number | optional | Default `null`. Nullable. | | `benchmark.breakout_count` | integer | optional | Default `0`. | | `freshness` | map of object | optional | | | `freshness.last_updated` | string | optional | Default `null`. Nullable. | | `freshness.age_hours` | number | optional | Default `null`. Nullable. | | `freshness.stale` | boolean | optional | True when older than 36 hours or unknown. Default `true`. | | `stale_platforms` | array of strings | optional | | | `computed_at` | string | required | | | `cache_age_seconds` | number | required | | | `note` | string | optional | Default `null`. Nullable. | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/shows/SHOW_ID/episodes/EPISODE_ID" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "episode", "show": { "id": "i3KlnFag2DgIpykraVxjrIZwJYcL", "name": "The Long Game", "handle": "the-long-game", "image": "https://images.example.com/listener/4b71276112f25fab.jpg", "public_url": "https://listener.com/the-long-game" }, "episode": { "total_reach": 1231, "youtube_reach": 0, "youtube_long_form_reach": 0, "youtube_shorts_reach": 0, "audio_reach": 1231, "social_reach": 0, "long_form_reach": 1231, "short_form_reach": 0, "long_form_count": 1, "short_form_count": 0, "clips": 1, "posts": 0, "total_engagement": 0, "engagement_rate": 0, "id": "2Uu4VPBZxmUSGm8pHYe9Cq", "title": "Midseason awards", "published_at": "2026-09-18", "date_raw": "2026-09-18", "duration": "01:10:12", "duration_seconds": 4212, "episode_number": null, "source_platform": "spotify", "platform_label": "Spotify", "is_manual": false, "is_external": true, "is_synthetic": false, "explicit": false, "cover": "https://images.example.com/listener/a3d3672df4e9ac70.jpg", "share_url": "https://open.spotify.com/episode/2Uu4VPBZxmUSGm8pHYe9Cq", "description": "The crew is back with rankings, hot takes and a listener mailbag.", "tags": { "series": [], "format": [], "guests": [] }, "brands": [], "cluster_id": "cluster-2Uu4VPBZxmUSGm8pHYe9Cq", "has_cluster": true, "cluster_type": "episode", "metrics_source": "nodes", "summary_stale": false, "freshness": { "kind": "unknown", "at": null, "source": null }, "last_refreshed_at": null, "refresh_error_count": 0, "cluster_public_url": "https://listener.com/cluster/i3KlnFag2DgIpykraVxjrIZwJYcL_cluster-2Uu4VPBZxmUSGm8pHYe9Cq", "cluster_app_url": "https://network.listener.com/app/cluster?id=cluster-2Uu4VPBZxmUSGm8pHYe9Cq&uid=i3KlnFag2DgIpykraVxjrIZwJYcL", "goals": { "comparable": true, "targets": { "total_reach": null, "long_form_reach": null, "short_form_reach": null }, "deltas": { "total_reach": null, "long_form_reach": null, "short_form_reach": null } }, "benchmark": { "format": "long", "year": 2026, "quarter": 3, "counts": { "long_form_count": 1, "short_form_count": 0 }, "periods": { "year": { "label": "2026", "peer_count": 0, "sufficient": false, "medians": {}, "deltas": {}, "median_counts": {} }, "quarter": { "label": "Q3 2026", "peer_count": 0, "sufficient": false, "medians": {}, "deltas": {}, "median_counts": {} } }, "breakout": null } }, "sources": [ "index" ], "spotify": { "plays": 1231, "streams": 1197, "audience_size": 755 }, "apple": null, "youtube": null, "cluster": { "id": "cluster-2Uu4VPBZxmUSGm8pHYe9Cq", "cluster_type": "episode", "title": "Midseason awards", "node_count": 2, "clips": 1, "posts": 0, "episode_ids": [ "2Uu4VPBZxmUSGm8pHYe9Cq" ], "sponsor_brands": [], "linked_campaign_ids": [], "linked_campaigns": [], "freshness": { "kind": "unknown", "at": null, "source": null }, "refresh_error_count": 0, "refresh_errors": [], "metrics_source": "nodes", "summary_stale": false, "public_url": "https://listener.com/cluster/i3KlnFag2DgIpykraVxjrIZwJYcL_cluster-2Uu4VPBZxmUSGm8pHYe9Cq", "app_url": "https://network.listener.com/app/cluster?id=cluster-2Uu4VPBZxmUSGm8pHYe9Cq&uid=i3KlnFag2DgIpykraVxjrIZwJYcL" }, "goals": { "targets": { "total_reach": null, "long_form_reach": null, "short_form_reach": null }, "sources": { "total_reach": null, "long_form_reach": null, "short_form_reach": null }, "series": null, "has_series_overrides": false, "has_any": false, "comparable_count": 1 }, "benchmark": { "population_size": 1, "years": [ 2026 ], "median_metrics": null, "breakout_count": 0 }, "freshness": { "youtube": { "last_updated": "2026-10-13T09:34:22.044000+00:00", "age_hours": 32, "stale": false }, "spotify": { "last_updated": "2026-09-22T22:06:27.429000+00:00", "age_hours": 523.46, "stale": true }, "applePodcasts": { "last_updated": "2026-04-13T17:28:59+00:00", "age_hours": 4416.09, "stale": true }, "tiktok": { "last_updated": "2026-10-13T09:35:24.476000+00:00", "age_hours": 31.98, "stale": false }, "instagram": { "last_updated": "2026-10-13T09:35:13.282000+00:00", "age_hours": 31.98, "stale": false } }, "stale_platforms": [ "applePodcasts", "spotify" ], "computed_at": "2026-10-14T17:34:13.314716+00:00", "cache_age_seconds": 7.8, "note": null } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Retrieve an episode transcript `GET /v1/shows/{show_id}/episodes/{episode_id}/transcript` Scope: `episodes:read` SDK: `client.shows.episodes.transcript()` (TypeScript) · `client.shows.episodes.transcript()` (Python) The best text available for an episode. Listener stores no transcripts, so `status` is `description-fallback` (the episode description, up to 9,000 characters) or `missing`; transcript text saved on a linked campaign's air check is returned in `aircheck_excerpts`. Do not present the description as a transcript. - Required scope: `episodes:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | | `episode_id` | string | required | Episode id (see GET /v1/shows/{show_id}/episodes). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `show` | object | required | The minimal show reference embedded in cross-show results. | | `show.id` | string | required | | | `show.name` | string | required | | | `show.handle` | string | optional | Default `""`. | | `show.image` | string | optional | Default `null`. Nullable. | | `show.public_url` | string | optional | Default `null`. Nullable. | | `episode_id` | string | required | | | `title` | string | required | | | `published_at` | string | optional | Default `null`. Nullable. | | `status` | string | required | One of `description-fallback`, `missing`. | | `text` | string | optional | Description text (there is no stored transcript); capped at 9,000 characters. Default `null`. Nullable. | | `truncated` | boolean | optional | Default `false`. | | `character_count` | integer | optional | Default `0`. | | `aircheck_excerpts` | array of objects | optional | Transcript text found on linked campaigns' air-check analyses, when any. | | `aircheck_excerpts[].campaign_id` | string | required | | | `aircheck_excerpts[].campaign_title` | string | required | | | `aircheck_excerpts[].brand_name` | string | optional | Default `null`. Nullable. | | `aircheck_excerpts[].source` | string | required | 'audio' \| 'youtube'. | | `aircheck_excerpts[].text` | string | required | | | `aircheck_excerpts[].truncated` | boolean | optional | Default `false`. | | `share_url` | string | optional | Default `null`. Nullable. | | `computed_at` | string | required | | | `cache_age_seconds` | number | required | | | `note` | string | optional | Default `null`. Nullable. | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/shows/SHOW_ID/episodes/EPISODE_ID/transcript" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "episode_transcript", "show": { "id": "i3KlnFag2DgIpykraVxjrIZwJYcL", "name": "The Long Game", "handle": "the-long-game", "image": "https://images.example.com/listener/4b71276112f25fab.jpg", "public_url": "https://listener.com/the-long-game" }, "episode_id": "2Uu4VPBZxmUSGm8pHYe9Cq", "title": "Midseason awards", "published_at": "2026-09-18", "status": "description-fallback", "text": "The crew is back with rankings, hot takes and a listener mailbag.", "truncated": false, "character_count": 1091, "aircheck_excerpts": [], "share_url": "https://open.spotify.com/episode/2Uu4VPBZxmUSGm8pHYe9Cq", "computed_at": "2026-10-14T17:34:13.317479+00:00", "cache_age_seconds": 7.8, "note": "Listener does not store episode transcripts; this is the episode description." } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Search episodes `GET /v1/episodes/search` Scope: `episodes:read` SDK: `client.episodes.search()` (TypeScript) · `client.episodes.search()` (Python) Search episodes by title, description, episode number or date across the workspace's shows; each hit carries its show, reach fields and cluster id. Real feed episodes rank above manual and synthetic rows. Pass `show_ids` to keep it fast on large networks. Results are not paginated; `limit` caps them. - Required scope: `episodes:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `query` | string | required | Search text. | | `show_ids` | array of strings | optional | Limit to these show ids (default: every show in the workspace). Repeat the parameter or comma-separate values. | | `limit` | integer | optional | Maximum number of items to return. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].total_reach` | integer \| number | optional | Default `0`. | | `data[].youtube_reach` | integer \| number | optional | Default `0`. | | `data[].youtube_long_form_reach` | integer \| number | optional | Default `0`. | | `data[].youtube_shorts_reach` | integer \| number | optional | Default `0`. | | `data[].audio_reach` | integer \| number | optional | Default `0`. | | `data[].social_reach` | integer \| number | optional | Default `0`. | | `data[].long_form_reach` | integer \| number | optional | Default `0`. | | `data[].short_form_reach` | integer \| number | optional | Default `0`. | | `data[].long_form_count` | integer | optional | Default `0`. | | `data[].short_form_count` | integer | optional | Default `0`. | | `data[].clips` | integer | optional | Default `0`. | | `data[].posts` | integer | optional | Default `0`. | | `data[].total_engagement` | integer \| number | optional | Default `0`. | | `data[].engagement_rate` | integer \| number | optional | Percent (1.93 == 1.93 %). Default `0`. | | `data[].id` | string | required | | | `data[].title` | string | required | | | `data[].published_at` | string | optional | YYYY-MM-DD parsed with the app's date rules; null when the stored date is unreadable. Default `null`. Nullable. | | `data[].date_raw` | string | optional | Default `null`. Nullable. | | `data[].duration` | string | optional | Default `null`. Nullable. | | `data[].duration_seconds` | integer | optional | Default `null`. Nullable. | | `data[].episode_number` | string | optional | Default `null`. Nullable. | | `data[].source_platform` | string | required | 'spotify' \| 'apple' \| 'youtube' \| 'manual' \| 'cluster' \| provider key \| 'unknown'. | | `data[].platform_label` | string | optional | Default `null`. Nullable. | | `data[].is_manual` | boolean | optional | Default `false`. | | `data[].is_external` | boolean | optional | Default `false`. | | `data[].is_synthetic` | boolean | optional | Row invented by the app for a chart / Apple entry without a stored episode. Default `false`. | | `data[].explicit` | boolean | optional | Default `null`. Nullable. | | `data[].cover` | string | optional | Default `null`. Nullable. | | `data[].share_url` | string | optional | Default `null`. Nullable. | | `data[].description` | string | optional | Truncated in list rows; full text on `GET /v1/shows/{show_id}/episodes/{episode_id}`. Default `null`. Nullable. | | `data[].tags` | map of array of strings | optional | {series, format, guests}. | | `data[].brands` | array of strings | optional | Sponsored-node brands and linked campaign brands on the source cluster. | | `data[].cluster_id` | string | optional | Default `null`. Nullable. | | `data[].has_cluster` | boolean | optional | A source cluster exists or any reach / clips / posts > 0 (the page's rule). Default `false`. | | `data[].cluster_type` | string | optional | Default `null`. Nullable. | | `data[].metrics_source` | string | optional | Default `"none"`. | | `data[].summary_stale` | boolean | optional | Default `false`. | | `data[].freshness` | object | required | | | `data[].freshness.kind` | string | optional | 'refreshed' (a refresh run) \| 'saved' (newest node fetch stamp) \| 'unknown'. Default `"unknown"`. | | `data[].freshness.at` | string | optional | Default `null`. Nullable. | | `data[].freshness.source` | string | optional | Refresh source when known ('manual' \| 'scheduled' \| …). Default `null`. Nullable. | | `data[].last_refreshed_at` | string | optional | Default `null`. Nullable. | | `data[].refresh_error_count` | integer | optional | Default `0`. | | `data[].cluster_public_url` | string | optional | Default `null`. Nullable. | | `data[].cluster_app_url` | string | optional | Default `null`. Nullable. | | `data[].goals` | object | optional | Default `null`. Nullable. | | `data[].goals.comparable` | boolean | optional | The episode has a cluster with reach, so deltas are meaningful. Default `false`. | | `data[].goals.targets` | object | required | | | `data[].goals.targets.total_reach` | integer | optional | Default `null`. Nullable. | | `data[].goals.targets.long_form_reach` | integer | optional | Default `null`. Nullable. | | `data[].goals.targets.short_form_reach` | integer | optional | Default `null`. Nullable. | | `data[].goals.deltas` | object | required | Percent vs target ((value − target) / target × 100); null without a target. | | `data[].goals.deltas.total_reach` | number | optional | Default `null`. Nullable. | | `data[].goals.deltas.long_form_reach` | number | optional | Default `null`. Nullable. | | `data[].goals.deltas.short_form_reach` | number | optional | Default `null`. Nullable. | | `data[].benchmark` | object | optional | Default `null`. Nullable. | | `data[].benchmark.format` | string | required | 'long' \| 'short' — the episode's dominant reach format. | | `data[].benchmark.year` | integer | required | | | `data[].benchmark.quarter` | integer | required | | | `data[].benchmark.counts` | map of integer | optional | | | `data[].benchmark.periods` | map of object | optional | 'year' and 'quarter' peer comparisons. | | `data[].benchmark.periods.label` | string | optional | Default `""`. | | `data[].benchmark.periods.peer_count` | integer | optional | Default `0`. | | `data[].benchmark.periods.sufficient` | boolean | optional | At least 3 same-format peers in the period. Default `false`. | | `data[].benchmark.periods.medians` | map of number | optional | | | `data[].benchmark.periods.deltas` | map of number | optional | Percent vs the peer median. | | `data[].benchmark.periods.median_counts` | map of number | optional | | | `data[].benchmark.breakout` | object | optional | {multiplier, format} when the episode is ≥ 3× its format's median this year. Default `null`. Nullable. | | `data[].show` | object | required | The minimal show reference embedded in cross-show results. | | `data[].show.id` | string | required | | | `data[].show.name` | string | required | | | `data[].show.handle` | string | optional | Default `""`. | | `data[].show.image` | string | optional | Default `null`. Nullable. | | `data[].show.public_url` | string | optional | Default `null`. Nullable. | | `data[].search_score` | number | optional | Default `0`. | | `data[].object` | string | required | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | | `meta.query` | string | optional | | | `meta.total_matches` | integer | optional | | | `meta.limit` | integer | optional | | | `meta.show_ids` | array of strings | optional | | | `meta.searched_episode_count` | integer | optional | Episodes scanned across the scoped shows. | | `meta.missing_show_ids` | array of strings | optional | | | `meta.computed_at` | string | optional | | | `meta.cache_age_seconds` | number | optional | | | `meta.note` | string | optional | Default `null`. Nullable. | ### Example request ```bash curl "https://api.listener.com/v1/episodes/search?query=QUERY" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/episodes/search", "data": [ { "object": "episode", "total_reach": 0, "youtube_reach": 0, "youtube_long_form_reach": 0, "youtube_shorts_reach": 0, "audio_reach": 0, "social_reach": 0, "long_form_reach": 0, "short_form_reach": 0, "long_form_count": 0, "short_form_count": 0, "clips": 0, "posts": 0, "total_engagement": 0, "engagement_rate": 0, "id": "6hTm2v2JOiy5gloMYD6jOU", "title": "What the numbers really say", "published_at": "2026-10-02", "date_raw": "2026-10-02", "duration": "45:00", "duration_seconds": 2700, "episode_number": null, "source_platform": "spotify", "platform_label": null, "is_manual": false, "is_external": false, "is_synthetic": false, "explicit": false, "cover": "https://images.example.com/listener/9ce451d729b585a9.jpg", "share_url": "https://open.spotify.com/episode/6hTm2v2JOiy5gloMYD6jOU", "description": "Clip from this week's episode. Full episode on YouTube and Spotify.", "tags": { "series": [], "format": [], "guests": [] }, "brands": [], "cluster_id": null, "has_cluster": false, "cluster_type": null, "metrics_source": "none", "summary_stale": false, "freshness": { "kind": "unknown", "at": null, "source": null }, "last_refreshed_at": null, "refresh_error_count": 0, "cluster_public_url": null, "cluster_app_url": null, "goals": null, "benchmark": null, "show": { "id": "sLLP2LKo9LJHqRCtBjjWDsbyDImn", "name": "Dugout Daily", "handle": "dugout-daily", "image": "https://images.example.com/listener/09c3beb030eae9d0.jpg", "public_url": "https://listener.com/dugout-daily" }, "search_score": 12 }, { "object": "episode", "total_reach": 0, "youtube_reach": 0, "youtube_long_form_reach": 0, "youtube_shorts_reach": 0, "audio_reach": 0, "social_reach": 0, "long_form_reach": 0, "short_form_reach": 0, "long_form_count": 0, "short_form_count": 0, "clips": 0, "posts": 0, "total_engagement": 0, "engagement_rate": 0, "id": "7P8qBzsz5VhK7Aor1UFnrN", "title": "Power rankings, week 6", "published_at": "2026-10-01", "date_raw": "2026-10-01", "duration": "44:37", "duration_seconds": 2677, "episode_number": null, "source_platform": "spotify", "platform_label": null, "is_manual": false, "is_external": false, "is_synthetic": false, "explicit": false, "cover": "https://images.example.com/listener/efd8cb77345c1231.jpg", "share_url": "https://open.spotify.com/episode/7P8qBzsz5VhK7Aor1UFnrN", "description": "New episode out now. We break down the week's biggest storylines and answer your questions.", "tags": { "series": [], "format": [], "guests": [] }, "brands": [], "cluster_id": null, "has_cluster": false, "cluster_type": null, "metrics_source": "none", "summary_stale": false, "freshness": { "kind": "unknown", "at": null, "source": null }, "last_refreshed_at": null, "refresh_error_count": 0, "cluster_public_url": null, "cluster_app_url": null, "goals": null, "benchmark": null, "show": { "id": "sLLP2LKo9LJHqRCtBjjWDsbyDImn", "name": "Dugout Daily", "handle": "dugout-daily", "image": "https://images.example.com/listener/09c3beb030eae9d0.jpg", "public_url": "https://listener.com/dugout-daily" }, "search_score": 12 } ], "has_more": false, "next_cursor": null, "meta": { "query": "the", "total_matches": 4082, "limit": 3, "show_ids": [ "paAtKPJGo56md5VyoEQ9Las7qahL", "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H" ], "searched_episode_count": 9135, "missing_show_ids": [ "0Iush8rT5eCcD4ePHroKFDlQvKhT", "nCJuUMzGC6EE3dfruqjRHtYdlCTr" ], "computed_at": "2026-10-14T17:34:13.319053+00:00", "cache_age_seconds": 7.8, "note": null } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Set episode tags `POST /v1/shows/{show_id}/episodes/tags` Scope: `episodes:write` SDK: `client.shows.episodes.setTags()` (TypeScript) · `client.shows.episodes.set_tags()` (Python) Set episode tags (`series`, `format`, `guests`) on one or more episodes of a show. `mode=replace` overwrites every dimension you send (omitted dimensions become empty); `add` unions and `remove` subtracts only the dimensions you send. Values are trimmed and de-duplicated case-insensitively. Any id the episodes list returns is accepted; episodes known only from provider data or clusters are added to the stored list (`data.appended`), unknown ids come back in `data.not_found`. Cluster node tags are a separate system (`POST /v1/shows/{show_id}/clusters/{cluster_id}/nodes/tags`). - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `episodes:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `episode_ids` | array of strings | required | Episode ids on the show. | | `series` | array of strings | optional | Series tags. Default `null`. Nullable. | | `format` | array of strings | optional | Format tags. Default `null`. Nullable. | | `guests` | array of strings | optional | Guest tags. Default `null`. Nullable. | | `mode` | string | optional | How the change applies (see the operation description). One of `replace`, `add`, `remove`. Default `"replace"`. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | ### Example request ```bash curl -X POST "https://api.listener.com/v1/shows/SHOW_ID/episodes/tags" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"episode_ids": [], "dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "episode_tags", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/i3KlnFag2DgIpykraVxjrIZwJYcL", "id": "i3KlnFag2DgIpykraVxjrIZwJYcL", "method": "transaction", "written": { "spotify.episodes": "<1 episodes, 1 patched>", "patched": { "2Uu4VPBZxmUSGm8pHYe9Cq": { "series": [ "API probe" ] } } }, "warnings": [ "1 episode(s) were only known from provider caches or clusters and were added to the stored episodes with their tags: 2Uu4VPBZxmUSGm8pHYe9Cq." ], "urls": { "app": "https://network.listener.com/app/episodes?uid=i3KlnFag2DgIpykraVxjrIZwJYcL", "public": null }, "error": null, "data": { "updated": [ "2Uu4VPBZxmUSGm8pHYe9Cq" ], "appended": [ "2Uu4VPBZxmUSGm8pHYe9Cq" ], "not_found": [], "show_id": "i3KlnFag2DgIpykraVxjrIZwJYcL", "tags_by_episode": { "2Uu4VPBZxmUSGm8pHYe9Cq": { "series": [ "API probe" ] } }, "doc_bytes_after": 440946 }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Set episode goals `PUT /v1/shows/{show_id}/episode-goals` Scope: `episodes:write` SDK: `client.shows.setEpisodeGoals()` (TypeScript) · `client.shows.set_episode_goals()` (Python) Set the show's episode reach goals: a `default` target plus per-series overrides in `by_series` (keyed by series tag). Targets accept integers or shorthand (`"250k"`, `"1.2M"`); only positive values are kept. `clear=true`, or no positive target at all, removes the goals. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `episodes:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `default` | object | optional | Reach targets; accept ints or shorthand strings like "250k", "1.2M", "1,200,000". Default `null`. Nullable. | | `default.total_reach` | integer \| string \| any | optional | Default `null`. | | `default.long_form_reach` | integer \| string \| any | optional | Default `null`. | | `default.short_form_reach` | integer \| string \| any | optional | Default `null`. | | `by_series` | map of object | optional | Default `null`. Nullable. | | `by_series.total_reach` | integer \| string \| any | optional | Default `null`. | | `by_series.long_form_reach` | integer \| string \| any | optional | Default `null`. | | `by_series.short_form_reach` | integer \| string \| any | optional | Default `null`. | | `clear` | boolean | optional | Default `false`. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | ### Example request ```bash curl -X PUT "https://api.listener.com/v1/shows/SHOW_ID/episode-goals" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "episode_goals", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/i3KlnFag2DgIpykraVxjrIZwJYcL", "id": "i3KlnFag2DgIpykraVxjrIZwJYcL", "method": "update", "written": { "profile.episodeGoals": { "version": 1, "default": { "totalReach": 250000 }, "bySeries": {} } }, "warnings": [], "urls": { "app": "https://network.listener.com/app/episodes?uid=i3KlnFag2DgIpykraVxjrIZwJYcL", "public": null }, "error": null, "data": { "show_id": "i3KlnFag2DgIpykraVxjrIZwJYcL", "goals": { "version": 1, "default": { "totalReach": 250000 }, "bySeries": {} }, "cleared": false }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Set Substack podcast downloads for an episode node `PUT /v1/shows/{show_id}/substack/downloads` Scope: `episodes:write` SDK: `client.shows.substack.setDownloads()` (TypeScript) · `client.shows.substack.set_downloads()` (Python) Set the podcast downloads on one Substack card by hand (the app's "Edit metrics"), or remove that override with `clear=true`. The figure is stored beside Substack's API count, counts in the cluster's audio reach and wins over an imported figure until cleared. `node_id` is a Substack post node from `GET /v1/shows/{show_id}/clusters/{cluster_id}` (its `podcast_downloads_source` is `manual`, `export` or `api`). For many episodes at once use `POST /v1/shows/{show_id}/substack/imports`. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `episodes:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `cluster_id` | string | required | Cluster id (see GET /v1/clusters). | | `node_id` | string | required | Cluster node id. | | `downloads` | integer | optional | Default `null`. Nullable. | | `clear` | boolean | optional | Default `false`. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | ### Example request ```bash curl -X PUT "https://api.listener.com/v1/shows/SHOW_ID/substack/downloads" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"cluster_id": "CLUSTER_ID", "node_id": "NODE_ID", "dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "substack_downloads", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/paAtKPJGo56md5VyoEQ9Las7qahL/clusters/cluster-0y0HEJ0OVF7EYdXAvD5ghY", "id": "cluster-0y0HEJ0OVF7EYdXAvD5ghY", "method": "transaction", "written": { "nodes[post-substack-1].data.podcastDownloadsManual": 18250, "updatedAt": "" }, "warnings": [], "urls": { "app": null, "public": null }, "error": null, "data": { "node_id": "post-substack-1", "before": { "downloads": 12400, "source": "api" }, "after": { "downloads": 18250, "source": "manual" } }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Import Substack episode stats (CSV) `POST /v1/shows/{show_id}/substack/imports` Scope: `episodes:write` SDK: `client.shows.substack.importStats()` (TypeScript) · `client.shows.substack.import_stats()` (Python) Import Substack's podcast "episode stats" CSV for a show: each row's `Total` becomes the downloads on that episode's Substack cards (source `export`) and in cluster audio reach. Substack's API only reports the downloads Substack tracks itself (often ~70 % of the export), so this brings in the publisher's full figure. `csv_text` is the whole exported file (columns Title, Date, Downloads (first 30d), Total, …); rows match posts on publish time to the second, then title. Re-importing is safe. Hand-set figures still win. Send it with `"dry_run": true` first and review each cluster's before → after. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `episodes:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `csv_text` | string | required | | | `file_name` | string | optional | Default `null`. Nullable. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | ### Example request ```bash curl -X POST "https://api.listener.com/v1/shows/SHOW_ID/substack/imports" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"csv_text": "CSV_TEXT", "dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "substack_import", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/paAtKPJGo56md5VyoEQ9Las7qahL/clusters", "id": "clusters", "method": "batch", "written": { "rows": 2 }, "warnings": [], "urls": { "app": null, "public": null }, "error": null, "data": { "rows_total": 2, "rows_matched": 1, "unmatched_rows": [ { "title": "Mailbag: your questions answered", "date": "2026-09-30" } ], "clusters": [ { "cluster_id": "cluster-0y0HEJ0OVF7EYdXAvD5ghY", "audio_before": 12400, "audio_after": 18250 } ] }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. --- # Analytics > Account-level reach, timelines, rankings, platform stats and recent content. These endpoints cost 2 rate-limit tokens. Base URL: `https://api.listener.com` ## Reach summary `GET /v1/analytics/reach` Scope: `analytics:read` SDK: `client.analytics.reach()` (TypeScript) · `client.analytics.reach()` (Python) The analytics-page KPI cards: total, YouTube, audio, social and newsletter reach for the window, with previous-period values, guarded growth, per-platform totals, coverage and freshness. `categories` is a case-insensitive substring match on show categories; `platforms` restricts the totals (aliases accepted); `social_count_mode=unique` counts an account shared by several shows once. Newsletter opens are their own category and part of `total`. For the daily series use `GET /v1/analytics/timeline`; for per-show numbers `GET /v1/analytics/shows`. **Windows.** `time_range` is `7d`, `14d`, `30d`, `90d`, `180d`, `365d`, `all` (rolling UTC days, today inclusive) or a calendar quarter such as `2026-Q2` (a bare `Q2` means the current year). `start_date` + `end_date` (`YYYY-MM-DD`, inclusive, both required) override it. Results carry `window` and `coverage`. **Growth.** Growth compares against the equal-length window immediately before (the previous quarter for quarter tokens). It is `null` (show it as "—", never 0) when the previous window has under 50 % day coverage. Windows longer than 30 days read stored history and compare like-for-like: only platforms whose data covers at least half of the previous window, both windows summed over those platforms (`coverage.growth_platforms`). **Freshness.** `freshness` maps each platform to `{last_updated, age_hours, stale}` (stale = older than 36 h); `stale_platforms` lists them. A zero usually means a stale connector, not a zero audience. - Costs 2 rate-limit tokens. - Required scope: `analytics:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_ids` | array of strings | optional | Limit to these show ids (default: every show in the workspace). Repeat the parameter or comma-separate values. | | `categories` | array of strings | optional | Only shows whose categories contain one of these (case-insensitive substring). Repeat the parameter or comma-separate values. | | `platforms` | array of strings | optional | Only these platforms (e.g. youtube, spotify, instagram; aliases accepted). Repeat the parameter or comma-separate values. | | `social_count_mode` | string | optional | all counts every show's accounts; unique counts an account shared by several shows once (totals, timelines, distributions only). | | `metric` | string | optional | Which measure to report. | | `time_range` | string | optional | 7d \| 14d \| 30d \| 90d \| 180d \| 365d \| all, or a quarter like 2026-Q2. Windows are UTC days anchored to today. | | `start_date` | string | optional | First day, YYYY-MM-DD (inclusive). With end_date, overrides time_range. | | `end_date` | string | optional | Last day, YYYY-MM-DD (inclusive). With start_date, overrides time_range. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `window` | object | required | The resolved analytics window (from ``core.dates.Window.as_dict()``). | | `window.time_range` | string | required | Normalised token: '30d', '2026-Q2', '2026-01-01..2026-03-31', 'all'. | | `window.kind` | string | required | 'rolling' \| 'quarter' \| 'custom' \| 'all'. | | `window.start` | string | required | First day (YYYY-MM-DD, inclusive, UTC). | | `window.end` | string | required | Last day (YYYY-MM-DD, inclusive, UTC). | | `window.days` | integer | required | | | `window.previous_start` | string | optional | Default `null`. Nullable. | | `window.previous_end` | string | optional | Default `null`. Nullable. | | `coverage` | object | required | How much of the window actually has data (design §2). | | `coverage.start` | string | optional | Earliest date with data across the scoped shows. Default `null`. Nullable. | | `coverage.embedded_boundary` | string | optional | today-29: dates before this come from analyticsHistory. Default `null`. Nullable. | | `coverage.history_used` | boolean | optional | Default `false`. | | `coverage.previous_window_coverage_days` | integer | optional | Default `0`. | | `coverage.current_window_coverage_days` | integer | optional | Default `0`. | | `coverage.growth_basis` | string | optional | 'like_for_like' on windows longer than 30 days (e.g. 90d): growth compares only platforms whose data covers at least half of the previous window, both windows summed over those platforms alone. One of `total`, `like_for_like`. Default `"total"`. | | `coverage.growth_platforms` | array of strings | optional | Like-for-like windows only: the platforms the headline growth compares (empty → growth is null). Default `null`. Nullable. | | `coverage.warnings` | array of strings | optional | | | `freshness` | map of object | optional | platform → oldest freshness across the scoped shows. | | `freshness.last_updated` | string | optional | Default `null`. Nullable. | | `freshness.age_hours` | number | optional | Default `null`. Nullable. | | `freshness.stale` | boolean | optional | True when older than 36 hours or unknown. Default `true`. | | `stale_platforms` | array of strings | optional | | | `show_ids` | array of strings | optional | Shows included after show_ids/categories filtering. | | `missing_show_ids` | array of strings | optional | | | `filters` | object | optional | | | `computed_at` | string | required | | | `cache_age_seconds` | number | optional | Default `0`. | | `note` | string | optional | Default `null`. Nullable. | | `metric` | string | optional | One of `reach`, `engagement`. Default `"reach"`. | | `social_count_mode` | string | optional | One of `all`, `unique`. Default `"all"`. | | `total` | object | required | | | `total.current` | integer | required | | | `total.previous` | integer | required | | | `total.growth` | number | optional | Percent; None when the baseline window has < 50 % day coverage. On windows > 30 days (90d) it is like-for-like: only platforms with data across ≥ 50 % of the previous window are compared (None when none qualify). Default `null`. Nullable. | | `total.platforms` | array of strings | optional | Chart fields included in this category (after the platforms filter). | | `youtube` | object | required | | | `youtube.current` | integer | required | | | `youtube.previous` | integer | required | | | `youtube.growth` | number | optional | Percent; None when the baseline window has < 50 % day coverage. On windows > 30 days (90d) it is like-for-like: only platforms with data across ≥ 50 % of the previous window are compared (None when none qualify). Default `null`. Nullable. | | `youtube.platforms` | array of strings | optional | Chart fields included in this category (after the platforms filter). | | `audio` | object | required | | | `audio.current` | integer | required | | | `audio.previous` | integer | required | | | `audio.growth` | number | optional | Percent; None when the baseline window has < 50 % day coverage. On windows > 30 days (90d) it is like-for-like: only platforms with data across ≥ 50 % of the previous window are compared (None when none qualify). Default `null`. Nullable. | | `audio.platforms` | array of strings | optional | Chart fields included in this category (after the platforms filter). | | `social` | object | required | | | `social.current` | integer | required | | | `social.previous` | integer | required | | | `social.growth` | number | optional | Percent; None when the baseline window has < 50 % day coverage. On windows > 30 days (90d) it is like-for-like: only platforms with data across ≥ 50 % of the previous window are compared (None when none qualify). Default `null`. Nullable. | | `social.platforms` | array of strings | optional | Chart fields included in this category (after the platforms filter). | | `newsletter` | object | required | Beehiiv + Substack newsletter opens; part of total on the analytics page. | | `newsletter.current` | integer | required | | | `newsletter.previous` | integer | required | | | `newsletter.growth` | number | optional | Percent; None when the baseline window has < 50 % day coverage. On windows > 30 days (90d) it is like-for-like: only platforms with data across ≥ 50 % of the previous window are compared (None when none qualify). Default `null`. Nullable. | | `newsletter.platforms` | array of strings | optional | Chart fields included in this category (after the platforms filter). | | `per_platform` | map of integer | optional | | | `previous_per_platform` | map of integer | optional | | | `suppressed_platforms_by_show` | map of array of strings | optional | Unique mode only: show → platforms skipped because another show is canonical for the same account. | | `shows_in_totals` | integer | optional | Default `0`. | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/analytics/reach" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "reach_summary", "window": { "time_range": "30d", "kind": "rolling", "start": "2026-09-15", "end": "2026-10-14", "days": 30, "previous_start": "2026-08-16", "previous_end": "2026-09-14" }, "coverage": { "start": "2019-11-26", "embedded_boundary": "2026-09-15", "history_used": true, "previous_window_coverage_days": 30, "current_window_coverage_days": 29, "growth_basis": "total", "growth_platforms": null, "warnings": [] }, "freshness": { "applePodcasts": { "last_updated": "2026-04-13T16:00:26+00:00", "age_hours": 4417.56, "stale": true }, "facebook": { "last_updated": "2026-10-08T07:02:13.420000+00:00", "age_hours": 154.53, "stale": true }, "instagram": { "last_updated": "2026-10-13T09:35:13.282000+00:00", "age_hours": 31.98, "stale": false }, "megaphone": { "last_updated": "2026-10-14T09:22:51.743000+00:00", "age_hours": 8.19, "stale": false }, "omnystudio": { "last_updated": "2026-10-13T09:33:48.789000+00:00", "age_hours": 32.01, "stale": false }, "spotify": { "last_updated": "2026-08-06T08:49:48.390000+00:00", "age_hours": 1664.74, "stale": true }, "threads": { "last_updated": "2026-10-13T09:36:40.899000+00:00", "age_hours": 31.96, "stale": false }, "tiktok": { "last_updated": "2026-10-13T09:34:15.574000+00:00", "age_hours": 32, "stale": false }, "twitter": { "last_updated": "2026-10-10T07:02:33.014000+00:00", "age_hours": 106.53, "stale": true }, "youtube": { "last_updated": "2026-10-02T10:37:33.857000+00:00", "age_hours": 294.94, "stale": true } }, "stale_platforms": [ "applePodcasts", "facebook" ], "show_ids": [ "paAtKPJGo56md5VyoEQ9Las7qahL", "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H" ], "missing_show_ids": [ "0Iush8rT5eCcD4ePHroKFDlQvKhT", "nCJuUMzGC6EE3dfruqjRHtYdlCTr" ], "filters": { "show_ids": null, "categories": null, "platforms": null, "social_count_mode": "all", "time_range": "30d", "metric": "reach" }, "computed_at": "2026-10-14T17:34:15.706382+00:00", "cache_age_seconds": 10.2, "note": null, "metric": "reach", "social_count_mode": "all", "total": { "current": 35840243, "previous": 77122707, "growth": -53.53, "platforms": [ "youtube", "instagram" ] }, "youtube": { "current": 1699627, "previous": 2680728, "growth": -36.6, "platforms": [ "youtube" ] }, "audio": { "current": 356022, "previous": 1744915, "growth": -79.6, "platforms": [ "spotify", "applePodcasts" ] }, "social": { "current": 33784594, "previous": 72697064, "growth": -53.53, "platforms": [ "instagram", "tiktok" ] }, "newsletter": { "current": 0, "previous": 0, "growth": null, "platforms": [ "beehiiv", "substack" ] }, "per_platform": { "youtube": 1699627, "instagram": 7666139, "tiktok": 2015820, "twitter": 24102635, "spotify": 291087, "applePodcasts": 590, "megaphone": 1288, "omnystudio": 63057 }, "previous_per_platform": { "youtube": 2680728, "instagram": 14725796, "tiktok": 1902422, "twitter": 56068846, "spotify": 1719177, "applePodcasts": 2859, "megaphone": 662, "omnystudio": 22217 }, "suppressed_platforms_by_show": {}, "shows_in_totals": 12 } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Reach timeline `GET /v1/analytics/timeline` Scope: `analytics:read` SDK: `client.analytics.timeline()` (TypeScript) · `client.analytics.timeline()` (Python) The daily performance series behind the analytics chart (date, total, per platform, running cumulative) with totals, growth and the peak day. `chart_type=cumulative` returns running sums; `include_previous=true` adds the baseline window's rows; `include_per_show=true` adds one series per show (never de-duplicated). **Windows.** `time_range` is `7d`, `14d`, `30d`, `90d`, `180d`, `365d`, `all` (rolling UTC days, today inclusive) or a calendar quarter such as `2026-Q2` (a bare `Q2` means the current year). `start_date` + `end_date` (`YYYY-MM-DD`, inclusive, both required) override it. Results carry `window` and `coverage`. **Growth.** Growth compares against the equal-length window immediately before (the previous quarter for quarter tokens). It is `null` (show it as "—", never 0) when the previous window has under 50 % day coverage. Windows longer than 30 days read stored history and compare like-for-like: only platforms whose data covers at least half of the previous window, both windows summed over those platforms (`coverage.growth_platforms`). - Costs 2 rate-limit tokens. - Required scope: `analytics:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_ids` | array of strings | optional | Limit to these show ids (default: every show in the workspace). Repeat the parameter or comma-separate values. | | `categories` | array of strings | optional | Only shows whose categories contain one of these (case-insensitive substring). Repeat the parameter or comma-separate values. | | `platforms` | array of strings | optional | Only these platforms (e.g. youtube, spotify, instagram; aliases accepted). Repeat the parameter or comma-separate values. | | `social_count_mode` | string | optional | all counts every show's accounts; unique counts an account shared by several shows once (totals, timelines, distributions only). | | `metric` | string | optional | Which measure to report. | | `chart_type` | string | optional | daily values or a cumulative running total. | | `include_previous` | boolean | optional | Also return the previous period's series. | | `include_per_show` | boolean | optional | Also return a per-show breakdown. | | `time_range` | string | optional | 7d \| 14d \| 30d \| 90d \| 180d \| 365d \| all, or a quarter like 2026-Q2. Windows are UTC days anchored to today. | | `start_date` | string | optional | First day, YYYY-MM-DD (inclusive). With end_date, overrides time_range. | | `end_date` | string | optional | Last day, YYYY-MM-DD (inclusive). With start_date, overrides time_range. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `window` | object | required | The resolved analytics window (from ``core.dates.Window.as_dict()``). | | `window.time_range` | string | required | Normalised token: '30d', '2026-Q2', '2026-01-01..2026-03-31', 'all'. | | `window.kind` | string | required | 'rolling' \| 'quarter' \| 'custom' \| 'all'. | | `window.start` | string | required | First day (YYYY-MM-DD, inclusive, UTC). | | `window.end` | string | required | Last day (YYYY-MM-DD, inclusive, UTC). | | `window.days` | integer | required | | | `window.previous_start` | string | optional | Default `null`. Nullable. | | `window.previous_end` | string | optional | Default `null`. Nullable. | | `coverage` | object | required | How much of the window actually has data (design §2). | | `coverage.start` | string | optional | Earliest date with data across the scoped shows. Default `null`. Nullable. | | `coverage.embedded_boundary` | string | optional | today-29: dates before this come from analyticsHistory. Default `null`. Nullable. | | `coverage.history_used` | boolean | optional | Default `false`. | | `coverage.previous_window_coverage_days` | integer | optional | Default `0`. | | `coverage.current_window_coverage_days` | integer | optional | Default `0`. | | `coverage.growth_basis` | string | optional | 'like_for_like' on windows longer than 30 days (e.g. 90d): growth compares only platforms whose data covers at least half of the previous window, both windows summed over those platforms alone. One of `total`, `like_for_like`. Default `"total"`. | | `coverage.growth_platforms` | array of strings | optional | Like-for-like windows only: the platforms the headline growth compares (empty → growth is null). Default `null`. Nullable. | | `coverage.warnings` | array of strings | optional | | | `freshness` | map of object | optional | platform → oldest freshness across the scoped shows. | | `freshness.last_updated` | string | optional | Default `null`. Nullable. | | `freshness.age_hours` | number | optional | Default `null`. Nullable. | | `freshness.stale` | boolean | optional | True when older than 36 hours or unknown. Default `true`. | | `stale_platforms` | array of strings | optional | | | `show_ids` | array of strings | optional | Shows included after show_ids/categories filtering. | | `missing_show_ids` | array of strings | optional | | | `filters` | object | optional | | | `computed_at` | string | required | | | `cache_age_seconds` | number | optional | Default `0`. | | `note` | string | optional | Default `null`. Nullable. | | `metric` | string | required | One of `reach`, `engagement`. | | `chart_type` | string | required | One of `daily`, `cumulative`. | | `social_count_mode` | string | optional | One of `all`, `unique`. Default `"all"`. | | `rows` | array of objects | required | | | `rows[].date` | string | required | | | `rows[].total` | integer | required | | | `rows[].per_platform` | map of integer | optional | | | `rows[].cumulative` | integer | optional | Running total of `total` from the first day of the window. Default `0`. | | `previous_rows` | array of objects | optional | Default `null`. Nullable. | | `previous_rows[].date` | string | required | | | `previous_rows[].total` | integer | required | | | `previous_rows[].per_platform` | map of integer | optional | | | `previous_rows[].cumulative` | integer | optional | Running total of `total` from the first day of the window. Default `0`. | | `total` | integer | required | | | `previous_total` | integer | required | | | `growth` | number | optional | Default `null`. Nullable. | | `per_platform` | map of integer | optional | | | `previous_per_platform` | map of integer | optional | | | `peak_day` | object | optional | Default `null`. Nullable. | | `peak_day.date` | string | required | | | `peak_day.total` | integer | required | | | `peak_day.per_platform` | map of integer | optional | | | `peak_day.cumulative` | integer | optional | Running total of `total` from the first day of the window. Default `0`. | | `per_show` | map of array of objects | optional | show id → daily totals (include_per_show=True). Default `null`. Nullable. | | `per_show.date` | string | required | | | `per_show.total` | integer | required | | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/analytics/timeline" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "reach_timeline", "window": { "time_range": "30d", "kind": "rolling", "start": "2026-09-15", "end": "2026-10-14", "days": 30, "previous_start": "2026-08-16", "previous_end": "2026-09-14" }, "coverage": { "start": "2019-11-26", "embedded_boundary": "2026-09-15", "history_used": true, "previous_window_coverage_days": 30, "current_window_coverage_days": 29, "growth_basis": "total", "growth_platforms": null, "warnings": [] }, "freshness": { "applePodcasts": { "last_updated": "2026-04-13T16:00:26+00:00", "age_hours": 4417.56, "stale": true }, "facebook": { "last_updated": "2026-10-08T07:02:13.420000+00:00", "age_hours": 154.53, "stale": true }, "instagram": { "last_updated": "2026-10-13T09:35:13.282000+00:00", "age_hours": 31.98, "stale": false }, "megaphone": { "last_updated": "2026-10-14T09:22:51.743000+00:00", "age_hours": 8.19, "stale": false }, "omnystudio": { "last_updated": "2026-10-13T09:33:48.789000+00:00", "age_hours": 32.01, "stale": false }, "spotify": { "last_updated": "2026-08-06T08:49:48.390000+00:00", "age_hours": 1664.74, "stale": true }, "threads": { "last_updated": "2026-10-13T09:36:40.899000+00:00", "age_hours": 31.96, "stale": false }, "tiktok": { "last_updated": "2026-10-13T09:34:15.574000+00:00", "age_hours": 32, "stale": false }, "twitter": { "last_updated": "2026-10-10T07:02:33.014000+00:00", "age_hours": 106.53, "stale": true }, "youtube": { "last_updated": "2026-10-02T10:37:33.857000+00:00", "age_hours": 294.94, "stale": true } }, "stale_platforms": [ "applePodcasts", "facebook" ], "show_ids": [ "paAtKPJGo56md5VyoEQ9Las7qahL", "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H" ], "missing_show_ids": [ "0Iush8rT5eCcD4ePHroKFDlQvKhT", "nCJuUMzGC6EE3dfruqjRHtYdlCTr" ], "filters": { "show_ids": null, "categories": null, "platforms": null, "social_count_mode": "all", "time_range": "30d", "metric": "reach", "chart_type": "daily" }, "computed_at": "2026-10-14T17:34:15.741276+00:00", "cache_age_seconds": 10.2, "note": null, "metric": "reach", "chart_type": "daily", "social_count_mode": "all", "rows": [ { "date": "2026-09-15", "total": 1546364, "per_platform": { "youtube": 77798, "instagram": 429254, "twitter": 978959, "spotify": 60159, "applePodcasts": 172, "megaphone": 22 }, "cumulative": 1546364 }, { "date": "2026-09-16", "total": 9046826, "per_platform": { "youtube": 44948, "instagram": 2062817, "tiktok": 23419, "twitter": 6858164, "spotify": 57298, "applePodcasts": 170, "megaphone": 10 }, "cumulative": 10593190 } ], "previous_rows": null, "total": 35840243, "previous_total": 77122707, "growth": -53.53, "per_platform": { "youtube": 1699627, "instagram": 7666139, "tiktok": 2015820, "twitter": 24102635, "spotify": 291087, "applePodcasts": 590, "megaphone": 1288, "omnystudio": 63057 }, "previous_per_platform": { "youtube": 2680728, "instagram": 14725796, "tiktok": 1902422, "twitter": 56068846, "spotify": 1719177, "applePodcasts": 2859, "megaphone": 662, "omnystudio": 22217 }, "peak_day": { "date": "2026-09-16", "total": 9046826, "per_platform": { "youtube": 44948, "instagram": 2062817, "tiktok": 23419, "twitter": 6858164, "spotify": 57298, "applePodcasts": 170, "megaphone": 10 }, "cumulative": 10593190 }, "per_show": null } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Rank shows `GET /v1/analytics/shows` Scope: `analytics:read` SDK: `client.analytics.shows()` (TypeScript) · `client.analytics.shows()` (Python) Every show in scope ranked by reach for the window (total, YouTube, audio, social, newsletter, per platform, previous period, growth). Rows are never de-duplicated, so their sum can exceed `GET /v1/analytics/reach` in `unique` mode. `sort_by=growth` puts shows without a baseline last. **Windows.** `time_range` is `7d`, `14d`, `30d`, `90d`, `180d`, `365d`, `all` (rolling UTC days, today inclusive) or a calendar quarter such as `2026-Q2` (a bare `Q2` means the current year). `start_date` + `end_date` (`YYYY-MM-DD`, inclusive, both required) override it. Results carry `window` and `coverage`. **Growth.** Growth compares against the equal-length window immediately before (the previous quarter for quarter tokens). It is `null` (show it as "—", never 0) when the previous window has under 50 % day coverage. Windows longer than 30 days read stored history and compare like-for-like: only platforms whose data covers at least half of the previous window, both windows summed over those platforms (`coverage.growth_platforms`). - Costs 2 rate-limit tokens. - Required scope: `analytics:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_ids` | array of strings | optional | Limit to these show ids (default: every show in the workspace). Repeat the parameter or comma-separate values. | | `categories` | array of strings | optional | Only shows whose categories contain one of these (case-insensitive substring). Repeat the parameter or comma-separate values. | | `platforms` | array of strings | optional | Only these platforms (e.g. youtube, spotify, instagram; aliases accepted). Repeat the parameter or comma-separate values. | | `sort_by` | string | optional | Sort field. | | `metric` | string | optional | Which measure to report. | | `limit` | integer | optional | Page size, 1–100 (default 25). | | `time_range` | string | optional | 7d \| 14d \| 30d \| 90d \| 180d \| 365d \| all, or a quarter like 2026-Q2. Windows are UTC days anchored to today. | | `start_date` | string | optional | First day, YYYY-MM-DD (inclusive). With end_date, overrides time_range. | | `end_date` | string | optional | Last day, YYYY-MM-DD (inclusive). With start_date, overrides time_range. | | `cursor` | string | optional | Opaque cursor from the previous page's next_cursor. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].rank` | integer | required | | | `data[].show` | object | required | The minimal show reference embedded in cross-show results. | | `data[].show.id` | string | required | | | `data[].show.name` | string | required | | | `data[].show.handle` | string | optional | Default `""`. | | `data[].show.image` | string | optional | Default `null`. Nullable. | | `data[].show.public_url` | string | optional | Default `null`. Nullable. | | `data[].total` | integer | required | | | `data[].youtube` | integer | required | | | `data[].audio` | integer | required | | | `data[].social` | integer | required | | | `data[].newsletter` | integer | optional | Default `0`. | | `data[].per_platform` | map of integer | optional | | | `data[].previous_total` | integer | required | | | `data[].previous_youtube` | integer | optional | Default `0`. | | `data[].previous_audio` | integer | optional | Default `0`. | | `data[].previous_social` | integer | optional | Default `0`. | | `data[].growth` | number | optional | Default `null`. Nullable. | | `data[].days_with_data` | integer | optional | Default `0`. | | `data[].previous_days_with_data` | integer | optional | Default `0`. | | `data[].object` | string | required | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | | `meta.window` | object | optional | The resolved analytics window (from ``core.dates.Window.as_dict()``). | | `meta.window.time_range` | string | required | Normalised token: '30d', '2026-Q2', '2026-01-01..2026-03-31', 'all'. | | `meta.window.kind` | string | required | 'rolling' \| 'quarter' \| 'custom' \| 'all'. | | `meta.window.start` | string | required | First day (YYYY-MM-DD, inclusive, UTC). | | `meta.window.end` | string | required | Last day (YYYY-MM-DD, inclusive, UTC). | | `meta.window.days` | integer | required | | | `meta.window.previous_start` | string | optional | Default `null`. Nullable. | | `meta.window.previous_end` | string | optional | Default `null`. Nullable. | | `meta.coverage` | object | optional | How much of the window actually has data (design §2). | | `meta.coverage.start` | string | optional | Earliest date with data across the scoped shows. Default `null`. Nullable. | | `meta.coverage.embedded_boundary` | string | optional | today-29: dates before this come from analyticsHistory. Default `null`. Nullable. | | `meta.coverage.history_used` | boolean | optional | Default `false`. | | `meta.coverage.previous_window_coverage_days` | integer | optional | Default `0`. | | `meta.coverage.current_window_coverage_days` | integer | optional | Default `0`. | | `meta.coverage.growth_basis` | string | optional | 'like_for_like' on windows longer than 30 days (e.g. 90d): growth compares only platforms whose data covers at least half of the previous window, both windows summed over those platforms alone. One of `total`, `like_for_like`. Default `"total"`. | | `meta.coverage.growth_platforms` | array of strings | optional | Like-for-like windows only: the platforms the headline growth compares (empty → growth is null). Default `null`. Nullable. | | `meta.coverage.warnings` | array of strings | optional | | | `meta.freshness` | map of object | optional | platform → oldest freshness across the scoped shows. | | `meta.freshness.last_updated` | string | optional | Default `null`. Nullable. | | `meta.freshness.age_hours` | number | optional | Default `null`. Nullable. | | `meta.freshness.stale` | boolean | optional | True when older than 36 hours or unknown. Default `true`. | | `meta.stale_platforms` | array of strings | optional | | | `meta.show_ids` | array of strings | optional | Shows included after show_ids/categories filtering. | | `meta.missing_show_ids` | array of strings | optional | | | `meta.filters` | object | optional | | | `meta.computed_at` | string | optional | | | `meta.cache_age_seconds` | number | optional | Default `0`. | | `meta.note` | string | optional | Default `null`. Nullable. | | `meta.metric` | string | optional | One of `reach`, `engagement`. Default `"reach"`. | | `meta.sort_by` | string | optional | One of `total`, `youtube`, `audio`, `social`, `growth`. | | `meta.total` | integer | optional | Sum of the rows' totals (never deduped, so it can exceed `GET /v1/analytics/reach` in unique mode). | ### Example request ```bash curl "https://api.listener.com/v1/analytics/shows" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/analytics/shows", "data": [ { "object": "show_ranking", "rank": 1, "show": { "id": "sLLP2LKo9LJHqRCtBjjWDsbyDImn", "name": "Dugout Daily", "handle": "dugout-daily", "image": "https://images.example.com/listener/09c3beb030eae9d0.jpg", "public_url": "https://listener.com/dugout-daily" }, "total": 26429121, "youtube": 0, "audio": 95919, "social": 26333202, "newsletter": 0, "per_platform": { "instagram": 5795481, "tiktok": 1647271, "twitter": 18890450, "spotify": 95919 }, "previous_total": 14404411, "previous_youtube": 0, "previous_audio": 366600, "previous_social": 14037811, "growth": 83.48, "days_with_data": 22, "previous_days_with_data": 30 }, { "object": "show_ranking", "rank": 2, "show": { "id": "jgDOYIsPG3c9Jk7mysodavhQxXhV", "name": "The Overtime Report", "handle": "the-overtime-report", "image": "https://images.example.com/listener/498a319e58218172.jpg", "public_url": "https://listener.com/the-overtime-report" }, "total": 5006355, "youtube": 0, "audio": 29011, "social": 4977344, "newsletter": 0, "per_platform": { "instagram": 157261, "tiktok": 125036, "twitter": 4695047, "spotify": 29011 }, "previous_total": 48968093, "previous_youtube": 0, "previous_audio": 451437, "previous_social": 48516656, "growth": -89.78, "days_with_data": 17, "previous_days_with_data": 30 } ], "has_more": false, "next_cursor": null, "meta": { "window": { "time_range": "30d", "kind": "rolling", "start": "2026-09-15", "end": "2026-10-14", "days": 30, "previous_start": "2026-08-16", "previous_end": "2026-09-14" }, "coverage": { "start": "2019-11-26", "embedded_boundary": "2026-09-15", "history_used": true, "previous_window_coverage_days": 30, "current_window_coverage_days": 29, "growth_basis": "total", "growth_platforms": null, "warnings": [] }, "freshness": { "applePodcasts": { "last_updated": "2026-04-13T16:00:26+00:00", "age_hours": 4417.56, "stale": true }, "facebook": { "last_updated": "2026-10-08T07:02:13.420000+00:00", "age_hours": 154.53, "stale": true }, "instagram": { "last_updated": "2026-10-13T09:35:13.282000+00:00", "age_hours": 31.98, "stale": false }, "megaphone": { "last_updated": "2026-10-14T09:22:51.743000+00:00", "age_hours": 8.19, "stale": false }, "omnystudio": { "last_updated": "2026-10-13T09:33:48.789000+00:00", "age_hours": 32.01, "stale": false }, "spotify": { "last_updated": "2026-08-06T08:49:48.390000+00:00", "age_hours": 1664.74, "stale": true }, "threads": { "last_updated": "2026-10-13T09:36:40.899000+00:00", "age_hours": 31.96, "stale": false }, "tiktok": { "last_updated": "2026-10-13T09:34:15.574000+00:00", "age_hours": 32, "stale": false }, "twitter": { "last_updated": "2026-10-10T07:02:33.014000+00:00", "age_hours": 106.53, "stale": true }, "youtube": { "last_updated": "2026-10-02T10:37:33.857000+00:00", "age_hours": 294.94, "stale": true } }, "stale_platforms": [ "applePodcasts", "facebook" ], "show_ids": [ "paAtKPJGo56md5VyoEQ9Las7qahL", "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H" ], "missing_show_ids": [ "0Iush8rT5eCcD4ePHroKFDlQvKhT", "nCJuUMzGC6EE3dfruqjRHtYdlCTr" ], "filters": { "show_ids": null, "categories": null, "platforms": null, "social_count_mode": "all", "time_range": "30d", "metric": "reach", "sort_by": "total", "page": { "total": 12, "limit": 2, "offset": 0, "has_more": true, "next_offset": 2 } }, "computed_at": "2026-10-14T17:34:15.767685+00:00", "cache_age_seconds": 10.2, "note": null, "metric": "reach", "sort_by": "total", "total": 35840243 } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Compare two shows `GET /v1/analytics/compare` Scope: `analytics:read` SDK: `client.analytics.compare()` (TypeScript) · `client.analytics.compare()` (Python) Two shows side by side for the window: category reach, growth, per-platform totals, optional daily series (`include_series=true`) and head-to-head deltas with a winner per metric. Never de-duplicated. **Windows.** `time_range` is `7d`, `14d`, `30d`, `90d`, `180d`, `365d`, `all` (rolling UTC days, today inclusive) or a calendar quarter such as `2026-Q2` (a bare `Q2` means the current year). `start_date` + `end_date` (`YYYY-MM-DD`, inclusive, both required) override it. Results carry `window` and `coverage`. **Growth.** Growth compares against the equal-length window immediately before (the previous quarter for quarter tokens). It is `null` (show it as "—", never 0) when the previous window has under 50 % day coverage. Windows longer than 30 days read stored history and compare like-for-like: only platforms whose data covers at least half of the previous window, both windows summed over those platforms (`coverage.growth_platforms`). - Costs 2 rate-limit tokens. - Required scope: `analytics:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id_a` | string | required | | | `show_id_b` | string | required | | | `platforms` | array of strings | optional | Only these platforms (e.g. youtube, spotify, instagram; aliases accepted). Repeat the parameter or comma-separate values. | | `metric` | string | optional | Which measure to report. | | `include_series` | boolean | optional | | | `time_range` | string | optional | 7d \| 14d \| 30d \| 90d \| 180d \| 365d \| all, or a quarter like 2026-Q2. Windows are UTC days anchored to today. | | `start_date` | string | optional | First day, YYYY-MM-DD (inclusive). With end_date, overrides time_range. | | `end_date` | string | optional | Last day, YYYY-MM-DD (inclusive). With start_date, overrides time_range. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `window` | object | required | The resolved analytics window (from ``core.dates.Window.as_dict()``). | | `window.time_range` | string | required | Normalised token: '30d', '2026-Q2', '2026-01-01..2026-03-31', 'all'. | | `window.kind` | string | required | 'rolling' \| 'quarter' \| 'custom' \| 'all'. | | `window.start` | string | required | First day (YYYY-MM-DD, inclusive, UTC). | | `window.end` | string | required | Last day (YYYY-MM-DD, inclusive, UTC). | | `window.days` | integer | required | | | `window.previous_start` | string | optional | Default `null`. Nullable. | | `window.previous_end` | string | optional | Default `null`. Nullable. | | `coverage` | object | required | How much of the window actually has data (design §2). | | `coverage.start` | string | optional | Earliest date with data across the scoped shows. Default `null`. Nullable. | | `coverage.embedded_boundary` | string | optional | today-29: dates before this come from analyticsHistory. Default `null`. Nullable. | | `coverage.history_used` | boolean | optional | Default `false`. | | `coverage.previous_window_coverage_days` | integer | optional | Default `0`. | | `coverage.current_window_coverage_days` | integer | optional | Default `0`. | | `coverage.growth_basis` | string | optional | 'like_for_like' on windows longer than 30 days (e.g. 90d): growth compares only platforms whose data covers at least half of the previous window, both windows summed over those platforms alone. One of `total`, `like_for_like`. Default `"total"`. | | `coverage.growth_platforms` | array of strings | optional | Like-for-like windows only: the platforms the headline growth compares (empty → growth is null). Default `null`. Nullable. | | `coverage.warnings` | array of strings | optional | | | `freshness` | map of object | optional | platform → oldest freshness across the scoped shows. | | `freshness.last_updated` | string | optional | Default `null`. Nullable. | | `freshness.age_hours` | number | optional | Default `null`. Nullable. | | `freshness.stale` | boolean | optional | True when older than 36 hours or unknown. Default `true`. | | `stale_platforms` | array of strings | optional | | | `show_ids` | array of strings | optional | Shows included after show_ids/categories filtering. | | `missing_show_ids` | array of strings | optional | | | `filters` | object | optional | | | `computed_at` | string | required | | | `cache_age_seconds` | number | optional | Default `0`. | | `note` | string | optional | Default `null`. Nullable. | | `metric` | string | optional | One of `reach`, `engagement`. Default `"reach"`. | | `a` | object | required | | | `a.show` | object | required | The minimal show reference embedded in cross-show results. | | `a.show.id` | string | required | | | `a.show.name` | string | required | | | `a.show.handle` | string | optional | Default `""`. | | `a.show.image` | string | optional | Default `null`. Nullable. | | `a.show.public_url` | string | optional | Default `null`. Nullable. | | `a.total` | integer | required | | | `a.youtube` | integer | required | | | `a.audio` | integer | required | | | `a.social` | integer | required | | | `a.newsletter` | integer | optional | Default `0`. | | `a.per_platform` | map of integer | optional | | | `a.previous_total` | integer | required | | | `a.previous_youtube` | integer | optional | Default `0`. | | `a.previous_audio` | integer | optional | Default `0`. | | `a.previous_social` | integer | optional | Default `0`. | | `a.previous_per_platform` | map of integer | optional | | | `a.growth` | number | optional | Default `null`. Nullable. | | `a.days_with_data` | integer | optional | Default `0`. | | `a.series` | array of objects | optional | Default `null`. Nullable. | | `a.series[].date` | string | required | | | `a.series[].total` | integer | required | | | `a.series[].per_platform` | map of integer | optional | | | `a.series[].cumulative` | integer | optional | Running total of `total` from the first day of the window. Default `0`. | | `b` | object | required | | | `b.show` | object | required | The minimal show reference embedded in cross-show results. | | `b.show.id` | string | required | | | `b.show.name` | string | required | | | `b.show.handle` | string | optional | Default `""`. | | `b.show.image` | string | optional | Default `null`. Nullable. | | `b.show.public_url` | string | optional | Default `null`. Nullable. | | `b.total` | integer | required | | | `b.youtube` | integer | required | | | `b.audio` | integer | required | | | `b.social` | integer | required | | | `b.newsletter` | integer | optional | Default `0`. | | `b.per_platform` | map of integer | optional | | | `b.previous_total` | integer | required | | | `b.previous_youtube` | integer | optional | Default `0`. | | `b.previous_audio` | integer | optional | Default `0`. | | `b.previous_social` | integer | optional | Default `0`. | | `b.previous_per_platform` | map of integer | optional | | | `b.growth` | number | optional | Default `null`. Nullable. | | `b.days_with_data` | integer | optional | Default `0`. | | `b.series` | array of objects | optional | Default `null`. Nullable. | | `b.series[].date` | string | required | | | `b.series[].total` | integer | required | | | `b.series[].per_platform` | map of integer | optional | | | `b.series[].cumulative` | integer | optional | Running total of `total` from the first day of the window. Default `0`. | | `head_to_head` | array of objects | required | | | `head_to_head[].metric` | string | required | | | `head_to_head[].a` | number | optional | Default `null`. Nullable. | | `head_to_head[].b` | number | optional | Default `null`. Nullable. | | `head_to_head[].delta_a_minus_b` | number | optional | Default `null`. Nullable. | | `head_to_head[].winner` | string | optional | One of `a`, `b`, `tie`. Default `null`. Nullable. | | `overall_winner` | string | optional | One of `a`, `b`, `tie`. Default `null`. Nullable. | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/analytics/compare?show_id_a=SHOW_ID_A&show_id_b=SHOW_ID_B" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "show_comparison", "window": { "time_range": "30d", "kind": "rolling", "start": "2026-09-15", "end": "2026-10-14", "days": 30, "previous_start": "2026-08-16", "previous_end": "2026-09-14" }, "coverage": { "start": "2023-08-09", "embedded_boundary": "2026-09-15", "history_used": true, "previous_window_coverage_days": 30, "current_window_coverage_days": 26, "growth_basis": "total", "growth_platforms": null, "warnings": [] }, "freshness": { "applePodcasts": { "last_updated": "2026-04-13T16:00:26+00:00", "age_hours": 4417.56, "stale": true }, "instagram": { "last_updated": "2026-10-13T09:35:13.282000+00:00", "age_hours": 31.98, "stale": false }, "spotify": { "last_updated": "2026-09-22T22:06:27.429000+00:00", "age_hours": 523.46, "stale": true }, "tiktok": { "last_updated": "2026-10-13T09:35:24.476000+00:00", "age_hours": 31.98, "stale": false }, "twitter": { "last_updated": "2026-10-13T09:38:16.251000+00:00", "age_hours": 31.93, "stale": false }, "youtube": { "last_updated": "2026-10-13T09:34:22.044000+00:00", "age_hours": 32, "stale": false } }, "stale_platforms": [ "applePodcasts", "spotify" ], "show_ids": [ "i3KlnFag2DgIpykraVxjrIZwJYcL", "sLLP2LKo9LJHqRCtBjjWDsbyDImn" ], "missing_show_ids": [ "0Iush8rT5eCcD4ePHroKFDlQvKhT", "nCJuUMzGC6EE3dfruqjRHtYdlCTr" ], "filters": { "show_ids": [ "i3KlnFag2DgIpykraVxjrIZwJYcL", "sLLP2LKo9LJHqRCtBjjWDsbyDImn" ], "categories": null, "platforms": null, "social_count_mode": "all", "time_range": "30d", "metric": "reach", "include_series": false }, "computed_at": "2026-10-14T17:34:15.788966+00:00", "cache_age_seconds": 10.3, "note": null, "metric": "reach", "a": { "show": { "id": "i3KlnFag2DgIpykraVxjrIZwJYcL", "name": "The Long Game", "handle": "the-long-game", "image": "https://images.example.com/listener/4b71276112f25fab.jpg", "public_url": "https://listener.com/the-long-game" }, "total": 228083, "youtube": 125564, "audio": 4440, "social": 98079, "newsletter": 0, "per_platform": { "youtube": 125564, "instagram": 94198, "tiktok": 3881, "spotify": 4440 }, "previous_total": 215711, "previous_youtube": 132029, "previous_audio": 29513, "previous_social": 54169, "previous_per_platform": { "youtube": 132029, "instagram": 30046, "tiktok": 24123, "spotify": 29513 }, "growth": 5.74, "days_with_data": 26, "series": null }, "b": { "show": { "id": "sLLP2LKo9LJHqRCtBjjWDsbyDImn", "name": "Dugout Daily", "handle": "dugout-daily", "image": "https://images.example.com/listener/09c3beb030eae9d0.jpg", "public_url": "https://listener.com/dugout-daily" }, "total": 26429121, "youtube": 0, "audio": 95919, "social": 26333202, "newsletter": 0, "per_platform": { "instagram": 5795481, "tiktok": 1647271, "twitter": 18890450, "spotify": 95919 }, "previous_total": 14404411, "previous_youtube": 0, "previous_audio": 366600, "previous_social": 14037811, "previous_per_platform": { "instagram": 6577322, "tiktok": 710823, "twitter": 6749666, "spotify": 366600 }, "growth": 83.48, "days_with_data": 22, "series": null }, "head_to_head": [ { "metric": "total", "a": 228083, "b": 26429121, "delta_a_minus_b": -26201038, "winner": "b" }, { "metric": "youtube", "a": 125564, "b": 0, "delta_a_minus_b": 125564, "winner": "a" } ], "overall_winner": "b" } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Platform distribution `GET /v1/analytics/distribution` Scope: `analytics:read` SDK: `client.analytics.distribution()` (TypeScript) · `client.analytics.distribution()` (Python) The platform share of `views` (reach), `engagement` or `followers`: value and percent per platform. Views and engagement sum the window's daily performance; followers are live counts from each platform and ignore the window (deliberately disconnected platforms count 0). **Windows.** `time_range` is `7d`, `14d`, `30d`, `90d`, `180d`, `365d`, `all` (rolling UTC days, today inclusive) or a calendar quarter such as `2026-Q2` (a bare `Q2` means the current year). `start_date` + `end_date` (`YYYY-MM-DD`, inclusive, both required) override it. Results carry `window` and `coverage`. **Growth.** Growth compares against the equal-length window immediately before (the previous quarter for quarter tokens). It is `null` (show it as "—", never 0) when the previous window has under 50 % day coverage. Windows longer than 30 days read stored history and compare like-for-like: only platforms whose data covers at least half of the previous window, both windows summed over those platforms (`coverage.growth_platforms`). - Costs 2 rate-limit tokens. - Required scope: `analytics:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_ids` | array of strings | optional | Limit to these show ids (default: every show in the workspace). Repeat the parameter or comma-separate values. | | `categories` | array of strings | optional | Only shows whose categories contain one of these (case-insensitive substring). Repeat the parameter or comma-separate values. | | `platforms` | array of strings | optional | Only these platforms (e.g. youtube, spotify, instagram; aliases accepted). Repeat the parameter or comma-separate values. | | `social_count_mode` | string | optional | all counts every show's accounts; unique counts an account shared by several shows once (totals, timelines, distributions only). | | `metric` | string | optional | Which measure to report. | | `include_zero` | boolean | optional | | | `time_range` | string | optional | 7d \| 14d \| 30d \| 90d \| 180d \| 365d \| all, or a quarter like 2026-Q2. Windows are UTC days anchored to today. | | `start_date` | string | optional | First day, YYYY-MM-DD (inclusive). With end_date, overrides time_range. | | `end_date` | string | optional | Last day, YYYY-MM-DD (inclusive). With start_date, overrides time_range. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `window` | object | required | The resolved analytics window (from ``core.dates.Window.as_dict()``). | | `window.time_range` | string | required | Normalised token: '30d', '2026-Q2', '2026-01-01..2026-03-31', 'all'. | | `window.kind` | string | required | 'rolling' \| 'quarter' \| 'custom' \| 'all'. | | `window.start` | string | required | First day (YYYY-MM-DD, inclusive, UTC). | | `window.end` | string | required | Last day (YYYY-MM-DD, inclusive, UTC). | | `window.days` | integer | required | | | `window.previous_start` | string | optional | Default `null`. Nullable. | | `window.previous_end` | string | optional | Default `null`. Nullable. | | `coverage` | object | required | How much of the window actually has data (design §2). | | `coverage.start` | string | optional | Earliest date with data across the scoped shows. Default `null`. Nullable. | | `coverage.embedded_boundary` | string | optional | today-29: dates before this come from analyticsHistory. Default `null`. Nullable. | | `coverage.history_used` | boolean | optional | Default `false`. | | `coverage.previous_window_coverage_days` | integer | optional | Default `0`. | | `coverage.current_window_coverage_days` | integer | optional | Default `0`. | | `coverage.growth_basis` | string | optional | 'like_for_like' on windows longer than 30 days (e.g. 90d): growth compares only platforms whose data covers at least half of the previous window, both windows summed over those platforms alone. One of `total`, `like_for_like`. Default `"total"`. | | `coverage.growth_platforms` | array of strings | optional | Like-for-like windows only: the platforms the headline growth compares (empty → growth is null). Default `null`. Nullable. | | `coverage.warnings` | array of strings | optional | | | `freshness` | map of object | optional | platform → oldest freshness across the scoped shows. | | `freshness.last_updated` | string | optional | Default `null`. Nullable. | | `freshness.age_hours` | number | optional | Default `null`. Nullable. | | `freshness.stale` | boolean | optional | True when older than 36 hours or unknown. Default `true`. | | `stale_platforms` | array of strings | optional | | | `show_ids` | array of strings | optional | Shows included after show_ids/categories filtering. | | `missing_show_ids` | array of strings | optional | | | `filters` | object | optional | | | `computed_at` | string | required | | | `cache_age_seconds` | number | optional | Default `0`. | | `note` | string | optional | Default `null`. Nullable. | | `metric` | string | required | One of `views`, `followers`, `engagement`. | | `social_count_mode` | string | optional | One of `all`, `unique`. Default `"all"`. | | `entries` | array of objects | required | | | `entries[].platform` | string | required | | | `entries[].label` | string | required | | | `entries[].value` | integer | required | | | `entries[].share` | number | required | Percent of the summed value (12.5 == 12.5 %). | | `total` | integer | required | | | `source` | string | required | Where the values come from: daily performance (views/engagement) or live follower counts (followers). | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/analytics/distribution" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "platform_distribution", "window": { "time_range": "30d", "kind": "rolling", "start": "2026-09-15", "end": "2026-10-14", "days": 30, "previous_start": "2026-08-16", "previous_end": "2026-09-14" }, "coverage": { "start": "2019-11-26", "embedded_boundary": "2026-09-15", "history_used": true, "previous_window_coverage_days": 30, "current_window_coverage_days": 29, "growth_basis": "total", "growth_platforms": null, "warnings": [] }, "freshness": { "applePodcasts": { "last_updated": "2026-04-13T16:00:26+00:00", "age_hours": 4417.56, "stale": true }, "facebook": { "last_updated": "2026-10-08T07:02:13.420000+00:00", "age_hours": 154.53, "stale": true }, "instagram": { "last_updated": "2026-10-13T09:35:13.282000+00:00", "age_hours": 31.98, "stale": false }, "megaphone": { "last_updated": "2026-10-14T09:22:51.743000+00:00", "age_hours": 8.19, "stale": false }, "omnystudio": { "last_updated": "2026-10-13T09:33:48.789000+00:00", "age_hours": 32.01, "stale": false }, "spotify": { "last_updated": "2026-08-06T08:49:48.390000+00:00", "age_hours": 1664.74, "stale": true }, "threads": { "last_updated": "2026-10-13T09:36:40.899000+00:00", "age_hours": 31.96, "stale": false }, "tiktok": { "last_updated": "2026-10-13T09:34:15.574000+00:00", "age_hours": 32, "stale": false }, "twitter": { "last_updated": "2026-10-10T07:02:33.014000+00:00", "age_hours": 106.53, "stale": true }, "youtube": { "last_updated": "2026-10-02T10:37:33.857000+00:00", "age_hours": 294.94, "stale": true } }, "stale_platforms": [ "applePodcasts", "facebook" ], "show_ids": [ "paAtKPJGo56md5VyoEQ9Las7qahL", "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H" ], "missing_show_ids": [ "0Iush8rT5eCcD4ePHroKFDlQvKhT", "nCJuUMzGC6EE3dfruqjRHtYdlCTr" ], "filters": { "show_ids": null, "categories": null, "platforms": null, "social_count_mode": "all", "time_range": "30d", "metric": "views", "include_zero": false }, "computed_at": "2026-10-14T17:34:15.794047+00:00", "cache_age_seconds": 10.3, "note": null, "metric": "views", "social_count_mode": "all", "entries": [ { "platform": "twitter", "label": "X (Twitter)", "value": 24102635, "share": 67.25 }, { "platform": "instagram", "label": "Instagram", "value": 7666139, "share": 21.39 } ], "total": 35840243, "source": "daily performance (reach) for the window" } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Dashboard KPIs `GET /v1/analytics/dashboard` Scope: `analytics:read` SDK: `client.analytics.dashboard()` (TypeScript) · `client.analytics.dashboard()` (Python) The network dashboard: eight KPI tiles (followers, impressions, likes, comments, posts, engagement rate, downloads, EMV), the EMV breakdown, a per-show table (`table_mode` = reach, audience or demographics) and the top content per platform. `time_range` is `7d`, `30d` or `90d` (or `start_date` + `end_date` spanning at most 90 days). On 90 days, impressions, downloads and engagement rate cover the full window from stored history (growth like-for-like), while likes, comments, posts and top content stay on the last 30 days with no growth; `metric_windows` and each tile's `window` say which window a value covers. EMV values audio downloads at the long-form CPM and every other impression at the short-form CPM, plus likes × like rate and comments × comment rate, using the network's default EMV rates (defaults 25 / 20 / 0.25 / 1.25). - Costs 2 rate-limit tokens. - Required scope: `analytics:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_ids` | array of strings | optional | Limit to these show ids (default: every show in the workspace). Repeat the parameter or comma-separate values. | | `categories` | array of strings | optional | Only shows whose categories contain one of these (case-insensitive substring). Repeat the parameter or comma-separate values. | | `platforms` | array of strings | optional | Only these platforms (e.g. youtube, spotify, instagram; aliases accepted). Repeat the parameter or comma-separate values. | | `social_count_mode` | string | optional | all counts every show's accounts; unique counts an account shared by several shows once (totals, timelines, distributions only). | | `time_range` | string | optional | 7d \| 14d \| 30d \| 90d \| 180d \| 365d \| all, or a quarter like 2026-Q2. Windows are UTC days anchored to today. | | `start_date` | string | optional | First day, YYYY-MM-DD (inclusive). With end_date, overrides time_range. | | `end_date` | string | optional | Last day, YYYY-MM-DD (inclusive). With start_date, overrides time_range. | | `table_mode` | string | optional | | | `top_content_per_platform` | integer | optional | | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `window` | object | required | The resolved analytics window (from ``core.dates.Window.as_dict()``). | | `window.time_range` | string | required | Normalised token: '30d', '2026-Q2', '2026-01-01..2026-03-31', 'all'. | | `window.kind` | string | required | 'rolling' \| 'quarter' \| 'custom' \| 'all'. | | `window.start` | string | required | First day (YYYY-MM-DD, inclusive, UTC). | | `window.end` | string | required | Last day (YYYY-MM-DD, inclusive, UTC). | | `window.days` | integer | required | | | `window.previous_start` | string | optional | Default `null`. Nullable. | | `window.previous_end` | string | optional | Default `null`. Nullable. | | `coverage` | object | required | How much of the window actually has data (design §2). | | `coverage.start` | string | optional | Earliest date with data across the scoped shows. Default `null`. Nullable. | | `coverage.embedded_boundary` | string | optional | today-29: dates before this come from analyticsHistory. Default `null`. Nullable. | | `coverage.history_used` | boolean | optional | Default `false`. | | `coverage.previous_window_coverage_days` | integer | optional | Default `0`. | | `coverage.current_window_coverage_days` | integer | optional | Default `0`. | | `coverage.growth_basis` | string | optional | 'like_for_like' on windows longer than 30 days (e.g. 90d): growth compares only platforms whose data covers at least half of the previous window, both windows summed over those platforms alone. One of `total`, `like_for_like`. Default `"total"`. | | `coverage.growth_platforms` | array of strings | optional | Like-for-like windows only: the platforms the headline growth compares (empty → growth is null). Default `null`. Nullable. | | `coverage.warnings` | array of strings | optional | | | `freshness` | map of object | optional | platform → oldest freshness across the scoped shows. | | `freshness.last_updated` | string | optional | Default `null`. Nullable. | | `freshness.age_hours` | number | optional | Default `null`. Nullable. | | `freshness.stale` | boolean | optional | True when older than 36 hours or unknown. Default `true`. | | `stale_platforms` | array of strings | optional | | | `show_ids` | array of strings | optional | Shows included after show_ids/categories filtering. | | `missing_show_ids` | array of strings | optional | | | `filters` | object | optional | | | `computed_at` | string | required | | | `cache_age_seconds` | number | optional | Default `0`. | | `note` | string | optional | Default `null`. Nullable. | | `time_range` | string | required | | | `table_mode` | string | required | One of `reach`, `audience`, `demographics`. | | `tiles` | array of objects | required | | | `tiles[].key` | string | required | | | `tiles[].label` | string | required | | | `tiles[].value` | number | required | | | `tiles[].previous` | number | optional | Default `null`. Nullable. | | `tiles[].growth` | number | optional | Default `null`. Nullable. | | `tiles[].unit` | string | optional | One of `count`, `percent`, `usd`. Default `"count"`. | | `tiles[].source` | string | optional | Default `""`. | | `tiles[].window` | string | optional | Window this tile's value covers ('90d', '30d', 'live', 'mixed', or a date range). On 90d, likes/comments/posts are last-30-days only. Default `null`. Nullable. | | `emv` | object | required | | | `emv.total` | number | required | | | `emv.audio_value` | number | required | | | `emv.social_value` | number | required | | | `emv.like_value` | number | required | | | `emv.comment_value` | number | required | | | `emv.audio_downloads` | integer | required | | | `emv.social_impressions` | integer | required | | | `emv.likes` | integer | required | | | `emv.comments` | integer | required | | | `emv.rates` | map of number | required | | | `emv.rates_source` | string | required | One of `network_default`, `default`. | | `emv.formula` | string | required | | | `per_platform_reach` | map of integer | optional | | | `shows` | array of objects | required | | | `shows[].show` | object | required | The minimal show reference embedded in cross-show results. | | `shows[].show.id` | string | required | | | `shows[].show.name` | string | required | | | `shows[].show.handle` | string | optional | Default `""`. | | `shows[].show.image` | string | optional | Default `null`. Nullable. | | `shows[].show.public_url` | string | optional | Default `null`. Nullable. | | `shows[].total` | integer | optional | Default `null`. Nullable. | | `shows[].youtube` | integer | optional | Default `null`. Nullable. | | `shows[].audio` | integer | optional | Default `null`. Nullable. | | `shows[].social` | integer | optional | Default `null`. Nullable. | | `shows[].growth` | number | optional | Default `null`. Nullable. | | `shows[].followers` | integer | optional | Default `null`. Nullable. | | `shows[].followers_by_platform` | map of integer | optional | Default `null`. Nullable. | | `shows[].demographics` | object | optional | Default `null`. Nullable. | | `shows[].connected_platforms` | array of strings | optional | | | `top_content` | map of array of objects | optional | platform → top 3 items in the window by plays/views/impressions (last 30 days on a 90d window). | | `top_content.show` | object | required | The minimal show reference embedded in cross-show results. | | `top_content.show.id` | string | required | | | `top_content.show.name` | string | required | | | `top_content.show.handle` | string | optional | Default `""`. | | `top_content.show.image` | string | optional | Default `null`. Nullable. | | `top_content.show.public_url` | string | optional | Default `null`. Nullable. | | `top_content.platform` | string | required | | | `top_content.post_type` | string | optional | Default `null`. Nullable. | | `top_content.id` | string | optional | Default `null`. Nullable. | | `top_content.date` | string | optional | Default `null`. Nullable. | | `top_content.url` | string | optional | Default `null`. Nullable. | | `top_content.title` | string | optional | Default `null`. Nullable. | | `top_content.caption` | string | optional | Default `null`. Nullable. | | `top_content.metrics` | object | optional | | | `top_content.primary_metric` | string | required | | | `top_content.primary_value` | number | required | | | `metric_windows` | map of string | optional | tile key → window its value covers. On 90d: impressions/downloads/engagement_rate cover 90 days (history-backed); likes/comments/posts/top_content are the last 30 days (no long-range source); followers are live; EMV is mixed. | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/analytics/dashboard" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "dashboard", "window": { "time_range": "30d", "kind": "rolling", "start": "2026-09-15", "end": "2026-10-14", "days": 30, "previous_start": "2026-08-16", "previous_end": "2026-09-14" }, "coverage": { "start": "2019-11-26", "embedded_boundary": "2026-09-15", "history_used": true, "previous_window_coverage_days": 30, "current_window_coverage_days": 29, "growth_basis": "total", "growth_platforms": null, "warnings": [] }, "freshness": { "applePodcasts": { "last_updated": "2026-04-13T16:00:26+00:00", "age_hours": 4417.56, "stale": true }, "facebook": { "last_updated": "2026-10-08T07:02:13.420000+00:00", "age_hours": 154.53, "stale": true }, "instagram": { "last_updated": "2026-10-13T09:35:13.282000+00:00", "age_hours": 31.98, "stale": false }, "megaphone": { "last_updated": "2026-10-14T09:22:51.743000+00:00", "age_hours": 8.19, "stale": false }, "omnystudio": { "last_updated": "2026-10-13T09:33:48.789000+00:00", "age_hours": 32.01, "stale": false }, "spotify": { "last_updated": "2026-08-06T08:49:48.390000+00:00", "age_hours": 1664.74, "stale": true }, "threads": { "last_updated": "2026-10-13T09:36:40.899000+00:00", "age_hours": 31.96, "stale": false }, "tiktok": { "last_updated": "2026-10-13T09:34:15.574000+00:00", "age_hours": 32, "stale": false }, "twitter": { "last_updated": "2026-10-10T07:02:33.014000+00:00", "age_hours": 106.53, "stale": true }, "youtube": { "last_updated": "2026-10-02T10:37:33.857000+00:00", "age_hours": 294.94, "stale": true } }, "stale_platforms": [ "applePodcasts", "facebook" ], "show_ids": [ "paAtKPJGo56md5VyoEQ9Las7qahL", "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H" ], "missing_show_ids": [ "0Iush8rT5eCcD4ePHroKFDlQvKhT", "nCJuUMzGC6EE3dfruqjRHtYdlCTr" ], "filters": { "show_ids": null, "categories": null, "platforms": null, "social_count_mode": "all", "time_range": "30d", "table_mode": "reach" }, "computed_at": "2026-10-14T17:34:15.812876+00:00", "cache_age_seconds": 10.3, "note": null, "time_range": "30d", "table_mode": "reach", "tiles": [ { "key": "followers", "label": "Followers", "value": 8037076, "previous": null, "growth": null, "unit": "count", "source": "live follower counts (deliberately disconnected platforms zeroed)", "window": "live" }, { "key": "impressions", "label": "Impressions", "value": 35840243, "previous": 77122707, "growth": -53.53, "unit": "count", "source": "daily performance total (reach)", "window": "30d" } ], "emv": { "total": 898024.97, "audio_value": 8900.55, "social_value": 709684.42, "like_value": 155665, "comment_value": 23775, "audio_downloads": 356022, "social_impressions": 35484221, "likes": 622660, "comments": 19020, "rates": { "long_form_cpm": 25, "short_form_cpm": 20, "like_rate": 0.25, "comment_rate": 1.25 }, "rates_source": "network_default", "formula": "audio_downloads × long_form_cpm / 1000 + (impressions − audio_downloads) × short_form_cpm / 1000 + likes × like_rate + comments × comment_rate" }, "per_platform_reach": { "youtube": 1699627, "instagram": 7666139, "tiktok": 2015820, "twitter": 24102635, "spotify": 291087, "applePodcasts": 590, "megaphone": 1288, "omnystudio": 63057 }, "shows": [ { "show": { "id": "sLLP2LKo9LJHqRCtBjjWDsbyDImn", "name": "Dugout Daily", "handle": "dugout-daily", "image": "https://images.example.com/listener/09c3beb030eae9d0.jpg", "public_url": "https://listener.com/dugout-daily" }, "total": 26429121, "youtube": 0, "audio": 95919, "social": 26333202, "growth": 83.48, "followers": null, "followers_by_platform": null, "demographics": null, "connected_platforms": [ "applePodcasts", "instagram" ] }, { "show": { "id": "jgDOYIsPG3c9Jk7mysodavhQxXhV", "name": "The Overtime Report", "handle": "the-overtime-report", "image": "https://images.example.com/listener/498a319e58218172.jpg", "public_url": "https://listener.com/the-overtime-report" }, "total": 5006355, "youtube": 0, "audio": 29011, "social": 4977344, "growth": -89.78, "followers": null, "followers_by_platform": null, "demographics": null, "connected_platforms": [ "applePodcasts", "instagram" ] } ], "top_content": { "instagram": [ { "show": { "id": "sLLP2LKo9LJHqRCtBjjWDsbyDImn", "name": "Dugout Daily", "handle": "dugout-daily", "image": "https://images.example.com/listener/09c3beb030eae9d0.jpg", "public_url": "https://listener.com/dugout-daily" }, "platform": "instagram", "post_type": "reel", "id": "SjNbswoa1F2", "date": "2026-09-16T16:18:13+00:00", "url": "https://www.instagram.com/reel/SjNbswoa1F2/", "title": null, "caption": "Our hosts react to the weekend's results and look ahead to next week.", "metrics": { "plays": 1792324, "likes": 91485, "comments": 2011, "shares": 0, "saves": 0, "engagement": 93496 }, "primary_metric": "plays", "primary_value": 1792324 }, { "show": { "id": "sLLP2LKo9LJHqRCtBjjWDsbyDImn", "name": "Dugout Daily", "handle": "dugout-daily", "image": "https://images.example.com/listener/09c3beb030eae9d0.jpg", "public_url": "https://listener.com/dugout-daily" }, "platform": "instagram", "post_type": "reel", "id": "DeKyFsSy-Gd", "date": "2026-10-06T21:47:23+00:00", "url": "https://www.instagram.com/reel/SrFsSoGl-Ox/", "title": null, "caption": "A deep dive into the numbers behind the season so far.", "metrics": { "plays": 1032660, "likes": 56838, "comments": 1176, "shares": 0, "saves": 0, "engagement": 58014 }, "primary_metric": "plays", "primary_value": 1032660 } ], "tiktok": [ { "show": { "id": "sLLP2LKo9LJHqRCtBjjWDsbyDImn", "name": "Dugout Daily", "handle": "dugout-daily", "image": "https://images.example.com/listener/09c3beb030eae9d0.jpg", "public_url": "https://listener.com/dugout-daily" }, "platform": "tiktok", "post_type": "video", "id": null, "date": "2026-10-06T14:51:11+00:00", "url": "https://www.tiktok.com/@northwindaudio/video/5393438153226530115", "title": null, "caption": "New episode out now. We break down the week's biggest storylines and answer your questions.", "metrics": { "plays": 759965, "likes": 35992, "comments": 1145, "shares": 8173, "engagement": 45310 }, "primary_metric": "plays", "primary_value": 759965 }, { "show": { "id": "sLLP2LKo9LJHqRCtBjjWDsbyDImn", "name": "Dugout Daily", "handle": "dugout-daily", "image": "https://images.example.com/listener/09c3beb030eae9d0.jpg", "public_url": "https://listener.com/dugout-daily" }, "platform": "tiktok", "post_type": "video", "id": null, "date": "2026-09-24T20:37:21+00:00", "url": "https://www.tiktok.com/@northwindaudio/video/5933203068549452749", "title": null, "caption": "Clip from this week's episode. Full episode on YouTube and Spotify.", "metrics": { "plays": 259315, "likes": 16347, "comments": 744, "shares": 3361, "engagement": 20452 }, "primary_metric": "plays", "primary_value": 259315 } ], "twitter": [ { "show": { "id": "sLLP2LKo9LJHqRCtBjjWDsbyDImn", "name": "Dugout Daily", "handle": "dugout-daily", "image": "https://images.example.com/listener/09c3beb030eae9d0.jpg", "public_url": "https://listener.com/dugout-daily" }, "platform": "twitter", "post_type": "tweet", "id": "7831648775074218402", "date": "2026-09-16T16:15:08+00:00", "url": "https://x.com/hljglnultbysm/status/7831648775074218402", "title": null, "caption": "Our hosts react to the weekend's results and look ahead to next week.", "metrics": { "impressions": 6068598, "likes": 16837, "retweets": 1567, "replies": 316, "quotes": 385, "engagement": 19105 }, "primary_metric": "impressions", "primary_value": 6068598 }, { "show": { "id": "sLLP2LKo9LJHqRCtBjjWDsbyDImn", "name": "Dugout Daily", "handle": "dugout-daily", "image": "https://images.example.com/listener/09c3beb030eae9d0.jpg", "public_url": "https://listener.com/dugout-daily" }, "platform": "twitter", "post_type": "tweet", "id": "2499797339557722654", "date": "2026-09-24T12:10:10+00:00", "url": "https://x.com/hljglnultbysm/status/2499797339557722654", "title": null, "caption": "A deep dive into the numbers behind the season so far.", "metrics": { "impressions": 2639906, "likes": 10883, "retweets": 1525, "replies": 592, "quotes": 484, "engagement": 13484 }, "primary_metric": "impressions", "primary_value": 2639906 } ], "threads": [ { "show": { "id": "jgDOYIsPG3c9Jk7mysodavhQxXhV", "name": "The Overtime Report", "handle": "the-overtime-report", "image": "https://images.example.com/listener/498a319e58218172.jpg", "public_url": "https://listener.com/the-overtime-report" }, "platform": "threads", "post_type": "post", "id": null, "date": "2026-09-20T18:26:02+00:00", "url": null, "title": null, "caption": "Clip from this week's episode. Full episode on YouTube and Spotify.", "metrics": { "likes": 2, "replies": 0, "reposts": 0, "quotes": 0, "views": 0 }, "primary_metric": "likes", "primary_value": 2 } ], "youtube": [ { "show": { "id": "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H", "name": "The Bullpen", "handle": "the-bullpen", "image": "https://images.example.com/listener/8f6c3c4428259526.jpg", "public_url": "https://listener.com/the-bullpen" }, "platform": "youtube", "post_type": "video", "id": null, "date": "2026-09-26T15:00:02+00:00", "url": null, "title": "The best and worst contracts", "caption": "Our hosts react to the weekend's results and look ahead to next week.", "metrics": { "views": 56369, "likes": 652, "comments": 60 }, "primary_metric": "views", "primary_value": 56369 }, { "show": { "id": "PXjDgD84U0pDobe25GOpKoNGTT1i", "name": "Baseline Banter", "handle": "baseline-banter", "image": "https://images.example.com/listener/b8cee59dda079226.jpg", "public_url": "https://listener.com/baseline-banter" }, "platform": "youtube", "post_type": "video", "id": null, "date": "2026-09-26T22:02:26+00:00", "url": null, "title": "Trade deadline winners and losers", "caption": "Highlights and analysis from the latest matchday.", "metrics": { "views": 47277, "likes": 413, "comments": 184 }, "primary_metric": "views", "primary_value": 47277 } ], "omnystudio": [ { "show": { "id": "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H", "name": "The Bullpen", "handle": "the-bullpen", "image": "https://images.example.com/listener/8f6c3c4428259526.jpg", "public_url": "https://listener.com/the-bullpen" }, "platform": "omnystudio", "post_type": "episode", "id": "1635o053-p31z-6b13-xwp9-y9me44770cpq", "date": "2026-09-27T07:00:00+00:00", "url": null, "title": "What the numbers really say", "caption": null, "metrics": { "downloads": 8497 }, "primary_metric": "downloads", "primary_value": 8497 }, { "show": { "id": "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H", "name": "The Bullpen", "handle": "the-bullpen", "image": "https://images.example.com/listener/8f6c3c4428259526.jpg", "public_url": "https://listener.com/the-bullpen" }, "platform": "omnystudio", "post_type": "episode", "id": "u1d5m28e-g88g-8968-a85y-g1k09323320l", "date": "2026-09-20T07:00:00+00:00", "url": null, "title": "Inside the locker room with a two-time champion", "caption": null, "metrics": { "downloads": 8032 }, "primary_metric": "downloads", "primary_value": 8032 } ] }, "metric_windows": { "followers": "live", "impressions": "30d", "likes": "30d", "comments": "30d", "posts": "30d", "engagement_rate": "30d", "downloads": "30d", "emv": "30d", "top_content": "30d" } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Rank hosts `GET /v1/analytics/hosts` Scope: `analytics:read` SDK: `client.analytics.hosts()` (TypeScript) · `client.analytics.hosts()` (Python) The Hosts tab: every host across the shows in scope with their own per-platform audience, YouTube and social splits, the shows they appear on and those shows' reach in the window. A host on several shows is one row (counts are never summed). Ranked by personal audience; `sort_by=shows_reach` ranks by the shows' window reach. **Windows.** `time_range` is `7d`, `14d`, `30d`, `90d`, `180d`, `365d`, `all` (rolling UTC days, today inclusive) or a calendar quarter such as `2026-Q2` (a bare `Q2` means the current year). `start_date` + `end_date` (`YYYY-MM-DD`, inclusive, both required) override it. Results carry `window` and `coverage`. **Growth.** Growth compares against the equal-length window immediately before (the previous quarter for quarter tokens). It is `null` (show it as "—", never 0) when the previous window has under 50 % day coverage. Windows longer than 30 days read stored history and compare like-for-like: only platforms whose data covers at least half of the previous window, both windows summed over those platforms (`coverage.growth_platforms`). - Costs 2 rate-limit tokens. - Required scope: `analytics:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_ids` | array of strings | optional | Limit to these show ids (default: every show in the workspace). Repeat the parameter or comma-separate values. | | `categories` | array of strings | optional | Only shows whose categories contain one of these (case-insensitive substring). Repeat the parameter or comma-separate values. | | `sort_by` | string | optional | Sort field. | | `search` | string | optional | Case-insensitive text filter. | | `limit` | integer | optional | Page size, 1–100 (default 25). | | `time_range` | string | optional | 7d \| 14d \| 30d \| 90d \| 180d \| 365d \| all, or a quarter like 2026-Q2. Windows are UTC days anchored to today. | | `start_date` | string | optional | First day, YYYY-MM-DD (inclusive). With end_date, overrides time_range. | | `end_date` | string | optional | Last day, YYYY-MM-DD (inclusive). With start_date, overrides time_range. | | `cursor` | string | optional | Opaque cursor from the previous page's next_cursor. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].rank` | integer | required | | | `data[].name` | string | required | | | `data[].bio` | string | optional | Default `""`. | | `data[].photo` | string | optional | Default `null`. Nullable. | | `data[].primary_link` | string | optional | Default `null`. Nullable. | | `data[].social_links` | map of string | optional | | | `data[].audience_by_platform` | map of integer | optional | Host's own per-platform audience (hostInfo.hosts[].socialStats), merged across shows (first non-zero wins, never summed). | | `data[].total_audience` | integer | required | | | `data[].youtube_audience` | integer | required | | | `data[].social_audience` | integer | required | | | `data[].shows` | array of objects | required | | | `data[].shows[].id` | string | required | | | `data[].shows[].name` | string | required | | | `data[].shows[].handle` | string | optional | Default `""`. | | `data[].shows[].image` | string | optional | Default `null`. Nullable. | | `data[].shows[].public_url` | string | optional | Default `null`. Nullable. | | `data[].shows_reach` | integer | required | Sum of the reach of the shows this host appears on in the window (each show counted once). | | `data[].object` | string | required | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | | `meta.window` | object | optional | The resolved analytics window (from ``core.dates.Window.as_dict()``). | | `meta.window.time_range` | string | required | Normalised token: '30d', '2026-Q2', '2026-01-01..2026-03-31', 'all'. | | `meta.window.kind` | string | required | 'rolling' \| 'quarter' \| 'custom' \| 'all'. | | `meta.window.start` | string | required | First day (YYYY-MM-DD, inclusive, UTC). | | `meta.window.end` | string | required | Last day (YYYY-MM-DD, inclusive, UTC). | | `meta.window.days` | integer | required | | | `meta.window.previous_start` | string | optional | Default `null`. Nullable. | | `meta.window.previous_end` | string | optional | Default `null`. Nullable. | | `meta.coverage` | object | optional | How much of the window actually has data (design §2). | | `meta.coverage.start` | string | optional | Earliest date with data across the scoped shows. Default `null`. Nullable. | | `meta.coverage.embedded_boundary` | string | optional | today-29: dates before this come from analyticsHistory. Default `null`. Nullable. | | `meta.coverage.history_used` | boolean | optional | Default `false`. | | `meta.coverage.previous_window_coverage_days` | integer | optional | Default `0`. | | `meta.coverage.current_window_coverage_days` | integer | optional | Default `0`. | | `meta.coverage.growth_basis` | string | optional | 'like_for_like' on windows longer than 30 days (e.g. 90d): growth compares only platforms whose data covers at least half of the previous window, both windows summed over those platforms alone. One of `total`, `like_for_like`. Default `"total"`. | | `meta.coverage.growth_platforms` | array of strings | optional | Like-for-like windows only: the platforms the headline growth compares (empty → growth is null). Default `null`. Nullable. | | `meta.coverage.warnings` | array of strings | optional | | | `meta.freshness` | map of object | optional | platform → oldest freshness across the scoped shows. | | `meta.freshness.last_updated` | string | optional | Default `null`. Nullable. | | `meta.freshness.age_hours` | number | optional | Default `null`. Nullable. | | `meta.freshness.stale` | boolean | optional | True when older than 36 hours or unknown. Default `true`. | | `meta.stale_platforms` | array of strings | optional | | | `meta.show_ids` | array of strings | optional | Shows included after show_ids/categories filtering. | | `meta.missing_show_ids` | array of strings | optional | | | `meta.filters` | object | optional | | | `meta.computed_at` | string | optional | | | `meta.cache_age_seconds` | number | optional | Default `0`. | | `meta.note` | string | optional | Default `null`. Nullable. | | `meta.sort_by` | string | optional | One of `audience`, `youtube`, `social`, `shows_reach`, `name`. | | `meta.total_hosts` | integer | optional | | | `meta.total_audience` | integer | optional | | | `meta.youtube_audience` | integer | optional | | | `meta.social_audience` | integer | optional | | ### Example request ```bash curl "https://api.listener.com/v1/analytics/hosts" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/analytics/hosts", "data": [ { "object": "host_ranking", "rank": 1, "name": "Alex Rivera", "bio": "A deep dive into the numbers behind the season so far.", "photo": "https://images.example.com/listener/d92b66b977c8790e.jpg", "primary_link": "https://www.instagram.com/iehltzn/", "social_links": { "instagram": "https://www.instagram.com/iehltzn/", "twitter": "https://x.com/XwbncCkntl", "linkedin": "https://www.linkedin.com/in/ttpiftmmqa/" }, "audience_by_platform": { "instagram": 72500, "twitter": 209032, "linkedin": 1090 }, "total_audience": 282622, "youtube_audience": 0, "social_audience": 282622, "shows": [ { "id": "sLLP2LKo9LJHqRCtBjjWDsbyDImn", "name": "Dugout Daily", "handle": "dugout-daily", "image": "https://images.example.com/listener/09c3beb030eae9d0.jpg", "public_url": "https://listener.com/dugout-daily" } ], "shows_reach": 26429121 }, { "object": "host_ranking", "rank": 2, "name": "Jordan Lee", "bio": "New episode out now. We break down the week's biggest storylines and answer your questions.", "photo": "https://images.example.com/listener/243b51e0f3551ea6.jpg", "primary_link": "https://www.instagram.com/tilcymz/", "social_links": { "instagram": "https://www.instagram.com/tilcymz/", "twitter": "https://x.com/tilcymz" }, "audience_by_platform": { "instagram": 5636, "twitter": 123821 }, "total_audience": 129457, "youtube_audience": 0, "social_audience": 129457, "shows": [ { "id": "PXjDgD84U0pDobe25GOpKoNGTT1i", "name": "Baseline Banter", "handle": "baseline-banter", "image": "https://images.example.com/listener/b8cee59dda079226.jpg", "public_url": "https://listener.com/baseline-banter" } ], "shows_reach": 1131220 } ], "has_more": false, "next_cursor": null, "meta": { "window": { "time_range": "30d", "kind": "rolling", "start": "2026-09-15", "end": "2026-10-14", "days": 30, "previous_start": "2026-08-16", "previous_end": "2026-09-14" }, "coverage": { "start": "2019-11-26", "embedded_boundary": "2026-09-15", "history_used": true, "previous_window_coverage_days": 30, "current_window_coverage_days": 29, "growth_basis": "total", "growth_platforms": null, "warnings": [] }, "freshness": { "applePodcasts": { "last_updated": "2026-04-13T16:00:26+00:00", "age_hours": 4417.56, "stale": true }, "facebook": { "last_updated": "2026-10-08T07:02:13.420000+00:00", "age_hours": 154.53, "stale": true }, "instagram": { "last_updated": "2026-10-13T09:35:13.282000+00:00", "age_hours": 31.98, "stale": false }, "megaphone": { "last_updated": "2026-10-14T09:22:51.743000+00:00", "age_hours": 8.19, "stale": false }, "omnystudio": { "last_updated": "2026-10-13T09:33:48.789000+00:00", "age_hours": 32.01, "stale": false }, "spotify": { "last_updated": "2026-08-06T08:49:48.390000+00:00", "age_hours": 1664.74, "stale": true }, "threads": { "last_updated": "2026-10-13T09:36:40.899000+00:00", "age_hours": 31.96, "stale": false }, "tiktok": { "last_updated": "2026-10-13T09:34:15.574000+00:00", "age_hours": 32, "stale": false }, "twitter": { "last_updated": "2026-10-10T07:02:33.014000+00:00", "age_hours": 106.53, "stale": true }, "youtube": { "last_updated": "2026-10-02T10:37:33.857000+00:00", "age_hours": 294.95, "stale": true } }, "stale_platforms": [ "applePodcasts", "facebook" ], "show_ids": [ "paAtKPJGo56md5VyoEQ9Las7qahL", "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H" ], "missing_show_ids": [ "0Iush8rT5eCcD4ePHroKFDlQvKhT", "nCJuUMzGC6EE3dfruqjRHtYdlCTr" ], "filters": { "show_ids": null, "categories": null, "platforms": null, "social_count_mode": "all", "time_range": "30d", "sort_by": "audience", "search": null, "page": { "total": 12, "limit": 2, "offset": 0, "has_more": true, "next_offset": 2 } }, "computed_at": "2026-10-14T17:34:15.912700+00:00", "cache_age_seconds": 10.4, "note": null, "sort_by": "audience", "total_hosts": 12, "total_audience": 645053, "youtube_audience": 251, "social_audience": 644802 } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Shared-account (dedup) view `GET /v1/analytics/accounts` Scope: `analytics:read` SDK: `client.analytics.accounts()` (TypeScript) · `client.analytics.accounts()` (Python) Connected accounts shared by several shows (the same YouTube channel, Instagram handle, Spotify show …), which show is canonical, and the platforms suppressed per show in `social_count_mode=unique`. Suppression applies to overview totals, timelines and distributions only, never to rankings or comparisons. - Costs 2 rate-limit tokens. - Required scope: `analytics:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_ids` | array of strings | optional | Limit to these show ids (default: every show in the workspace). Repeat the parameter or comma-separate values. | | `categories` | array of strings | optional | Only shows whose categories contain one of these (case-insensitive substring). Repeat the parameter or comma-separate values. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `platforms_checked` | array of strings | required | | | `has_duplicates` | boolean | required | | | `duplicate_platforms` | array of strings | required | | | `groups` | array of objects | required | | | `groups[].group_key` | string | required | | | `groups[].platform` | string | required | | | `groups[].platform_category` | string | required | | | `groups[].account_key` | string | required | | | `groups[].account_label` | string | required | | | `groups[].canonical_show_id` | string | required | | | `groups[].canonical_show_name` | string | required | | | `groups[].members` | array of objects | required | | | `groups[].members[].show_id` | string | required | | | `groups[].members[].show_name` | string | required | | | `groups[].members[].show_handle` | string | optional | Default `""`. | | `groups[].members[].platform` | string | required | | | `groups[].members[].account_key` | string | required | | | `groups[].members[].account_label` | string | required | | | `groups[].members[].profile_url` | string | optional | Default `""`. | | `groups[].members[].last_updated` | string | optional | Default `null`. Nullable. | | `groups[].members[].data_quality` | number | optional | Default `0`. | | `groups[].is_duplicate` | boolean | required | | | `duplicate_groups` | array of objects | required | | | `duplicate_groups[].group_key` | string | required | | | `duplicate_groups[].platform` | string | required | | | `duplicate_groups[].platform_category` | string | required | | | `duplicate_groups[].account_key` | string | required | | | `duplicate_groups[].account_label` | string | required | | | `duplicate_groups[].canonical_show_id` | string | required | | | `duplicate_groups[].canonical_show_name` | string | required | | | `duplicate_groups[].members` | array of objects | required | | | `duplicate_groups[].members[].show_id` | string | required | | | `duplicate_groups[].members[].show_name` | string | required | | | `duplicate_groups[].members[].show_handle` | string | optional | Default `""`. | | `duplicate_groups[].members[].platform` | string | required | | | `duplicate_groups[].members[].account_key` | string | required | | | `duplicate_groups[].members[].account_label` | string | required | | | `duplicate_groups[].members[].profile_url` | string | optional | Default `""`. | | `duplicate_groups[].members[].last_updated` | string | optional | Default `null`. Nullable. | | `duplicate_groups[].members[].data_quality` | number | optional | Default `0`. | | `duplicate_groups[].is_duplicate` | boolean | required | | | `suppressed` | map of array of strings | optional | show id → platforms skipped in unique mode. | | `applies_to` | array of strings | required | | | `never_applied_to` | array of strings | required | | | `show_ids` | array of strings | required | | | `missing_show_ids` | array of strings | optional | | | `computed_at` | string | required | | | `cache_age_seconds` | number | optional | Default `0`. | | `note` | string | optional | Default `null`. Nullable. | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/analytics/accounts" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "account_dedup", "platforms_checked": [ "youtube", "spotify" ], "has_duplicates": true, "duplicate_platforms": [ "spotify", "megaphone" ], "groups": [ { "group_key": "youtube:uctr-zjgq5cumahxam6xvx8c", "platform": "youtube", "platform_category": "youtube", "account_key": "uctr-zjgq5cumahxam6xvx8c", "account_label": "Hard Court", "canonical_show_id": "paAtKPJGo56md5VyoEQ9Las7qahL", "canonical_show_name": "Hard Court", "members": [ { "show_id": "paAtKPJGo56md5VyoEQ9Las7qahL", "show_name": "Hard Court", "show_handle": "hard-court", "platform": "youtube", "account_key": "uctr-zjgq5cumahxam6xvx8c", "account_label": "Hard Court", "profile_url": "https://youtube.com/channel/SPrh-SDQx4HYhJEqfL0dnD4j", "last_updated": "2026-10-14T07:52:53.974000Z", "data_quality": 9 } ], "is_duplicate": false }, { "group_key": "youtube:mh2bnlijjv7-8m4hle7qid2j", "platform": "youtube", "platform_category": "youtube", "account_key": "mh2bnlijjv7-8m4hle7qid2j", "account_label": "The Bullpen", "canonical_show_id": "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H", "canonical_show_name": "The Bullpen", "members": [ { "show_id": "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H", "show_name": "The Bullpen", "show_handle": "the-bullpen", "platform": "youtube", "account_key": "mh2bnlijjv7-8m4hle7qid2j", "account_label": "The Bullpen", "profile_url": "https://youtube.com/channel/TE0aUgJYJs8-7y8oBh6CPv3n", "last_updated": "2026-10-02T10:37:33.857000Z", "data_quality": 9 } ], "is_duplicate": false } ], "duplicate_groups": [ { "group_key": "spotify:5angy0pobo1s6ghbqfrvzv", "platform": "spotify", "platform_category": "audio", "account_key": "5angy0pobo1s6ghbqfrvzv", "account_label": "Baseline Banter", "canonical_show_id": "PXjDgD84U0pDobe25GOpKoNGTT1i", "canonical_show_name": "Baseline Banter", "members": [ { "show_id": "PXjDgD84U0pDobe25GOpKoNGTT1i", "show_name": "Baseline Banter", "show_handle": "baseline-banter", "platform": "spotify", "account_key": "5angy0pobo1s6ghbqfrvzv", "account_label": "Baseline Banter", "profile_url": "https://open.spotify.com/show/2pGhC7Mvqu6L2VzmndqWLq", "last_updated": "2026-09-22T22:46:03.191000Z", "data_quality": 8 }, { "show_id": "i3KlnFag2DgIpykraVxjrIZwJYcL", "show_name": "The Long Game", "show_handle": "the-long-game", "platform": "spotify", "account_key": "5angy0pobo1s6ghbqfrvzv", "account_label": "The Long Game", "profile_url": "https://open.spotify.com/show/2pGhC7Mvqu6L2VzmndqWLq", "last_updated": "2026-09-22T22:06:27.429000Z", "data_quality": 8 } ], "is_duplicate": true }, { "group_key": "megaphone:874r0412-5iet-50q5-3178-6b8lo30uza90", "platform": "megaphone", "platform_category": "audio", "account_key": "874r0412-5iet-50q5-3178-6b8lo30uza90", "account_label": "The Red Zone", "canonical_show_id": "h2DMGe9hfktytWWa0ZxVj1jMQbUb", "canonical_show_name": "The Red Zone", "members": [ { "show_id": "h2DMGe9hfktytWWa0ZxVj1jMQbUb", "show_name": "The Red Zone", "show_handle": "the-red-zone", "platform": "megaphone", "account_key": "874r0412-5iet-50q5-3178-6b8lo30uza90", "account_label": "The Red Zone", "profile_url": "", "last_updated": "2026-10-14T17:30:25.621000Z", "data_quality": 7 }, { "show_id": "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H", "show_name": "The Bullpen", "show_handle": "the-bullpen", "platform": "megaphone", "account_key": "874r0412-5iet-50q5-3178-6b8lo30uza90", "account_label": "The Bullpen", "profile_url": "", "last_updated": "2026-10-14T09:22:51.743000Z", "data_quality": 7 } ], "is_duplicate": true } ], "suppressed": { "i3KlnFag2DgIpykraVxjrIZwJYcL": [ "spotify" ], "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H": [ "megaphone" ] }, "applies_to": [ "overview_totals", "timeline" ], "never_applied_to": [ "shows_ranking", "compare_shows" ], "show_ids": [ "paAtKPJGo56md5VyoEQ9Las7qahL", "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H" ], "missing_show_ids": [ "0Iush8rT5eCcD4ePHroKFDlQvKhT", "nCJuUMzGC6EE3dfruqjRHtYdlCTr" ], "computed_at": "2026-10-14T17:34:15.929156+00:00", "cache_age_seconds": 10.4, "note": null } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## List show categories `GET /v1/analytics/categories` Scope: `analytics:read` SDK: `client.analytics.categories()` (TypeScript) · `client.analytics.categories()` (Python) The distinct show categories in the workspace (the analytics Topics filter) with the shows in each; use them as `categories` values. Placeholder categories such as "Category 3" are dropped unless `include_placeholders=true`. - Costs 2 rate-limit tokens. - Required scope: `analytics:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `include_placeholders` | boolean | optional | | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].category` | string | required | | | `data[].show_count` | integer | required | | | `data[].show_ids` | array of strings | required | | | `data[].object` | string | required | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | | `meta.total` | integer | optional | | | `meta.placeholders_dropped` | integer | optional | 'Category N' placeholders skipped, like the analytics page. Default `0`. | | `meta.uncategorised_show_ids` | array of strings | optional | | | `meta.missing_show_ids` | array of strings | optional | | | `meta.computed_at` | string | optional | | | `meta.cache_age_seconds` | number | optional | Default `0`. | | `meta.note` | string | optional | Default `null`. Nullable. | ### Example request ```bash curl "https://api.listener.com/v1/analytics/categories" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/analytics/categories", "data": [ { "object": "show_category", "category": "Baseball", "show_count": 1, "show_ids": [ "HTSVG2lSc1RmPELJNtCnvOEb4Z2u" ] }, { "object": "show_category", "category": "Basketball", "show_count": 1, "show_ids": [ "i3KlnFag2DgIpykraVxjrIZwJYcL" ] } ], "has_more": false, "next_cursor": null, "meta": { "total": 18, "placeholders_dropped": 0, "uncategorised_show_ids": [], "missing_show_ids": [ "0Iush8rT5eCcD4ePHroKFDlQvKhT", "nCJuUMzGC6EE3dfruqjRHtYdlCTr" ], "computed_at": "2026-10-14T17:34:15.933657+00:00", "cache_age_seconds": 10.4, "note": null } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## A show's platform stats `GET /v1/shows/{show_id}/platforms` Scope: `analytics:read` SDK: `client.shows.platforms.list()` (TypeScript) · `client.shows.platforms.list()` (Python) One show's per-platform metric blocks (views, plays or downloads, followers, engagement, growth against the previous period), audience signals (demographics, geography, retention, traffic sources) and freshness. `platform` is a platform key (aliases accepted) or `all`; `time_range` is `7d`, `30d` or `90d`; `include_raw=true` appends the stored platform data with secrets removed and arrays capped at 200 items. On 90 days the likes, comments, posts and follower deltas stay on the last 30 days; each block's `window_totals` adds the platform's 90-day reach and engagement with growth (null under 50 % coverage). `metric_windows` states which window each part covers. **Freshness.** `freshness` maps each platform to `{last_updated, age_hours, stale}` (stale = older than 36 h); `stale_platforms` lists them. A zero usually means a stale connector, not a zero audience. - Costs 2 rate-limit tokens. - Required scope: `analytics:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `platform` | string | optional | A platform name (e.g. youtube, spotify, applePodcasts, instagram). | | `time_range` | string | optional | 7d \| 14d \| 30d \| 90d \| 180d \| 365d \| all, or a quarter like 2026-Q2. Windows are UTC days anchored to today. | | `include_raw` | boolean | optional | | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `show` | object | required | The minimal show reference embedded in cross-show results. | | `show.id` | string | required | | | `show.name` | string | required | | | `show.handle` | string | optional | Default `""`. | | `show.image` | string | optional | Default `null`. Nullable. | | `show.public_url` | string | optional | Default `null`. Nullable. | | `time_range` | string | required | One of `7d`, `30d`, `90d`. | | `window` | object | required | The resolved analytics window (from ``core.dates.Window.as_dict()``). | | `window.time_range` | string | required | Normalised token: '30d', '2026-Q2', '2026-01-01..2026-03-31', 'all'. | | `window.kind` | string | required | 'rolling' \| 'quarter' \| 'custom' \| 'all'. | | `window.start` | string | required | First day (YYYY-MM-DD, inclusive, UTC). | | `window.end` | string | required | Last day (YYYY-MM-DD, inclusive, UTC). | | `window.days` | integer | required | | | `window.previous_start` | string | optional | Default `null`. Nullable. | | `window.previous_end` | string | optional | Default `null`. Nullable. | | `metric_windows` | map of string | optional | Which window each part of the response covers. On 90d the ported metric blocks stay on the last 30 days (no long-range source for likes, comments, posts, follower deltas); `window_totals` covers the full 90 days. | | `platforms` | array of objects | required | | | `platforms[].platform` | string | required | | | `platforms[].label` | string | required | | | `platforms[].connected` | boolean | required | | | `platforms[].has_data` | boolean | required | | | `platforms[].importing_state` | any | optional | Default `null`. Nullable. | | `platforms[].source` | string | optional | JS function the metric block ports (platformMetrics.calculate*Metrics). Default `null`. Nullable. | | `platforms[].metrics` | object | optional | Ported metric block in snake_case (values for the window + *_growth vs the previous window). | | `platforms[].audience` | object | optional | Demographics / geo / retention / traffic sources when the platform stores them. | | `platforms[].freshness` | object | required | | | `platforms[].freshness.last_updated` | string | optional | Default `null`. Nullable. | | `platforms[].freshness.age_hours` | number | optional | Default `null`. Nullable. | | `platforms[].freshness.stale` | boolean | optional | True when older than 36 hours or unknown. Default `true`. | | `platforms[].raw` | object | optional | Secret-stripped platform sub-document (include_raw=True only). Default `null`. Nullable. | | `platforms[].raw_truncated` | boolean | optional | Default `false`. | | `platforms[].window_totals` | object | optional | Windows longer than 30 days (90d) only: this platform's reach/engagement over the full window and the previous one from the daily series + analyticsHistory, with growth (null when the platform's data covers < 50% of the previous window). Default `null`. Nullable. | | `requested_platform` | string | required | | | `freshness` | map of object | optional | | | `freshness.last_updated` | string | optional | Default `null`. Nullable. | | `freshness.age_hours` | number | optional | Default `null`. Nullable. | | `freshness.stale` | boolean | optional | True when older than 36 hours or unknown. Default `true`. | | `stale_platforms` | array of strings | optional | | | `missing_show_ids` | array of strings | optional | | | `computed_at` | string | required | | | `cache_age_seconds` | number | optional | Default `0`. | | `note` | string | optional | Default `null`. Nullable. | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/shows/SHOW_ID/platforms" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "platform_stats", "show": { "id": "i3KlnFag2DgIpykraVxjrIZwJYcL", "name": "The Long Game", "handle": "the-long-game", "image": "https://images.example.com/listener/4b71276112f25fab.jpg", "public_url": "https://listener.com/the-long-game" }, "time_range": "30d", "window": { "time_range": "30d", "kind": "rolling", "start": "2026-09-15", "end": "2026-10-14", "days": 30, "previous_start": "2026-08-16", "previous_end": "2026-09-14" }, "metric_windows": { "metrics": "30d" }, "platforms": [ { "platform": "youtube", "label": "YouTube", "connected": true, "has_data": true, "importing_state": null, "source": "platformMetrics.calculateYoutubeMetrics", "metrics": { "lifetime_views": 1665727, "views": 125564, "watch_time": 890845, "subscribers": 7410, "subscribers_gained": 302, "avg_view_duration": 386, "views_growth": 861.5867667330372, "watch_time_growth": 0, "subscribers_growth": 0, "subs_gained_growth": 788.2352941176471, "avg_duration_growth": 0, "shows_with_data": 1 }, "audience": { "demographics": { "age": [ { "category": "age65-", "percentage": 27.3 }, { "category": "age55-64", "percentage": 19.5 } ], "gender": [ { "category": "male", "percentage": 61.4 }, { "category": "female", "percentage": 38.6 } ] }, "geographic": [ { "country": "US", "watchTime": 620132, "views": 89911 }, { "country": "CA", "watchTime": 27683, "views": 4085 } ], "states": [ { "province": "US-CA", "watchTime": 110410, "views": 12807 }, { "province": "US-NY", "watchTime": 45316, "views": 5800 } ], "video_retention": [], "traffic_sources": [ { "source": "Subscriber Feed", "rawSource": "SUBSCRIBER", "views": 74134 }, { "source": "RELATED_VIDEO", "rawSource": "RELATED_VIDEO", "views": 18220 } ] }, "freshness": { "last_updated": "2026-10-13T09:34:22.044000+00:00", "age_hours": 32, "stale": false }, "raw": null, "raw_truncated": false, "window_totals": null } ], "requested_platform": "youtube", "freshness": { "youtube": { "last_updated": "2026-10-13T09:34:22.044000+00:00", "age_hours": 32, "stale": false }, "spotify": { "last_updated": "2026-09-22T22:06:27.429000+00:00", "age_hours": 523.46, "stale": true }, "applePodcasts": { "last_updated": "2026-04-13T17:28:59+00:00", "age_hours": 4416.09, "stale": true }, "tiktok": { "last_updated": "2026-10-13T09:35:24.476000+00:00", "age_hours": 31.98, "stale": false }, "instagram": { "last_updated": "2026-10-13T09:35:13.282000+00:00", "age_hours": 31.98, "stale": false } }, "stale_platforms": [ "applePodcasts", "spotify" ], "missing_show_ids": [ "0Iush8rT5eCcD4ePHroKFDlQvKhT", "nCJuUMzGC6EE3dfruqjRHtYdlCTr" ], "computed_at": "2026-10-14T17:34:15.934519+00:00", "cache_age_seconds": 10.4, "note": null } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Recent posts and content `GET /v1/content` Scope: `analytics:read` SDK: `client.content.list()` (TypeScript) · `client.content.list()` (Python) Recent content across platforms (Instagram reels, TikToks, X posts, LinkedIn / Threads / Facebook posts, YouTube videos, newsletters, Art19 / Omny episodes) published inside the window, newest first, with per-item metrics. Captions are truncated to 280 characters. `group_by=day` or `platform` adds `meta.groups` for the page; `limit_per_platform` caps each platform before pagination (0 = no cap). **Windows.** `time_range` is `7d`, `14d`, `30d`, `90d`, `180d`, `365d`, `all` (rolling UTC days, today inclusive) or a calendar quarter such as `2026-Q2` (a bare `Q2` means the current year). `start_date` + `end_date` (`YYYY-MM-DD`, inclusive, both required) override it. Results carry `window` and `coverage`. **Growth.** Growth compares against the equal-length window immediately before (the previous quarter for quarter tokens). It is `null` (show it as "—", never 0) when the previous window has under 50 % day coverage. Windows longer than 30 days read stored history and compare like-for-like: only platforms whose data covers at least half of the previous window, both windows summed over those platforms (`coverage.growth_platforms`). - Costs 2 rate-limit tokens. - Required scope: `analytics:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_ids` | array of strings | optional | Limit to these show ids (default: every show in the workspace). Repeat the parameter or comma-separate values. | | `categories` | array of strings | optional | Only shows whose categories contain one of these (case-insensitive substring). Repeat the parameter or comma-separate values. | | `platforms` | array of strings | optional | Only these platforms (e.g. youtube, spotify, instagram; aliases accepted). Repeat the parameter or comma-separate values. | | `social_count_mode` | string | optional | all counts every show's accounts; unique counts an account shared by several shows once (totals, timelines, distributions only). | | `group_by` | string | optional | Group the results by this field. | | `limit` | integer | optional | Page size, 1–100 (default 25). | | `limit_per_platform` | integer | optional | | | `time_range` | string | optional | 7d \| 14d \| 30d \| 90d \| 180d \| 365d \| all, or a quarter like 2026-Q2. Windows are UTC days anchored to today. | | `start_date` | string | optional | First day, YYYY-MM-DD (inclusive). With end_date, overrides time_range. | | `end_date` | string | optional | Last day, YYYY-MM-DD (inclusive). With start_date, overrides time_range. | | `cursor` | string | optional | Opaque cursor from the previous page's next_cursor. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].show` | object | required | The minimal show reference embedded in cross-show results. | | `data[].show.id` | string | required | | | `data[].show.name` | string | required | | | `data[].show.handle` | string | optional | Default `""`. | | `data[].show.image` | string | optional | Default `null`. Nullable. | | `data[].show.public_url` | string | optional | Default `null`. Nullable. | | `data[].platform` | string | required | | | `data[].post_type` | string | optional | Default `null`. Nullable. | | `data[].id` | string | optional | Default `null`. Nullable. | | `data[].date` | string | optional | Default `null`. Nullable. | | `data[].day` | string | optional | Default `null`. Nullable. | | `data[].url` | string | optional | Default `null`. Nullable. | | `data[].thumbnail` | string | optional | Default `null`. Nullable. | | `data[].title` | string | optional | Default `null`. Nullable. | | `data[].caption` | string | optional | Default `null`. Nullable. | | `data[].metrics` | object | optional | | | `data[].object` | string | required | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | | `meta.window` | object | optional | The resolved analytics window (from ``core.dates.Window.as_dict()``). | | `meta.window.time_range` | string | required | Normalised token: '30d', '2026-Q2', '2026-01-01..2026-03-31', 'all'. | | `meta.window.kind` | string | required | 'rolling' \| 'quarter' \| 'custom' \| 'all'. | | `meta.window.start` | string | required | First day (YYYY-MM-DD, inclusive, UTC). | | `meta.window.end` | string | required | Last day (YYYY-MM-DD, inclusive, UTC). | | `meta.window.days` | integer | required | | | `meta.window.previous_start` | string | optional | Default `null`. Nullable. | | `meta.window.previous_end` | string | optional | Default `null`. Nullable. | | `meta.coverage` | object | optional | How much of the window actually has data (design §2). | | `meta.coverage.start` | string | optional | Earliest date with data across the scoped shows. Default `null`. Nullable. | | `meta.coverage.embedded_boundary` | string | optional | today-29: dates before this come from analyticsHistory. Default `null`. Nullable. | | `meta.coverage.history_used` | boolean | optional | Default `false`. | | `meta.coverage.previous_window_coverage_days` | integer | optional | Default `0`. | | `meta.coverage.current_window_coverage_days` | integer | optional | Default `0`. | | `meta.coverage.growth_basis` | string | optional | 'like_for_like' on windows longer than 30 days (e.g. 90d): growth compares only platforms whose data covers at least half of the previous window, both windows summed over those platforms alone. One of `total`, `like_for_like`. Default `"total"`. | | `meta.coverage.growth_platforms` | array of strings | optional | Like-for-like windows only: the platforms the headline growth compares (empty → growth is null). Default `null`. Nullable. | | `meta.coverage.warnings` | array of strings | optional | | | `meta.freshness` | map of object | optional | platform → oldest freshness across the scoped shows. | | `meta.freshness.last_updated` | string | optional | Default `null`. Nullable. | | `meta.freshness.age_hours` | number | optional | Default `null`. Nullable. | | `meta.freshness.stale` | boolean | optional | True when older than 36 hours or unknown. Default `true`. | | `meta.stale_platforms` | array of strings | optional | | | `meta.show_ids` | array of strings | optional | Shows included after show_ids/categories filtering. | | `meta.missing_show_ids` | array of strings | optional | | | `meta.filters` | object | optional | | | `meta.computed_at` | string | optional | | | `meta.cache_age_seconds` | number | optional | Default `0`. | | `meta.note` | string | optional | Default `null`. Nullable. | | `meta.group_by` | string | optional | One of `none`, `day`, `platform`. | | `meta.platforms` | array of strings | optional | | | `meta.groups` | array of objects | optional | Default `null`. Nullable. | | `meta.groups[].key` | string | required | Day (YYYY-MM-DD) or platform key. | | `meta.groups[].count` | integer | required | | | `meta.groups[].posts` | array of objects | required | | | `meta.groups[].posts[].show` | object | required | The minimal show reference embedded in cross-show results. | | `meta.groups[].posts[].show.id` | string | required | | | `meta.groups[].posts[].show.name` | string | required | | | `meta.groups[].posts[].show.handle` | string | optional | Default `""`. | | `meta.groups[].posts[].show.image` | string | optional | Default `null`. Nullable. | | `meta.groups[].posts[].show.public_url` | string | optional | Default `null`. Nullable. | | `meta.groups[].posts[].platform` | string | required | | | `meta.groups[].posts[].post_type` | string | optional | Default `null`. Nullable. | | `meta.groups[].posts[].id` | string | optional | Default `null`. Nullable. | | `meta.groups[].posts[].date` | string | optional | Default `null`. Nullable. | | `meta.groups[].posts[].day` | string | optional | Default `null`. Nullable. | | `meta.groups[].posts[].url` | string | optional | Default `null`. Nullable. | | `meta.groups[].posts[].thumbnail` | string | optional | Default `null`. Nullable. | | `meta.groups[].posts[].title` | string | optional | Default `null`. Nullable. | | `meta.groups[].posts[].caption` | string | optional | Default `null`. Nullable. | | `meta.groups[].posts[].metrics` | object | optional | | | `meta.posts_by_platform` | map of integer | optional | Counts before limit/offset. | ### Example request ```bash curl "https://api.listener.com/v1/content" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/content", "data": [ { "object": "post", "show": { "id": "paAtKPJGo56md5VyoEQ9Las7qahL", "name": "Hard Court", "handle": "hard-court", "image": "https://images.example.com/listener/6cbd825d3f77ec48.jpg", "public_url": "https://listener.com/hard-court" }, "platform": "instagram", "post_type": "reel", "id": "LvfmwCJJN9O", "date": "2026-10-13T20:30:16+00:00", "day": "2026-10-13", "url": "https://www.instagram.com/reel/LvfmwCJJN9O/", "thumbnail": "https://images.example.com/listener/aa3f151af787003a.jpg", "title": null, "caption": "Highlights and analysis from the latest matchday.", "metrics": { "plays": 1216, "likes": 11, "comments": 0, "shares": 0, "saves": 0, "engagement": 11 } }, { "object": "post", "show": { "id": "paAtKPJGo56md5VyoEQ9Las7qahL", "name": "Hard Court", "handle": "hard-court", "image": "https://images.example.com/listener/6cbd825d3f77ec48.jpg", "public_url": "https://listener.com/hard-court" }, "platform": "youtube", "post_type": "video", "id": null, "date": "2026-10-13T20:30:15+00:00", "day": "2026-10-13", "url": null, "thumbnail": "https://images.example.com/listener/ad1db58b84403df9.jpg", "title": "The best and worst contracts", "caption": "The crew is back with rankings, hot takes and a listener mailbag.", "metrics": { "views": 891, "likes": 10, "comments": 0 } } ], "has_more": true, "next_cursor": "eyJvIjoyfQ", "meta": { "window": { "time_range": "30d", "kind": "rolling", "start": "2026-09-15", "end": "2026-10-14", "days": 30, "previous_start": "2026-08-16", "previous_end": "2026-09-14" }, "coverage": { "start": null, "embedded_boundary": "2026-09-15", "history_used": false, "previous_window_coverage_days": 0, "current_window_coverage_days": 0, "growth_basis": "total", "growth_platforms": null, "warnings": [] }, "freshness": { "applePodcasts": { "last_updated": "2026-04-13T16:00:26+00:00", "age_hours": 4417.56, "stale": true }, "facebook": { "last_updated": "2026-10-08T07:02:13.420000+00:00", "age_hours": 154.53, "stale": true }, "instagram": { "last_updated": "2026-10-13T09:35:13.282000+00:00", "age_hours": 31.98, "stale": false }, "megaphone": { "last_updated": "2026-10-14T09:22:51.743000+00:00", "age_hours": 8.19, "stale": false }, "omnystudio": { "last_updated": "2026-10-13T09:33:48.789000+00:00", "age_hours": 32.01, "stale": false }, "spotify": { "last_updated": "2026-08-06T08:49:48.390000+00:00", "age_hours": 1664.74, "stale": true }, "threads": { "last_updated": "2026-10-13T09:36:40.899000+00:00", "age_hours": 31.96, "stale": false }, "tiktok": { "last_updated": "2026-10-13T09:34:15.574000+00:00", "age_hours": 32, "stale": false }, "twitter": { "last_updated": "2026-10-10T07:02:33.014000+00:00", "age_hours": 106.53, "stale": true }, "youtube": { "last_updated": "2026-10-02T10:37:33.857000+00:00", "age_hours": 294.95, "stale": true } }, "stale_platforms": [ "applePodcasts", "facebook" ], "show_ids": [ "paAtKPJGo56md5VyoEQ9Las7qahL", "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H" ], "missing_show_ids": [ "0Iush8rT5eCcD4ePHroKFDlQvKhT", "nCJuUMzGC6EE3dfruqjRHtYdlCTr" ], "filters": { "show_ids": null, "categories": null, "platforms": null, "social_count_mode": "all", "time_range": "30d", "group_by": "none", "page": { "total": 531, "limit": 2, "offset": 0, "has_more": true, "next_offset": 2 }, "limit_per_platform": 0 }, "computed_at": "2026-10-14T17:34:16.106371+00:00", "cache_age_seconds": 10.6, "note": null, "group_by": "none", "platforms": [ "instagram", "tiktok" ], "groups": null, "posts_by_platform": { "instagram": 138, "tiktok": 79, "twitter": 147, "threads": 1, "youtube": 152, "omnystudio": 14 }, "total": 531 } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. --- # Clusters > Episode clusters (an episode plus its clips and posts), cluster reports and timelines, and the AI cluster builder. Base URL: `https://api.listener.com` ## List clusters `GET /v1/clusters` Scope: `clusters:read` SDK: `client.clusters.list()` (TypeScript) · `client.clusters.list()` (Python) Episode clusters across shows as the Clusters page lists them: reach by category and form, node, clip and post counts, type (`episode`, `merged`, `manual`), emptiness, freshness, sponsor brands, linked campaigns and public URL, plus `meta.totals` over reach-contributing clusters. Rows come from stored summaries (`summary_stale` marks rows that could not be recomputed). `time_range` (default `all`) buckets by the cluster's episode publish date. `series`, `guests` and `format` match the episode's tags; `custom_tags` matches node tags (applied to at most 300 rows). `state` is `all`, `active`, `empty`, `merged` or `manual`. `brand` matches sponsored-node or linked-campaign brands. - Required scope: `clusters:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_ids` | array of strings | optional | Limit to these show ids (default: every show in the workspace). Repeat the parameter or comma-separate values. | | `time_range` | string | optional | 7d \| 14d \| 30d \| 90d \| 180d \| 365d \| all, or a quarter like 2026-Q2. Windows are UTC days anchored to today. | | `start_date` | string | optional | First day, YYYY-MM-DD (inclusive). With end_date, overrides time_range. | | `end_date` | string | optional | Last day, YYYY-MM-DD (inclusive). With start_date, overrides time_range. | | `series` | array of strings | optional | Series tags. Repeat the parameter or comma-separate values. | | `guests` | array of strings | optional | Guest tags. Repeat the parameter or comma-separate values. | | `format` | array of strings | optional | Format tags. Repeat the parameter or comma-separate values. | | `custom_tags` | array of strings | optional | Repeat the parameter or comma-separate values. | | `brand` | string | optional | Brand name or slug filter. | | `state` | string | optional | | | `search` | string | optional | Case-insensitive text filter. | | `sort_by` | string | optional | Sort field. | | `sort_order` | string | optional | asc or desc. | | `limit` | integer | optional | Page size, 1–100 (default 25). | | `cursor` | string | optional | Opaque cursor from the previous page's next_cursor. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].total_reach` | integer \| number | optional | Default `0`. | | `data[].youtube_reach` | integer \| number | optional | Default `0`. | | `data[].youtube_long_form_reach` | integer \| number | optional | Default `0`. | | `data[].youtube_shorts_reach` | integer \| number | optional | Default `0`. | | `data[].audio_reach` | integer \| number | optional | Default `0`. | | `data[].social_reach` | integer \| number | optional | Default `0`. | | `data[].long_form_reach` | integer \| number | optional | Default `0`. | | `data[].short_form_reach` | integer \| number | optional | Default `0`. | | `data[].long_form_count` | integer | optional | Default `0`. | | `data[].short_form_count` | integer | optional | Default `0`. | | `data[].clips` | integer | optional | Default `0`. | | `data[].posts` | integer | optional | Default `0`. | | `data[].total_engagement` | integer \| number | optional | Default `0`. | | `data[].engagement_rate` | integer \| number | optional | Percent (1.93 == 1.93 %). Default `0`. | | `data[].id` | string | required | Cluster document id (e.g. 'cluster-', 'cluster-merged-…', 'manual-cluster-'). | | `data[].show` | object | required | The minimal show reference embedded in cross-show results. | | `data[].show.id` | string | required | | | `data[].show.name` | string | required | | | `data[].show.handle` | string | optional | Default `""`. | | `data[].show.image` | string | optional | Default `null`. Nullable. | | `data[].show.public_url` | string | optional | Default `null`. Nullable. | | `data[].title` | string | required | | | `data[].name` | string | optional | Default `null`. Nullable. | | `data[].internal_title` | string | optional | Default `null`. Nullable. | | `data[].cluster_type` | string | required | One of `episode`, `merged`, `manual`. | | `data[].is_merged` | boolean | optional | Default `false`. | | `data[].is_manual` | boolean | optional | Default `false`. | | `data[].is_empty` | boolean | optional | Default `false`. | | `data[].contributing` | boolean | optional | Counted in show totals (selectReachContributingClusters): individual clusters; merged only when a show has nothing else. Default `true`. | | `data[].episode_ids` | array of strings | optional | | | `data[].episodes` | array of objects | optional | Linked episode records (id, title, published day) when the show's episode index knows them. | | `data[].episodes[].id` | string | required | | | `data[].episodes[].title` | string | optional | Default `null`. Nullable. | | `data[].episodes[].published_at` | string | optional | Default `null`. Nullable. | | `data[].episode_count` | integer | optional | Default `0`. | | `data[].published_at` | string | optional | Network-list date rule: linked episode day (merged → latest), else the stored episodePublishedAt. Default `null`. Nullable. | | `data[].episode_date` | string | optional | KPI-bar date rule used by `GET /v1/clusters/report` windows. Default `null`. Nullable. | | `data[].tags` | map of array of strings | optional | Episode tags merged onto the row: {series, guests, format}. | | `data[].custom_tags` | array of strings | optional | Node custom tags; only known when the full document was loaded. Default `null`. Nullable. | | `data[].sponsor_brands` | array of strings | optional | | | `data[].brands` | array of strings | optional | Sponsored-node brands then linked campaign brands. | | `data[].node_count` | integer | optional | Default `0`. | | `data[].metrics_source` | string | optional | 'nodes' \| 'summary' \| 'summary_stale' \| 'none'. Default `"summary"`. | | `data[].summary_fresh` | boolean | optional | Default `null`. Nullable. | | `data[].summary_stale` | boolean | optional | True when the stored summary could not be verified and the full document was not fetched (beyond the recompute cap). Default `false`. | | `data[].freshness` | object | required | | | `data[].freshness.kind` | string | optional | 'refreshed' (a refresh run) \| 'saved' (newest node fetch stamp) \| 'unknown'. Default `"unknown"`. | | `data[].freshness.at` | string | optional | Default `null`. Nullable. | | `data[].freshness.source` | string | optional | Refresh source when known ('manual' \| 'scheduled' \| …). Default `null`. Nullable. | | `data[].last_refreshed_at` | string | optional | Default `null`. Nullable. | | `data[].last_refresh_attempt_at` | string | optional | Default `null`. Nullable. | | `data[].last_refresh_source` | string | optional | Default `null`. Nullable. | | `data[].refresh_error_count` | integer | optional | Default `0`. | | `data[].created_at` | string | optional | Default `null`. Nullable. | | `data[].updated_at` | string | optional | Default `null`. Nullable. | | `data[].auto_generated` | boolean | optional | Default `false`. | | `data[].brand_collab` | any | optional | Default `null`. Nullable. | | `data[].has_emv_rates` | boolean | optional | Default `false`. | | `data[].customization` | object | optional | Public-page overrides: background_video_url, cover_image_url, featured_url and, when set, co_brand {name, logo_url, href}. Default `null`. Nullable. | | `data[].linked_campaign_ids` | array of strings | optional | | | `data[].linked_campaigns` | array of objects | optional | | | `data[].linked_campaigns[].campaign_id` | string | required | | | `data[].linked_campaigns[].title` | string | required | | | `data[].linked_campaigns[].brand_name` | string | optional | Default `null`. Nullable. | | `data[].linked_campaigns[].display_brand_name` | string | required | | | `data[].linked_campaigns[].is_brand_fallback` | boolean | optional | Default `false`. | | `data[].linked_campaigns[].status` | string | optional | Default `null`. Nullable. | | `data[].linked_campaigns[].excluded_node_count` | integer | optional | Nodes the campaign excludes across every cluster it links. Default `0`. | | `data[].linked_campaigns[].excluded_node_ids_in_cluster` | array of strings | optional | This cluster's nodes the campaign excludes (known when node ids are loaded). Default `null`. Nullable. | | `data[].linked_campaigns[].excluded_node_count_in_cluster` | integer | optional | Default `null`. Nullable. | | `data[].linked_campaigns[].included_node_count_in_cluster` | integer | optional | Default `null`. Nullable. | | `data[].linked_campaigns[].public_url` | string | optional | Default `null`. Nullable. | | `data[].public_url` | string | optional | Default `null`. Nullable. | | `data[].app_url` | string | optional | Default `null`. Nullable. | | `data[].object` | string | required | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | | `meta.totals` | object | optional | ``aggregateClusterStats`` over the listed rows (reach-contributing clusters only). | | `meta.totals.has_clusters` | boolean | optional | Default `false`. | | `meta.totals.cluster_count` | integer | optional | Every cluster document in the list (contributing or not). Default `0`. | | `meta.totals.source_cluster_count` | integer | optional | Reach-contributing clusters the totals sum. Default `0`. | | `meta.totals.merged_cluster_count` | integer | optional | Default `0`. | | `meta.totals.manual_cluster_count` | integer | optional | Default `0`. | | `meta.totals.empty_cluster_count` | integer | optional | Default `0`. | | `meta.totals.unlinked_cluster_count` | integer | optional | Documents with no explicit episodeIds / selectedEpisodes. Default `0`. | | `meta.totals.episode_count` | integer | optional | Default `0`. | | `meta.totals.total_reach` | integer \| number | optional | Default `0`. | | `meta.totals.youtube_reach` | integer \| number | optional | Default `0`. | | `meta.totals.audio_reach` | integer \| number | optional | Default `0`. | | `meta.totals.social_reach` | integer \| number | optional | Default `0`. | | `meta.totals.clips` | integer | optional | Default `0`. | | `meta.totals.posts` | integer | optional | Default `0`. | | `meta.totals.content_pieces` | integer | optional | Default `0`. | | `meta.totals.avg_reach_per_episode` | integer \| number | optional | Default `0`. | | `meta.totals.avg_reach_per_cluster` | integer \| number | optional | Default `0`. | | `meta.totals.summary_fresh_count` | integer | optional | Rows served from a verified v3 summary map. Default `0`. | | `meta.totals.summary_recomputed_count` | integer | optional | Rows whose summary was stale and were recomputed from the full document. Default `0`. | | `meta.totals.summary_stale_count` | integer | optional | Rows whose summary was stale but not recomputed (beyond the recompute cap); their numbers may lag. Default `0`. | | `meta.totals.summary_missing_count` | integer | optional | Rows with no summary and no document to recompute from. Default `0`. | | `meta.window` | object | optional | The resolved analytics window (from ``core.dates.Window.as_dict()``). | | `meta.window.time_range` | string | required | Normalised token: '30d', '2026-Q2', '2026-01-01..2026-03-31', 'all'. | | `meta.window.kind` | string | required | 'rolling' \| 'quarter' \| 'custom' \| 'all'. | | `meta.window.start` | string | required | First day (YYYY-MM-DD, inclusive, UTC). | | `meta.window.end` | string | required | Last day (YYYY-MM-DD, inclusive, UTC). | | `meta.window.days` | integer | required | | | `meta.window.previous_start` | string | optional | Default `null`. Nullable. | | `meta.window.previous_end` | string | optional | Default `null`. Nullable. | | `meta.filters` | object | optional | | | `meta.show_ids` | array of strings | optional | | | `meta.show_cluster_freshness` | map of object | optional | show id → newest cluster refresh / save. | | `meta.show_cluster_freshness.kind` | string | optional | Default `"unknown"`. | | `meta.show_cluster_freshness.at` | string | optional | Default `null`. Nullable. | | `meta.show_cluster_freshness.last_refreshed_at` | string | optional | Default `null`. Nullable. | | `meta.show_cluster_freshness.failed_node_count` | integer | optional | Default `0`. | | `meta.show_cluster_freshness.clusters_with_errors` | integer | optional | Default `0`. | | `meta.tag_options` | map of array of objects | optional | | | `meta.tag_options.value` | string | required | | | `meta.tag_options.count` | integer | required | | | `meta.brand_options` | array of strings | optional | | | `meta.freshness` | map of object | optional | | | `meta.freshness.last_updated` | string | optional | Default `null`. Nullable. | | `meta.freshness.age_hours` | number | optional | Default `null`. Nullable. | | `meta.freshness.stale` | boolean | optional | True when older than 36 hours or unknown. Default `true`. | | `meta.stale_platforms` | array of strings | optional | | | `meta.missing_show_ids` | array of strings | optional | | | `meta.computed_at` | string | optional | | | `meta.cache_age_seconds` | number | optional | | | `meta.note` | string | optional | Default `null`. Nullable. | ### Example request ```bash curl "https://api.listener.com/v1/clusters" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/clusters", "data": [ { "object": "cluster", "total_reach": 2988, "youtube_reach": 732, "youtube_long_form_reach": 0, "youtube_shorts_reach": 732, "audio_reach": 0, "social_reach": 2256, "long_form_reach": 0, "short_form_reach": 2988, "long_form_count": 0, "short_form_count": 3, "clips": 3, "posts": 0, "total_engagement": 83, "engagement_rate": 2.7777777777777777, "id": "cluster-0y0HEJ0OVF7EYdXAvD5ghY", "show": { "id": "paAtKPJGo56md5VyoEQ9Las7qahL", "name": "Our bold predictions for the second half", "handle": "hard-court", "image": "https://images.example.com/listener/6cbd825d3f77ec48.jpg", "public_url": "https://listener.com/hard-court" }, "title": "The best and worst contracts", "name": "Five takeaways from the opener", "internal_title": null, "cluster_type": "episode", "is_merged": false, "is_manual": false, "is_empty": false, "contributing": true, "episode_ids": [ "0y0HEJ0OVF7EYdXAvD5ghY" ], "episodes": [], "episode_count": 1, "published_at": "2026-10-10", "episode_date": "2026-10-10", "tags": { "series": [], "guests": [], "format": [] }, "custom_tags": null, "sponsor_brands": [], "brands": [], "node_count": 4, "metrics_source": "nodes", "summary_fresh": false, "summary_stale": false, "freshness": { "kind": "saved", "at": "2026-10-12T11:17:18.153Z", "source": "nodes" }, "last_refreshed_at": null, "last_refresh_attempt_at": null, "last_refresh_source": null, "refresh_error_count": 0, "created_at": null, "updated_at": "2026-10-12T18:40:06.002Z", "auto_generated": false, "brand_collab": null, "has_emv_rates": false, "customization": null, "linked_campaign_ids": [], "linked_campaigns": [], "public_url": "https://listener.com/cluster/paAtKPJGo56md5VyoEQ9Las7qahL_cluster-0y0HEJ0OVF7EYdXAvD5ghY", "app_url": "https://network.listener.com/app/cluster?id=cluster-0y0HEJ0OVF7EYdXAvD5ghY&uid=paAtKPJGo56md5VyoEQ9Las7qahL" }, { "object": "cluster", "total_reach": 409, "youtube_reach": 409, "youtube_long_form_reach": 65, "youtube_shorts_reach": 344, "audio_reach": 0, "social_reach": 0, "long_form_reach": 65, "short_form_reach": 344, "long_form_count": 1, "short_form_count": 1, "clips": 2, "posts": 0, "total_engagement": 12, "engagement_rate": 2.93398533007335, "id": "cluster-7ys2rRKJbGMF7uozA47MEs", "show": { "id": "paAtKPJGo56md5VyoEQ9Las7qahL", "name": "Our bold predictions for the second half", "handle": "hard-court", "image": "https://images.example.com/listener/6cbd825d3f77ec48.jpg", "public_url": "https://listener.com/hard-court" }, "title": "Is the dynasty over?", "name": "Is the dynasty over?", "internal_title": null, "cluster_type": "episode", "is_merged": false, "is_manual": false, "is_empty": false, "contributing": true, "episode_ids": [ "7ys2rRKJbGMF7uozA47MEs" ], "episodes": [], "episode_count": 1, "published_at": "2026-10-09", "episode_date": "2026-10-09", "tags": { "series": [], "guests": [], "format": [] }, "custom_tags": null, "sponsor_brands": [], "brands": [], "node_count": 3, "metrics_source": "nodes", "summary_fresh": false, "summary_stale": false, "freshness": { "kind": "unknown", "at": "2026-10-09T14:52:31.648Z", "source": "createdAt" }, "last_refreshed_at": null, "last_refresh_attempt_at": null, "last_refresh_source": null, "refresh_error_count": 0, "created_at": "2026-10-09T14:52:31.648Z", "updated_at": "2026-10-12T09:02:25.056Z", "auto_generated": false, "brand_collab": null, "has_emv_rates": false, "customization": null, "linked_campaign_ids": [], "linked_campaigns": [], "public_url": "https://listener.com/cluster/paAtKPJGo56md5VyoEQ9Las7qahL_cluster-7ys2rRKJbGMF7uozA47MEs", "app_url": "https://network.listener.com/app/cluster?id=cluster-7ys2rRKJbGMF7uozA47MEs&uid=paAtKPJGo56md5VyoEQ9Las7qahL" } ], "has_more": true, "next_cursor": "eyJvIjoyfQ", "meta": { "totals": { "has_clusters": true, "cluster_count": 159, "source_cluster_count": 144, "merged_cluster_count": 15, "manual_cluster_count": 11, "empty_cluster_count": 22, "unlinked_cluster_count": 0, "episode_count": 134, "total_reach": 9527003, "youtube_reach": 2883483, "audio_reach": 233988, "social_reach": 6409532, "clips": 669, "posts": 161, "content_pieces": 830, "avg_reach_per_episode": 71097, "avg_reach_per_cluster": 66160, "summary_fresh_count": 0, "summary_recomputed_count": 159, "summary_stale_count": 0, "summary_missing_count": 0 }, "window": { "time_range": "all", "kind": "all", "start": "1999-05-30", "end": "2026-10-14", "days": 10000, "previous_start": null, "previous_end": null }, "filters": { "series": null, "guests": null, "format": null, "custom_tags": null, "brand": null, "state": "all", "search": null, "sort_by": "published_at", "sort_order": "desc" }, "show_ids": [ "paAtKPJGo56md5VyoEQ9Las7qahL", "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H" ], "show_cluster_freshness": { "paAtKPJGo56md5VyoEQ9Las7qahL": { "kind": "refreshed", "at": "2026-10-08T05:55:29.001Z", "last_refreshed_at": "2026-10-08T05:55:29.001Z", "failed_node_count": 0, "clusters_with_errors": 0 }, "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H": { "kind": "saved", "at": "2026-03-17T17:16:36.453Z", "last_refreshed_at": null, "failed_node_count": 0, "clusters_with_errors": 0 }, "HTSVG2lSc1RmPELJNtCnvOEb4Z2u": { "kind": "refreshed", "at": "2026-08-27T10:16:43.193Z", "last_refreshed_at": "2026-08-27T10:16:43.193Z", "failed_node_count": 0, "clusters_with_errors": 0 }, "Xl2xMNVBZpyUA7MVJpqcKkFhEM2X": { "kind": "unknown", "at": null, "last_refreshed_at": null, "failed_node_count": 0, "clusters_with_errors": 0 }, "i3KlnFag2DgIpykraVxjrIZwJYcL": { "kind": "unknown", "at": null, "last_refreshed_at": null, "failed_node_count": 0, "clusters_with_errors": 0 }, "p3KfMe4Kfe1mY1eWjLLGFzS2wtUu": { "kind": "saved", "at": "2026-04-16T13:44:46.506Z", "last_refreshed_at": null, "failed_node_count": 0, "clusters_with_errors": 0 }, "sLLP2LKo9LJHqRCtBjjWDsbyDImn": { "kind": "unknown", "at": null, "last_refreshed_at": null, "failed_node_count": 0, "clusters_with_errors": 0 }, "PXjDgD84U0pDobe25GOpKoNGTT1i": { "kind": "unknown", "at": null, "last_refreshed_at": null, "failed_node_count": 0, "clusters_with_errors": 0 }, "jgDOYIsPG3c9Jk7mysodavhQxXhV": { "kind": "unknown", "at": null, "last_refreshed_at": null, "failed_node_count": 0, "clusters_with_errors": 0 }, "Of9Els0RskCtlyZFPG8ujblH3NKI": { "kind": "unknown", "at": null, "last_refreshed_at": null, "failed_node_count": 0, "clusters_with_errors": 0 }, "2RSfMoyvMja3bzIq9vdY0UeZ0Jp8": { "kind": "unknown", "at": null, "last_refreshed_at": null, "failed_node_count": 0, "clusters_with_errors": 0 }, "h2DMGe9hfktytWWa0ZxVj1jMQbUb": { "kind": "unknown", "at": null, "last_refreshed_at": null, "failed_node_count": 0, "clusters_with_errors": 0 } }, "tag_options": { "series": [], "guests": [], "format": [] }, "brand_options": [ "Acme Coffee", "Umbrella Energy" ], "freshness": { "applePodcasts": { "last_updated": "2026-04-13T16:00:26+00:00", "age_hours": 4417.56, "stale": true }, "facebook": { "last_updated": "2026-10-08T07:02:13.420000+00:00", "age_hours": 154.53, "stale": true }, "instagram": { "last_updated": "2026-10-13T09:35:13.282000+00:00", "age_hours": 31.98, "stale": false }, "megaphone": { "last_updated": "2026-10-14T09:22:51.743000+00:00", "age_hours": 8.19, "stale": false }, "omnystudio": { "last_updated": "2026-10-13T09:33:48.789000+00:00", "age_hours": 32.01, "stale": false }, "spotify": { "last_updated": "2026-08-06T08:49:48.390000+00:00", "age_hours": 1664.74, "stale": true }, "threads": { "last_updated": "2026-10-13T09:36:40.899000+00:00", "age_hours": 31.96, "stale": false }, "tiktok": { "last_updated": "2026-10-13T09:34:15.574000+00:00", "age_hours": 32, "stale": false }, "twitter": { "last_updated": "2026-10-10T07:02:33.014000+00:00", "age_hours": 106.53, "stale": true }, "youtube": { "last_updated": "2026-10-02T10:37:33.857000+00:00", "age_hours": 294.95, "stale": true } }, "stale_platforms": [ "applePodcasts", "facebook" ], "missing_show_ids": [ "0Iush8rT5eCcD4ePHroKFDlQvKhT", "nCJuUMzGC6EE3dfruqjRHtYdlCTr" ], "computed_at": "2026-10-14T17:34:16.117623+00:00", "cache_age_seconds": 10.6, "note": null, "total": 159 } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Cluster report `GET /v1/clusters/report` Scope: `clusters:read` SDK: `client.clusters.report()` (TypeScript) · `client.clusters.report()` (Python) Cluster analytics per show and for the network over a window, as the Episodes-page KPI bar computes them: reach by category (YouTube long-form and Shorts, audio, social) and form, cluster count, per-cluster averages, growth against the previous window, reach distribution, content mix and cluster counts by type. This is cluster reach (the clips and posts attached to episodes), a different number from the account-level `GET /v1/analytics/reach`. Clusters are bucketed by their episode's publish date; merged clusters are excluded unless `include_merged=true` and never double-counted with their sources. Growth is null when the previous window had no clusters. **Windows.** `time_range` is `7d`, `14d`, `30d`, `90d`, `180d`, `365d`, `all` (rolling UTC days, today inclusive) or a calendar quarter such as `2026-Q2` (a bare `Q2` means the current year). `start_date` + `end_date` (`YYYY-MM-DD`, inclusive, both required) override it. Results carry `window` and `coverage`. **Growth.** Growth compares against the equal-length window immediately before (the previous quarter for quarter tokens). It is `null` (show it as "—", never 0) when the previous window has under 50 % day coverage. Windows longer than 30 days read stored history and compare like-for-like: only platforms whose data covers at least half of the previous window, both windows summed over those platforms (`coverage.growth_platforms`). - Required scope: `clusters:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_ids` | array of strings | optional | Limit to these show ids (default: every show in the workspace). Repeat the parameter or comma-separate values. | | `categories` | array of strings | optional | Only shows whose categories contain one of these (case-insensitive substring). Repeat the parameter or comma-separate values. | | `time_range` | string | optional | 7d \| 14d \| 30d \| 90d \| 180d \| 365d \| all, or a quarter like 2026-Q2. Windows are UTC days anchored to today. | | `start_date` | string | optional | First day, YYYY-MM-DD (inclusive). With end_date, overrides time_range. | | `end_date` | string | optional | Last day, YYYY-MM-DD (inclusive). With start_date, overrides time_range. | | `include_merged` | boolean | optional | | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `window` | object | required | The resolved analytics window (from ``core.dates.Window.as_dict()``). | | `window.time_range` | string | required | Normalised token: '30d', '2026-Q2', '2026-01-01..2026-03-31', 'all'. | | `window.kind` | string | required | 'rolling' \| 'quarter' \| 'custom' \| 'all'. | | `window.start` | string | required | First day (YYYY-MM-DD, inclusive, UTC). | | `window.end` | string | required | Last day (YYYY-MM-DD, inclusive, UTC). | | `window.days` | integer | required | | | `window.previous_start` | string | optional | Default `null`. Nullable. | | `window.previous_end` | string | optional | Default `null`. Nullable. | | `include_merged` | boolean | optional | Default `false`. | | `network` | object | required | ``buildClusterSummaryFromRows`` for one window (the episodes-page KPI bar). | | `network.total_reach` | integer \| number | optional | Default `0`. | | `network.youtube_reach` | integer \| number | optional | Default `0`. | | `network.youtube_long_form_reach` | integer \| number | optional | Default `0`. | | `network.youtube_shorts_reach` | integer \| number | optional | Default `0`. | | `network.audio_reach` | integer \| number | optional | Default `0`. | | `network.social_reach` | integer \| number | optional | Default `0`. | | `network.long_form_reach` | integer \| number | optional | Default `0`. | | `network.short_form_reach` | integer \| number | optional | Default `0`. | | `network.long_form_count` | integer | optional | Default `0`. | | `network.short_form_count` | integer | optional | Default `0`. | | `network.clips` | integer | optional | Default `0`. | | `network.posts` | integer | optional | Default `0`. | | `network.total_engagement` | integer \| number | optional | Default `0`. | | `network.engagement_rate` | integer \| number | optional | Percent (1.93 == 1.93 %). Default `0`. | | `network.cluster_count` | integer | optional | Reach-contributing clusters whose episode date falls in the window. Default `0`. | | `network.content_pieces` | integer | optional | Default `0`. | | `network.averages` | object | required | | | `network.averages.avg_reach_per_cluster` | integer \| number | optional | Default `0`. | | `network.averages.avg_youtube_reach_per_cluster` | integer \| number | optional | Default `0`. | | `network.averages.avg_youtube_long_form_reach_per_cluster` | integer \| number | optional | Default `0`. | | `network.averages.avg_youtube_shorts_reach_per_cluster` | integer \| number | optional | Default `0`. | | `network.averages.avg_audio_reach_per_cluster` | integer \| number | optional | Default `0`. | | `network.averages.avg_social_reach_per_cluster` | integer \| number | optional | Default `0`. | | `network.averages.avg_long_form_reach_per_cluster` | integer \| number | optional | Default `0`. | | `network.averages.avg_short_form_reach_per_cluster` | integer \| number | optional | Default `0`. | | `network.averages.avg_long_form_count_per_cluster` | integer \| number | optional | Default `0`. | | `network.averages.avg_short_form_count_per_cluster` | integer \| number | optional | Default `0`. | | `network.averages.avg_clips_per_cluster` | integer \| number | optional | Default `0`. | | `network.averages.avg_posts_per_cluster` | integer \| number | optional | Default `0`. | | `network.averages.avg_engagement_per_cluster` | integer \| number | optional | Default `0`. | | `network.growth` | object | required | Growth vs the previous window (percent; null when the previous window had no clusters). | | `network.growth.count_growth` | number | optional | Default `null`. Nullable. | | `network.growth.reach_growth` | number | optional | Default `null`. Nullable. | | `network.growth.youtube_growth` | number | optional | Default `null`. Nullable. | | `network.growth.youtube_long_form_growth` | number | optional | Default `null`. Nullable. | | `network.growth.youtube_shorts_growth` | number | optional | Default `null`. Nullable. | | `network.growth.audio_growth` | number | optional | Default `null`. Nullable. | | `network.growth.social_growth` | number | optional | Default `null`. Nullable. | | `network.growth.long_form_growth` | number | optional | Default `null`. Nullable. | | `network.growth.short_form_growth` | number | optional | Default `null`. Nullable. | | `network.growth.avg_reach_growth` | number | optional | Default `null`. Nullable. | | `network.growth.avg_youtube_growth` | number | optional | Default `null`. Nullable. | | `network.growth.avg_audio_growth` | number | optional | Default `null`. Nullable. | | `network.growth.avg_social_growth` | number | optional | Default `null`. Nullable. | | `network.growth.avg_long_form_growth` | number | optional | Default `null`. Nullable. | | `network.growth.avg_short_form_growth` | number | optional | Default `null`. Nullable. | | `network.previous` | object | required | | | `network.previous.total_reach` | integer \| number | optional | Default `0`. | | `network.previous.youtube_reach` | integer \| number | optional | Default `0`. | | `network.previous.youtube_long_form_reach` | integer \| number | optional | Default `0`. | | `network.previous.youtube_shorts_reach` | integer \| number | optional | Default `0`. | | `network.previous.audio_reach` | integer \| number | optional | Default `0`. | | `network.previous.social_reach` | integer \| number | optional | Default `0`. | | `network.previous.long_form_reach` | integer \| number | optional | Default `0`. | | `network.previous.short_form_reach` | integer \| number | optional | Default `0`. | | `network.previous.long_form_count` | integer | optional | Default `0`. | | `network.previous.short_form_count` | integer | optional | Default `0`. | | `network.previous.clips` | integer | optional | Default `0`. | | `network.previous.posts` | integer | optional | Default `0`. | | `network.previous.total_engagement` | integer \| number | optional | Default `0`. | | `network.previous.engagement_rate` | integer \| number | optional | Percent (1.93 == 1.93 %). Default `0`. | | `network.previous.cluster_count` | integer | optional | Default `0`. | | `network.reach_distribution` | array of objects | optional | | | `network.reach_distribution[].name` | string | required | | | `network.reach_distribution[].value` | integer \| number | required | | | `network.reach_distribution[].color` | string | required | | | `network.content_mix` | array of objects | optional | | | `network.content_mix[].name` | string | required | | | `network.content_mix[].value` | integer \| number | required | | | `network.content_mix[].color` | string | required | | | `network.has_reach_distribution_data` | boolean | optional | Default `false`. | | `network.has_content_mix_data` | boolean | optional | Default `false`. | | `network.counts` | object | required | | | `network.counts.cluster_doc_count` | integer | optional | Default `0`. | | `network.counts.source_cluster_count` | integer | optional | Default `0`. | | `network.counts.merged_cluster_count` | integer | optional | Default `0`. | | `network.counts.manual_cluster_count` | integer | optional | Default `0`. | | `network.counts.empty_cluster_count` | integer | optional | Default `0`. | | `network.counts.unlinked_cluster_count` | integer | optional | Default `0`. | | `network.counts.undated_cluster_count` | integer | optional | Default `0`. | | `network.counts.episode_count` | integer | optional | Default `0`. | | `shows` | array of objects | required | | | `shows[].total_reach` | integer \| number | optional | Default `0`. | | `shows[].youtube_reach` | integer \| number | optional | Default `0`. | | `shows[].youtube_long_form_reach` | integer \| number | optional | Default `0`. | | `shows[].youtube_shorts_reach` | integer \| number | optional | Default `0`. | | `shows[].audio_reach` | integer \| number | optional | Default `0`. | | `shows[].social_reach` | integer \| number | optional | Default `0`. | | `shows[].long_form_reach` | integer \| number | optional | Default `0`. | | `shows[].short_form_reach` | integer \| number | optional | Default `0`. | | `shows[].long_form_count` | integer | optional | Default `0`. | | `shows[].short_form_count` | integer | optional | Default `0`. | | `shows[].clips` | integer | optional | Default `0`. | | `shows[].posts` | integer | optional | Default `0`. | | `shows[].total_engagement` | integer \| number | optional | Default `0`. | | `shows[].engagement_rate` | integer \| number | optional | Percent (1.93 == 1.93 %). Default `0`. | | `shows[].cluster_count` | integer | optional | Reach-contributing clusters whose episode date falls in the window. Default `0`. | | `shows[].content_pieces` | integer | optional | Default `0`. | | `shows[].averages` | object | required | | | `shows[].averages.avg_reach_per_cluster` | integer \| number | optional | Default `0`. | | `shows[].averages.avg_youtube_reach_per_cluster` | integer \| number | optional | Default `0`. | | `shows[].averages.avg_youtube_long_form_reach_per_cluster` | integer \| number | optional | Default `0`. | | `shows[].averages.avg_youtube_shorts_reach_per_cluster` | integer \| number | optional | Default `0`. | | `shows[].averages.avg_audio_reach_per_cluster` | integer \| number | optional | Default `0`. | | `shows[].averages.avg_social_reach_per_cluster` | integer \| number | optional | Default `0`. | | `shows[].averages.avg_long_form_reach_per_cluster` | integer \| number | optional | Default `0`. | | `shows[].averages.avg_short_form_reach_per_cluster` | integer \| number | optional | Default `0`. | | `shows[].averages.avg_long_form_count_per_cluster` | integer \| number | optional | Default `0`. | | `shows[].averages.avg_short_form_count_per_cluster` | integer \| number | optional | Default `0`. | | `shows[].averages.avg_clips_per_cluster` | integer \| number | optional | Default `0`. | | `shows[].averages.avg_posts_per_cluster` | integer \| number | optional | Default `0`. | | `shows[].averages.avg_engagement_per_cluster` | integer \| number | optional | Default `0`. | | `shows[].growth` | object | required | Growth vs the previous window (percent; null when the previous window had no clusters). | | `shows[].growth.count_growth` | number | optional | Default `null`. Nullable. | | `shows[].growth.reach_growth` | number | optional | Default `null`. Nullable. | | `shows[].growth.youtube_growth` | number | optional | Default `null`. Nullable. | | `shows[].growth.youtube_long_form_growth` | number | optional | Default `null`. Nullable. | | `shows[].growth.youtube_shorts_growth` | number | optional | Default `null`. Nullable. | | `shows[].growth.audio_growth` | number | optional | Default `null`. Nullable. | | `shows[].growth.social_growth` | number | optional | Default `null`. Nullable. | | `shows[].growth.long_form_growth` | number | optional | Default `null`. Nullable. | | `shows[].growth.short_form_growth` | number | optional | Default `null`. Nullable. | | `shows[].growth.avg_reach_growth` | number | optional | Default `null`. Nullable. | | `shows[].growth.avg_youtube_growth` | number | optional | Default `null`. Nullable. | | `shows[].growth.avg_audio_growth` | number | optional | Default `null`. Nullable. | | `shows[].growth.avg_social_growth` | number | optional | Default `null`. Nullable. | | `shows[].growth.avg_long_form_growth` | number | optional | Default `null`. Nullable. | | `shows[].growth.avg_short_form_growth` | number | optional | Default `null`. Nullable. | | `shows[].previous` | object | required | | | `shows[].previous.total_reach` | integer \| number | optional | Default `0`. | | `shows[].previous.youtube_reach` | integer \| number | optional | Default `0`. | | `shows[].previous.youtube_long_form_reach` | integer \| number | optional | Default `0`. | | `shows[].previous.youtube_shorts_reach` | integer \| number | optional | Default `0`. | | `shows[].previous.audio_reach` | integer \| number | optional | Default `0`. | | `shows[].previous.social_reach` | integer \| number | optional | Default `0`. | | `shows[].previous.long_form_reach` | integer \| number | optional | Default `0`. | | `shows[].previous.short_form_reach` | integer \| number | optional | Default `0`. | | `shows[].previous.long_form_count` | integer | optional | Default `0`. | | `shows[].previous.short_form_count` | integer | optional | Default `0`. | | `shows[].previous.clips` | integer | optional | Default `0`. | | `shows[].previous.posts` | integer | optional | Default `0`. | | `shows[].previous.total_engagement` | integer \| number | optional | Default `0`. | | `shows[].previous.engagement_rate` | integer \| number | optional | Percent (1.93 == 1.93 %). Default `0`. | | `shows[].previous.cluster_count` | integer | optional | Default `0`. | | `shows[].reach_distribution` | array of objects | optional | | | `shows[].reach_distribution[].name` | string | required | | | `shows[].reach_distribution[].value` | integer \| number | required | | | `shows[].reach_distribution[].color` | string | required | | | `shows[].content_mix` | array of objects | optional | | | `shows[].content_mix[].name` | string | required | | | `shows[].content_mix[].value` | integer \| number | required | | | `shows[].content_mix[].color` | string | required | | | `shows[].has_reach_distribution_data` | boolean | optional | Default `false`. | | `shows[].has_content_mix_data` | boolean | optional | Default `false`. | | `shows[].counts` | object | required | | | `shows[].counts.cluster_doc_count` | integer | optional | Default `0`. | | `shows[].counts.source_cluster_count` | integer | optional | Default `0`. | | `shows[].counts.merged_cluster_count` | integer | optional | Default `0`. | | `shows[].counts.manual_cluster_count` | integer | optional | Default `0`. | | `shows[].counts.empty_cluster_count` | integer | optional | Default `0`. | | `shows[].counts.unlinked_cluster_count` | integer | optional | Default `0`. | | `shows[].counts.undated_cluster_count` | integer | optional | Default `0`. | | `shows[].counts.episode_count` | integer | optional | Default `0`. | | `shows[].show` | object | required | The minimal show reference embedded in cross-show results. | | `shows[].show.id` | string | required | | | `shows[].show.name` | string | required | | | `shows[].show.handle` | string | optional | Default `""`. | | `shows[].show.image` | string | optional | Default `null`. Nullable. | | `shows[].show.public_url` | string | optional | Default `null`. Nullable. | | `shows[].categories` | array of strings | optional | | | `shows[].cluster_freshness` | object | required | ``getShowRefreshFreshness``: newest refresh across a show's clusters. | | `shows[].cluster_freshness.kind` | string | optional | Default `"unknown"`. | | `shows[].cluster_freshness.at` | string | optional | Default `null`. Nullable. | | `shows[].cluster_freshness.last_refreshed_at` | string | optional | Default `null`. Nullable. | | `shows[].cluster_freshness.failed_node_count` | integer | optional | Default `0`. | | `shows[].cluster_freshness.clusters_with_errors` | integer | optional | Default `0`. | | `show_ids` | array of strings | required | | | `categories` | array of strings | optional | Default `null`. Nullable. | | `coverage` | object | required | How much of the window actually has data (design §2). | | `coverage.start` | string | optional | Earliest date with data across the scoped shows. Default `null`. Nullable. | | `coverage.embedded_boundary` | string | optional | today-29: dates before this come from analyticsHistory. Default `null`. Nullable. | | `coverage.history_used` | boolean | optional | Default `false`. | | `coverage.previous_window_coverage_days` | integer | optional | Default `0`. | | `coverage.current_window_coverage_days` | integer | optional | Default `0`. | | `coverage.growth_basis` | string | optional | 'like_for_like' on windows longer than 30 days (e.g. 90d): growth compares only platforms whose data covers at least half of the previous window, both windows summed over those platforms alone. One of `total`, `like_for_like`. Default `"total"`. | | `coverage.growth_platforms` | array of strings | optional | Like-for-like windows only: the platforms the headline growth compares (empty → growth is null). Default `null`. Nullable. | | `coverage.warnings` | array of strings | optional | | | `freshness` | map of object | optional | | | `freshness.last_updated` | string | optional | Default `null`. Nullable. | | `freshness.age_hours` | number | optional | Default `null`. Nullable. | | `freshness.stale` | boolean | optional | True when older than 36 hours or unknown. Default `true`. | | `stale_platforms` | array of strings | optional | | | `missing_show_ids` | array of strings | optional | | | `summary_stale_count` | integer | optional | Cluster rows whose stored summary could not be verified and were not recomputed. Default `0`. | | `computed_at` | string | required | | | `cache_age_seconds` | number | required | | | `note` | string | optional | Default `null`. Nullable. | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/clusters/report" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "cluster_report", "window": { "time_range": "30d", "kind": "rolling", "start": "2026-09-15", "end": "2026-10-14", "days": 30, "previous_start": "2026-08-16", "previous_end": "2026-09-14" }, "include_merged": false, "network": { "total_reach": 87263, "youtube_reach": 68228, "youtube_long_form_reach": 64066, "youtube_shorts_reach": 4162, "audio_reach": 4477, "social_reach": 14558, "long_form_reach": 68543, "short_form_reach": 18720, "long_form_count": 11, "short_form_count": 16, "clips": 27, "posts": 0, "total_engagement": 1432, "engagement_rate": 1.6410162382682236, "cluster_count": 12, "content_pieces": 27, "averages": { "avg_reach_per_cluster": 7271.916666666667, "avg_youtube_reach_per_cluster": 5685.666666666667, "avg_youtube_long_form_reach_per_cluster": 5338.833333333333, "avg_youtube_shorts_reach_per_cluster": 346.8333333333333, "avg_audio_reach_per_cluster": 373.0833333333333, "avg_social_reach_per_cluster": 1213.1666666666667, "avg_long_form_reach_per_cluster": 5711.916666666667, "avg_short_form_reach_per_cluster": 1560, "avg_long_form_count_per_cluster": 0.9166666666666666, "avg_short_form_count_per_cluster": 1.3333333333333333, "avg_clips_per_cluster": 2.25, "avg_posts_per_cluster": 0, "avg_engagement_per_cluster": 119.33333333333333 }, "growth": { "count_growth": -33.33333333333333, "reach_growth": -96.36663346812607, "youtube_growth": -65.88787616681083, "youtube_long_form_growth": -65.49748229528502, "youtube_shorts_growth": -70.94792684629346, "audio_growth": -72.67289263260696, "social_growth": -99.33382693045131, "long_form_growth": -66.07924065166182, "short_form_growth": -99.14895319424416, "avg_reach_growth": -94.54995020218911, "avg_youtube_growth": -48.83181425021624, "avg_audio_growth": -59.009338948910454, "avg_social_growth": -99.00074039567697, "avg_long_form_growth": -49.11886097749272, "avg_short_form_growth": -98.72342979136623 }, "previous": { "total_reach": 2401712, "youtube_reach": 200011, "youtube_long_form_reach": 185685, "youtube_shorts_reach": 14326, "audio_reach": 16383, "social_reach": 2185318, "long_form_reach": 202068, "short_form_reach": 2199644, "long_form_count": 20, "short_form_count": 42, "clips": 52, "posts": 10, "total_engagement": 16651, "engagement_rate": 0.6932971147248297, "cluster_count": 18 }, "reach_distribution": [ { "name": "Mailbag: your questions answered", "value": 68228, "color": "#FF0000" }, { "name": "What the numbers really say", "value": 4477, "color": "#8B5CF6" } ], "content_mix": [ { "name": "The rookie who changed everything", "value": 68543, "color": "#2563EB" }, { "name": "Inside the locker room with a two-time champion", "value": 18720, "color": "#F59E0B" } ], "has_reach_distribution_data": true, "has_content_mix_data": true, "counts": { "cluster_doc_count": 159, "source_cluster_count": 144, "merged_cluster_count": 15, "manual_cluster_count": 11, "empty_cluster_count": 22, "unlinked_cluster_count": 0, "undated_cluster_count": 0, "episode_count": 134 } }, "shows": [ { "total_reach": 86032, "youtube_reach": 68228, "youtube_long_form_reach": 64066, "youtube_shorts_reach": 4162, "audio_reach": 3246, "social_reach": 14558, "long_form_reach": 67312, "short_form_reach": 18720, "long_form_count": 10, "short_form_count": 16, "clips": 26, "posts": 0, "total_engagement": 1432, "engagement_rate": 1.6644969313743725, "cluster_count": 11, "content_pieces": 26, "averages": { "avg_reach_per_cluster": 7821.090909090909, "avg_youtube_reach_per_cluster": 6202.545454545455, "avg_youtube_long_form_reach_per_cluster": 5824.181818181818, "avg_youtube_shorts_reach_per_cluster": 378.3636363636364, "avg_audio_reach_per_cluster": 295.09090909090907, "avg_social_reach_per_cluster": 1323.4545454545455, "avg_long_form_reach_per_cluster": 6119.272727272727, "avg_short_form_reach_per_cluster": 1701.8181818181818, "avg_long_form_count_per_cluster": 0.9090909090909091, "avg_short_form_count_per_cluster": 1.4545454545454546, "avg_clips_per_cluster": 2.3636363636363638, "avg_posts_per_cluster": 0, "avg_engagement_per_cluster": 130.1818181818182 }, "growth": { "count_growth": -31.25, "reach_growth": -95.50045135652773, "youtube_growth": -48.27371836666616, "youtube_long_form_growth": -50.24888757736482, "youtube_shorts_growth": 33.01374240971556, "audio_growth": 682.1686746987953, "social_growth": -99.18199558688923, "long_form_growth": -47.89608942006998, "short_form_growth": -98.94998165833346, "avg_reach_growth": -93.45520197313122, "avg_youtube_growth": -24.76177216969623, "avg_audio_growth": 1037.6998904709747, "avg_social_growth": -98.81017539911161, "avg_long_form_growth": -24.212493701919968, "avg_short_form_growth": -98.47270059393958 }, "previous": { "total_reach": 1912014, "youtube_reach": 131902, "youtube_long_form_reach": 128773, "youtube_shorts_reach": 3129, "audio_reach": 415, "social_reach": 1779697, "long_form_reach": 129188, "short_form_reach": 1782826, "long_form_count": 17, "short_form_count": 34, "clips": 47, "posts": 4, "total_engagement": 13768, "engagement_rate": 0.7200784094677131, "cluster_count": 16 }, "reach_distribution": [ { "name": "Mailbag: your questions answered", "value": 68228, "color": "#FF0000" }, { "name": "What the numbers really say", "value": 3246, "color": "#8B5CF6" } ], "content_mix": [ { "name": "The rookie who changed everything", "value": 67312, "color": "#2563EB" }, { "name": "Inside the locker room with a two-time champion", "value": 18720, "color": "#F59E0B" } ], "has_reach_distribution_data": true, "has_content_mix_data": true, "counts": { "cluster_doc_count": 136, "source_cluster_count": 121, "merged_cluster_count": 15, "manual_cluster_count": 11, "empty_cluster_count": 13, "unlinked_cluster_count": 0, "undated_cluster_count": 0, "episode_count": 112 }, "show": { "id": "paAtKPJGo56md5VyoEQ9Las7qahL", "name": "Our bold predictions for the second half", "handle": "hard-court", "image": "https://images.example.com/listener/6cbd825d3f77ec48.jpg", "public_url": "https://listener.com/hard-court" }, "categories": [ "Sports", "Comedy" ], "cluster_freshness": { "kind": "refreshed", "at": "2026-10-08T05:55:29.001Z", "last_refreshed_at": "2026-10-08T05:55:29.001Z", "failed_node_count": 0, "clusters_with_errors": 0 } }, { "total_reach": 0, "youtube_reach": 0, "youtube_long_form_reach": 0, "youtube_shorts_reach": 0, "audio_reach": 0, "social_reach": 0, "long_form_reach": 0, "short_form_reach": 0, "long_form_count": 0, "short_form_count": 0, "clips": 0, "posts": 0, "total_engagement": 0, "engagement_rate": 0, "cluster_count": 0, "content_pieces": 0, "averages": { "avg_reach_per_cluster": 0, "avg_youtube_reach_per_cluster": 0, "avg_youtube_long_form_reach_per_cluster": 0, "avg_youtube_shorts_reach_per_cluster": 0, "avg_audio_reach_per_cluster": 0, "avg_social_reach_per_cluster": 0, "avg_long_form_reach_per_cluster": 0, "avg_short_form_reach_per_cluster": 0, "avg_long_form_count_per_cluster": 0, "avg_short_form_count_per_cluster": 0, "avg_clips_per_cluster": 0, "avg_posts_per_cluster": 0, "avg_engagement_per_cluster": 0 }, "growth": { "count_growth": -100, "reach_growth": -100, "youtube_growth": -100, "youtube_long_form_growth": -100, "youtube_shorts_growth": -100, "audio_growth": -100, "social_growth": -100, "long_form_growth": -100, "short_form_growth": -100, "avg_reach_growth": -100, "avg_youtube_growth": -100, "avg_audio_growth": -100, "avg_social_growth": -100, "avg_long_form_growth": -100, "avg_short_form_growth": -100 }, "previous": { "total_reach": 489698, "youtube_reach": 68109, "youtube_long_form_reach": 56912, "youtube_shorts_reach": 11197, "audio_reach": 15968, "social_reach": 405621, "long_form_reach": 72880, "short_form_reach": 416818, "long_form_count": 3, "short_form_count": 8, "clips": 5, "posts": 6, "total_engagement": 2883, "engagement_rate": 0.588730196978546, "cluster_count": 2 }, "reach_distribution": [ { "name": "Mailbag: your questions answered", "value": 0, "color": "#FF0000" }, { "name": "What the numbers really say", "value": 0, "color": "#8B5CF6" } ], "content_mix": [ { "name": "The rookie who changed everything", "value": 0, "color": "#2563EB" }, { "name": "Inside the locker room with a two-time champion", "value": 0, "color": "#F59E0B" } ], "has_reach_distribution_data": false, "has_content_mix_data": false, "counts": { "cluster_doc_count": 16, "source_cluster_count": 16, "merged_cluster_count": 0, "manual_cluster_count": 0, "empty_cluster_count": 7, "unlinked_cluster_count": 0, "undated_cluster_count": 0, "episode_count": 16 }, "show": { "id": "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H", "name": "Mailbag: your questions answered", "handle": "the-bullpen", "image": "https://images.example.com/listener/8f6c3c4428259526.jpg", "public_url": "https://listener.com/the-bullpen" }, "categories": [ "Fantasy Sports", "Sports News" ], "cluster_freshness": { "kind": "saved", "at": "2026-03-17T17:16:36.453Z", "last_refreshed_at": null, "failed_node_count": 0, "clusters_with_errors": 0 } } ], "show_ids": [ "paAtKPJGo56md5VyoEQ9Las7qahL", "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H" ], "categories": null, "coverage": { "start": "2025-01-14", "embedded_boundary": null, "history_used": false, "previous_window_coverage_days": 14, "current_window_coverage_days": 8, "growth_basis": "total", "growth_platforms": null, "warnings": [ "The Long Game: no clusters in the previous window, growth is null." ] }, "freshness": { "applePodcasts": { "last_updated": "2026-04-13T16:00:26+00:00", "age_hours": 4417.56, "stale": true }, "facebook": { "last_updated": "2026-10-08T07:02:13.420000+00:00", "age_hours": 154.53, "stale": true }, "instagram": { "last_updated": "2026-10-13T09:35:13.282000+00:00", "age_hours": 31.98, "stale": false }, "megaphone": { "last_updated": "2026-10-14T09:22:51.743000+00:00", "age_hours": 8.19, "stale": false }, "omnystudio": { "last_updated": "2026-10-13T09:33:48.789000+00:00", "age_hours": 32.01, "stale": false }, "spotify": { "last_updated": "2026-08-06T08:49:48.390000+00:00", "age_hours": 1664.74, "stale": true }, "threads": { "last_updated": "2026-10-13T09:36:40.899000+00:00", "age_hours": 31.96, "stale": false }, "tiktok": { "last_updated": "2026-10-13T09:34:15.574000+00:00", "age_hours": 32, "stale": false }, "twitter": { "last_updated": "2026-10-10T07:02:33.014000+00:00", "age_hours": 106.53, "stale": true }, "youtube": { "last_updated": "2026-10-02T10:37:33.857000+00:00", "age_hours": 294.95, "stale": true } }, "stale_platforms": [ "applePodcasts", "facebook" ], "missing_show_ids": [ "0Iush8rT5eCcD4ePHroKFDlQvKhT", "nCJuUMzGC6EE3dfruqjRHtYdlCTr" ], "summary_stale_count": 0, "computed_at": "2026-10-14T17:34:16.152082+00:00", "cache_age_seconds": 10.6, "note": null } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Create a cluster `POST /v1/shows/{show_id}/clusters` Scope: `clusters:write` SDK: `client.shows.clusters.create()` (TypeScript) · `client.shows.clusters.create()` (Python) Create a cluster for a show: an episode cluster (one id in `episode_ids`), a merged cluster (two or more ids) or a manual cluster (`manual: {name, …}`, no episode ids). Episode ids must be episodes of the show. `content_nodes` optionally attaches clips and posts (`platform` label such as "YouTube Shorts", `url`, optional `type`, `title`, `thumbnail`, `views`, `likes`, `comments`, `date`). An existing cluster id is never overwritten; a merged cluster first creates any missing per-episode clusters. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `clusters:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `episode_ids` | array of strings | optional | Episode ids on the show. Default `null`. Nullable. | | `name` | string | optional | Name. Default `null`. Nullable. | | `internal_title` | string | optional | Default `null`. Nullable. | | `content_nodes` | array of objects | optional | Default `null`. Nullable. | | `manual` | object | optional | Default `null`. Nullable. | | `manual.name` | string | required | | | `manual.description` | string | optional | Default `""`. | | `manual.image` | string | optional | Default `""`. | | `manual.date` | string | optional | Default `""`. | | `emv_rates` | object | optional | EMV rates: {long_form_cpm, short_form_cpm, like_rate, comment_rate}. Default `null`. Nullable. | | `emv_rates.long_form_cpm` | integer \| number \| string \| any | optional | Default `null`. | | `emv_rates.short_form_cpm` | integer \| number \| string \| any | optional | Default `null`. | | `emv_rates.like_rate` | integer \| number \| string \| any | optional | Default `null`. | | `emv_rates.comment_rate` | integer \| number \| string \| any | optional | Default `null`. | | `emv_rates.show_on_share_page` | boolean | optional | Default `null`. Nullable. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | ### Example request ```bash curl -X POST "https://api.listener.com/v1/shows/SHOW_ID/clusters" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "cluster", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/paAtKPJGo56md5VyoEQ9Las7qahL/clusters/manual-cluster-7507348341473", "id": "manual-cluster-7507348341473", "method": "transaction", "written": { "id": "manual-cluster-7507348341473", "userId": "paAtKPJGo56md5VyoEQ9Las7qahL", "name": "The comeback nobody saw coming", "description": "", "date": "", "image": "https://images.example.com/listener/6cbd825d3f77ec48.jpg", "cover": "https://images.example.com/listener/6cbd825d3f77ec48.jpg", "selectedEpisodes": [ "manual-7507348341473" ], "episodeIds": [ "manual-7507348341473" ], "nodes": [ { "id": "episode-manual-7507348341473", "type": "episode", "position": { "x": 250, "y": 50 }, "data": { "id": "episode-manual-7507348341473", "title": "The comeback nobody saw coming", "description": "", "date": "", "image": "https://images.example.com/listener/6cbd825d3f77ec48.jpg", "cover": "https://images.example.com/listener/6cbd825d3f77ec48.jpg", "duration": "0:00", "durationMinutes": 0, "isManual": true, "addedAt": "2026-10-14T17:34:16.341Z" } } ], "edges": [], "createdAt": "2026-10-14T17:34:16.341Z", "updatedAt": "2026-10-14T17:34:16.341Z", "isManual": true }, "warnings": [ "No `summary` was written; the All clusters list recomputes it on next load." ], "urls": { "app": "https://network.listener.com/app/cluster?uid=paAtKPJGo56md5VyoEQ9Las7qahL&episode=manual-7507348341473", "public": "https://listener.com/cluster/paAtKPJGo56md5VyoEQ9Las7qahL_manual-cluster-7507348341473" }, "error": null, "data": { "show_id": "paAtKPJGo56md5VyoEQ9Las7qahL", "episode_row": { "id": "manual-7507348341473", "title": "The comeback nobody saw coming", "description": "", "date": "", "image": "https://images.example.com/listener/6cbd825d3f77ec48.jpg", "cover": "https://images.example.com/listener/6cbd825d3f77ec48.jpg", "duration": "0:00", "durationMinutes": 0, "isManual": true, "addedAt": "2026-10-14T17:34:16.341Z" }, "cluster_id": "manual-cluster-7507348341473", "node_count": 1, "edge_count": 0, "episode_ids": [ "manual-7507348341473" ], "kind": "manual" }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Merge episode clusters `POST /v1/shows/{show_id}/clusters/merge` Scope: `clusters:write` SDK: `client.shows.clusters.merge()` (TypeScript) · `client.shows.clusters.merge()` (Python) Build, or rebuild, the merged cluster for several episodes from their episode clusters. Source clusters are never modified; missing ones are created first. Rebuilding keeps the merged cluster's EMV rates, customization, internal title, brand collaboration and creation date. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `clusters:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `episode_ids` | array of strings | required | Episode ids on the show. | | `name` | string | optional | Name. Default `null`. Nullable. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | ### Example request ```bash curl -X POST "https://api.listener.com/v1/shows/SHOW_ID/clusters/merge" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"episode_ids": [], "dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "cluster", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/paAtKPJGo56md5VyoEQ9Las7qahL/clusters/cluster-merged-7ys2rRKJbGMF7uozA47MEs-0y0HEJ0OVF7EYdXAvD5ghY", "id": "cluster-merged-7ys2rRKJbGMF7uozA47MEs-0y0HEJ0OVF7EYdXAvD5ghY", "method": "set-merge", "written": { "id": "cluster-merged-7ys2rRKJbGMF7uozA47MEs-0y0HEJ0OVF7EYdXAvD5ghY", "userId": "paAtKPJGo56md5VyoEQ9Las7qahL", "name": "Playoff picture, explained", "description": "", "selectedEpisodes": [ "7ys2rRKJbGMF7uozA47MEs", "0y0HEJ0OVF7EYdXAvD5ghY" ], "episodeIds": [ "7ys2rRKJbGMF7uozA47MEs", "0y0HEJ0OVF7EYdXAvD5ghY" ], "nodes": [ { "id": "episode-7ys2rRKJbGMF7uozA47MEs", "type": "episode", "position": { "x": 250, "y": 50 }, "data": { "id": "episode-7ys2rRKJbGMF7uozA47MEs", "title": "Draft night grades", "date": "" } }, { "id": "episode-0y0HEJ0OVF7EYdXAvD5ghY", "type": "episode", "position": { "x": 600, "y": 50 }, "data": { "id": "episode-0y0HEJ0OVF7EYdXAvD5ghY", "title": "Power rankings, week 6", "date": "" } } ], "edges": [ { "source": "episode-7ys2rRKJbGMF7uozA47MEs", "id": "edge-episode-7ys2rRKJbGMF7uozA47MEs-clip-8194150637810-8235", "target": "clip-8194150637810-8235", "type": "default" }, { "source": "episode-7ys2rRKJbGMF7uozA47MEs", "id": "edge-episode-7ys2rRKJbGMF7uozA47MEs-clip-3445805415745-785", "target": "clip-3445805415745-785", "type": "default" } ], "createdAt": "2026-10-14T17:34:17.480Z", "updatedAt": "2026-10-14T17:34:17.480Z", "mergedTemplateId": null, "mergedTemplateMetric": null, "mergedTemplateRange": { "preset": "all", "customFrom": null, "customTo": null } }, "warnings": [ "No `summary` was written; the All clusters list recomputes it on next load." ], "urls": { "app": "https://network.listener.com/app/cluster?uid=paAtKPJGo56md5VyoEQ9Las7qahL&episodes=7ys2rRKJbGMF7uozA47MEs%2C0y0HEJ0OVF7EYdXAvD5ghY", "public": "https://listener.com/cluster/paAtKPJGo56md5VyoEQ9Las7qahL_cluster-merged-7ys2rRKJbGMF7uozA47MEs-0y0HEJ0OVF7EYdXAvD5ghY" }, "error": null, "data": { "show_id": "paAtKPJGo56md5VyoEQ9Las7qahL", "cluster_id": "cluster-merged-7ys2rRKJbGMF7uozA47MEs-0y0HEJ0OVF7EYdXAvD5ghY", "episode_ids": [ "7ys2rRKJbGMF7uozA47MEs", "0y0HEJ0OVF7EYdXAvD5ghY" ], "node_count": 7, "edge_count": 5, "existed": false, "carried_forward": [] }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Retrieve a cluster `GET /v1/shows/{show_id}/clusters/{cluster_id}` Scope: `clusters:read` SDK: `client.shows.clusters.retrieve()` (TypeScript) · `client.shows.clusters.retrieve()` (Python) One cluster in full: metadata (type, episodes, customization, brand collaboration), 14 reach and engagement metrics computed from its nodes, every content node (platform, URL, reach, category, form, engagement, publish date, custom tags, sponsor, `parent_id` / `child_ids` canvas links), the canvas edges, subcluster rollups, custom-tag rollups, linked campaigns, the EMV estimate with the rate precedence used, the first-30-days estimate, refresh freshness and errors, and share URLs. Nodes are sorted by reach and capped at `node_limit` (`nodes_truncated` says when more exist); `edges` and `subclusters` always cover the whole cluster. Rebuild the canvas tree by grouping nodes on `parent_id`. - Required scope: `clusters:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | | `cluster_id` | string | required | Cluster id (see GET /v1/clusters). | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `include_nodes` | boolean | optional | | | `node_limit` | integer | optional | | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `cluster` | object | required | | | `cluster.id` | string | required | | | `cluster.show` | object | required | The minimal show reference embedded in cross-show results. | | `cluster.show.id` | string | required | | | `cluster.show.name` | string | required | | | `cluster.show.handle` | string | optional | Default `""`. | | `cluster.show.image` | string | optional | Default `null`. Nullable. | | `cluster.show.public_url` | string | optional | Default `null`. Nullable. | | `cluster.title` | string | required | | | `cluster.name` | string | optional | Default `null`. Nullable. | | `cluster.internal_title` | string | optional | Default `null`. Nullable. | | `cluster.description` | string | optional | Default `null`. Nullable. | | `cluster.cluster_type` | string | required | One of `episode`, `merged`, `manual`. | | `cluster.is_merged` | boolean | optional | Default `false`. | | `cluster.is_manual` | boolean | optional | Default `false`. | | `cluster.is_empty` | boolean | optional | Default `false`. | | `cluster.contributing` | boolean | optional | Default `true`. | | `cluster.episode_ids` | array of strings | optional | | | `cluster.episodes` | array of objects | optional | | | `cluster.episodes[].id` | string | required | | | `cluster.episodes[].title` | string | optional | Default `null`. Nullable. | | `cluster.episodes[].published_at` | string | optional | Default `null`. Nullable. | | `cluster.published_at` | string | optional | Default `null`. Nullable. | | `cluster.episode_date` | string | optional | Default `null`. Nullable. | | `cluster.tags` | map of array of strings | optional | | | `cluster.customization` | object | optional | Public-page overrides: background_video_url, cover_image_url, featured_url and, when set, co_brand {name, logo_url, href}. Default `null`. Nullable. | | `cluster.brand_collab` | any | optional | Default `null`. Nullable. | | `cluster.emv_rates` | object | optional | The cluster's own stored emvRates, when set. Default `null`. Nullable. | | `cluster.auto_generated` | boolean | optional | Default `false`. | | `cluster.created_at` | string | optional | Default `null`. Nullable. | | `cluster.updated_at` | string | optional | Default `null`. Nullable. | | `cluster.public_url` | string | optional | Default `null`. Nullable. | | `cluster.app_url` | string | optional | Default `null`. Nullable. | | `metrics` | object | required | The 14 reach / engagement fields ``calculateClusterMetricsFromNodes`` returns. | | `metrics.total_reach` | integer \| number | optional | Default `0`. | | `metrics.youtube_reach` | integer \| number | optional | Default `0`. | | `metrics.youtube_long_form_reach` | integer \| number | optional | Default `0`. | | `metrics.youtube_shorts_reach` | integer \| number | optional | Default `0`. | | `metrics.audio_reach` | integer \| number | optional | Default `0`. | | `metrics.social_reach` | integer \| number | optional | Default `0`. | | `metrics.long_form_reach` | integer \| number | optional | Default `0`. | | `metrics.short_form_reach` | integer \| number | optional | Default `0`. | | `metrics.long_form_count` | integer | optional | Default `0`. | | `metrics.short_form_count` | integer | optional | Default `0`. | | `metrics.clips` | integer | optional | Default `0`. | | `metrics.posts` | integer | optional | Default `0`. | | `metrics.total_engagement` | integer \| number | optional | Default `0`. | | `metrics.engagement_rate` | integer \| number | optional | Percent (1.93 == 1.93 %). Default `0`. | | `metrics_source` | string | required | | | `first_30_days` | object | required | | | `first_30_days.total_reach` | integer \| number | optional | Default `0`. | | `first_30_days.youtube_reach` | integer \| number | optional | Default `0`. | | `first_30_days.youtube_long_form_reach` | integer \| number | optional | Default `0`. | | `first_30_days.youtube_shorts_reach` | integer \| number | optional | Default `0`. | | `first_30_days.audio_reach` | integer \| number | optional | Default `0`. | | `first_30_days.social_reach` | integer \| number | optional | Default `0`. | | `first_30_days.long_form_reach` | integer \| number | optional | Default `0`. | | `first_30_days.short_form_reach` | integer \| number | optional | Default `0`. | | `first_30_days.long_form_count` | integer | optional | Default `0`. | | `first_30_days.short_form_count` | integer | optional | Default `0`. | | `first_30_days.clips` | integer | optional | Default `0`. | | `first_30_days.posts` | integer | optional | Default `0`. | | `first_30_days.total_engagement` | integer \| number | optional | Default `0`. | | `first_30_days.engagement_rate` | integer \| number | optional | Percent (1.93 == 1.93 %). Default `0`. | | `first_30_days.availability` | object | required | | | `first_30_days.availability.available` | boolean | required | | | `first_30_days.availability.reason` | string | optional | Default `null`. Nullable. | | `first_30_days.availability.dated_count` | integer | optional | Default `0`. | | `first_30_days.availability.total_count` | integer | optional | Default `0`. | | `first_30_days.availability.undated_with_metrics_count` | integer | optional | Default `0`. | | `first_30_days.as_of` | string | optional | Default `null`. Nullable. | | `emv` | object | required | | | `emv.total` | number | required | | | `emv.long_form_value` | number | required | | | `emv.short_form_value` | number | required | | | `emv.like_value` | number | required | | | `emv.comment_value` | number | required | | | `emv.engagement_value` | number | required | | | `emv.nodes` | integer | optional | Default `null`. Nullable. | | `emv.includes_engagement` | boolean | required | | | `emv.method` | string | required | 'nodes' (per-node sum, the public cluster page) \| 'metrics' (CPM on summary reach only). | | `emv.rates` | object | required | | | `emv.rates.long_form_cpm` | integer \| number | required | | | `emv.rates.short_form_cpm` | integer \| number | required | | | `emv.rates.like_rate` | integer \| number | required | | | `emv.rates.comment_rate` | integer \| number | required | | | `emv.rates.show_on_share_page` | boolean | optional | Default `true`. | | `emv.inherited_rates` | object | required | | | `emv.inherited_rates.long_form_cpm` | integer \| number | required | | | `emv.inherited_rates.short_form_cpm` | integer \| number | required | | | `emv.inherited_rates.like_rate` | integer \| number | required | | | `emv.inherited_rates.comment_rate` | integer \| number | required | | | `emv.inherited_rates.show_on_share_page` | boolean | optional | Default `true`. | | `emv.rates_source` | string | required | 'cluster' \| 'campaign' \| 'network' \| 'default'. | | `emv.rates_campaign_id` | string | optional | Linked campaign whose emvSettings supplied the rates, when rates_source == 'campaign'. Default `null`. Nullable. | | `node_count` | integer | required | Content nodes (episode / clip / post) on the document. | | `nodes` | array of objects | optional | | | `nodes[].id` | string | required | | | `nodes[].type` | string | required | | | `nodes[].platform` | string | required | | | `nodes[].platform_key` | string | optional | Default `null`. Nullable. | | `nodes[].platform_raw` | string | optional | Default `null`. Nullable. | | `nodes[].url` | string | optional | Default `null`. Nullable. | | `nodes[].title` | string | optional | Default `null`. Nullable. | | `nodes[].thumbnail` | string | optional | Default `null`. Nullable. | | `nodes[].published_at` | string | optional | Default `null`. Nullable. | | `nodes[].reach` | integer \| number | optional | Default `0`. | | `nodes[].reach_category` | string | optional | 'youtube' \| 'audio' \| 'social' \| null. Default `null`. Nullable. | | `nodes[].reach_contributions` | map of integer \| number | optional | | | `nodes[].video_form` | string | optional | 'long' \| 'short' \| null (episode anchors). Default `null`. Nullable. | | `nodes[].is_long_form` | boolean | optional | Default `false`. | | `nodes[].youtube_bucket` | string | optional | Default `null`. Nullable. | | `nodes[].engagement` | integer \| number | optional | Default `0`. | | `nodes[].engagement_breakdown` | map of integer \| number | optional | | | `nodes[].custom_tags` | array of strings | optional | | | `nodes[].is_sponsored` | boolean | optional | Default `false`. | | `nodes[].sponsor_brand` | string | optional | Default `null`. Nullable. | | `nodes[].is_pending` | boolean | optional | Default `false`. | | `nodes[].no_data_returned` | boolean | optional | Default `false`. | | `nodes[].is_shorts` | boolean | optional | Default `false`. | | `nodes[].is_selection_episode` | boolean | optional | Default `false`. | | `nodes[].episode_id` | string | optional | Default `null`. Nullable. | | `nodes[].source_show_id` | string | optional | Default `null`. Nullable. | | `nodes[].ai_relevance_score` | any | optional | Default `null`. Nullable. | | `nodes[].last_updated` | string | optional | Default `null`. Nullable. | | `nodes[].podcast_downloads` | integer | optional | Substack cards: the downloads counted in Audio. Default `null`. Nullable. | | `nodes[].podcast_downloads_source` | string | optional | Substack cards: 'manual' (set with `PUT /v1/shows/{show_id}/substack/downloads`), 'export' (`POST /v1/shows/{show_id}/substack/imports`) or 'api' (Substack-tracked only). Default `null`. Nullable. | | `nodes[].podcast_downloads_api` | integer | optional | Substack cards: Substack's own API count, kept even when another figure is used. Default `null`. Nullable. | | `nodes[].source_content_id` | string | optional | Substack cards: the Substack post id. Default `null`. Nullable. | | `nodes[].parent_id` | string | optional | The node this one hangs under on the canvas (the edge's source; an episode is always the parent of what it touches). Null for an episode anchor or a loose card. When a node has several parents this is the first; see parent_ids. Default `null`. Nullable. | | `nodes[].parent_ids` | array of strings | optional | Every node this one hangs under (usually one). | | `nodes[].parent_url` | string | optional | The URL of parent_id's content, so integrations can join on links like the CSV export. Default `null`. Nullable. | | `nodes[].child_ids` | array of strings | optional | Nodes that hang directly under this one. | | `nodes[].connected_to` | array of strings | optional | Every node joined to this one by a canvas edge, either direction: the 'Connected To' column of the clusters-all-nodes CSV export, as node ids. | | `nodes[].subcluster_id` | string | optional | Anchor node id of the innermost subcluster this node belongs to (an anchor belongs to its own). Null when it is in none. Default `null`. Nullable. | | `nodes[].subcluster_name` | string | optional | That subcluster's label. Default `null`. Nullable. | | `nodes[].subcluster_ids` | array of strings | optional | Every subcluster containing this node, innermost first (subclusters can nest). | | `nodes[].is_subcluster_anchor` | boolean | optional | True when this node is a labelled subcluster anchor. Default `false`. | | `nodes_truncated` | boolean | optional | Default `false`. | | `nodes_returned` | integer | optional | Default `0`. | | `tag_rollups` | object | required | | | `tag_rollups.rows` | array of objects | optional | | | `tag_rollups.rows[].tag` | string | required | | | `tag_rollups.rows[].key` | string | required | | | `tag_rollups.rows[].is_untagged` | boolean | optional | Default `false`. | | `tag_rollups.rows[].pieces` | integer | optional | Default `0`. | | `tag_rollups.rows[].reach` | integer \| number | optional | Default `0`. | | `tag_rollups.rows[].engagement` | integer \| number | optional | Default `0`. | | `tag_rollups.rows[].engagement_rate` | integer \| number | optional | Default `0`. | | `tag_rollups.rows[].share_of_reach` | number | optional | Default `0`. | | `tag_rollups.rows[].platforms` | array of objects | optional | | | `tag_rollups.rows[].platforms[].platform` | string | required | | | `tag_rollups.rows[].platforms[].pieces` | integer | optional | Default `0`. | | `tag_rollups.rows[].platforms[].reach` | integer \| number | optional | Default `0`. | | `tag_rollups.total_reach` | integer \| number | optional | Default `0`. | | `tag_rollups.total_engagement` | integer \| number | optional | Default `0`. | | `tag_rollups.content_node_count` | integer | optional | Default `0`. | | `tag_rollups.tagged_node_count` | integer | optional | Default `0`. | | `tag_rollups.untagged_node_count` | integer | optional | Default `0`. | | `tag_rollups.multi_tagged_node_count` | integer | optional | Default `0`. | | `tag_rollups.has_tags` | boolean | optional | Default `false`. | | `subclusters` | object | required | The cluster's subclusters, as the canvas Subclusters list and the Cluster Analytics 'Subcluster' tab show them. | | `subclusters.rows` | array of objects | optional | | | `subclusters.rows[].key` | string | required | | | `subclusters.rows[].name` | string | required | | | `subclusters.rows[].anchor_id` | string | optional | The anchor node id (null on the 'Not in a subcluster' row). Default `null`. Nullable. | | `subclusters.rows[].anchor_label` | string | optional | Default `""`. | | `subclusters.rows[].is_unassigned` | boolean | optional | True on the trailing 'Not in a subcluster' row (episodes and loose pieces). Default `false`. | | `subclusters.rows[].member_ids` | array of strings | optional | Content node ids in this row, the anchor first. | | `subclusters.rows[].pieces` | integer | optional | Default `0`. | | `subclusters.rows[].reach` | integer \| number | optional | Default `0`. | | `subclusters.rows[].engagement` | integer \| number | optional | Default `0`. | | `subclusters.rows[].engagement_rate` | integer \| number | optional | Default `0`. | | `subclusters.rows[].share_of_reach` | number | optional | Default `0`. | | `subclusters.rows[].platforms` | array of objects | optional | | | `subclusters.rows[].platforms[].platform` | string | required | | | `subclusters.rows[].platforms[].pieces` | integer | optional | Default `0`. | | `subclusters.rows[].platforms[].reach` | integer \| number | optional | Default `0`. | | `subclusters.subcluster_count` | integer | optional | Default `0`. | | `subclusters.average_reach` | integer \| number | optional | Mean reach per subcluster (the canvas header's 'Avg. subcluster reach'). Default `0`. | | `subclusters.total_reach` | integer \| number | optional | Default `0`. | | `subclusters.total_engagement` | integer \| number | optional | Default `0`. | | `subclusters.content_node_count` | integer | optional | Default `0`. | | `subclusters.grouped_node_count` | integer | optional | Default `0`. | | `subclusters.unassigned_node_count` | integer | optional | Default `0`. | | `subclusters.multi_subcluster_node_count` | integer | optional | Default `0`. | | `subclusters.has_subclusters` | boolean | optional | Default `false`. | | `edges` | array of objects | optional | Every canvas edge between content nodes, never truncated by node_limit, so parent links resolve even for nodes not returned. | | `edges[].source` | string | required | | | `edges[].target` | string | required | | | `sponsor_brands` | array of strings | optional | | | `brands` | array of strings | optional | | | `linked_campaigns` | array of objects | optional | | | `linked_campaigns[].campaign_id` | string | required | | | `linked_campaigns[].title` | string | required | | | `linked_campaigns[].brand_name` | string | optional | Default `null`. Nullable. | | `linked_campaigns[].display_brand_name` | string | required | | | `linked_campaigns[].is_brand_fallback` | boolean | optional | Default `false`. | | `linked_campaigns[].status` | string | optional | Default `null`. Nullable. | | `linked_campaigns[].excluded_node_count` | integer | optional | Nodes the campaign excludes across every cluster it links. Default `0`. | | `linked_campaigns[].excluded_node_ids_in_cluster` | array of strings | optional | This cluster's nodes the campaign excludes (known when node ids are loaded). Default `null`. Nullable. | | `linked_campaigns[].excluded_node_count_in_cluster` | integer | optional | Default `null`. Nullable. | | `linked_campaigns[].included_node_count_in_cluster` | integer | optional | Default `null`. Nullable. | | `linked_campaigns[].public_url` | string | optional | Default `null`. Nullable. | | `freshness` | object | required | | | `freshness.kind` | string | optional | 'refreshed' (a refresh run) \| 'saved' (newest node fetch stamp) \| 'unknown'. Default `"unknown"`. | | `freshness.at` | string | optional | Default `null`. Nullable. | | `freshness.source` | string | optional | Refresh source when known ('manual' \| 'scheduled' \| …). Default `null`. Nullable. | | `last_refreshed_at` | string | optional | Default `null`. Nullable. | | `last_refresh_attempt_at` | string | optional | Default `null`. Nullable. | | `last_refresh_source` | string | optional | Default `null`. Nullable. | | `refresh_errors` | array of objects | optional | | | `refresh_errors[].node_id` | string | optional | Default `null`. Nullable. | | `refresh_errors[].platform` | string | optional | Default `null`. Nullable. | | `refresh_errors[].kind` | string | optional | Default `null`. Nullable. | | `refresh_errors[].message` | string | optional | Default `null`. Nullable. | | `refresh_errors[].status` | any | optional | Default `null`. Nullable. | | `refresh_errors[].at` | string | optional | Default `null`. Nullable. | | `refresh_errors[].url` | string | optional | Default `null`. Nullable. | | `refresh_error_count` | integer | optional | Default `0`. | | `platform_freshness` | map of object | optional | | | `platform_freshness.last_updated` | string | optional | Default `null`. Nullable. | | `platform_freshness.age_hours` | number | optional | Default `null`. Nullable. | | `platform_freshness.stale` | boolean | optional | True when older than 36 hours or unknown. Default `true`. | | `stale_platforms` | array of strings | optional | | | `computed_at` | string | required | | | `cache_age_seconds` | number | required | | | `note` | string | optional | Default `null`. Nullable. | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/shows/SHOW_ID/clusters/CLUSTER_ID" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "cluster", "cluster": { "id": "cluster-0y0HEJ0OVF7EYdXAvD5ghY", "show": { "id": "paAtKPJGo56md5VyoEQ9Las7qahL", "name": "Our bold predictions for the second half", "handle": "hard-court", "image": "https://images.example.com/listener/6cbd825d3f77ec48.jpg", "public_url": "https://listener.com/hard-court" }, "title": "The best and worst contracts", "name": "Five takeaways from the opener", "internal_title": null, "description": null, "cluster_type": "episode", "is_merged": false, "is_manual": false, "is_empty": false, "contributing": true, "episode_ids": [ "0y0HEJ0OVF7EYdXAvD5ghY" ], "episodes": [], "published_at": "2026-10-10", "episode_date": "2026-10-10", "tags": { "series": [], "guests": [], "format": [] }, "customization": null, "brand_collab": null, "emv_rates": null, "auto_generated": false, "created_at": null, "updated_at": "2026-10-12T18:40:06.002Z", "public_url": "https://listener.com/cluster/paAtKPJGo56md5VyoEQ9Las7qahL_cluster-0y0HEJ0OVF7EYdXAvD5ghY", "app_url": "https://network.listener.com/app/cluster?id=cluster-0y0HEJ0OVF7EYdXAvD5ghY&uid=paAtKPJGo56md5VyoEQ9Las7qahL" }, "metrics": { "total_reach": 2988, "youtube_reach": 732, "youtube_long_form_reach": 0, "youtube_shorts_reach": 732, "audio_reach": 0, "social_reach": 2256, "long_form_reach": 0, "short_form_reach": 2988, "long_form_count": 0, "short_form_count": 3, "clips": 3, "posts": 0, "total_engagement": 83, "engagement_rate": 2.7777777777777777 }, "metrics_source": "nodes", "first_30_days": { "total_reach": 2988, "youtube_reach": 732, "youtube_long_form_reach": 0, "youtube_shorts_reach": 732, "audio_reach": 0, "social_reach": 2256, "long_form_reach": 0, "short_form_reach": 2988, "long_form_count": 0, "short_form_count": 3, "clips": 3, "posts": 0, "total_engagement": 83, "engagement_rate": 2.7777777777777777, "availability": { "available": true, "reason": null, "dated_count": 3, "total_count": 3, "undated_with_metrics_count": 0 }, "as_of": "2026-10-14T17:34:18.674Z" }, "emv": { "total": 81.26, "long_form_value": 0, "short_form_value": 59.76, "like_value": 20.25, "comment_value": 1.25, "engagement_value": 21.5, "nodes": 3, "includes_engagement": true, "method": "nodes", "rates": { "long_form_cpm": 25, "short_form_cpm": 20, "like_rate": 0.25, "comment_rate": 1.25, "show_on_share_page": true }, "inherited_rates": { "long_form_cpm": 25, "short_form_cpm": 20, "like_rate": 0.25, "comment_rate": 1.25, "show_on_share_page": true }, "rates_source": "network", "rates_campaign_id": null }, "node_count": 4, "nodes": [ { "id": "clip-0718188106790-9358", "type": "clip", "platform": "Instagram", "platform_key": "instagram", "platform_raw": "Instagram", "url": "https://www.instagram.com/reel/VvUVi4qn6K0/", "title": "Draft night grades", "thumbnail": "https://images.example.com/listener/5549925bd54f80de.jpg", "published_at": "2026-10-10", "reach": 1784, "reach_category": "social", "reach_contributions": { "youtube": 0, "audio": 0, "social": 1784 }, "video_form": "short", "is_long_form": false, "youtube_bucket": null, "engagement": 36, "engagement_breakdown": { "likes": 36, "comments": 0, "shares": 0, "reposts": 0, "retweets": 0, "replies": 0, "quotes": 0, "reactions": 0, "appreciations": 0, "empathy": 0, "praises": 0 }, "custom_tags": [], "is_sponsored": false, "sponsor_brand": null, "is_pending": false, "no_data_returned": false, "is_shorts": false, "is_selection_episode": false, "episode_id": null, "source_show_id": "paAtKPJGo56md5VyoEQ9Las7qahL", "ai_relevance_score": null, "last_updated": null, "podcast_downloads": null, "podcast_downloads_source": null, "podcast_downloads_api": null, "source_content_id": null, "parent_id": "episode-0y0HEJ0OVF7EYdXAvD5ghY", "parent_ids": [ "episode-0y0HEJ0OVF7EYdXAvD5ghY" ], "parent_url": "https://open.spotify.com/episode/0y0HEJ0OVF7EYdXAvD5ghY", "child_ids": [], "connected_to": [ "episode-0y0HEJ0OVF7EYdXAvD5ghY" ], "subcluster_id": null, "subcluster_name": null, "subcluster_ids": [], "is_subcluster_anchor": false }, { "id": "clip-4547877422640-8832", "type": "clip", "platform": "YouTube", "platform_key": "youtube", "platform_raw": "YouTube Shorts", "url": "https://www.youtube.com/shorts/UomV7A7ON2m", "title": "Five takeaways from the opener", "thumbnail": "https://images.example.com/listener/494d89fc43becd8a.jpg", "published_at": "2026-10-10", "reach": 732, "reach_category": "youtube", "reach_contributions": { "youtube": 732, "audio": 0, "social": 0 }, "video_form": "short", "is_long_form": false, "youtube_bucket": "youtube_shorts_reach", "engagement": 18, "engagement_breakdown": { "likes": 17, "comments": 1, "shares": 0, "reposts": 0, "retweets": 0, "replies": 0, "quotes": 0, "reactions": 0, "appreciations": 0, "empathy": 0, "praises": 0 }, "custom_tags": [], "is_sponsored": false, "sponsor_brand": null, "is_pending": false, "no_data_returned": false, "is_shorts": true, "is_selection_episode": false, "episode_id": null, "source_show_id": "paAtKPJGo56md5VyoEQ9Las7qahL", "ai_relevance_score": null, "last_updated": null, "podcast_downloads": null, "podcast_downloads_source": null, "podcast_downloads_api": null, "source_content_id": null, "parent_id": "episode-0y0HEJ0OVF7EYdXAvD5ghY", "parent_ids": [ "episode-0y0HEJ0OVF7EYdXAvD5ghY" ], "parent_url": "https://open.spotify.com/episode/0y0HEJ0OVF7EYdXAvD5ghY", "child_ids": [], "connected_to": [ "episode-0y0HEJ0OVF7EYdXAvD5ghY" ], "subcluster_id": null, "subcluster_name": null, "subcluster_ids": [], "is_subcluster_anchor": false } ], "nodes_truncated": false, "nodes_returned": 4, "tag_rollups": { "rows": [ { "tag": "Untagged", "key": "__untagged__", "is_untagged": true, "pieces": 4, "reach": 2988, "engagement": 83, "engagement_rate": 2.7777777777777777, "share_of_reach": 1, "platforms": [ { "platform": "Instagram", "pieces": 1, "reach": 1784 }, { "platform": "YouTube Shorts", "pieces": 1, "reach": 732 } ] } ], "total_reach": 2988, "total_engagement": 83, "content_node_count": 4, "tagged_node_count": 0, "untagged_node_count": 4, "multi_tagged_node_count": 0, "has_tags": false }, "subclusters": { "rows": [], "subcluster_count": 0, "average_reach": 0, "total_reach": 2988, "total_engagement": 83, "content_node_count": 4, "grouped_node_count": 0, "unassigned_node_count": 4, "multi_subcluster_node_count": 0, "has_subclusters": false }, "edges": [ { "source": "episode-0y0HEJ0OVF7EYdXAvD5ghY", "target": "clip-0718188106790-9358" }, { "source": "episode-0y0HEJ0OVF7EYdXAvD5ghY", "target": "clip-4547877422640-8832" } ], "sponsor_brands": [], "brands": [], "linked_campaigns": [], "freshness": { "kind": "saved", "at": "2026-10-12T11:17:18.153Z", "source": "nodes" }, "last_refreshed_at": null, "last_refresh_attempt_at": null, "last_refresh_source": null, "refresh_errors": [], "refresh_error_count": 0, "platform_freshness": { "youtube": { "last_updated": "2026-10-14T07:52:53.974000+00:00", "age_hours": 9.69, "stale": false }, "spotify": { "last_updated": "2026-10-14T07:20:29.026000+00:00", "age_hours": 10.23, "stale": false }, "applePodcasts": { "last_updated": "2026-04-13T17:36:30+00:00", "age_hours": 4415.96, "stale": true }, "tiktok": { "last_updated": "2026-10-14T07:54:34.140000+00:00", "age_hours": 9.66, "stale": false }, "instagram": { "last_updated": "2026-10-14T07:54:18.265000+00:00", "age_hours": 9.67, "stale": false }, "twitter": { "last_updated": "2026-10-14T07:55:11.134000+00:00", "age_hours": 9.65, "stale": false } }, "stale_platforms": [ "applePodcasts" ], "computed_at": "2026-10-14T17:34:18.669983+00:00", "cache_age_seconds": 13.1, "note": null } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Update a cluster `PATCH /v1/shows/{show_id}/clusters/{cluster_id}` Scope: `clusters:write` SDK: `client.shows.clusters.update()` (TypeScript) · `client.shows.clusters.update()` (Python) Update cluster metadata: name, internal title, description, image, cover, date, public-page `customization`, `emv_rates` and `brand_collab`. Only the fields you send change. `emv_rates` merges over the current rates; `customization` merges over the stored page overrides, and `customization.co_brand` (`{name, logo_url, href}`, https URLs) adds a partner co-brand to the public cluster page (`"co_brand": null` removes it); `clear_customization`, `clear_internal_title` and `clear_brand_collab` remove those fields. Description, image, cover and date on non-manual clusters last only until the next canvas save in the app. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `clusters:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | | `cluster_id` | string | required | Cluster id (see GET /v1/clusters). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `name` | string | optional | Name. Default `null`. Nullable. | | `internal_title` | string | optional | Default `null`. Nullable. | | `clear_internal_title` | boolean | optional | Default `false`. | | `description` | string | optional | Free-text description. Default `null`. Nullable. | | `image` | string | optional | Default `null`. Nullable. | | `cover` | string | optional | Default `null`. Nullable. | | `date` | string | optional | Default `null`. Nullable. | | `customization` | object | optional | Public cluster page overrides (``clusterCustomization.js``). Only the keys you send change. Default `null`. Nullable. | | `customization.background_video_url` | string | optional | Default `""`. | | `customization.cover_image_url` | string | optional | Default `""`. | | `customization.featured_url` | string | optional | Default `""`. | | `customization.co_brand` | object | optional | Co-brand shown on the public cluster page: {name, logo_url (https), href (https)}. null removes it. Default `null`. Nullable. | | `customization.co_brand.name` | string | required | Partner name shown next to the show's branding (1–80 characters). | | `customization.co_brand.logo_url` | string | optional | https:// URL of the partner's logo. Default `null`. Nullable. | | `customization.co_brand.href` | string | optional | https:// link the co-brand points to. Default `null`. Nullable. | | `clear_customization` | boolean | optional | Default `false`. | | `emv_rates` | object | optional | EMV rates: {long_form_cpm, short_form_cpm, like_rate, comment_rate}. Default `null`. Nullable. | | `emv_rates.long_form_cpm` | integer \| number \| string \| any | optional | Default `null`. | | `emv_rates.short_form_cpm` | integer \| number \| string \| any | optional | Default `null`. | | `emv_rates.like_rate` | integer \| number \| string \| any | optional | Default `null`. | | `emv_rates.comment_rate` | integer \| number \| string \| any | optional | Default `null`. | | `emv_rates.show_on_share_page` | boolean | optional | Default `null`. Nullable. | | `brand_collab` | object | optional | Default `null`. Nullable. | | `brand_collab.name` | string | optional | Default `""`. | | `brand_collab.url` | string | optional | Default `""`. | | `brand_collab.logo_url` | string | optional | Default `""`. | | `brand_collab.description` | string | optional | Default `""`. | | `clear_brand_collab` | boolean | optional | Default `false`. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | ### Example request ```bash curl -X PATCH "https://api.listener.com/v1/shows/SHOW_ID/clusters/CLUSTER_ID" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "cluster", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/paAtKPJGo56md5VyoEQ9Las7qahL/clusters/cluster-0y0HEJ0OVF7EYdXAvD5ghY", "id": "cluster-0y0HEJ0OVF7EYdXAvD5ghY", "method": "update", "written": { "internalTitle": "API probe", "updatedAt": "2026-10-14T17:34:19.009Z" }, "warnings": [], "urls": { "app": "https://network.listener.com/app/cluster?uid=paAtKPJGo56md5VyoEQ9Las7qahL&episode=0y0HEJ0OVF7EYdXAvD5ghY", "public": "https://listener.com/cluster/paAtKPJGo56md5VyoEQ9Las7qahL_cluster-0y0HEJ0OVF7EYdXAvD5ghY" }, "error": null, "data": { "show_id": "paAtKPJGo56md5VyoEQ9Las7qahL", "cluster_id": "cluster-0y0HEJ0OVF7EYdXAvD5ghY" }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Delete a cluster `DELETE /v1/shows/{show_id}/clusters/{cluster_id}` Scope: `clusters:write` · some changes need `confirm=true` SDK: `client.shows.clusters.delete()` (TypeScript) · `client.shows.clusters.delete()` (Python) Permanently delete a cluster (no undo) and report, without fixing, the campaigns and shared reports that still reference it. Send `?dry_run=true` first to see them. A manual cluster's synthetic episode is removed too. Clusters spanning several episodes are refused unless `allow_merged=true`. - Send `?dry_run=true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Requires `?confirm=true`; without it the API answers 409 `confirmation_required` with the exact `preview`. - Required scope: `clusters:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | | `cluster_id` | string | required | Cluster id (see GET /v1/clusters). | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `allow_merged` | boolean | optional | | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). | | `confirm` | boolean | optional | Must be true to apply destructive or metered changes; without it the API answers 409 with a preview. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Example request ```bash curl -X DELETE "https://api.listener.com/v1/shows/SHOW_ID/clusters/CLUSTER_ID?dry_run=true" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "dry_run", "resource": "cluster", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/paAtKPJGo56md5VyoEQ9Las7qahL/clusters/cluster-0y0HEJ0OVF7EYdXAvD5ghY", "id": "cluster-0y0HEJ0OVF7EYdXAvD5ghY", "method": "transaction", "written": { "deleted": true, "cluster": { "id": "cluster-0y0HEJ0OVF7EYdXAvD5ghY", "name": "Five takeaways from the opener", "episode_ids": [ "0y0HEJ0OVF7EYdXAvD5ghY" ] }, "show_doc_updates": {}, "dangling": { "campaign_ids": [], "shared_report_ids": [ "U408eAHMUK0pl7OlcvFU", "yDOF1QDhp4fJsSahuI0L" ] } }, "warnings": [ "Shared report(s) embed this cluster (not fixed): U408eAHMUK0pl7OlcvFU, yDOF1QDhp4fJsSahuI0L." ], "urls": { "app": "https://network.listener.com/app/episodes?uid=paAtKPJGo56md5VyoEQ9Las7qahL", "public": null }, "error": null, "data": { "show_id": "paAtKPJGo56md5VyoEQ9Las7qahL", "cluster_id": "cluster-0y0HEJ0OVF7EYdXAvD5ghY", "episode_ids": [ "0y0HEJ0OVF7EYdXAvD5ghY" ], "dangling": { "campaign_ids": [], "shared_report_ids": [ "U408eAHMUK0pl7OlcvFU", "yDOF1QDhp4fJsSahuI0L" ] }, "confirm_required": true }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Cluster timeline `GET /v1/shows/{show_id}/clusters/{cluster_id}/timeline` Scope: `clusters:read` SDK: `client.shows.clusters.timeline()` (TypeScript) · `client.shows.clusters.timeline()` (Python) The cluster page's "views / engagement over time" curve (`granularity` daily, weekly or monthly; `chart_type` cumulative or per period). It spreads the cluster's lifetime totals from its earliest episode date to today on a fixed decay, so `synthetic` is always true; for measured dates use the nodes' `published_at` from `GET /v1/shows/{show_id}/clusters/{cluster_id}`. - Required scope: `clusters:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | | `cluster_id` | string | required | Cluster id (see GET /v1/clusters). | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `granularity` | string | optional | | | `chart_type` | string | optional | daily values or a cumulative running total. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `show` | object | required | The minimal show reference embedded in cross-show results. | | `show.id` | string | required | | | `show.name` | string | required | | | `show.handle` | string | optional | Default `""`. | | `show.image` | string | optional | Default `null`. Nullable. | | `show.public_url` | string | optional | Default `null`. Nullable. | | `cluster_id` | string | required | | | `title` | string | required | | | `granularity` | string | required | One of `daily`, `weekly`, `monthly`. | | `chart_type` | string | required | One of `daily`, `cumulative`. | | `synthetic` | boolean | required | True: the app's shape-only curve (lifetime totals spread over time), not measured data. | | `start_date` | string | required | | | `start_date_from_episode` | boolean | required | | | `total_days` | integer | required | | | `data_points` | integer | required | | | `total_views` | integer \| number | required | | | `total_engagement` | integer \| number | required | | | `points` | array of objects | required | | | `points[].date` | string | required | | | `points[].views` | integer \| number | required | | | `points[].engagement` | integer \| number | required | | | `computed_at` | string | required | | | `cache_age_seconds` | number | required | | | `note` | string | optional | Default `null`. Nullable. | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/shows/SHOW_ID/clusters/CLUSTER_ID/timeline" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "cluster_timeline", "show": { "id": "paAtKPJGo56md5VyoEQ9Las7qahL", "name": "Our bold predictions for the second half", "handle": "hard-court", "image": "https://images.example.com/listener/6cbd825d3f77ec48.jpg", "public_url": "https://listener.com/hard-court" }, "cluster_id": "cluster-0y0HEJ0OVF7EYdXAvD5ghY", "title": "The best and worst contracts", "granularity": "daily", "chart_type": "cumulative", "synthetic": true, "start_date": "2026-10-10", "start_date_from_episode": true, "total_days": 5, "data_points": 5, "total_views": 2988, "total_engagement": 83, "points": [ { "date": "2026-10-10", "views": 1363, "engagement": 12 }, { "date": "2026-10-11", "views": 2138, "engagement": 29 } ], "computed_at": "2026-10-14T17:34:21.296110+00:00", "cache_age_seconds": 15.8, "note": "Shape-only series: the cluster's lifetime totals spread from the earliest episode date to today on the app's fixed curve. Use node_publish_timeline for measured buckets." } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Add posts to an episode's cluster `POST /v1/shows/{show_id}/episodes/{episode_id}/posts` Scope: `clusters:write` SDK: `client.shows.episodes.addPosts()` (TypeScript) · `client.shows.episodes.add_posts()` (Python) Add clips or posts to an episode's cluster by URL (YouTube, Shorts, TikTok, Instagram, Facebook, X, Threads, LinkedIn and the show's own Substack posts), including posts from accounts that are not connected. Each link is fetched (title, thumbnail, views, likes, comments) and added as a node; the cluster is created when the episode has none. Links already in the cluster are skipped. Up to 25 URLs per request. A dry run checks every link (supported platform, duplicates, whether a cluster will be created) without fetching. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `clusters:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | | `episode_id` | string | required | Episode id (see GET /v1/shows/{show_id}/episodes). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `urls` | array of strings | required | Post or clip URLs (up to 25). | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | ### Example request ```bash curl -X POST "https://api.listener.com/v1/shows/SHOW_ID/episodes/EPISODE_ID/posts" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"urls": [], "dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "cluster", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/paAtKPJGo56md5VyoEQ9Las7qahL/clusters/cluster-0y0HEJ0OVF7EYdXAvD5ghY", "id": "cluster-0y0HEJ0OVF7EYdXAvD5ghY", "method": "http", "written": { "episodeId": "0y0HEJ0OVF7EYdXAvD5ghY", "urls": [ "https://www.youtube.com/shorts/wig-TZ-bpZD" ] }, "warnings": [], "urls": { "app": "https://network.listener.com/app/cluster?uid=paAtKPJGo56md5VyoEQ9Las7qahL&episode=0y0HEJ0OVF7EYdXAvD5ghY", "public": "https://listener.com/cluster/paAtKPJGo56md5VyoEQ9Las7qahL_cluster-0y0HEJ0OVF7EYdXAvD5ghY" }, "error": null, "data": { "show_id": "paAtKPJGo56md5VyoEQ9Las7qahL", "episode_id": "0y0HEJ0OVF7EYdXAvD5ghY", "episode_title": "The best and worst contracts", "cluster_id": "cluster-0y0HEJ0OVF7EYdXAvD5ghY", "would_add": [ { "url": "https://www.youtube.com/shorts/wig-TZ-bpZD", "platform": "YouTube Shorts" } ], "already_in_cluster": [], "rejected": [] }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Remove cluster nodes `POST /v1/shows/{show_id}/clusters/{cluster_id}/nodes/remove` Scope: `clusters:write` SDK: `client.shows.clusters.nodes.remove()` (TypeScript) · `client.shows.clusters.nodes.remove()` (Python) Remove nodes, and every edge touching them, from a cluster. The last episode node cannot be removed; to remove the whole cluster use `DELETE /v1/shows/{show_id}/clusters/{cluster_id}`. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `clusters:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | | `cluster_id` | string | required | Cluster id (see GET /v1/clusters). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `node_ids` | array of strings | required | Cluster node ids. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | ### Example request ```bash curl -X POST "https://api.listener.com/v1/shows/SHOW_ID/clusters/CLUSTER_ID/nodes/remove" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"node_ids": [], "dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "cluster", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/paAtKPJGo56md5VyoEQ9Las7qahL/clusters/cluster-0y0HEJ0OVF7EYdXAvD5ghY", "id": "cluster-0y0HEJ0OVF7EYdXAvD5ghY", "method": "transaction", "written": { "nodes": "<3 nodes>", "edges": "<2 edges>", "updatedAt": "2026-10-14T17:34:22.771Z" }, "warnings": [], "urls": { "app": "https://network.listener.com/app/cluster?uid=paAtKPJGo56md5VyoEQ9Las7qahL&episode=0y0HEJ0OVF7EYdXAvD5ghY", "public": "https://listener.com/cluster/paAtKPJGo56md5VyoEQ9Las7qahL_cluster-0y0HEJ0OVF7EYdXAvD5ghY" }, "error": null, "data": { "show_id": "paAtKPJGo56md5VyoEQ9Las7qahL", "cluster_id": "cluster-0y0HEJ0OVF7EYdXAvD5ghY", "removed": [ "clip-0718188106790-9358" ], "node_count": 3, "edges_removed": 1 }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Set cluster node tags `POST /v1/shows/{show_id}/clusters/{cluster_id}/nodes/tags` Scope: `clusters:write` SDK: `client.shows.clusters.nodes.setTags()` (TypeScript) · `client.shows.clusters.nodes.set_tags()` (Python) Set, add or remove custom tags on specific cluster nodes (`mode` = `replace`, `add`, `remove`). Tags are trimmed and de-duplicated case-insensitively; only episode, clip and post nodes take tags (others come back in `data.skipped`); unknown node ids are an error. Episode tags are a separate system (`POST /v1/shows/{show_id}/episodes/tags`). - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `clusters:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | | `cluster_id` | string | required | Cluster id (see GET /v1/clusters). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `node_ids` | array of strings | required | Cluster node ids. | | `tags` | array of strings | required | Tags to apply. | | `mode` | string | optional | How the change applies (see the operation description). One of `replace`, `add`, `remove`. Default `"replace"`. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | ### Example request ```bash curl -X POST "https://api.listener.com/v1/shows/SHOW_ID/clusters/CLUSTER_ID/nodes/tags" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"node_ids": [], "tags": [], "dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "cluster", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/paAtKPJGo56md5VyoEQ9Las7qahL/clusters/cluster-0y0HEJ0OVF7EYdXAvD5ghY", "id": "cluster-0y0HEJ0OVF7EYdXAvD5ghY", "method": "transaction", "written": { "nodes": "<4 nodes>", "updatedAt": "2026-10-14T17:34:23.104Z" }, "warnings": [], "urls": { "app": "https://network.listener.com/app/cluster?uid=paAtKPJGo56md5VyoEQ9Las7qahL&episode=0y0HEJ0OVF7EYdXAvD5ghY", "public": "https://listener.com/cluster/paAtKPJGo56md5VyoEQ9Las7qahL_cluster-0y0HEJ0OVF7EYdXAvD5ghY" }, "error": null, "data": { "show_id": "paAtKPJGo56md5VyoEQ9Las7qahL", "cluster_id": "cluster-0y0HEJ0OVF7EYdXAvD5ghY", "tags_by_node": { "clip-0718188106790-9358": [ "API probe" ] }, "skipped": [] }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Refresh a cluster's metrics `POST /v1/shows/{show_id}/clusters/{cluster_id}/refresh` Scope: `refresh:write` · needs `confirm=true` SDK: `client.shows.clusters.refresh()` (TypeScript) · `client.shows.clusters.refresh()` (Python) Re-fetch every clip and post of one cluster from its platforms and return per-platform node counts, the provider-call estimate and the result. Needs `"confirm": true` (one metered provider call per node). Merged clusters are refused; refresh their source clusters. A node whose fresh value reads 0 keeps its previous value. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Requires `"confirm": true`; without it the API answers 409 `confirmation_required` with the exact `preview`. - Required scope: `refresh:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | | `cluster_id` | string | required | Cluster id (see GET /v1/clusters). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | | `confirm` | boolean | optional | Must be true to apply destructive or metered changes; without it the API answers 409 with a preview. Default `false`. | ### Example request ```bash curl -X POST "https://api.listener.com/v1/shows/SHOW_ID/clusters/CLUSTER_ID/refresh" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "cluster_refresh", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/paAtKPJGo56md5VyoEQ9Las7qahL/clusters/cluster-0y0HEJ0OVF7EYdXAvD5ghY", "id": "cluster-0y0HEJ0OVF7EYdXAvD5ghY", "method": "http", "written": { "uid": "paAtKPJGo56md5VyoEQ9Las7qahL", "clusterId": "cluster-0y0HEJ0OVF7EYdXAvD5ghY" }, "warnings": [ "A node whose fresh value reads 0 keeps its previous value (treated as a failed fetch)." ], "urls": { "app": "https://network.listener.com/app/cluster?uid=paAtKPJGo56md5VyoEQ9Las7qahL&episode=0y0HEJ0OVF7EYdXAvD5ghY", "public": "https://listener.com/cluster/paAtKPJGo56md5VyoEQ9Las7qahL_cluster-0y0HEJ0OVF7EYdXAvD5ghY" }, "error": null, "data": { "show_id": "paAtKPJGo56md5VyoEQ9Las7qahL", "cluster_id": "cluster-0y0HEJ0OVF7EYdXAvD5ghY", "estimated_provider_calls": 3, "per_platform": { "YouTube Shorts": 1, "Instagram": 1, "TikTok": 1 }, "last_refreshed_at": null, "last_refresh_attempt_at": null, "status": "would-refresh" }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Accounts the cluster builder searches `GET /v1/shows/{show_id}/cluster-sources` Scope: `clusters:read` SDK: `client.shows.clusterSources.retrieve()` (TypeScript) · `client.shows.cluster_sources.retrieve()` (Python) The accounts the cluster builder searches for a show's clips and posts: connected accounts, accounts learned from the show's past clusters (with their share of past nodes) and accounts a person added, each with platform, handle, role (`own`, `partner`, `guest`) and on/off, plus the date window around each episode. The first request for a show learns the list from its past clusters (no cluster is modified). `configured=false` means the builder is not reachable from this server. - Required scope: `clusters:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | | | `show_name` | string | required | | | `configured` | boolean | required | | | `handles` | array of objects | optional | | | `handles[].id` | string | required | ':'. | | `handles[].platform` | string | required | | | `handles[].handle` | string | required | | | `handles[].display_name` | string | optional | Default `null`. Nullable. | | `handles[].avatar` | string | optional | Default `null`. Nullable. | | `handles[].url` | string | optional | Default `null`. Nullable. | | `handles[].origin` | string | required | connected (linked to the show) \| learned (seen in past clusters) \| added (by a person). | | `handles[].role` | string | optional | Default `null`. Nullable. | | `handles[].enabled` | boolean | required | | | `handles[].learned_count` | integer | optional | Nodes from this account in the show's past clusters (learned only). Default `null`. Nullable. | | `handles[].learned_share` | number | optional | 0-1 share of the show's past cluster nodes (learned only). Default `null`. Nullable. | | `handles[].last_fetched_at` | string | optional | Default `null`. Nullable. | | `handles[].last_error` | string | optional | Default `null`. Nullable. | | `enabled_count` | integer | optional | Default `0`. | | `by_platform` | map of integer | optional | Enabled handles per platform. | | `window` | object | optional | Default `null`. Nullable. | | `window.before_days` | integer | optional | Posts up to this many days before the episode date count. Default `null`. Nullable. | | `window.after_days` | integer | optional | Posts up to this many days after the episode date count. Default `null`. Nullable. | | `window.learned_from` | any | optional | Default `null`. Nullable. | | `learned_at` | string | optional | Default `null`. Nullable. | | `updated_at` | string | optional | Default `null`. Nullable. | | `app_url` | string | optional | Default `null`. Nullable. | | `computed_at` | string | required | | | `cache_age_seconds` | number | required | | | `note` | string | optional | Default `null`. Nullable. | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/shows/SHOW_ID/cluster-sources" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "cluster_sources", "show_id": "i3KlnFag2DgIpykraVxjrIZwJYcL", "show_name": "The Long Game", "configured": false, "handles": [], "enabled_count": 0, "by_platform": {}, "window": null, "learned_at": null, "updated_at": null, "app_url": null, "computed_at": "2026-10-14T17:34:23.437868+00:00", "cache_age_seconds": 17.9, "note": "The cluster builder is not configured on this server: set LISTENER_INTERNAL_API_KEY (and LISTENER_APP_BASE_URL) so it can call the Listener app." } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Update the cluster builder's accounts `PATCH /v1/shows/{show_id}/cluster-sources` Scope: `clusters:write` SDK: `client.shows.clusterSources.update()` (TypeScript) · `client.shows.cluster_sources.update()` (Python) Change which accounts the cluster builder searches for a show: `add` accounts (profile URL, `platform:handle` or `{handle, platform, role}`), `remove` accounts a person added, or `enable` / `disable` any account (`platform:handle`, `@handle` or a profile URL). Connected and learned accounts can only be turned off. Added accounts default to role `partner`; use `own` for the show's brand accounts and `guest` for a guest. Takes effect on the next build or content search. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `clusters:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `add` | array of objects | optional | Default `null`. Nullable. | | `add[].handle` | string | required | Profile URL, `platform:handle`, or `@handle` (then `platform` is required). | | `add[].platform` | string | optional | One of `tiktok`, `instagram`, `facebook`, `x`, `youtube`, `threads`, `linkedin`. Default `null`. Nullable. | | `add[].role` | string | optional | own (the client's brand), partner (default for added accounts) or guest. One of `own`, `partner`, `guest`. Default `null`. Nullable. | | `remove` | array of strings | optional | Default `null`. Nullable. | | `enable` | array of strings | optional | Default `null`. Nullable. | | `disable` | array of strings | optional | Default `null`. Nullable. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | ### Example request ```bash curl -X PATCH "https://api.listener.com/v1/shows/SHOW_ID/cluster-sources" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "cluster_sources", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "clusterSources/paAtKPJGo56md5VyoEQ9Las7qahL", "id": "paAtKPJGo56md5VyoEQ9Las7qahL", "method": "set-merge", "written": { "handles": [ { "platform": "tiktok", "handle": "ttcnnerju78", "role": "partner", "enabled": true, "added_by": "person" } ] }, "warnings": [], "urls": { "app": null, "public": null }, "error": null, "data": { "added": [ "tiktok:listenerprobe" ], "removed": [], "enabled": [], "disabled": [], "handle_count": 9 }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Episodes to cluster `GET /v1/shows/{show_id}/cluster-candidates` Scope: `clusters:read` SDK: `client.shows.clusterCandidates.list()` (TypeScript) · `client.shows.cluster_candidates.list()` (Python) The show's episodes as the cluster builder sees them, newest first, including the newest episodes from the live feed that the episodes list may not have yet: id, title, date, whether it has a cluster, its build status (`ready` = suggestions waiting) and source (`stored` or `live`). `query` keeps titles containing every word; `from_date` / `to_date` bound the publish date. Not paginated. - Required scope: `clusters:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `query` | string | optional | Search text. | | `from_date` | string | optional | Earliest publish date, YYYY-MM-DD. | | `to_date` | string | optional | Latest publish date, YYYY-MM-DD. | | `limit` | integer | optional | Maximum number of items to return. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].id` | string | required | | | `data[].title` | string | optional | Default `null`. Nullable. | | `data[].published_at` | string | optional | Default `null`. Nullable. | | `data[].has_cluster` | boolean | optional | Default `false`. | | `data[].cluster_id` | string | optional | Default `null`. Nullable. | | `data[].build_status` | string | optional | AI build status for this episode: building \| ready (suggestions waiting) \| built \| accepted \| nothing \| failed \| dismissed. Default `null`. Nullable. | | `data[].source` | string | optional | stored (in the show's saved feed) \| live (newest episodes read from the platform feed, not saved yet) \| catalog (older episodes from the show's loaded back catalogue). Default `null`. Nullable. | | `data[].object` | string | required | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | | `meta.show_id` | string | optional | | | `meta.show_name` | string | optional | | | `meta.configured` | boolean | optional | | | `meta.total` | integer | optional | Default `0`. | | `meta.computed_at` | string | optional | | | `meta.cache_age_seconds` | number | optional | | | `meta.note` | string | optional | Default `null`. Nullable. | ### Example request ```bash curl "https://api.listener.com/v1/shows/SHOW_ID/cluster-candidates" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/shows/i3KlnFag2DgIpykraVxjrIZwJYcL/cluster-candidates", "data": [], "has_more": false, "next_cursor": null, "meta": { "show_id": "i3KlnFag2DgIpykraVxjrIZwJYcL", "show_name": "The Long Game", "configured": false, "total": 0, "computed_at": "2026-10-14T17:34:23.603774+00:00", "cache_age_seconds": 18.1, "note": "The cluster builder is not configured on this server: set LISTENER_INTERNAL_API_KEY (and LISTENER_APP_BASE_URL) so it can call the Listener app." } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Find an episode's clips and posts `GET /v1/shows/{show_id}/episodes/{episode_id}/content-candidates` Scope: `clusters:read` SDK: `client.shows.episodes.contentCandidates()` (TypeScript) · `client.shows.episodes.content_candidates()` (Python) Find the clips and posts that belong to one episode, including external posts not in the Listener library: searches the show's connected accounts, every enabled account in its cluster sources and any extra `handles`, then an AI judge scores each candidate (confidence, `sure` / `likely`, reason) and flags the ones already in the cluster. `handles` adds accounts for this search only (`x:northwindaudio`, `tiktok:@name`, a profile URL, or `@name` with `platforms`). `query` (a guest, a topic) is a ranking hint, never a filter; `judge=false` skips the scoring (faster). `from_date` / `to_date` override the window around the episode date. Writes nothing, but external accounts can spend scraper credits on their first fetch of a month. Add the posts you pick with `POST /v1/shows/{show_id}/episodes/{episode_id}/posts`. - Required scope: `clusters:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | | `episode_id` | string | required | Episode id (see GET /v1/shows/{show_id}/episodes). | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `query` | string | optional | Search text. | | `handles` | array of strings | optional | Repeat the parameter or comma-separate values. | | `platforms` | array of strings | optional | Only these platforms (e.g. youtube, spotify, instagram; aliases accepted). Repeat the parameter or comma-separate values. | | `from_date` | string | optional | Earliest publish date, YYYY-MM-DD. | | `to_date` | string | optional | Latest publish date, YYYY-MM-DD. | | `limit` | integer | optional | Maximum number of items to return. | | `judge` | boolean | optional | | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | | | `show_name` | string | required | | | `configured` | boolean | required | | | `episode` | object | optional | Default `null`. Nullable. | | `episode.id` | string | required | | | `episode.title` | string | optional | Default `null`. Nullable. | | `episode.published_at` | string | optional | Default `null`. Nullable. | | `episode.cluster_id` | string | optional | Default `null`. Nullable. | | `candidates` | array of objects | optional | Set when episode_title matched several episodes: pick one and call again with its id. | | `candidates[].id` | string | required | | | `candidates[].title` | string | optional | Default `null`. Nullable. | | `candidates[].published_at` | string | optional | Default `null`. Nullable. | | `candidates[].has_cluster` | boolean | optional | Default `false`. | | `candidates[].cluster_id` | string | optional | Default `null`. Nullable. | | `candidates[].build_status` | string | optional | AI build status for this episode: building \| ready (suggestions waiting) \| built \| accepted \| nothing \| failed \| dismissed. Default `null`. Nullable. | | `candidates[].source` | string | optional | stored (in the show's saved feed) \| live (newest episodes read from the platform feed, not saved yet) \| catalog (older episodes from the show's loaded back catalogue). Default `null`. Nullable. | | `items` | array of objects | optional | | | `items[].key` | string | optional | Default `null`. Nullable. | | `items[].source` | string | optional | library \| external. Default `null`. Nullable. | | `items[].platform` | string | optional | Display label (TikTok, YouTube Shorts, X …). Default `null`. Nullable. | | `items[].platform_key` | string | optional | Internal slug (tiktok, youtube_short, x …). Default `null`. Nullable. | | `items[].canonical_id` | string | optional | Default `null`. Nullable. | | `items[].matched_by` | string | optional | Default `null`. Nullable. | | `items[].url` | string | optional | Default `null`. Nullable. | | `items[].title` | string | optional | Default `null`. Nullable. | | `items[].text` | string | optional | Default `null`. Nullable. | | `items[].handle` | string | optional | Default `null`. Nullable. | | `items[].origin` | string | optional | connected \| learned \| added \| search. Default `null`. Nullable. | | `items[].published_at` | string | optional | Default `null`. Nullable. | | `items[].is_short` | boolean | optional | Default `null`. Nullable. | | `items[].duration_sec` | number | optional | Default `null`. Nullable. | | `items[].thumbnail` | string | optional | Default `null`. Nullable. | | `items[].metrics` | object | optional | Default `null`. Nullable. | | `items[].metrics.views` | integer \| number \| any | optional | Default `null`. | | `items[].metrics.likes` | integer \| number \| any | optional | Default `null`. | | `items[].metrics.comments` | integer \| number \| any | optional | Default `null`. | | `items[].metrics.shares` | integer \| number \| any | optional | Default `null`. | | `items[].confidence` | number | optional | Default `null`. Nullable. | | `items[].label` | string | optional | sure \| likely \| possible (from the judge; possible = less certain, 0.3-0.5); null when not matched to this episode or when judge=false. Default `null`. Nullable. | | `items[].query_match` | number | optional | 0-1 share of the `query` words found in the post (only when a query was given). Default `null`. Nullable. | | `items[].reason` | string | optional | Default `null`. Nullable. | | `items[].in_cluster` | boolean | optional | Already a node of this episode's cluster. Default `false`. | | `total` | integer | optional | Default `0`. | | `library_count` | integer | optional | Default `0`. | | `external_count` | integer | optional | Default `0`. | | `sure_count` | integer | optional | Default `0`. | | `in_cluster_count` | integer | optional | Default `0`. | | `searched_handles` | array of strings | optional | Extra accounts passed in for this search. | | `searched` | array of objects | optional | Every account searched, with posts found and any fetch error. | | `searched[].id` | string | required | ':'. | | `searched[].posts` | integer | optional | Default `0`. | | `searched[].error` | string | optional | Default `null`. Nullable. | | `window` | object | optional | Days searched before/after the episode date. Default `null`. Nullable. | | `window.before_days` | integer | optional | Posts up to this many days before the episode date count. Default `null`. Nullable. | | `window.after_days` | integer | optional | Posts up to this many days after the episode date count. Default `null`. Nullable. | | `window.learned_from` | any | optional | Default `null`. Nullable. | | `computed_at` | string | required | | | `cache_age_seconds` | number | required | | | `note` | string | optional | Default `null`. Nullable. | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/shows/SHOW_ID/episodes/EPISODE_ID/content-candidates" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "content_candidates", "show_id": "paAtKPJGo56md5VyoEQ9Las7qahL", "show_name": "Hard Court", "configured": false, "episode": null, "candidates": [], "items": [], "total": 0, "library_count": 0, "external_count": 0, "sure_count": 0, "in_cluster_count": 0, "searched_handles": [], "searched": [], "window": null, "computed_at": "2026-10-14T17:34:23.605720+00:00", "cache_age_seconds": 18.1, "note": "The cluster builder is not configured on this server: set LISTENER_INTERNAL_API_KEY (and LISTENER_APP_BASE_URL) so it can call the Listener app." } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Start an AI cluster build `POST /v1/cluster-builds` Scope: `clusters:write` SDK: `client.clusterBuilds.create()` (TypeScript) · `client.cluster_builds.create()` (Python) Build episode clusters with AI for a period or chosen episodes: for every episode in scope the builder gathers clips and posts from the show's connected and other accounts, judges which belong to the episode, and records suggestions (`mode=suggest`, default) or writes the sure ones straight into clusters (`mode=auto`). Send `"dry_run": true` first: `would_write.data.estimate` gives episodes in scope, already clustered, to build, minutes and scraper credits. The real request starts a background job (`data.job.job_id`); follow it with `GET /v1/cluster-builds/{job_id}`, then review with `GET /v1/shows/{show_id}/cluster-suggestions`. `scope`: `week`, `last30`, `range` (`from_date` + `to_date`), `backcatalog` (up to 500 episodes per job) or `episodes` (`episode_ids`). Clustered episodes are skipped unless `force=true`. One job runs per show; starting another returns the running one (`data.already_running`). - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `clusters:write`. ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | | `scope` | string | optional | One of `week`, `last30`, `range`, `backcatalog`, `episodes`. Default `"week"`. | | `from_date` | string | optional | Earliest publish date, YYYY-MM-DD. Default `null`. Nullable. | | `to_date` | string | optional | Latest publish date, YYYY-MM-DD. Default `null`. Nullable. | | `episode_ids` | array of strings | optional | Episode ids on the show. Default `null`. Nullable. | | `mode` | string | optional | How the change applies (see the operation description). One of `suggest`, `auto`. Default `"suggest"`. | | `force` | boolean | optional | Default `false`. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | ### Example request ```bash curl -X POST "https://api.listener.com/v1/cluster-builds" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"show_id": "SHOW_ID", "dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "cluster_build", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "clusterBuildJobs/paAtKPJGo56md5VyoEQ9Las7qahL", "id": "paAtKPJGo56md5VyoEQ9Las7qahL", "method": "http", "written": { "scope": "week", "mode": "suggest" }, "warnings": [], "urls": { "app": null, "public": null }, "error": null, "data": { "estimate": { "episodes_in_scope": 5, "already_clustered": 2, "to_build": 3, "minutes": 6, "scraper_credits": 42 } }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Retrieve a show's latest cluster build `GET /v1/cluster-builds/latest` Scope: `clusters:read` SDK: `client.clusterBuilds.latest()` (TypeScript) · `client.cluster_builds.latest()` (Python) The show's most recent cluster build: status, done / total, current `phase` and episode, counts (built, ready to review, nothing found, failed, skipped), per-episode outcomes, a one-line `summary` and the app link where suggestions are reviewed. Never cached; `wait_seconds` (up to 50) returns as soon as the job progresses. - Required scope: `clusters:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | | `wait_seconds` | integer | optional | | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | | | `show_name` | string | required | | | `configured` | boolean | required | | | `job` | object | optional | Default `null`. Nullable. | | `job.job_id` | string | required | | | `job.status` | string | required | queued \| running \| done \| failed \| cancelled. | | `job.scope` | object | required | | | `job.scope.kind` | string | required | | | `job.scope.from_date` | string | optional | Default `null`. Nullable. | | `job.scope.to_date` | string | optional | Default `null`. Nullable. | | `job.scope.episode_ids` | array of strings | optional | | | `job.mode` | string | optional | Default `null`. Nullable. | | `job.source` | string | optional | web \| agent \| mcp \| cron. Default `null`. Nullable. | | `job.total` | integer | optional | Default `0`. | | `job.done` | integer | optional | Default `0`. | | `job.progress_pct` | number | optional | Default `null`. Nullable. | | `job.counts` | object | optional | | | `job.counts.built` | integer | optional | Default `0`. | | `job.counts.ready` | integer | optional | Default `0`. | | `job.counts.nothing` | integer | optional | Default `0`. | | `job.counts.failed` | integer | optional | Default `0`. | | `job.counts.skipped` | integer | optional | Default `0`. | | `job.current_episode_id` | string | optional | Default `null`. Nullable. | | `job.current_title` | string | optional | Default `null`. Nullable. | | `job.phase` | string | optional | The step the job is on, in plain English (e.g. 'Reading posts from 9 accounts…'). Default `null`. Nullable. | | `job.auto_gate` | object | optional | Whether auto mode may add posts for this show, and why not. Default `null`. Nullable. | | `job.auto_gate.auto_off` | boolean | optional | True when this show's AI picks aren't accurate enough to add posts by themselves; everything waits for review. Default `false`. | | `job.auto_gate.basis` | string | optional | measured (checked against the show's recent clusters) \| insufficient (too few to check) \| default. Default `null`. Nullable. | | `job.auto_gate.precision` | number | optional | Share of the AI's sure picks the show's editors had also clustered, in the check. Default `null`. Nullable. | | `job.auto_gate.picks` | integer | optional | Default `null`. Nullable. | | `job.auto_gate.reason` | string | optional | Plain-English explanation to relay. Default `null`. Nullable. | | `job.credits_used` | number | optional | Default `null`. Nullable. | | `job.error` | string | optional | Default `null`. Nullable. | | `job.created_at` | string | optional | Default `null`. Nullable. | | `job.started_at` | string | optional | Default `null`. Nullable. | | `job.finished_at` | string | optional | Default `null`. Nullable. | | `job.heartbeat_at` | string | optional | Default `null`. Nullable. | | `job.stalled` | boolean | optional | Running but no heartbeat for over 10 minutes (the hourly cron resumes it). Default `false`. | | `finished` | boolean | optional | True once the job is done, failed or cancelled; stop polling then. Default `false`. | | `summary` | string | optional | One plain-English line to relay to the user. Default `""`. | | `outcomes` | array of objects | optional | | | `outcomes[].episode_id` | string | required | | | `outcomes[].title` | string | optional | Default `null`. Nullable. | | `outcomes[].status` | string | required | built \| ready \| nothing \| failed \| skipped \| building … | | `outcomes[].count` | integer | optional | Suggested posts on the episode's status doc. Default `0`. | | `outcomes[].added` | integer | optional | Posts written into the cluster. Default `0`. | | `outcomes[].cluster_id` | string | optional | Default `null`. Nullable. | | `outcomes[].published_at` | string | optional | Default `null`. Nullable. | | `outcomes[].reason` | string | optional | Why nothing was found (status nothing). Default `null`. Nullable. | | `outcomes[].error` | string | optional | Why it failed (status failed). Default `null`. Nullable. | | `outcomes[].review_url` | string | optional | Default `null`. Nullable. | | `review_url` | string | optional | App page where the suggestions are reviewed. Default `null`. Nullable. | | `computed_at` | string | required | | | `cache_age_seconds` | number | required | | | `note` | string | optional | Default `null`. Nullable. | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/cluster-builds/latest?show_id=SHOW_ID" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "cluster_build", "show_id": "i3KlnFag2DgIpykraVxjrIZwJYcL", "show_name": "The Long Game", "configured": false, "job": null, "finished": false, "summary": "", "outcomes": [], "review_url": null, "computed_at": "2026-10-14T17:34:23.789055+00:00", "cache_age_seconds": 18.3, "note": "The cluster builder is not configured on this server: set LISTENER_INTERNAL_API_KEY (and LISTENER_APP_BASE_URL) so it can call the Listener app." } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Retrieve a cluster build `GET /v1/cluster-builds/{job_id}` Scope: `clusters:read` SDK: `client.clusterBuilds.retrieve()` (TypeScript) · `client.cluster_builds.retrieve()` (Python) One cluster build job (from `POST /v1/cluster-builds`): status, done / total, current `phase` and episode, counts (built, ready to review, nothing found, failed, skipped), per-episode outcomes, a one-line `summary` and the review link. Never cached. To follow a running job pass `wait_seconds` (up to 50): the request returns as soon as the job progresses or finishes. - Required scope: `clusters:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `job_id` | string | required | Cluster build job id returned by POST /v1/cluster-builds. | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | | `wait_seconds` | integer | optional | | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | | | `show_name` | string | required | | | `configured` | boolean | required | | | `job` | object | optional | Default `null`. Nullable. | | `job.job_id` | string | required | | | `job.status` | string | required | queued \| running \| done \| failed \| cancelled. | | `job.scope` | object | required | | | `job.scope.kind` | string | required | | | `job.scope.from_date` | string | optional | Default `null`. Nullable. | | `job.scope.to_date` | string | optional | Default `null`. Nullable. | | `job.scope.episode_ids` | array of strings | optional | | | `job.mode` | string | optional | Default `null`. Nullable. | | `job.source` | string | optional | web \| agent \| mcp \| cron. Default `null`. Nullable. | | `job.total` | integer | optional | Default `0`. | | `job.done` | integer | optional | Default `0`. | | `job.progress_pct` | number | optional | Default `null`. Nullable. | | `job.counts` | object | optional | | | `job.counts.built` | integer | optional | Default `0`. | | `job.counts.ready` | integer | optional | Default `0`. | | `job.counts.nothing` | integer | optional | Default `0`. | | `job.counts.failed` | integer | optional | Default `0`. | | `job.counts.skipped` | integer | optional | Default `0`. | | `job.current_episode_id` | string | optional | Default `null`. Nullable. | | `job.current_title` | string | optional | Default `null`. Nullable. | | `job.phase` | string | optional | The step the job is on, in plain English (e.g. 'Reading posts from 9 accounts…'). Default `null`. Nullable. | | `job.auto_gate` | object | optional | Whether auto mode may add posts for this show, and why not. Default `null`. Nullable. | | `job.auto_gate.auto_off` | boolean | optional | True when this show's AI picks aren't accurate enough to add posts by themselves; everything waits for review. Default `false`. | | `job.auto_gate.basis` | string | optional | measured (checked against the show's recent clusters) \| insufficient (too few to check) \| default. Default `null`. Nullable. | | `job.auto_gate.precision` | number | optional | Share of the AI's sure picks the show's editors had also clustered, in the check. Default `null`. Nullable. | | `job.auto_gate.picks` | integer | optional | Default `null`. Nullable. | | `job.auto_gate.reason` | string | optional | Plain-English explanation to relay. Default `null`. Nullable. | | `job.credits_used` | number | optional | Default `null`. Nullable. | | `job.error` | string | optional | Default `null`. Nullable. | | `job.created_at` | string | optional | Default `null`. Nullable. | | `job.started_at` | string | optional | Default `null`. Nullable. | | `job.finished_at` | string | optional | Default `null`. Nullable. | | `job.heartbeat_at` | string | optional | Default `null`. Nullable. | | `job.stalled` | boolean | optional | Running but no heartbeat for over 10 minutes (the hourly cron resumes it). Default `false`. | | `finished` | boolean | optional | True once the job is done, failed or cancelled; stop polling then. Default `false`. | | `summary` | string | optional | One plain-English line to relay to the user. Default `""`. | | `outcomes` | array of objects | optional | | | `outcomes[].episode_id` | string | required | | | `outcomes[].title` | string | optional | Default `null`. Nullable. | | `outcomes[].status` | string | required | built \| ready \| nothing \| failed \| skipped \| building … | | `outcomes[].count` | integer | optional | Suggested posts on the episode's status doc. Default `0`. | | `outcomes[].added` | integer | optional | Posts written into the cluster. Default `0`. | | `outcomes[].cluster_id` | string | optional | Default `null`. Nullable. | | `outcomes[].published_at` | string | optional | Default `null`. Nullable. | | `outcomes[].reason` | string | optional | Why nothing was found (status nothing). Default `null`. Nullable. | | `outcomes[].error` | string | optional | Why it failed (status failed). Default `null`. Nullable. | | `outcomes[].review_url` | string | optional | Default `null`. Nullable. | | `review_url` | string | optional | App page where the suggestions are reviewed. Default `null`. Nullable. | | `computed_at` | string | required | | | `cache_age_seconds` | number | required | | | `note` | string | optional | Default `null`. Nullable. | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/cluster-builds/JOB_ID?show_id=SHOW_ID" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "cluster_build", "show_id": "i3KlnFag2DgIpykraVxjrIZwJYcL", "show_name": "The Long Game", "configured": false, "job": null, "finished": false, "summary": "", "outcomes": [], "review_url": null, "computed_at": "2026-10-14T17:34:23.790855+00:00", "cache_age_seconds": 18.3, "note": "The cluster builder is not configured on this server: set LISTENER_INTERNAL_API_KEY (and LISTENER_APP_BASE_URL) so it can call the Listener app." } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## List cluster suggestions `GET /v1/shows/{show_id}/cluster-suggestions` Scope: `clusters:read` SDK: `client.shows.clusterSuggestions.list()` (TypeScript) · `client.shows.cluster_suggestions.list()` (Python) Episodes with AI cluster suggestions, newest first (`status=ready` by default), each with its suggested clips and posts: key, platform, title, URL, handle, source (`library` or `external`), confidence, label (`sure` / `likely`), reason and metrics. Other statuses: `built`, `accepted`, `nothing`, `failed`, `building`, `dismissed`, `all`. `items_per_episode=0` returns counts only. Not paginated; `has_more` says when more episodes wait. - Required scope: `clusters:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `status` | string | optional | Status filter or value. | | `limit` | integer | optional | Maximum number of items to return. | | `items_per_episode` | integer | optional | | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].episode_id` | string | required | | | `data[].title` | string | optional | Default `null`. Nullable. | | `data[].published_at` | string | optional | Default `null`. Nullable. | | `data[].status` | string | required | | | `data[].mode` | string | optional | Default `null`. Nullable. | | `data[].run_id` | string | optional | Default `null`. Nullable. | | `data[].cluster_id` | string | optional | Set when a cluster already exists (auto mode or after accepting). Default `null`. Nullable. | | `data[].added_count` | integer | optional | Default `null`. Nullable. | | `data[].reason` | string | optional | Default `null`. Nullable. | | `data[].error` | string | optional | Default `null`. Nullable. | | `data[].items_total` | integer | optional | Default `0`. | | `data[].sure_count` | integer | optional | Default `0`. | | `data[].likely_count` | integer | optional | Default `0`. | | `data[].possible_count` | integer | optional | Default `0`. | | `data[].external_count` | integer | optional | Default `0`. | | `data[].items` | array of objects | optional | | | `data[].items[].key` | string | required | Pass to `POST /v1/shows/{show_id}/cluster-suggestions/{episode_id}/review`. | | `data[].items[].platform` | string | optional | Display label (TikTok, YouTube Shorts, X …). Default `null`. Nullable. | | `data[].items[].platform_key` | string | optional | Internal slug (tiktok, youtube_short, x …). Default `null`. Nullable. | | `data[].items[].canonical_id` | string | optional | Default `null`. Nullable. | | `data[].items[].matched_by` | string | optional | Default `null`. Nullable. | | `data[].items[].title` | string | optional | Default `null`. Nullable. | | `data[].items[].url` | string | optional | Default `null`. Nullable. | | `data[].items[].thumbnail` | string | optional | Default `null`. Nullable. | | `data[].items[].published_at` | string | optional | Default `null`. Nullable. | | `data[].items[].confidence` | number | optional | Default `null`. Nullable. | | `data[].items[].label` | string | optional | sure \| likely \| possible (less certain; never added automatically, offer it last). Default `null`. Nullable. | | `data[].items[].auto_write` | boolean | optional | True when auto mode would add this post by itself (the show's gate allows it). Default `null`. Nullable. | | `data[].items[].reason` | string | optional | Default `null`. Nullable. | | `data[].items[].source` | string | optional | library (connected accounts) \| external (not in the library). Default `null`. Nullable. | | `data[].items[].handle` | string | optional | Default `null`. Nullable. | | `data[].items[].origin` | string | optional | Default `null`. Nullable. | | `data[].items[].metrics` | object | optional | Default `null`. Nullable. | | `data[].items[].metrics.views` | integer \| number \| any | optional | Default `null`. | | `data[].items[].metrics.likes` | integer \| number \| any | optional | Default `null`. | | `data[].items[].metrics.comments` | integer \| number \| any | optional | Default `null`. | | `data[].items[].metrics.shares` | integer \| number \| any | optional | Default `null`. | | `data[].review_url` | string | optional | Default `null`. Nullable. | | `data[].updated_at` | string | optional | Default `null`. Nullable. | | `data[].object` | string | required | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | | `meta.show_id` | string | optional | | | `meta.show_name` | string | optional | | | `meta.status` | string | optional | | | `meta.total` | integer | optional | | | `meta.has_more` | boolean | optional | Default `false`. | | `meta.review_url` | string | optional | Default `null`. Nullable. | | `meta.computed_at` | string | optional | | | `meta.cache_age_seconds` | number | optional | | | `meta.note` | string | optional | Default `null`. Nullable. | ### Example request ```bash curl "https://api.listener.com/v1/shows/SHOW_ID/cluster-suggestions" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/shows/i3KlnFag2DgIpykraVxjrIZwJYcL/cluster-suggestions", "data": [], "has_more": false, "next_cursor": null, "meta": { "show_id": "i3KlnFag2DgIpykraVxjrIZwJYcL", "show_name": "The Long Game", "status": "ready", "total": 0, "review_url": "https://network.listener.com/app/episodes?uid=i3KlnFag2DgIpykraVxjrIZwJYcL", "computed_at": "2026-10-14T17:34:23.793221+00:00", "cache_age_seconds": 18.3, "note": "Nothing is waiting for review. Run `POST /v1/cluster-builds` to look for posts, or `GET /v1/shows/{show_id}/episodes/{episode_id}/content-candidates` for one episode." } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Review cluster suggestions `POST /v1/shows/{show_id}/cluster-suggestions/{episode_id}/review` Scope: `clusters:write` SDK: `client.shows.clusterSuggestions.review()` (TypeScript) · `client.shows.cluster_suggestions.review()` (Python) Accept or dismiss one episode's AI suggestions. Send `accept_keys` (item keys from `GET /v1/shows/{show_id}/cluster-suggestions`) and/or `accept_all_sure=true`, or `dismiss=true`, never both. Accepted posts are added to the episode's cluster (created when missing); dismiss drops every remaining suggestion. Unknown keys are an error listing the valid ones. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `clusters:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | | `episode_id` | string | required | Episode id (see GET /v1/shows/{show_id}/episodes). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `accept_keys` | array of strings | optional | Default `null`. Nullable. | | `accept_all_sure` | boolean | optional | Default `false`. | | `dismiss` | boolean | optional | Default `false`. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | ### Example request ```bash curl -X POST "https://api.listener.com/v1/shows/SHOW_ID/cluster-suggestions/EPISODE_ID/review" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "cluster_suggestion_review", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/paAtKPJGo56md5VyoEQ9Las7qahL/clusters/cluster-0y0HEJ0OVF7EYdXAvD5ghY", "id": "cluster-0y0HEJ0OVF7EYdXAvD5ghY", "method": "http", "written": { "episodeId": "0y0HEJ0OVF7EYdXAvD5ghY", "acceptKeys": [ "tiktok:7421" ] }, "warnings": [], "urls": { "app": null, "public": null }, "error": null, "data": { "accepted": [ { "key": "tiktok:7421", "platform": "TikTok", "url": "https://www.tiktok.com/@caseymorgan66/video/8564" } ], "cluster_id": "cluster-0y0HEJ0OVF7EYdXAvD5ghY" }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. --- # Campaigns > Sponsorship campaigns, slots, cluster links, brand profiles and air checks. Base URL: `https://api.listener.com` ## List campaigns `GET /v1/campaigns` Scope: `campaigns:read` SDK: `client.campaigns.list()` (TypeScript) · `client.campaigns.list()` (Python) The workspace's campaigns as the Campaigns page lists them: status, flight, goal progress, pacing, slots, reach by category and format, expected and delivered CPM, EMV, budget, unlinked slots, brand and public URL. `status` filters after `meta.status_counts` are taken; `brand` matches name or slug; `show_ids` keeps campaigns with a slot or cluster on those shows; `search` matches title, brand and show names; `sort_by` is `:` (keys: startDate, endDate, status, createdAt, updatedAt, title, brand, reach, goal, pacing, cpm, budget, show). `precision=summary` (default) reads stored cluster summaries, fast but EMV, first-30-days and delivered CPM skip summary-only clusters; `precision=nodes` loads every linked cluster (slow on large networks). - Required scope: `campaigns:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `status` | string | optional | Status filter or value. | | `brand` | string | optional | Brand name or slug filter. | | `show_ids` | array of strings | optional | Limit to these show ids (default: every show in the workspace). Repeat the parameter or comma-separate values. | | `search` | string | optional | Case-insensitive text filter. | | `unlinked_only` | boolean | optional | | | `sort_by` | string | optional | Sort field. | | `precision` | string | optional | summary (fast, stored totals) or a more precise, slower computation. | | `limit` | integer | optional | Page size, 1–100 (default 25). | | `cursor` | string | optional | Opaque cursor from the previous page's next_cursor. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].id` | string | required | | | `data[].title` | string | required | | | `data[].description` | string | optional | Default `""`. | | `data[].status` | string | required | One of `upcoming`, `active`, `completed`. | | `data[].status_label` | string | required | | | `data[].status_is_manual` | boolean | optional | Default `false`. | | `data[].start_date` | string | optional | Default `null`. Nullable. | | `data[].end_date` | string | optional | Default `null`. Nullable. | | `data[].start_is_fallback` | boolean | optional | Default `false`. | | `data[].days_remaining` | integer | optional | Default `null`. Nullable. | | `data[].days_until_start` | integer | optional | Default `null`. Nullable. | | `data[].timing` | string | optional | Default `null`. Nullable. | | `data[].brand` | object | required | | | `data[].brand.name` | string | optional | Default `""`. | | `data[].brand.slug` | string | optional | Default `null`. Nullable. | | `data[].brand.domain` | string | optional | Default `null`. Nullable. | | `data[].brand.logo` | string | optional | Default `null`. Nullable. | | `data[].brand.notes` | string | optional | Default `null`. Nullable. | | `data[].brand.has_contact` | boolean | optional | Default `false`. | | `data[].brand.page_url` | string | optional | Public brand page on the network site (None without a network handle). Default `null`. Nullable. | | `data[].goals` | object | required | | | `data[].goals.total_views` | number | optional | Default `0`. | | `data[].goals.show_goals` | object | optional | | | `data[].goals.total_budget` | number | optional | Default `0`. | | `data[].goals.show_budgets` | object | optional | | | `data[].goals.platform_goals` | object | optional | | | `data[].has_goal` | boolean | optional | Default `false`. | | `data[].goal_progress_pct` | number | optional | Default `null`. Nullable. | | `data[].pacing` | object | required | | | `data[].pacing.total_slots` | integer | optional | Default `0`. | | `data[].pacing.live_slots` | integer | optional | Default `0`. | | `data[].pacing.expected_reach` | number | optional | Default `0`. | | `data[].pacing.pacing_pct` | number | optional | Default `0`. | | `data[].pacing.delta` | number | optional | Default `0`. | | `data[].pacing.delta_pct` | number | optional | Default `null`. Nullable. | | `data[].pacing.has_live_pacing` | boolean | optional | Default `false`. | | `data[].pacing.is_ahead` | boolean | optional | Default `false`. | | `data[].pacing.status_label` | string | optional | Default `""`. | | `data[].pacing.bar_pct` | number | optional | Default `0`. | | `data[].budget` | number | optional | Default `0`. | | `data[].expected_impressions` | number | optional | Default `0`. | | `data[].expected_cpm` | number | optional | Default `0`. | | `data[].delivered_cpm` | number | optional | Default `0`. | | `data[].delivered_cpm_status` | string | optional | Default `"pending"`. | | `data[].delivered_cpm_long_form` | number | optional | Default `0`. | | `data[].delivered_cpm_short_form` | number | optional | Default `0`. | | `data[].emv_total` | number | optional | Default `0`. | | `data[].emv_enabled` | boolean | optional | Default `false`. | | `data[].reach` | object | required | | | `data[].reach.total` | number | optional | Default `0`. | | `data[].reach.youtube` | number | optional | Default `0`. | | `data[].reach.youtube_long_form` | number | optional | Default `0`. | | `data[].reach.youtube_shorts` | number | optional | Default `0`. | | `data[].reach.audio` | number | optional | Default `0`. | | `data[].reach.social` | number | optional | Default `0`. | | `data[].reach.long_form` | number | optional | List-page format split (nodes inferred as long-form). Default `0`. | | `data[].reach.short_form` | number | optional | Default `0`. | | `data[].reach.cluster_long_form` | number | optional | Default `0`. | | `data[].reach.cluster_short_form` | number | optional | Default `0`. | | `data[].reach.long_form_count` | integer | optional | Default `0`. | | `data[].reach.short_form_count` | integer | optional | Default `0`. | | `data[].reach.clips` | integer | optional | Default `0`. | | `data[].reach.posts` | integer | optional | Default `0`. | | `data[].engagement` | number | optional | Default `0`. | | `data[].engagement_rate` | number | optional | Default `0`. | | `data[].linked_show_ids` | array of strings | optional | | | `data[].linked_show_names` | array of strings | optional | | | `data[].show_count` | integer | optional | Default `0`. | | `data[].total_slots` | integer | optional | Default `0`. | | `data[].linked_slots` | integer | optional | Default `0`. | | `data[].unlinked_slot_count` | integer | optional | Default `0`. | | `data[].live_slots` | integer | optional | Default `0`. | | `data[].cluster_count` | integer | optional | Linked clusters that were loaded. Default `0`. | | `data[].clusters_missing` | array of strings | optional | | | `data[].air_checks_detected` | integer | optional | Default `0`. | | `data[].duplicate_of` | array of strings | optional | Other campaign ids with the same brand + title. Default `null`. Nullable. | | `data[].precision` | string | required | One of `summary`, `nodes`, `none`. | | `data[].warnings` | array of strings | optional | | | `data[].public_url` | string | optional | Default `null`. Nullable. | | `data[].app_url` | string | required | | | `data[].created_at` | string | optional | Default `null`. Nullable. | | `data[].updated_at` | string | optional | Default `null`. Nullable. | | `data[].object` | string | required | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | | `meta.status_counts` | map of integer | optional | Counts before the status filter (like the page chips). | | `meta.brands` | array of strings | optional | Every brand name across the workspace's campaigns. | | `meta.unlinked_count` | integer | optional | Campaigns with at least one unlinked slot (before filters). Default `0`. | | `meta.duplicate_count` | integer | optional | Default `0`. | | `meta.totals` | object | optional | Sums over the filtered rows: reach, budget, expected impressions, EMV. | | `meta.filters` | object | optional | | | `meta.sort_by` | string | optional | | | `meta.precision` | string | optional | 'summary' rows use stored cluster summaries; 'nodes' loaded every linked cluster. One of `summary`, `nodes`, `none`. | | `meta.missing_show_ids` | array of strings | optional | | | `meta.computed_at` | string | optional | | | `meta.cache_age_seconds` | number | optional | | | `meta.note` | string | optional | Default `null`. Nullable. | ### Example request ```bash curl "https://api.listener.com/v1/campaigns" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/campaigns", "data": [ { "object": "campaign", "id": "B9GpElnJc2UQ1JLfav8j", "title": "Wonka Snacks — Q4 Push", "description": "", "status": "active", "status_label": "Active", "status_is_manual": false, "start_date": "2026-10-01", "end_date": "2026-10-31", "start_is_fallback": false, "days_remaining": 17, "days_until_start": -13, "timing": "17 days left", "brand": { "name": "Listener.com", "slug": "listener-com", "domain": "listener.com", "logo": "https://images.example.com/listener/785e0a7080216ca9.png", "notes": null, "has_contact": false, "page_url": "https://network.listener.com/northwind-audio/brands/listener-com" }, "goals": { "total_views": 500000, "show_goals": { "paAtKPJGo56md5VyoEQ9Las7qahL": 500000 }, "total_budget": 100000, "show_budgets": {}, "platform_goals": {} }, "has_goal": true, "goal_progress_pct": 4.9836, "pacing": { "total_slots": 3, "live_slots": 3, "expected_reach": 140728, "pacing_pct": 28.145599999999998, "delta": -115810, "delta_pct": -82.29350235916093, "has_live_pacing": true, "is_ahead": false, "status_label": "Behind", "bar_pct": 28.145599999999998 }, "budget": 100000, "expected_impressions": 140728, "expected_cpm": 710.5906429424138, "delivered_cpm": 0, "delivered_cpm_status": "measuring", "delivered_cpm_long_form": 1775.7086559742554, "delivered_cpm_short_form": 4651.941203974333, "emv_total": 912.78, "emv_enabled": true, "reach": { "total": 24918, "youtube": 20443, "youtube_long_form": 3850, "youtube_shorts": 16593, "audio": 1311, "social": 3164, "long_form": 5534, "short_form": 19384, "cluster_long_form": 5534, "cluster_short_form": 19384, "long_form_count": 4, "short_form_count": 8, "clips": 12, "posts": 0 }, "engagement": 735, "engagement_rate": 2.949674933782808, "linked_show_ids": [ "paAtKPJGo56md5VyoEQ9Las7qahL", "HTSVG2lSc1RmPELJNtCnvOEb4Z2u" ], "linked_show_names": [ "Hard Court", "Fourth Quarter" ], "show_count": 2, "total_slots": 3, "linked_slots": 3, "unlinked_slot_count": 0, "live_slots": 3, "cluster_count": 3, "clusters_missing": [], "air_checks_detected": 0, "duplicate_of": null, "precision": "nodes", "warnings": [], "public_url": "https://network.listener.com/northwind-audio/campaigns/B9GpElnJc2UQ1JLfav8j", "app_url": "https://network.listener.com/app/campaign?id=B9GpElnJc2UQ1JLfav8j&uid=XXfzcGxkLsQaKAvRy1SwLsZCcH78", "created_at": "2026-08-20T15:33:35.789Z", "updated_at": "2026-10-07T17:00:55.475Z" }, { "object": "campaign", "id": "5dnZczrPhPUcJ2Df1eLB", "title": "Cyberdyne Fitness — Season Opener", "description": "", "status": "completed", "status_label": "Completed", "status_is_manual": true, "start_date": "2026-08-21", "end_date": null, "start_is_fallback": true, "days_remaining": null, "days_until_start": -54, "timing": "Marked completed", "brand": { "name": "Vandelay Imports", "slug": null, "domain": null, "logo": null, "notes": null, "has_contact": false, "page_url": null }, "goals": { "total_views": 0, "show_goals": {}, "total_budget": 0, "show_budgets": {}, "platform_goals": {} }, "has_goal": false, "goal_progress_pct": null, "pacing": { "total_slots": 0, "live_slots": 0, "expected_reach": 0, "pacing_pct": 0, "delta": 0, "delta_pct": null, "has_live_pacing": false, "is_ahead": true, "status_label": "No goal", "bar_pct": 0 }, "budget": 0, "expected_impressions": 0, "expected_cpm": 0, "delivered_cpm": 0, "delivered_cpm_status": "unavailable", "delivered_cpm_long_form": 0, "delivered_cpm_short_form": 0, "emv_total": 0, "emv_enabled": false, "reach": { "total": 0, "youtube": 0, "youtube_long_form": 0, "youtube_shorts": 0, "audio": 0, "social": 0, "long_form": 0, "short_form": 0, "cluster_long_form": 0, "cluster_short_form": 0, "long_form_count": 0, "short_form_count": 0, "clips": 0, "posts": 0 }, "engagement": 0, "engagement_rate": 0, "linked_show_ids": [], "linked_show_names": [], "show_count": 0, "total_slots": 0, "linked_slots": 0, "unlinked_slot_count": 0, "live_slots": 0, "cluster_count": 0, "clusters_missing": [], "air_checks_detected": 0, "duplicate_of": null, "precision": "none", "warnings": [], "public_url": "https://network.listener.com/northwind-audio/campaigns/5dnZczrPhPUcJ2Df1eLB", "app_url": "https://network.listener.com/app/campaign?id=5dnZczrPhPUcJ2Df1eLB&uid=XXfzcGxkLsQaKAvRy1SwLsZCcH78", "created_at": "2026-08-21T15:14:02.174Z", "updated_at": "2026-09-07T20:56:58.158Z" } ], "has_more": true, "next_cursor": "eyJvIjoyfQ", "meta": { "status_counts": { "all": 21, "active": 20, "upcoming": 0, "completed": 1 }, "brands": [ "Acme Coffee", "Umbrella Energy" ], "unlinked_count": 12, "duplicate_count": 2, "totals": { "reach_total": 806568, "reach_youtube": 314345, "reach_audio": 239374, "reach_social": 252849, "reach_long_form": 396828, "reach_short_form": 409740, "budget": 361000, "expected_impressions": 8281369, "emv_total": 25372.500000000004, "slots": 71, "linked_slots": 31, "unlinked_slots": 40, "campaigns": 21 }, "filters": { "status": "all", "brand": null, "show_ids": null, "search": null, "unlinked_only": false }, "sort_by": "startDate:desc", "precision": "nodes", "missing_show_ids": [ "0Iush8rT5eCcD4ePHroKFDlQvKhT", "nCJuUMzGC6EE3dfruqjRHtYdlCTr" ], "computed_at": "2026-10-14T17:34:24.302850+00:00", "cache_age_seconds": 18.8, "note": null, "total": 21 } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Create a campaign `POST /v1/campaigns` Scope: `campaigns:write` SDK: `client.campaigns.create()` (TypeScript) · `client.campaigns.create()` (Python) Create a campaign. At least one show must be referenced through `show_goals`, `show_budgets` or `planned_episodes`. Dates are `YYYY-MM-DD` and `end_date` must not precede `start_date`. When `brand.name` is set the brand profile is created or updated first and campaigns already sharing that brand get its details re-stamped (listed in `warnings`). A duplicate title + brand is a warning, not an error. No emails or notifications are sent. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `campaigns:write`. ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `title` | string | required | Title. | | `description` | string | optional | Free-text description. Default `""`. | | `start_date` | string | optional | First day, YYYY-MM-DD (inclusive). With end_date, overrides time_range. Default `null`. Nullable. | | `end_date` | string | optional | Last day, YYYY-MM-DD (inclusive). With start_date, overrides time_range. Default `null`. Nullable. | | `brand` | object | optional | Brand name or slug filter. Default `null`. Nullable. | | `brand.name` | string | optional | Default `""`. | | `brand.contact` | string | optional | Default `""`. | | `brand.notes` | string | optional | Default `""`. | | `brand.logo` | string | optional | Default `""`. | | `brand.domain` | string | optional | Default `""`. | | `brand.invite_email` | string | optional | Default `""`. | | `show_goals` | map of integer | optional | Per-show view goals: {show_id: views}. Default `null`. Nullable. | | `show_budgets` | map of number | optional | Per-show budgets in dollars: {show_id: amount}. Default `null`. Nullable. | | `total_budget` | number | optional | Total campaign budget in dollars. Default `null`. Nullable. | | `platform_goals` | map of integer | optional | Per-platform goals: {platform: views}. Default `null`. Nullable. | | `planned_episodes` | array of objects | optional | Campaign slots: [{show_id, title?, expected_impressions?, budget?}]. Default `null`. Nullable. | | `planned_episodes[].show_id` | string | required | | | `planned_episodes[].title` | string | optional | Default `null`. Nullable. | | `planned_episodes[].expected_impressions` | integer \| number \| string \| any | optional | Default `null`. | | `planned_episodes[].budget` | integer \| number \| string \| any | optional | Default `null`. | | `planned_episodes[].linked_cluster_id` | string | optional | Default `null`. Nullable. | | `show_ids` | array of strings | optional | Limit to these show ids (default: every show in the workspace). Default `null`. Nullable. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | ### Example request ```bash curl -X POST "https://api.listener.com/v1/campaigns" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"title": "TITLE", "dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "campaign", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/XXfzcGxkLsQaKAvRy1SwLsZCcH78/campaigns/wJ7WendsjngdnLGuGoXa", "id": "wJ7WendsjngdnLGuGoXa", "method": "add", "written": { "title": "Soylent Foods — Spring Launch", "description": "", "startDate": null, "endDate": null, "brandDetails": { "name": "Vandelay Imports", "logo": "", "domain": "", "contact": "", "inviteEmail": "", "notes": "" }, "goals": { "totalViews": 1000, "showGoals": { "i3KlnFag2DgIpykraVxjrIZwJYcL": 1000 }, "totalBudget": 0, "showBudgets": {} }, "clusterIds": [], "plannedEpisodes": [], "createdAt": "", "updatedAt": "", "createdVia": "mcp" }, "warnings": [], "urls": { "app": "https://network.listener.com/app/campaign?id=wJ7WendsjngdnLGuGoXa&uid=XXfzcGxkLsQaKAvRy1SwLsZCcH78", "public": "https://network.listener.com/northwind-audio/campaigns/wJ7WendsjngdnLGuGoXa" }, "error": null, "data": { "campaign_id": "wJ7WendsjngdnLGuGoXa", "eligible_show_ids": [ "paAtKPJGo56md5VyoEQ9Las7qahL", "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H" ], "brand_slug": null }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Retrieve a campaign `GET /v1/campaigns/{campaign_id}` Scope: `campaigns:read` SDK: `client.campaigns.retrieve()` (TypeScript) · `client.campaigns.retrieve()` (Python) One campaign in full: headline KPIs (reach by category and format, engagement, expected vs delivered CPM, EMV with resolved settings, first-30-days estimate), fulfilment against goal and pacing, every slot with its linked cluster's reach, per-show breakdown, air-check summary, share settings, brand, activity log and URLs. Node-precise: every linked cluster is loaded. - Required scope: `campaigns:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `campaign_id` | string | required | Campaign id (see GET /v1/campaigns). | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `activity_limit` | integer | optional | | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `id` | string | required | | | `title` | string | required | | | `description` | string | optional | Default `""`. | | `status` | string | required | One of `upcoming`, `active`, `completed`. | | `status_label` | string | required | | | `status_is_manual` | boolean | optional | Default `false`. | | `status_updated_at` | string | optional | Default `null`. Nullable. | | `start_date` | string | optional | Default `null`. Nullable. | | `end_date` | string | optional | Default `null`. Nullable. | | `start_is_fallback` | boolean | optional | Default `false`. | | `days_remaining` | integer | optional | Default `null`. Nullable. | | `days_until_start` | integer | optional | Default `null`. Nullable. | | `timing` | string | optional | Default `null`. Nullable. | | `brand` | object | required | | | `brand.name` | string | optional | Default `""`. | | `brand.slug` | string | optional | Default `null`. Nullable. | | `brand.domain` | string | optional | Default `null`. Nullable. | | `brand.logo` | string | optional | Default `null`. Nullable. | | `brand.notes` | string | optional | Default `null`. Nullable. | | `brand.has_contact` | boolean | optional | Default `false`. | | `brand.page_url` | string | optional | Public brand page on the network site (None without a network handle). Default `null`. Nullable. | | `goals` | object | required | | | `goals.total_views` | number | optional | Default `0`. | | `goals.show_goals` | object | optional | | | `goals.total_budget` | number | optional | Default `0`. | | `goals.show_budgets` | object | optional | | | `goals.platform_goals` | object | optional | | | `has_goal` | boolean | optional | Default `false`. | | `goal_progress_pct` | number | optional | Default `null`. Nullable. | | `goal_progress_clamped_pct` | number | optional | Default `null`. Nullable. | | `pacing` | object | required | | | `pacing.total_slots` | integer | optional | Default `0`. | | `pacing.live_slots` | integer | optional | Default `0`. | | `pacing.expected_reach` | number | optional | Default `0`. | | `pacing.pacing_pct` | number | optional | Default `0`. | | `pacing.delta` | number | optional | Default `0`. | | `pacing.delta_pct` | number | optional | Default `null`. Nullable. | | `pacing.has_live_pacing` | boolean | optional | Default `false`. | | `pacing.is_ahead` | boolean | optional | Default `false`. | | `pacing.status_label` | string | optional | Default `""`. | | `pacing.bar_pct` | number | optional | Default `0`. | | `budget` | number | optional | Default `0`. | | `slot_budget_total` | number | optional | Default `0`. | | `expected_impressions` | number | optional | Default `0`. | | `expected_cpm` | number | optional | Default `0`. | | `delivered_cpm` | number | optional | Default `0`. | | `delivered_cpm_status` | string | optional | Default `"pending"`. | | `delivered_cpm_detail` | object | optional | | | `format_cpm` | object | optional | | | `emv` | object | optional | EMV block incl. resolved settings + settings_source. | | `reach` | object | required | | | `reach.total` | number | optional | Default `0`. | | `reach.youtube` | number | optional | Default `0`. | | `reach.youtube_long_form` | number | optional | Default `0`. | | `reach.youtube_shorts` | number | optional | Default `0`. | | `reach.audio` | number | optional | Default `0`. | | `reach.social` | number | optional | Default `0`. | | `reach.long_form` | number | optional | List-page format split (nodes inferred as long-form). Default `0`. | | `reach.short_form` | number | optional | Default `0`. | | `reach.cluster_long_form` | number | optional | Default `0`. | | `reach.cluster_short_form` | number | optional | Default `0`. | | `reach.long_form_count` | integer | optional | Default `0`. | | `reach.short_form_count` | integer | optional | Default `0`. | | `reach.clips` | integer | optional | Default `0`. | | `reach.posts` | integer | optional | Default `0`. | | `engagement` | number | optional | Default `0`. | | `engagement_rate` | number | optional | Default `0`. | | `first_30_days` | object | optional | | | `linked_show_ids` | array of strings | optional | | | `linked_show_names` | array of strings | optional | | | `show_count` | integer | optional | Default `0`. | | `cluster_ids` | array of strings | optional | | | `clusters_loaded` | integer | optional | Default `0`. | | `clusters_missing` | array of strings | optional | | | `total_slots` | integer | optional | Default `0`. | | `linked_slots` | integer | optional | Default `0`. | | `unlinked_slot_count` | integer | optional | Default `0`. | | `live_slots` | integer | optional | Default `0`. | | `slots` | array of objects | optional | | | `slots[].id` | string | optional | Default `null`. Nullable. | | `slots[].show_id` | string | optional | Default `null`. Nullable. | | `slots[].show_name` | string | optional | Default `null`. Nullable. | | `slots[].title` | string | optional | Default `null`. Nullable. | | `slots[].slot_number` | integer | optional | Default `null`. Nullable. | | `slots[].expected_impressions` | number | optional | Default `0`. | | `slots[].expected_impressions_estimated` | boolean | optional | Default `false`. | | `slots[].budget` | number | optional | Default `0`. | | `slots[].budget_pinned` | boolean | optional | Default `false`. | | `slots[].impressions_pinned` | boolean | optional | Default `false`. | | `slots[].linked_cluster_id` | string | optional | Default `null`. Nullable. | | `slots[].linked_cluster_name` | string | optional | Default `null`. Nullable. | | `slots[].linked_cluster_loaded` | boolean | optional | Default `false`. | | `slots[].is_live` | boolean | optional | None when the linked cluster was not loaded or has no nodes. Default `null`. Nullable. | | `slots[].cluster_reach` | object | optional | Default `null`. Nullable. | | `slots[].cluster_public_url` | string | optional | Default `null`. Nullable. | | `slots[].fulfillment_pct` | number | optional | Default `null`. Nullable. | | `slots[].excluded_node_count` | integer | optional | Default `0`. | | `show_breakdown` | array of objects | optional | | | `show_breakdown[].id` | string | required | | | `show_breakdown[].name` | string | required | | | `show_breakdown[].image` | string | optional | Default `null`. Nullable. | | `show_breakdown[].public_url` | string | optional | Default `null`. Nullable. | | `show_breakdown[].metrics` | object | required | | | `show_breakdown[].slots` | integer | optional | Default `0`. | | `show_breakdown[].linked_slots` | integer | optional | Default `0`. | | `show_breakdown[].live_slots` | integer | optional | Default `0`. | | `show_breakdown[].pacing_expected_reach` | number | optional | Default `0`. | | `show_breakdown[].delta_from_pacing` | number | optional | Default `0`. | | `show_breakdown[].reach_goal` | number | optional | Default `0`. | | `show_breakdown[].fulfillment_pct` | number | optional | Default `0`. | | `show_breakdown[].precision` | string | optional | Default `"nodes"`. | | `air_checks` | object | optional | | | `excluded_node_count` | integer | optional | Default `0`. | | `excluded_node_ids_count` | integer | optional | Default `0`. | | `featured_content_node_id` | string | optional | Default `null`. Nullable. | | `merged_from` | array of objects | optional | Default `null`. Nullable. | | `copied_from` | object | optional | Default `null`. Nullable. | | `created_via` | string | optional | Default `null`. Nullable. | | `duplicate` | object | optional | Default `null`. Nullable. | | `share` | object | optional | | | `activity_log` | object | required | | | `activity_log.events` | array of objects | required | | | `activity_log.events[].type` | string | required | | | `activity_log.events[].key` | string | required | | | `activity_log.events[].day` | string | optional | Default `null`. Nullable. | | `activity_log.events[].at` | string | optional | Default `null`. Nullable. | | `activity_log.events[].platform` | string | optional | Default `null`. Nullable. | | `activity_log.events[].title` | string | optional | Default `null`. Nullable. | | `activity_log.events[].show_id` | string | optional | Default `null`. Nullable. | | `activity_log.events[].show_name` | string | optional | Default `null`. Nullable. | | `activity_log.events[].show_names` | array of strings | optional | | | `activity_log.events[].cluster_id` | string | optional | Default `null`. Nullable. | | `activity_log.events[].is_first` | boolean | optional | Default `null`. Nullable. | | `activity_log.events[].clip_count` | integer | optional | Default `0`. | | `activity_log.events[].post_count` | integer | optional | Default `0`. | | `activity_log.events[].views` | number | optional | Default `0`. | | `activity_log.events[].count_label` | string | optional | Default `null`. Nullable. | | `activity_log.events[].label` | string | optional | Default `null`. Nullable. | | `activity_log.events[].upcoming` | boolean | optional | Default `false`. | | `activity_log.total_events` | integer | required | | | `activity_log.summary` | object | required | | | `public_url` | string | optional | Default `null`. Nullable. | | `app_url` | string | required | | | `created_at` | string | optional | Default `null`. Nullable. | | `updated_at` | string | optional | Default `null`. Nullable. | | `precision` | string | required | One of `summary`, `nodes`, `none`. | | `warnings` | array of strings | optional | | | `computed_at` | string | required | | | `cache_age_seconds` | number | required | | | `note` | string | optional | Default `null`. Nullable. | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/campaigns/CAMPAIGN_ID" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "campaign", "id": "B9GpElnJc2UQ1JLfav8j", "title": "Wonka Snacks — Q4 Push", "description": "", "status": "active", "status_label": "Active", "status_is_manual": false, "status_updated_at": null, "start_date": "2026-10-01", "end_date": "2026-10-31", "start_is_fallback": false, "days_remaining": 17, "days_until_start": -13, "timing": "17 days left", "brand": { "name": "Listener.com", "slug": "listener-com", "domain": "listener.com", "logo": "https://images.example.com/listener/785e0a7080216ca9.png", "notes": null, "has_contact": false, "page_url": "https://network.listener.com/northwind-audio/brands/listener-com" }, "goals": { "total_views": 500000, "show_goals": { "paAtKPJGo56md5VyoEQ9Las7qahL": 500000 }, "total_budget": 100000, "show_budgets": {}, "platform_goals": {} }, "has_goal": true, "goal_progress_pct": 29.486400000000003, "goal_progress_clamped_pct": 29.486400000000003, "pacing": { "total_slots": 3, "live_slots": 3, "expected_reach": 140728, "pacing_pct": 28.145599999999998, "delta": 6704, "delta_pct": 4.763799670285942, "has_live_pacing": true, "is_ahead": true, "status_label": "Ahead", "bar_pct": 28.145599999999998 }, "budget": 100000, "slot_budget_total": 100000, "expected_impressions": 140728, "expected_cpm": 710.5906429424138, "delivered_cpm": 678.278799717836, "delivered_cpm_status": "reported", "delivered_cpm_detail": { "cpm": 678.278799717836, "budget": 100000, "delivered_impressions": 147432, "expected_impressions": 140728, "delivered_slots": 3, "total_slots": 3, "delivery_pct": 104.76379967028595, "is_provisional": false }, "format_cpm": { "total_budget": 100000, "expected_impressions": { "long_form": 9993.341991319874, "short_form": 130734.65800868013, "total": 140728 }, "delivered_impressions": { "long_form": 12338, "short_form": 135094, "total": 147432 }, "budget_allocation": { "long_form": 7101.1753107554105, "short_form": 92898.82468924459 }, "expected_cpm": { "long_form": 710.5906429424138, "short_form": 710.5906429424138 }, "delivered_cpm": { "long_form": 575.5531942580168, "short_form": 687.6606265951455 } }, "emv": { "enabled": true, "total": 88645.58, "impression_value": 3010.3300000000004, "engagement_value": 85635.25, "like_value": 79599, "comment_value": 6036.25, "impressions": { "long_form": 12338, "short_form": 135094, "total": 147432 }, "value_by_format": { "long_form": 308.45, "short_form": 2701.88 }, "engagement": { "likes": 318396, "comments": 4829 }, "nodes": 18, "settings": { "long_form_cpm": 25, "short_form_cpm": 20, "like_rate": 0.25, "comment_rate": 1.25, "include_long_form": true, "include_short_form": true, "include_likes": true, "include_comments": true, "show_overrides": {} }, "settings_source": "campaign" }, "reach": { "total": 147432, "youtube": 20486, "youtube_long_form": 3850, "youtube_shorts": 16636, "audio": 8115, "social": 118831, "long_form": 12338, "short_form": 135094, "cluster_long_form": 12338, "cluster_short_form": 135094, "long_form_count": 5, "short_form_count": 12, "clips": 17, "posts": 0 }, "engagement": 759, "engagement_rate": 0.5148136089858375, "first_30_days": { "available": true, "reason": null, "dated_count": 18, "total_count": 18, "undated_with_metrics_count": 0, "metrics": { "youtube_reach": 18000, "youtube_long_form_reach": 2695, "youtube_shorts_reach": 15305, "audio_reach": 5681, "social_reach": 109242, "total_reach": 132923, "long_form_reach": 8637, "short_form_reach": 124286, "long_form_count": 5, "short_form_count": 12, "total_engagement": 663, "engagement_rate": 0.4987850108709554, "clips": 17, "posts": 0 }, "long_form_share": 0.7, "short_form_share": 0.92 }, "linked_show_ids": [ "paAtKPJGo56md5VyoEQ9Las7qahL" ], "linked_show_names": [ "Hard Court" ], "show_count": 1, "cluster_ids": [ "cluster-4uzlgvt6p8QekbVvYkGnbh", "cluster-80WiH0Qs4iCPC3OLgg4Xy9" ], "clusters_loaded": 3, "clusters_missing": [], "total_slots": 3, "linked_slots": 3, "unlinked_slot_count": 0, "live_slots": 3, "slots": [ { "id": "ep_6503898273889_tgolj5", "show_id": "paAtKPJGo56md5VyoEQ9Las7qahL", "show_name": "Hard Court", "title": "Wayne Motors — Spring Launch", "slot_number": 1, "expected_impressions": 268, "expected_impressions_estimated": false, "budget": 190.44, "budget_pinned": false, "impressions_pinned": true, "linked_cluster_id": "cluster-4uzlgvt6p8QekbVvYkGnbh", "linked_cluster_name": "Coaching hot seat check-in", "linked_cluster_loaded": true, "is_live": true, "cluster_reach": { "total_reach": 2191, "youtube_reach": 2191, "audio_reach": 0, "social_reach": 0, "long_form_reach": 2191, "short_form_reach": 0, "clips": 1, "posts": 0, "precision": "nodes" }, "cluster_public_url": "https://listener.com/cluster/paAtKPJGo56md5VyoEQ9Las7qahL_cluster-4uzlgvt6p8QekbVvYkGnbh", "fulfillment_pct": 817.5373134328358, "excluded_node_count": 0 }, { "id": "ep_6503898273889_psqwbn", "show_id": "paAtKPJGo56md5VyoEQ9Las7qahL", "show_name": "Hard Court", "title": "Cyberdyne Fitness — Season Opener", "slot_number": 2, "expected_impressions": 3227, "expected_impressions_estimated": false, "budget": 2293.08, "budget_pinned": false, "impressions_pinned": true, "linked_cluster_id": "cluster-80WiH0Qs4iCPC3OLgg4Xy9", "linked_cluster_name": "Coaching hot seat check-in", "linked_cluster_loaded": true, "is_live": true, "cluster_reach": { "total_reach": 3560, "youtube_reach": 1659, "audio_reach": 0, "social_reach": 1901, "long_form_reach": 1659, "short_form_reach": 1901, "clips": 2, "posts": 0, "precision": "nodes" }, "cluster_public_url": "https://listener.com/cluster/paAtKPJGo56md5VyoEQ9Las7qahL_cluster-80WiH0Qs4iCPC3OLgg4Xy9", "fulfillment_pct": 110.319181902696, "excluded_node_count": 0 } ], "show_breakdown": [ { "id": "paAtKPJGo56md5VyoEQ9Las7qahL", "name": "Hard Court", "image": "https://images.example.com/listener/6cbd825d3f77ec48.jpg", "public_url": "https://listener.com/hard-court", "metrics": { "youtube_reach": 20486, "youtube_long_form_reach": 3850, "youtube_shorts_reach": 16636, "audio_reach": 8115, "social_reach": 118831, "total_reach": 147432, "long_form_reach": 12338, "short_form_reach": 135094, "long_form_count": 5, "short_form_count": 12, "total_engagement": 759, "engagement_rate": 0.5148136089858375, "clips": 17, "posts": 0 }, "slots": 3, "linked_slots": 3, "live_slots": 3, "pacing_expected_reach": 140728, "delta_from_pacing": 6704, "reach_goal": 500000, "fulfillment_pct": 29.486400000000003, "precision": "nodes" } ], "air_checks": { "has_air_checks": true, "youtube_videos_analyzed": 2, "audio_episodes_analyzed": 2, "youtube_analyzed_at": "2026-10-10T11:54:45.386000+00:00", "audio_analyzed_at": "2026-10-12T17:51:26.859000+00:00", "mentions_detected": 0, "rss_url": null, "custom_brands": [], "excluded_show_ids": [], "excluded_placement_cluster_ids": [], "exclusions_count": 0 }, "excluded_node_count": 0, "excluded_node_ids_count": 0, "featured_content_node_id": null, "merged_from": null, "copied_from": null, "created_via": null, "duplicate": null, "share": { "metric_keys": [ "hero_total_reach", "hero_video" ], "sm": "", "fc": true, "featured_content_node_id": null, "available_metric_keys": [ "hero_total_reach", "hero_video" ], "presets": { "all": "", "brand-safe": "mtg27", "summary": "mnvf3" }, "public_url": "https://network.listener.com/northwind-audio/campaigns/B9GpElnJc2UQ1JLfav8j" }, "activity_log": { "events": [ { "type": "flight", "key": "flight-end", "day": "2026-10-31", "at": "2026-10-31T00:00:00.000Z", "platform": null, "title": null, "show_id": null, "show_name": null, "show_names": [], "cluster_id": null, "is_first": null, "clip_count": 0, "post_count": 0, "views": 0, "count_label": null, "label": "Flight ends", "upcoming": true }, { "type": "flight", "key": "flight-start", "day": "2026-10-01", "at": "2026-10-01T00:00:00.000Z", "platform": null, "title": null, "show_id": null, "show_name": null, "show_names": [], "cluster_id": null, "is_first": null, "clip_count": 0, "post_count": 0, "views": 0, "count_label": null, "label": "Flight started", "upcoming": false } ], "total_events": 21, "summary": { "total_slots": 3, "linked_slots": 3, "unlinked_slots": 0, "released_episodes": 3, "content_pieces": 17 } }, "public_url": "https://network.listener.com/northwind-audio/campaigns/B9GpElnJc2UQ1JLfav8j", "app_url": "https://network.listener.com/app/campaign?id=B9GpElnJc2UQ1JLfav8j&uid=XXfzcGxkLsQaKAvRy1SwLsZCcH78", "created_at": "2026-08-20T15:33:35.789Z", "updated_at": "2026-10-07T17:00:55.475Z", "precision": "nodes", "warnings": [], "computed_at": "2026-10-14T17:34:24.909341+00:00", "cache_age_seconds": 19.4, "note": null } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Update a campaign `PATCH /v1/campaigns/{campaign_id}` Scope: `campaigns:write` SDK: `client.campaigns.update()` (TypeScript) · `client.campaigns.update()` (Python) Update a campaign's title, description, dates, brand, goals, budgets, EMV settings or featured content. Only the fields you send change. `brand` fields merge into the campaign's brand; `show_goals` replaces the per-show goals and recomputes the total; `emv_settings` merges and is normalised. Renaming the brand re-stamps other campaigns of that brand (listed in `warnings`). `clear_featured_content_node` / `clear_platform_goals` remove those fields. Slots, cluster links and status have their own endpoints. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `campaigns:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `campaign_id` | string | required | Campaign id (see GET /v1/campaigns). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `title` | string | optional | Title. Default `null`. Nullable. | | `description` | string | optional | Free-text description. Default `null`. Nullable. | | `start_date` | string | optional | First day, YYYY-MM-DD (inclusive). With end_date, overrides time_range. Default `null`. Nullable. | | `end_date` | string | optional | Last day, YYYY-MM-DD (inclusive). With start_date, overrides time_range. Default `null`. Nullable. | | `brand` | object | optional | Brand name or slug filter. Default `null`. Nullable. | | `brand.name` | string | optional | Default `""`. | | `brand.contact` | string | optional | Default `""`. | | `brand.notes` | string | optional | Default `""`. | | `brand.logo` | string | optional | Default `""`. | | `brand.domain` | string | optional | Default `""`. | | `brand.invite_email` | string | optional | Default `""`. | | `show_goals` | map of integer | optional | Per-show view goals: {show_id: views}. Default `null`. Nullable. | | `show_budgets` | map of number | optional | Per-show budgets in dollars: {show_id: amount}. Default `null`. Nullable. | | `total_budget` | number | optional | Total campaign budget in dollars. Default `null`. Nullable. | | `platform_goals` | map of integer | optional | Per-platform goals: {platform: views}. Default `null`. Nullable. | | `emv_settings` | object | optional | Campaign ``emvSettings`` (port of ``normalizeCampaignEmvSettings``). Default `null`. Nullable. | | `emv_settings.enabled` | boolean | optional | Default `null`. Nullable. | | `emv_settings.long_form_cpm` | integer \| number \| string \| any | optional | Default `null`. | | `emv_settings.short_form_cpm` | integer \| number \| string \| any | optional | Default `null`. | | `emv_settings.like_rate` | integer \| number \| string \| any | optional | Default `null`. | | `emv_settings.comment_rate` | integer \| number \| string \| any | optional | Default `null`. | | `emv_settings.include_long_form` | boolean | optional | Default `null`. Nullable. | | `emv_settings.include_short_form` | boolean | optional | Default `null`. Nullable. | | `emv_settings.include_likes` | boolean | optional | Default `null`. Nullable. | | `emv_settings.include_comments` | boolean | optional | Default `null`. Nullable. | | `emv_settings.show_overrides` | map of object | optional | Default `null`. Nullable. | | `featured_content_node_id` | string | optional | Default `null`. Nullable. | | `clear_featured_content_node` | boolean | optional | Default `false`. | | `clear_platform_goals` | boolean | optional | Default `false`. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | ### Example request ```bash curl -X PATCH "https://api.listener.com/v1/campaigns/CAMPAIGN_ID" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "campaign", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/XXfzcGxkLsQaKAvRy1SwLsZCcH78/campaigns/B9GpElnJc2UQ1JLfav8j", "id": "B9GpElnJc2UQ1JLfav8j", "method": "update", "written": { "description": "A deep dive into the numbers behind the season so far.", "updatedAt": "" }, "warnings": [], "urls": { "app": "https://network.listener.com/app/campaign?id=B9GpElnJc2UQ1JLfav8j&uid=XXfzcGxkLsQaKAvRy1SwLsZCcH78", "public": "https://network.listener.com/northwind-audio/campaigns/B9GpElnJc2UQ1JLfav8j" }, "error": null, "data": { "campaign_id": "B9GpElnJc2UQ1JLfav8j", "brand_slug": null }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Delete a campaign `DELETE /v1/campaigns/{campaign_id}` Scope: `campaigns:write` · some changes need `confirm=true` SDK: `client.campaigns.delete()` (TypeScript) · `client.campaigns.delete()` (Python) Permanently delete a campaign (no undo, no cascade) and report what it leaves behind: pending brand invites and shared reports that embed it. Send `?dry_run=true` first to see them. - Send `?dry_run=true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Requires `?confirm=true`; without it the API answers 409 `confirmation_required` with the exact `preview`. - Required scope: `campaigns:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `campaign_id` | string | required | Campaign id (see GET /v1/campaigns). | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). | | `confirm` | boolean | optional | Must be true to apply destructive or metered changes; without it the API answers 409 with a preview. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Example request ```bash curl -X DELETE "https://api.listener.com/v1/campaigns/CAMPAIGN_ID?dry_run=true" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "dry_run", "resource": "campaign", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/XXfzcGxkLsQaKAvRy1SwLsZCcH78/campaigns/B9GpElnJc2UQ1JLfav8j", "id": "B9GpElnJc2UQ1JLfav8j", "method": "delete", "written": { "deleted": true, "campaign": { "id": "B9GpElnJc2UQ1JLfav8j", "title": "Wonka Snacks — Q4 Push", "slots": 3 }, "orphans": { "brand_invite_ids": [ "brand_B9GpElnJc2UQ1JLfav8j_casey_brand1_listener_com" ], "shared_report_ids": [] } }, "warnings": [ "1 brand invite(s) will stay 'pending' forever: brand_B9GpElnJc2UQ1JLfav8j_casey_brand1_listener_com." ], "urls": { "app": "https://network.listener.com/app/campaigns?uid=XXfzcGxkLsQaKAvRy1SwLsZCcH78", "public": null }, "error": null, "data": { "campaign_id": "B9GpElnJc2UQ1JLfav8j", "orphans": { "brand_invite_ids": [ "brand_B9GpElnJc2UQ1JLfav8j_casey_brand1_listener_com" ], "shared_report_ids": [] }, "confirm_required": true }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Campaign timeline `GET /v1/campaigns/{campaign_id}/timeline` Scope: `campaigns:read` SDK: `client.campaigns.timeline()` (TypeScript) · `client.campaigns.timeline()` (Python) A campaign's reach series (total, audio, YouTube, social; daily buckets as the campaign chart draws them) and its EMV accrual over a window. `window` is `7d`, `30d`, `90d` or `all`, or send `start_date` + `end_date`; `chart_type` is `daily` or `cumulative`. EMV points are thinned to at most 180 by summing whole weeks. - Required scope: `campaigns:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `campaign_id` | string | required | Campaign id (see GET /v1/campaigns). | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `window` | string | optional | Reporting window. | | `start_date` | string | optional | First day, YYYY-MM-DD (inclusive). With end_date, overrides time_range. | | `end_date` | string | optional | Last day, YYYY-MM-DD (inclusive). With start_date, overrides time_range. | | `chart_type` | string | optional | daily values or a cumulative running total. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `campaign_id` | string | required | | | `title` | string | required | | | `window` | object | required | {time_range, chart_type, start, end, previous_start, previous_end} as the chart resolves it. | | `chart_type` | string | required | One of `daily`, `cumulative`. | | `totals` | map of number | required | | | `growth` | map of number | required | vs the equal-length previous window; None for all-time. | | `reach_series` | array of objects | required | | | `reach_series[].day` | string | required | | | `reach_series[].date` | string | required | Short label as the chart shows it ('Sep 10'). | | `reach_series[].value` | number | required | | | `audio_series` | array of objects | required | | | `audio_series[].day` | string | required | | | `audio_series[].date` | string | required | Short label as the chart shows it ('Sep 10'). | | `audio_series[].value` | number | required | | | `youtube_series` | array of objects | required | | | `youtube_series[].day` | string | required | | | `youtube_series[].date` | string | required | Short label as the chart shows it ('Sep 10'). | | `youtube_series[].value` | number | required | | | `social_series` | array of objects | required | | | `social_series[].day` | string | required | | | `social_series[].date` | string | required | Short label as the chart shows it ('Sep 10'). | | `social_series[].value` | number | required | | | `events_counted` | integer | required | | | `emv_series` | array of objects | required | | | `emv_series[].day` | string | required | | | `emv_series[].date` | string | required | | | `emv_series[].emv` | number | required | Cumulative EMV at that point (accrual curve). | | `emv_series[].period_emv` | number | required | EMV accrued inside the bucket. | | `emv_series[].emv_by_platform` | map of number | optional | | | `emv_series[].period_emv_by_platform` | map of number | optional | | | `emv_total` | number | required | | | `emv_by_platform` | map of number | optional | | | `emv_enabled` | boolean | optional | Default `false`. | | `emv_nodes_counted` | integer | optional | Default `0`. | | `emv_points_thinned` | boolean | optional | True when the EMV series was thinned to ≤ 180 points by summing whole weeks. Default `false`. | | `precision` | string | required | One of `summary`, `nodes`, `none`. | | `warnings` | array of strings | optional | | | `public_url` | string | optional | Default `null`. Nullable. | | `computed_at` | string | required | | | `cache_age_seconds` | number | required | | | `note` | string | optional | Default `null`. Nullable. | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/campaigns/CAMPAIGN_ID/timeline" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "campaign_timeline", "campaign_id": "B9GpElnJc2UQ1JLfav8j", "title": "Wonka Snacks — Q4 Push", "window": { "time_range": "30d", "chart_type": "cumulative", "start": "2026-09-15", "end": "2026-10-14", "previous_start": "2026-08-16", "previous_end": "2026-09-14" }, "chart_type": "cumulative", "totals": { "total_reach": 0, "audio_reach": 0, "youtube_reach": 0, "social_reach": 0 }, "growth": { "total_reach": -100, "audio_reach": 0, "youtube_reach": -100, "social_reach": 0 }, "reach_series": [ { "day": "2026-09-15", "date": "Sep 15", "value": 0 }, { "day": "2026-09-16", "date": "Sep 16", "value": 0 } ], "audio_series": [ { "day": "2026-09-15", "date": "Sep 15", "value": 0 }, { "day": "2026-09-16", "date": "Sep 16", "value": 0 } ], "youtube_series": [ { "day": "2026-09-15", "date": "Sep 15", "value": 0 }, { "day": "2026-09-16", "date": "Sep 16", "value": 0 } ], "social_series": [ { "day": "2026-09-15", "date": "Sep 15", "value": 0 }, { "day": "2026-09-16", "date": "Sep 16", "value": 0 } ], "events_counted": 17, "emv_series": [ { "day": "2026-09-15", "date": "Sep 15", "emv": 88600.05256129433, "period_emv": 2.8482813043228816, "emv_by_platform": { "YouTube": 151.40882228633853, "Instagram": 2321.8376718143772, "Spotify": 202.76501577920686, "Facebook": 61.49992830113916, "YouTube Shorts": 606.9699356133888, "X": 85241.5, "TikTok": 14.071187499887778 }, "period_emv_by_platform": { "YouTube": 2.6398200198562165, "Instagram": 0.19506094488315284, "Spotify": 0.006698907672415544, "Facebook": 0.000006968743882396211, "YouTube Shorts": 0.000006258032499317778, "X": 0, "TikTok": 0.006688205135725767 } }, { "day": "2026-09-16", "date": "Sep 16", "emv": 88602.78044605965, "period_emv": 2.7278847653215053, "emv_by_platform": { "YouTube": 153.94479762931465, "Instagram": 2322.01698551974, "Spotify": 202.7714511666781, "Facebook": 61.49993470719671, "YouTube Shorts": 606.969941366121, "X": 85241.5, "TikTok": 14.077335670601867 }, "period_emv_by_platform": { "YouTube": 2.5359753429761156, "Instagram": 0.17931370536280156, "Spotify": 0.006435387471242393, "Facebook": 0.000006406057551089361, "YouTube Shorts": 0.000005752732249675319, "X": 0, "TikTok": 0.006148170714089574 } } ], "emv_total": 88645.58, "emv_by_platform": { "YouTube": 194.75, "Instagram": 2323.8450000000003, "Spotify": 202.875, "Facebook": 61.5, "YouTube Shorts": 606.9699999999999, "X": 85241.5, "TikTok": 14.14 }, "emv_enabled": true, "emv_nodes_counted": 18, "emv_points_thinned": false, "precision": "nodes", "warnings": [], "public_url": "https://network.listener.com/northwind-audio/campaigns/B9GpElnJc2UQ1JLfav8j", "computed_at": "2026-10-14T17:34:27.073407+00:00", "cache_age_seconds": 21.5, "note": null } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Campaign CPM insights `GET /v1/campaigns/{campaign_id}/cpm-insights` Scope: `campaigns:read` SDK: `client.campaigns.cpmInsights()` (TypeScript) · `client.campaigns.cpm_insights()` (Python) A campaign's expected vs delivered CPM per format and where it sits against the workspace's recent campaigns (median of the 12 most recent peers with a delivered CPM; within ±3 % counts as in line). Peers are the `peer_limit` most recently updated other campaigns, loaded at node precision. Delivered CPM is null until a campaign has delivered at least 25 % of its expected impressions. - Required scope: `campaigns:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `campaign_id` | string | required | Campaign id (see GET /v1/campaigns). | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `peer_limit` | integer | optional | | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `campaign_id` | string | required | | | `title` | string | required | | | `expected_cpm` | number | optional | Default `0`. | | `delivered_cpm` | number | optional | Default `0`. | | `delivered_cpm_status` | string | optional | Default `"pending"`. | | `delivered_cpm_detail` | object | optional | | | `format_cpm` | object | optional | | | `benchmarks` | map of object | optional | long_form / short_form delivered CPM vs the median of the 12 most recent peers with a CPM. | | `benchmarks.current_cpm` | number | optional | Default `0`. | | `benchmarks.median_cpm` | number | optional | Default `0`. | | `benchmarks.delta_pct` | number | optional | Absolute % distance from the peer median (0 inside the ±3 % neutral band). Default `null`. Nullable. | | `benchmarks.signed_delta_pct` | number | optional | Default `null`. Nullable. | | `benchmarks.direction` | string | optional | below \| above \| neutral \| unavailable Default `"unavailable"`. | | `benchmarks.sample_size` | integer | optional | Default `0`. | | `benchmarks.label` | string | optional | Default `""`. | | `benchmarks.is_renderable` | boolean | optional | Default `false`. | | `peers` | array of objects | optional | | | `peers[].id` | string | required | | | `peers[].title` | string | required | | | `peers[].status` | string | required | One of `upcoming`, `active`, `completed`. | | `peers[].brand` | string | optional | Default `""`. | | `peers[].updated_at` | string | optional | Default `null`. Nullable. | | `peers[].delivered_cpm` | number | optional | Default `0`. | | `peers[].delivered_cpm_status` | string | optional | Default `"pending"`. | | `peers[].delivered_cpm_long_form` | number | optional | Default `0`. | | `peers[].delivered_cpm_short_form` | number | optional | Default `0`. | | `peers[].expected_cpm` | number | optional | Default `0`. | | `peers[].precision` | string | required | One of `summary`, `nodes`, `none`. | | `peers_considered` | integer | optional | Default `0`. | | `peers_with_cpm` | map of integer | optional | | | `workspace_campaign_count` | integer | optional | Default `0`. | | `precision` | string | required | One of `summary`, `nodes`, `none`. | | `warnings` | array of strings | optional | | | `public_url` | string | optional | Default `null`. Nullable. | | `computed_at` | string | required | | | `cache_age_seconds` | number | required | | | `note` | string | optional | Default `null`. Nullable. | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/campaigns/CAMPAIGN_ID/cpm-insights" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "campaign_cpm_insights", "campaign_id": "B9GpElnJc2UQ1JLfav8j", "title": "Wonka Snacks — Q4 Push", "expected_cpm": 710.5906429424138, "delivered_cpm": 678.278799717836, "delivered_cpm_status": "reported", "delivered_cpm_detail": { "cpm": 678.278799717836, "budget": 100000, "delivered_impressions": 147432, "expected_impressions": 140728, "delivered_slots": 3, "total_slots": 3, "delivery_pct": 104.76379967028595, "is_provisional": false }, "format_cpm": { "total_budget": 100000, "expected_impressions": { "long_form": 9993.341991319874, "short_form": 130734.65800868013, "total": 140728 }, "delivered_impressions": { "long_form": 12338, "short_form": 135094, "total": 147432 }, "budget_allocation": { "long_form": 7101.1753107554105, "short_form": 92898.82468924459 }, "expected_cpm": { "long_form": 710.5906429424138, "short_form": 710.5906429424138 }, "delivered_cpm": { "long_form": 575.5531942580168, "short_form": 687.6606265951455 } }, "benchmarks": { "long_form": { "current_cpm": 575.5531942580168, "median_cpm": 104.068522482916, "delta_pct": 453, "signed_delta_pct": 453.0521434591331, "direction": "above", "sample_size": 3, "label": "453% above recent median", "is_renderable": true }, "short_form": { "current_cpm": 687.6606265951455, "median_cpm": 126.79834712338442, "delta_pct": 442, "signed_delta_pct": 442.32617553444874, "direction": "above", "sample_size": 3, "label": "442% above recent median", "is_renderable": true } }, "peers": [ { "id": "dfEUh5kkZU1O7vAkaMYK", "title": "Cyberdyne Fitness — Season Opener", "status": "active", "brand": "Umbrella Energy", "updated_at": "2026-09-29T17:32:28.239Z", "delivered_cpm": 125.1112099644128, "delivered_cpm_status": "reported", "delivered_cpm_long_form": 104.068522482916, "delivered_cpm_short_form": 126.79834712338442, "expected_cpm": 130.90813884989927, "precision": "nodes" }, { "id": "sdwY3BeF2mVbCDIqsM6u", "title": "Vandelay Imports — Q4 Push", "status": "active", "brand": "Acme Coffee", "updated_at": "2026-09-25T01:44:15.349Z", "delivered_cpm": 273.993757924024, "delivered_cpm_status": "reported", "delivered_cpm_long_form": 1036.9049105085603, "delivered_cpm_short_form": 186.43648177325696, "expected_cpm": 100, "precision": "nodes" } ], "peers_considered": 20, "peers_with_cpm": { "long_form": 3, "short_form": 3 }, "workspace_campaign_count": 21, "precision": "nodes", "warnings": [], "public_url": "https://network.listener.com/northwind-audio/campaigns/B9GpElnJc2UQ1JLfav8j", "computed_at": "2026-10-14T17:34:27.089408+00:00", "cache_age_seconds": 21.6, "note": null } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Set a campaign's status `POST /v1/campaigns/{campaign_id}/status` Scope: `campaigns:write` SDK: `client.campaigns.setStatus()` (TypeScript) · `client.campaigns.set_status()` (Python) Set a campaign's manual status (`upcoming`, `active`, `completed`), or send `"status": null` to clear the override so the status follows the dates again. No other side effects. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `campaigns:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `campaign_id` | string | required | Campaign id (see GET /v1/campaigns). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `status` | string | optional | Status filter or value. One of `upcoming`, `active`, `completed`. Default `null`. Nullable. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | ### Example request ```bash curl -X POST "https://api.listener.com/v1/campaigns/CAMPAIGN_ID/status" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "campaign", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/XXfzcGxkLsQaKAvRy1SwLsZCcH78/campaigns/B9GpElnJc2UQ1JLfav8j", "id": "B9GpElnJc2UQ1JLfav8j", "method": "update", "written": { "status": "active", "statusUpdatedAt": "", "updatedAt": "" }, "warnings": [], "urls": { "app": "https://network.listener.com/app/campaign?id=B9GpElnJc2UQ1JLfav8j&uid=XXfzcGxkLsQaKAvRy1SwLsZCcH78", "public": "https://network.listener.com/northwind-audio/campaigns/B9GpElnJc2UQ1JLfav8j" }, "error": null, "data": { "campaign_id": "B9GpElnJc2UQ1JLfav8j", "status": "active" }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Add campaign slots `POST /v1/campaigns/{campaign_id}/slots` Scope: `campaigns:write` SDK: `client.campaigns.slots.create()` (TypeScript) · `client.campaigns.slots.create()` (Python) Append slots (planned episodes) to a campaign: a show plus optional `title`, `expected_impressions`, `budget` and `linked_cluster_id` each. Returns the new slot ids and the full slot list. A cluster already linked to another slot is refused (single-slot rule); up to 2,000 slots per campaign. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `campaigns:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `campaign_id` | string | required | Campaign id (see GET /v1/campaigns). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `planned_episodes` | array of objects | required | Campaign slots: [{show_id, title?, expected_impressions?, budget?}]. | | `planned_episodes[].show_id` | string | required | | | `planned_episodes[].title` | string | optional | Default `null`. Nullable. | | `planned_episodes[].expected_impressions` | integer \| number \| string \| any | optional | Default `null`. | | `planned_episodes[].budget` | integer \| number \| string \| any | optional | Default `null`. | | `planned_episodes[].linked_cluster_id` | string | optional | Default `null`. Nullable. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | ### Example request ```bash curl -X POST "https://api.listener.com/v1/campaigns/CAMPAIGN_ID/slots" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"planned_episodes": [], "dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "campaign", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/XXfzcGxkLsQaKAvRy1SwLsZCcH78/campaigns/B9GpElnJc2UQ1JLfav8j", "id": "B9GpElnJc2UQ1JLfav8j", "method": "transaction", "written": { "plannedEpisodes": [ { "impressionsPinned": true, "budget": 190.44, "id": "ep_6503898273889_tgolj5", "linkedClusterId": "cluster-4uzlgvt6p8QekbVvYkGnbh", "title": "Wayne Motors — Spring Launch", "showId": "paAtKPJGo56md5VyoEQ9Las7qahL", "budgetPinned": false, "expectedImpressions": 268 }, { "impressionsPinned": true, "budget": 2293.08, "id": "ep_6503898273889_psqwbn", "linkedClusterId": "cluster-80WiH0Qs4iCPC3OLgg4Xy9", "title": "Cyberdyne Fitness — Season Opener", "showId": "paAtKPJGo56md5VyoEQ9Las7qahL", "budgetPinned": false, "expectedImpressions": 3227 } ], "clusterIds": [ "cluster-4uzlgvt6p8QekbVvYkGnbh", "cluster-80WiH0Qs4iCPC3OLgg4Xy9" ], "updatedAt": "" }, "warnings": [], "urls": { "app": "https://network.listener.com/app/campaign?id=B9GpElnJc2UQ1JLfav8j&uid=XXfzcGxkLsQaKAvRy1SwLsZCcH78", "public": "https://network.listener.com/northwind-audio/campaigns/B9GpElnJc2UQ1JLfav8j" }, "error": null, "data": { "campaign_id": "B9GpElnJc2UQ1JLfav8j", "added_slot_ids": [ "ep_2585703747213_7iep6b" ] }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Update a campaign slot `PATCH /v1/campaigns/{campaign_id}/slots/{slot_id}` Scope: `campaigns:write` SDK: `client.campaigns.slots.update()` (TypeScript) · `client.campaigns.slots.update()` (Python) Edit one slot's `title`, `expected_impressions`, `budget` or `show_id`. Setting impressions or budget pins the value so the app stops auto-distributing it. To change the linked cluster use `PUT` / `DELETE /v1/campaigns/{campaign_id}/slots/{slot_id}/cluster`. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `campaigns:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `campaign_id` | string | required | Campaign id (see GET /v1/campaigns). | | `slot_id` | string | required | Campaign slot (planned episode) id from the campaign's slots. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `title` | string | optional | Title. Default `null`. Nullable. | | `expected_impressions` | integer | optional | Default `null`. Nullable. | | `budget` | number | optional | Default `null`. Nullable. | | `show_id` | string | optional | A show id in this workspace (see GET /v1/shows). Default `null`. Nullable. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | ### Example request ```bash curl -X PATCH "https://api.listener.com/v1/campaigns/CAMPAIGN_ID/slots/SLOT_ID" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "campaign", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/XXfzcGxkLsQaKAvRy1SwLsZCcH78/campaigns/B9GpElnJc2UQ1JLfav8j", "id": "B9GpElnJc2UQ1JLfav8j", "method": "transaction", "written": { "plannedEpisodes": [ { "impressionsPinned": true, "budget": 190.44, "id": "ep_6503898273889_tgolj5", "linkedClusterId": "cluster-4uzlgvt6p8QekbVvYkGnbh", "title": "Wayne Motors — Q4 Push", "showId": "paAtKPJGo56md5VyoEQ9Las7qahL", "budgetPinned": false, "expectedImpressions": 268 }, { "impressionsPinned": true, "budget": 2293.08, "id": "ep_6503898273889_psqwbn", "linkedClusterId": "cluster-80WiH0Qs4iCPC3OLgg4Xy9", "title": "Cyberdyne Fitness — Season Opener", "showId": "paAtKPJGo56md5VyoEQ9Las7qahL", "budgetPinned": false, "expectedImpressions": 3227 } ], "clusterIds": [ "cluster-4uzlgvt6p8QekbVvYkGnbh", "cluster-80WiH0Qs4iCPC3OLgg4Xy9" ], "updatedAt": "" }, "warnings": [], "urls": { "app": "https://network.listener.com/app/campaign?id=B9GpElnJc2UQ1JLfav8j&uid=XXfzcGxkLsQaKAvRy1SwLsZCcH78", "public": "https://network.listener.com/northwind-audio/campaigns/B9GpElnJc2UQ1JLfav8j" }, "error": null, "data": { "campaign_id": "B9GpElnJc2UQ1JLfav8j", "slot_id": "ep_6503898273889_tgolj5" }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Link a cluster to a slot `PUT /v1/campaigns/{campaign_id}/slots/{slot_id}/cluster` Scope: `campaigns:write` SDK: `client.campaigns.slots.linkCluster()` (TypeScript) · `client.campaigns.slots.link_cluster()` (Python) Link an existing cluster (`show_id` + `cluster_id`, from `GET /v1/clusters`) to one campaign slot, attributing its reach to the campaign. A cluster already linked to another slot of the same campaign is refused (double-count guard). `included_node_ids` limits which of the cluster's nodes count. The cluster itself is never modified. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `campaigns:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `campaign_id` | string | required | Campaign id (see GET /v1/campaigns). | | `slot_id` | string | required | Campaign slot (planned episode) id from the campaign's slots. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `cluster_id` | string | required | Cluster id (see GET /v1/clusters). | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | | `included_node_ids` | array of strings | optional | Default `null`. Nullable. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | ### Example request ```bash curl -X PUT "https://api.listener.com/v1/campaigns/CAMPAIGN_ID/slots/SLOT_ID/cluster" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"cluster_id": "CLUSTER_ID", "show_id": "SHOW_ID", "dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "campaign", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/XXfzcGxkLsQaKAvRy1SwLsZCcH78/campaigns/B9GpElnJc2UQ1JLfav8j", "id": "B9GpElnJc2UQ1JLfav8j", "method": "transaction", "written": { "plannedEpisodes": [ { "impressionsPinned": true, "budget": 190.44, "id": "ep_6503898273889_tgolj5", "linkedClusterId": "cluster-0y0HEJ0OVF7EYdXAvD5ghY", "title": "Wayne Motors — Spring Launch", "showId": "paAtKPJGo56md5VyoEQ9Las7qahL", "budgetPinned": false, "expectedImpressions": 268 }, { "impressionsPinned": true, "budget": 2293.08, "id": "ep_6503898273889_psqwbn", "linkedClusterId": "cluster-80WiH0Qs4iCPC3OLgg4Xy9", "title": "Cyberdyne Fitness — Season Opener", "showId": "paAtKPJGo56md5VyoEQ9Las7qahL", "budgetPinned": false, "expectedImpressions": 3227 } ], "clusterIds": [ "cluster-0y0HEJ0OVF7EYdXAvD5ghY", "cluster-80WiH0Qs4iCPC3OLgg4Xy9" ], "updatedAt": "" }, "warnings": [], "urls": { "app": "https://network.listener.com/app/campaign?id=B9GpElnJc2UQ1JLfav8j&uid=XXfzcGxkLsQaKAvRy1SwLsZCcH78", "public": "https://network.listener.com/northwind-audio/campaigns/B9GpElnJc2UQ1JLfav8j" }, "error": null, "data": { "campaign_id": "B9GpElnJc2UQ1JLfav8j", "slot_id": "ep_6503898273889_tgolj5", "cluster_id": "cluster-0y0HEJ0OVF7EYdXAvD5ghY", "show_id": "paAtKPJGo56md5VyoEQ9Las7qahL", "cluster_name": "Five takeaways from the opener" }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Unlink a slot's cluster `DELETE /v1/campaigns/{campaign_id}/slots/{slot_id}/cluster` Scope: `campaigns:write` SDK: `client.campaigns.slots.unlinkCluster()` (TypeScript) · `client.campaigns.slots.unlink_cluster()` (Python) Detach the linked cluster from one campaign slot without deleting the slot. The cluster itself is never modified. - Send `?dry_run=true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `campaigns:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `campaign_id` | string | required | Campaign id (see GET /v1/campaigns). | | `slot_id` | string | required | Campaign slot (planned episode) id from the campaign's slots. | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Example request ```bash curl -X DELETE "https://api.listener.com/v1/campaigns/CAMPAIGN_ID/slots/SLOT_ID/cluster?dry_run=true" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "dry_run", "resource": "campaign", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/XXfzcGxkLsQaKAvRy1SwLsZCcH78/campaigns/B9GpElnJc2UQ1JLfav8j", "id": "B9GpElnJc2UQ1JLfav8j", "method": "transaction", "written": { "plannedEpisodes": [ { "impressionsPinned": true, "budget": 190.44, "id": "ep_6503898273889_tgolj5", "linkedClusterId": null, "title": "Wayne Motors — Spring Launch", "showId": "paAtKPJGo56md5VyoEQ9Las7qahL", "budgetPinned": false, "expectedImpressions": 268 }, { "impressionsPinned": true, "budget": 2293.08, "id": "ep_6503898273889_psqwbn", "linkedClusterId": "cluster-80WiH0Qs4iCPC3OLgg4Xy9", "title": "Cyberdyne Fitness — Season Opener", "showId": "paAtKPJGo56md5VyoEQ9Las7qahL", "budgetPinned": false, "expectedImpressions": 3227 } ], "clusterIds": [ "cluster-80WiH0Qs4iCPC3OLgg4Xy9", "cluster-1xwmdkckUe29biQhmpMkx9" ], "updatedAt": "" }, "warnings": [], "urls": { "app": "https://network.listener.com/app/campaign?id=B9GpElnJc2UQ1JLfav8j&uid=XXfzcGxkLsQaKAvRy1SwLsZCcH78", "public": "https://network.listener.com/northwind-audio/campaigns/B9GpElnJc2UQ1JLfav8j" }, "error": null, "data": { "campaign_id": "B9GpElnJc2UQ1JLfav8j", "slot_id": "ep_6503898273889_tgolj5", "previous_cluster_id": "cluster-4uzlgvt6p8QekbVvYkGnbh" }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## List brands `GET /v1/brands` Scope: `campaigns:read` SDK: `client.brands.list()` (TypeScript) · `client.brands.list()` (Python) The workspace's advertisers (brand profiles plus every brand named on a campaign) with campaign counts, status mix, shows reached, aggregated reach, budget and CPM, EMV and the public brand page URL. `search` matches name, slug or domain; `precision=summary` (default) reads stored cluster summaries, `nodes` loads every linked cluster. - Required scope: `campaigns:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `search` | string | optional | Case-insensitive text filter. | | `precision` | string | optional | summary (fast, stored totals) or a more precise, slower computation. | | `limit` | integer | optional | Page size, 1–100 (default 25). | | `cursor` | string | optional | Opaque cursor from the previous page's next_cursor. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].slug` | string | required | | | `data[].name` | string | required | | | `data[].normalized_name` | string | optional | Default `""`. | | `data[].domain` | string | optional | Default `null`. Nullable. | | `data[].logo` | string | optional | Default `null`. Nullable. | | `data[].notes` | string | optional | Default `null`. Nullable. | | `data[].has_contact` | boolean | optional | Default `false`. | | `data[].has_brand_doc` | boolean | optional | True when a campaignBrands/{slug} canonical doc exists. Default `false`. | | `data[].campaign_ids` | array of strings | optional | | | `data[].campaign_count` | integer | optional | Default `0`. | | `data[].status_counts` | map of integer | optional | | | `data[].show_ids` | array of strings | optional | | | `data[].show_count` | integer | optional | Default `0`. | | `data[].last_used_at` | string | optional | Default `null`. Nullable. | | `data[].totals` | object | optional | brand_campaign_report totals: reach by category, budget, planned impressions, expected/delivered CPM. | | `data[].emv_total` | number | optional | Default `0`. | | `data[].precision` | string | required | One of `summary`, `nodes`, `none`. | | `data[].public_url` | string | optional | Default `null`. Nullable. | | `data[].app_url` | string | required | | | `data[].object` | string | required | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | | `meta.filters` | object | optional | | | `meta.precision` | string | optional | One of `summary`, `nodes`, `none`. | | `meta.computed_at` | string | optional | | | `meta.cache_age_seconds` | number | optional | | | `meta.note` | string | optional | Default `null`. Nullable. | ### Example request ```bash curl "https://api.listener.com/v1/brands" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/brands", "data": [ { "object": "brand", "slug": "acme-coffee", "name": "Acme Coffee", "normalized_name": "acme coffee", "domain": "acme-coffee.example.com", "logo": "https://www.example.com/b23436f3", "notes": null, "has_contact": false, "has_brand_doc": true, "campaign_ids": [ "sdwY3BeF2mVbCDIqsM6u", "YaVU05YMJcmX4k5uRGkM" ], "campaign_count": 2, "status_counts": { "active": 2 }, "show_ids": [ "paAtKPJGo56md5VyoEQ9Las7qahL", "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H" ], "show_count": 5, "last_used_at": "2026-09-25T01:44:15.349Z", "totals": { "total_reach": 278955, "audio_reach": 63296, "youtube_reach": 106020, "social_reach": 109639, "long_form_reach": 140352, "short_form_reach": 138603, "budget": 200000, "planned_impressions": 2000000, "planned_slots": 17, "linked_slots": 8, "expected_cpm": 100, "delivered_cpm": 394.3288343998136, "delivered_cpm_status": "reported", "delivered_cpm_budget": 110000, "delivered_cpm_impressions": 278955, "delivered_cpm_delivery_pct": 25.359545454545458, "delivered_cpm_campaigns": 1, "delivery_rate": 13.947750000000001, "campaign_count": 2, "show_count": 5 }, "emv_total": 8765.86, "precision": "nodes", "public_url": "https://network.listener.com/northwind-audio/brands/acme-coffee", "app_url": "https://network.listener.com/app/campaigns?uid=XXfzcGxkLsQaKAvRy1SwLsZCcH78" }, { "object": "brand", "slug": "globex-sports", "name": "Globex Sports", "normalized_name": "globex-sports", "domain": "globex-sports.example.com", "logo": "https://www.example.com/cd0528ea", "notes": null, "has_contact": false, "has_brand_doc": true, "campaign_ids": [ "IbnWq9HETdVekDCCNnYu", "5BUeGDiYfhlbz5NrFLmt" ], "campaign_count": 2, "status_counts": { "active": 2 }, "show_ids": [ "paAtKPJGo56md5VyoEQ9Las7qahL", "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H" ], "show_count": 3, "last_used_at": "2026-09-15T15:53:50.428Z", "totals": { "total_reach": 0, "audio_reach": 0, "youtube_reach": 0, "social_reach": 0, "long_form_reach": 0, "short_form_reach": 0, "budget": 15000, "planned_impressions": 900000, "planned_slots": 8, "linked_slots": 1, "expected_cpm": 16.666666666666668, "delivered_cpm": 0, "delivered_cpm_status": "measuring", "delivered_cpm_budget": 0, "delivered_cpm_impressions": 0, "delivered_cpm_delivery_pct": null, "delivered_cpm_campaigns": 0, "delivery_rate": 0, "campaign_count": 2, "show_count": 3 }, "emv_total": 0, "precision": "nodes", "public_url": "https://network.listener.com/northwind-audio/brands/globex-sports", "app_url": "https://network.listener.com/app/campaigns?uid=XXfzcGxkLsQaKAvRy1SwLsZCcH78" } ], "has_more": true, "next_cursor": "eyJvIjoyfQ", "meta": { "filters": { "search": null }, "precision": "nodes", "computed_at": "2026-10-14T17:34:29.929160+00:00", "cache_age_seconds": 24.4, "note": null, "total": 8 } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Retrieve a brand `GET /v1/brands/{brand_slug}` Scope: `campaigns:read` SDK: `client.brands.retrieve()` (TypeScript) · `client.brands.retrieve()` (Python) One brand's report as its public page computes it: totals (reach by category, budget, planned impressions, expected and delivered CPM), per-campaign rows, per-show performance, EMV, invites and a reach trend. The path also accepts the brand name. `time_range` (`7d`, `30d`, `90d`, `all`) and `chart_type` shape the trend; `precision=nodes` (default) loads every linked cluster, `summary` is faster but skips EMV. - Required scope: `campaigns:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `brand_slug` | string | required | Brand slug (see GET /v1/brands). | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `time_range` | string | optional | 7d \| 14d \| 30d \| 90d \| 180d \| 365d \| all, or a quarter like 2026-Q2. Windows are UTC days anchored to today. | | `chart_type` | string | optional | daily values or a cumulative running total. | | `precision` | string | optional | summary (fast, stored totals) or a more precise, slower computation. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `slug` | string | required | | | `name` | string | required | | | `domain` | string | optional | Default `null`. Nullable. | | `logo` | string | optional | Default `null`. Nullable. | | `notes` | string | optional | Default `null`. Nullable. | | `has_contact` | boolean | optional | Default `false`. | | `has_brand_doc` | boolean | optional | Default `false`. | | `brand_doc` | object | optional | The canonical campaignBrands doc (contact fields removed). Default `null`. Nullable. | | `invites` | array of objects | optional | brandInvites for this brand (emails masked). | | `campaign_ids` | array of strings | optional | | | `campaign_count` | integer | optional | Default `0`. | | `totals` | object | required | | | `campaign_rows` | array of objects | required | | | `show_performance` | array of objects | required | | | `shows` | array of objects | required | | | `emv_total` | number | optional | Default `0`. | | `emv_by_campaign` | map of number | optional | | | `trend` | object | required | {time_range, chart_type, points: [...]} from brand_trend_series. | | `precision` | string | required | One of `summary`, `nodes`, `none`. | | `public_url` | string | optional | Default `null`. Nullable. | | `app_url` | string | required | | | `computed_at` | string | required | | | `cache_age_seconds` | number | required | | | `note` | string | optional | Default `null`. Nullable. | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/brands/BRAND_SLUG" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "brand", "slug": "acme-coffee", "name": "Acme Coffee", "domain": "acme-coffee.example.com", "logo": "https://www.example.com/b23436f3", "notes": null, "has_contact": false, "has_brand_doc": true, "brand_doc": { "logo": "https://www.example.com/b23436f3", "domain": "acme-coffee.example.com", "name": "Acme Coffee", "notes": "", "updatedAt": "2026-08-19T14:20:56.683Z", "createdAt": "2026-08-19T14:20:56.683Z", "normalizedName": "acme coffee", "slug": "acme-coffee" }, "invites": [ { "id": "brand_sdwY3BeF2mVbCDIqsM6u_vic_brands_listener_com", "brand_name": "Acme Coffee", "campaign_id": "sdwY3BeF2mVbCDIqsM6u", "campaign_title": "Vandelay Imports — Q4 Push", "status": "pending", "invite_email_masked": "v***@mxsxmlhkfvuhc15", "has_brand_account": false, "created_at": "2026-05-01T17:40:46.864Z", "accepted_at": null, "revoked_at": null } ], "campaign_ids": [ "sdwY3BeF2mVbCDIqsM6u", "YaVU05YMJcmX4k5uRGkM" ], "campaign_count": 2, "totals": { "total_reach": 401469, "audio_reach": 70100, "youtube_reach": 106063, "social_reach": 225306, "long_form_reach": 147156, "short_form_reach": 254313, "budget": 200000, "planned_impressions": 2000000, "planned_slots": 17, "linked_slots": 8, "expected_cpm": 100, "delivered_cpm": 273.993757924024, "delivered_cpm_status": "reported", "delivered_cpm_budget": 110000, "delivered_cpm_impressions": 401469, "delivered_cpm_delivery_pct": 36.497181818181815, "delivered_cpm_campaigns": 1, "delivery_rate": 20.07345, "campaign_count": 2, "show_count": 4 }, "campaign_rows": [ { "id": "sdwY3BeF2mVbCDIqsM6u", "title": "Vandelay Imports — Q4 Push", "description": "", "status": "active", "show_ids": [ "paAtKPJGo56md5VyoEQ9Las7qahL", "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H" ], "show_names": [ "Hard Court", "The Bullpen" ], "show_count": 4, "total_reach": 401469, "audio_reach": 70100, "youtube_reach": 106063, "social_reach": 225306, "long_form_reach": 147156, "short_form_reach": 254313, "budget": 200000, "planned_impressions": 2000000, "expected_cpm": 100, "delivered_cpm": 273.993757924024, "delivered_cpm_status": "reported", "delivered_cpm_budget": 110000, "delivered_cpm_impressions": 401469, "delivered_cpm_delivery_pct": 36.497181818181815, "delivered_cpm_slots": 8, "delivery_rate": 20.07345, "goal": 2000000, "fulfillment_rate": 20.07345, "planned_slots": 17, "linked_slots": 8, "start_at": "2025-11-06T00:00:00.000Z", "end_at": "2026-09-10T00:00:00.000Z" }, { "id": "YaVU05YMJcmX4k5uRGkM", "title": "Initech Insurance — Summer Series", "description": "Our hosts react to the weekend's results and look ahead to next week.", "status": "active", "show_ids": [], "show_names": [], "show_count": 0, "total_reach": 0, "audio_reach": 0, "youtube_reach": 0, "social_reach": 0, "long_form_reach": 0, "short_form_reach": 0, "budget": 0, "planned_impressions": 0, "expected_cpm": 0, "delivered_cpm": 0, "delivered_cpm_status": "unavailable", "delivered_cpm_budget": 0, "delivered_cpm_impressions": 0, "delivered_cpm_delivery_pct": null, "delivered_cpm_slots": 0, "delivery_rate": null, "goal": 1000000, "fulfillment_rate": 0, "planned_slots": 0, "linked_slots": 0, "start_at": "2026-02-04T17:32:33.598Z", "end_at": "2026-02-04T17:32:33.598Z" } ], "show_performance": [ { "id": "paAtKPJGo56md5VyoEQ9Las7qahL", "name": "Hard Court", "image": "https://images.example.com/listener/6cbd825d3f77ec48.jpg", "handle": "hard-court", "total_reach": 311838, "audio_reach": 15265, "youtube_reach": 94248, "social_reach": 202325, "long_form_reach": 87070, "short_form_reach": 224768, "planned_impressions": 500000, "budget": 50000, "planned_slots": 5, "linked_slots": 5, "campaign_ids": [ "sdwY3BeF2mVbCDIqsM6u" ], "campaign_count": 1, "expected_cpm": 100, "delivered_cpm": 276.7675761249218, "delivered_cpm_status": "reported", "delivered_cpm_budget": 50000, "delivered_cpm_impressions": 180657, "delivery_rate": 62.3676 }, { "id": "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H", "name": "The Bullpen", "image": "https://images.example.com/listener/8f6c3c4428259526.jpg", "handle": "the-bullpen", "total_reach": 89631, "audio_reach": 54835, "youtube_reach": 11815, "social_reach": 22981, "long_form_reach": 60086, "short_form_reach": 29545, "planned_impressions": 500000, "budget": 50000, "planned_slots": 2, "linked_slots": 2, "campaign_ids": [ "sdwY3BeF2mVbCDIqsM6u" ], "campaign_count": 1, "expected_cpm": 100, "delivered_cpm": 295.16461330484015, "delivered_cpm_status": "reported", "delivered_cpm_budget": 50000, "delivered_cpm_impressions": 169397, "delivery_rate": 17.9262 } ], "shows": [ { "id": "paAtKPJGo56md5VyoEQ9Las7qahL", "name": "Hard Court", "image": "https://images.example.com/listener/6cbd825d3f77ec48.jpg", "handle": "hard-court" }, { "id": "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H", "name": "The Bullpen", "image": "https://images.example.com/listener/8f6c3c4428259526.jpg", "handle": "the-bullpen" } ], "emv_total": 96498.66, "emv_by_campaign": { "sdwY3BeF2mVbCDIqsM6u": 96498.66, "YaVU05YMJcmX4k5uRGkM": 0 }, "trend": { "time_range": "all", "chart_type": "cumulative", "points": [ { "total_reach": 89408, "audio_reach": 54612, "youtube_reach": 11815, "social_reach": 22981, "long_form_reach": 0, "short_form_reach": 0, "date": "2025-11-06" }, { "total_reach": 90900, "audio_reach": 54612, "youtube_reach": 11815, "social_reach": 24473, "long_form_reach": 0, "short_form_reach": 0, "date": "2026-01-12" } ] }, "precision": "nodes", "public_url": "https://network.listener.com/northwind-audio/brands/acme-coffee", "app_url": "https://network.listener.com/app/campaigns?uid=XXfzcGxkLsQaKAvRy1SwLsZCcH78", "computed_at": "2026-10-14T17:34:29.940750+00:00", "cache_age_seconds": 24.4, "note": null } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Create or update a brand `PUT /v1/brands/{brand_slug}` Scope: `campaigns:write` · some changes need `confirm=true` SDK: `client.brands.upsert()` (TypeScript) · `client.brands.upsert()` (Python) Create or update a brand profile once for every campaign: name, logo, domain, contact, invite email, notes. Every campaign of the brand gets its brand details re-stamped in the same write; when more than one campaign is affected the request needs `"confirm": true` (the 409 preview and a dry run both list `affected_campaign_ids`). - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `campaigns:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `brand_slug` | string | required | | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `name` | string | required | Name. | | `logo` | string | optional | Default `null`. Nullable. | | `domain` | string | optional | Default `null`. Nullable. | | `contact` | string | optional | Default `null`. Nullable. | | `invite_email` | string | optional | Default `null`. Nullable. | | `notes` | string | optional | Default `null`. Nullable. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | | `confirm` | boolean | optional | Must be true to apply destructive or metered changes; without it the API answers 409 with a preview. Default `false`. | ### Example request ```bash curl -X PUT "https://api.listener.com/v1/brands/BRAND_SLUG" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "NAME", "dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "brand", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/XXfzcGxkLsQaKAvRy1SwLsZCcH78/campaignBrands/acme-coffee", "id": "acme-coffee", "method": "set-merge", "written": { "name": "Acme Coffee", "logo": "https://www.example.com/b23436f3", "domain": "acme-coffee.example.com", "contact": "", "inviteEmail": "", "notes": "", "slug": "acme-coffee", "normalizedName": "acme coffee", "updatedAt": "" }, "warnings": [], "urls": { "app": "https://network.listener.com/app/campaigns?uid=XXfzcGxkLsQaKAvRy1SwLsZCcH78", "public": "https://network.listener.com/northwind-audio/brands/acme-coffee" }, "error": null, "data": { "slug": "acme-coffee", "brand": { "name": "Acme Coffee", "logo": "https://www.example.com/b23436f3", "domain": "acme-coffee.example.com", "contact": "", "inviteEmail": "", "notes": "", "slug": "acme-coffee" }, "affected_campaign_ids": [], "created": false }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## List air checks `GET /v1/air-checks` Scope: `campaigns:read` SDK: `client.airChecks.list()` (TypeScript) · `client.air_checks.list()` (Python) Ad-read verification rows, one per campaign placement, with status (`detected`, `missing`, `pending`, `blocked`, `excluded`), the transcript-capable targets (Spotify episode, long-form YouTube video), every brand mention found (collapsed into ad reads with YouTube timestamp links) and pre / mid / post placement. `group_by` (`advertiser`, `publisher`, `campaign`, `show`) adds groups in `meta`. Narrow with `campaign_id`, `brand` or `show_id` on large networks. - Required scope: `campaigns:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `campaign_id` | string | optional | Campaign id (see GET /v1/campaigns). | | `show_id` | string | optional | A show id in this workspace (see GET /v1/shows). | | `brand` | string | optional | Brand name or slug filter. | | `status` | string | optional | Status filter or value. | | `platform` | string | optional | A platform name (e.g. youtube, spotify, applePodcasts, instagram). | | `group_by` | string | optional | Group the results by this field. | | `limit` | integer | optional | Page size, 1–100 (default 25). | | `cursor` | string | optional | Opaque cursor from the previous page's next_cursor. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].id` | string | required | | | `data[].campaign_id` | string | required | | | `data[].campaign_title` | string | optional | Default `""`. | | `data[].campaign_type` | string | optional | Default `""`. | | `data[].campaign_sub` | string | optional | Default `""`. | | `data[].advertiser` | string | required | | | `data[].brand_slug` | string | optional | Default `null`. Nullable. | | `data[].brand_logo` | string | optional | Default `null`. Nullable. | | `data[].show` | string | required | | | `data[].show_id` | string | optional | Default `null`. Nullable. | | `data[].show_image` | string | optional | Default `null`. Nullable. | | `data[].publish_date` | string | optional | Default `null`. Nullable. | | `data[].geo` | string | optional | Default `"US"`. | | `data[].episode` | string | required | | | `data[].episode_id` | string | optional | Default `null`. Nullable. | | `data[].episode_url` | string | optional | Default `null`. Nullable. | | `data[].cluster_id` | string | required | | | `data[].cluster_public_url` | string | optional | Default `null`. Nullable. | | `data[].campaign_public_url` | string | optional | Default `null`. Nullable. | | `data[].targets` | array of objects | optional | | | `data[].targets[].platform` | string | required | | | `data[].targets[].label` | string | required | | | `data[].targets[].id` | string | optional | Default `null`. Nullable. | | `data[].targets[].node_id` | string | optional | Default `null`. Nullable. | | `data[].targets[].url` | string | optional | Default `null`. Nullable. | | `data[].targets[].title` | string | optional | Default `null`. Nullable. | | `data[].targets[].duration_seconds` | number | optional | Default `0`. | | `data[].targets[].views` | number | optional | Default `0`. | | `data[].targets[].analyzed` | boolean | optional | Default `false`. | | `data[].platforms` | array of strings | optional | | | `data[].status` | string | required | One of `detected`, `missing`, `pending`, `blocked`, `excluded`. | | `data[].transcript_available` | boolean | optional | Default `false`. | | `data[].needs_aircheck` | boolean | optional | Default `false`. | | `data[].is_excluded` | boolean | optional | Default `false`. | | `data[].scan_blocked_reason` | string | optional | Default `null`. Nullable. | | `data[].episode_length_seconds` | number | optional | Default `0`. | | `data[].node_reach` | number | optional | Default `0`. | | `data[].start_seconds` | number | optional | Default `0`. | | `data[].length_seconds` | number | optional | Default `0`. | | `data[].location_pct` | number | optional | Default `null`. Nullable. | | `data[].unit` | string | optional | Default `null`. Nullable. | | `data[].place_total` | integer | optional | Default `null`. Nullable. | | `data[].group_count` | integer | optional | Default `0`. | | `data[].mentions` | array of objects | optional | | | `data[].ad_reads` | array of objects | optional | | | `data[].ad_reads[].id` | string | required | | | `data[].ad_reads[].start_seconds` | number | optional | Default `0`. | | `data[].ad_reads[].length_seconds` | number | optional | Default `0`. | | `data[].ad_reads[].location_pct` | number | optional | Default `null`. Nullable. | | `data[].ad_reads[].unit` | string | optional | Default `null`. Nullable. | | `data[].ad_reads[].place_index` | integer | required | | | `data[].ad_reads[].place_total` | integer | required | | | `data[].ad_reads[].status` | string | required | | | `data[].ad_reads[].youtube_timestamp_url` | string | optional | Default `null`. Nullable. | | `data[].ad_reads[].mention` | object | optional | | | `data[].object` | string | required | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | | `meta.status_counts` | map of integer | optional | Counts over the filtered rows. | | `meta.groups` | array of objects | optional | Groups over the filtered rows (all pages) when group_by is set. | | `meta.groups[].key` | string | required | | | `meta.groups[].group_by` | string | required | | | `meta.groups[].counts` | map of integer | required | | | `meta.groups[].row_ids` | array of strings | required | | | `meta.group_by` | string | optional | Default `null`. Nullable. | | `meta.filters` | object | optional | | | `meta.campaigns_scanned` | integer | optional | Default `0`. | | `meta.clusters_loaded` | integer | optional | Default `0`. | | `meta.clusters_skipped` | integer | optional | Linked clusters beyond the fetch cap (narrow with campaign_id/brand/show_id). Default `0`. | | `meta.missing_show_ids` | array of strings | optional | | | `meta.computed_at` | string | optional | | | `meta.cache_age_seconds` | number | optional | | | `meta.note` | string | optional | Default `null`. Nullable. | ### Example request ```bash curl "https://api.listener.com/v1/air-checks" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/air-checks", "data": [ { "object": "air_check", "id": "dfEUh5kkZU1O7vAkaMYK::cluster-4uzlgvt6p8QekbVvYkGnbh", "campaign_id": "dfEUh5kkZU1O7vAkaMYK", "campaign_title": "Cyberdyne Fitness — Season Opener", "campaign_type": "Baked-in", "campaign_sub": "", "advertiser": "Umbrella Energy", "brand_slug": "acmecoffee", "brand_logo": "https://images.example.com/listener/a3504ff4873daa28.png", "show": "Hard Court", "show_id": "paAtKPJGo56md5VyoEQ9Las7qahL", "show_image": "https://images.example.com/listener/6cbd825d3f77ec48.jpg", "publish_date": "2026-08-19T00:00:00.000Z", "geo": "US", "episode": "Coaching hot seat check-in", "episode_id": "4uzlgvt6p8QekbVvYkGnbh", "episode_url": "https://open.spotify.com/episode/4uzlgvt6p8QekbVvYkGnbh", "cluster_id": "cluster-4uzlgvt6p8QekbVvYkGnbh", "cluster_public_url": "https://listener.com/cluster/paAtKPJGo56md5VyoEQ9Las7qahL_cluster-4uzlgvt6p8QekbVvYkGnbh", "campaign_public_url": "https://network.listener.com/northwind-audio/campaigns/dfEUh5kkZU1O7vAkaMYK", "targets": [ { "platform": "spotify", "label": "Spotify", "id": "4uzlgvt6p8QekbVvYkGnbh", "node_id": "episode-4uzlgvt6p8QekbVvYkGnbh", "url": "https://open.spotify.com/episode/4uzlgvt6p8QekbVvYkGnbh", "title": "Coaching hot seat check-in", "duration_seconds": 2295, "views": 0, "analyzed": true }, { "platform": "youtube", "label": "YouTube", "id": "EbiTsnaP3Ef", "node_id": "clip-3878174953528-5204", "url": "https://www.youtube.com/watch?v=EbiTsnaP3Ef", "title": "Coaching hot seat check-in", "duration_seconds": 2296, "views": 2191, "analyzed": true } ], "platforms": [ "spotify", "youtube" ], "status": "missing", "transcript_available": true, "needs_aircheck": false, "is_excluded": false, "scan_blocked_reason": null, "episode_length_seconds": 2295, "node_reach": 0, "start_seconds": 0, "length_seconds": 0, "location_pct": null, "unit": null, "place_total": null, "group_count": 0, "mentions": [], "ad_reads": [] }, { "object": "air_check", "id": "YTnljcesYENBaua7HG19::cluster-4uzlgvt6p8QekbVvYkGnbh", "campaign_id": "YTnljcesYENBaua7HG19", "campaign_title": "Acme Coffee — Playoff Run", "campaign_type": "Baked-in", "campaign_sub": "", "advertiser": "Acme Coffee", "brand_slug": null, "brand_logo": null, "show": "Hard Court", "show_id": "paAtKPJGo56md5VyoEQ9Las7qahL", "show_image": "https://images.example.com/listener/6cbd825d3f77ec48.jpg", "publish_date": "2026-08-19T00:00:00.000Z", "geo": "US", "episode": "Coaching hot seat check-in", "episode_id": "4uzlgvt6p8QekbVvYkGnbh", "episode_url": "https://open.spotify.com/episode/4uzlgvt6p8QekbVvYkGnbh", "cluster_id": "cluster-4uzlgvt6p8QekbVvYkGnbh", "cluster_public_url": "https://listener.com/cluster/paAtKPJGo56md5VyoEQ9Las7qahL_cluster-4uzlgvt6p8QekbVvYkGnbh", "campaign_public_url": "https://network.listener.com/northwind-audio/campaigns/YTnljcesYENBaua7HG19", "targets": [ { "platform": "spotify", "label": "Spotify", "id": "4uzlgvt6p8QekbVvYkGnbh", "node_id": "episode-4uzlgvt6p8QekbVvYkGnbh", "url": "https://open.spotify.com/episode/4uzlgvt6p8QekbVvYkGnbh", "title": "Coaching hot seat check-in", "duration_seconds": 2295, "views": 0, "analyzed": false }, { "platform": "youtube", "label": "YouTube", "id": "EbiTsnaP3Ef", "node_id": "clip-3878174953528-5204", "url": "https://www.youtube.com/watch?v=EbiTsnaP3Ef", "title": "Coaching hot seat check-in", "duration_seconds": 2296, "views": 2191, "analyzed": false } ], "platforms": [ "spotify", "youtube" ], "status": "blocked", "transcript_available": false, "needs_aircheck": false, "is_excluded": false, "scan_blocked_reason": "This campaign has no brand name, so there is nothing to look for in the transcript. Add a brand to the campaign to make it scannable.", "episode_length_seconds": 2295, "node_reach": 0, "start_seconds": 0, "length_seconds": 0, "location_pct": null, "unit": null, "place_total": null, "group_count": 0, "mentions": [], "ad_reads": [] } ], "has_more": true, "next_cursor": "eyJvIjoyfQ", "meta": { "status_counts": { "all": 32, "detected": 0, "missing": 18, "pending": 0, "blocked": 14, "excluded": 0 }, "groups": [], "group_by": null, "filters": { "campaign_id": null, "show_id": null, "brand": null, "status": "all", "platform": null }, "campaigns_scanned": 21, "clusters_loaded": 16, "clusters_skipped": 0, "missing_show_ids": [ "0Iush8rT5eCcD4ePHroKFDlQvKhT", "nCJuUMzGC6EE3dfruqjRHtYdlCTr" ], "computed_at": "2026-10-14T17:34:30.737422+00:00", "cache_age_seconds": 25.2, "note": null, "total": 32 } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. --- # Reports > Shared reports, report templates and report automations. Base URL: `https://api.listener.com` ## List shared reports `GET /v1/reports` Scope: `reports:read` SDK: `client.reports.list()` (TypeScript) · `client.reports.list()` (Python) Shared reports, newest first, with headline reach numbers, element ids, scope and share URLs. `search` matches title, template id or network name. - Required scope: `reports:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `search` | string | optional | Case-insensitive text filter. | | `limit` | integer | optional | Page size, 1–100 (default 25). | | `cursor` | string | optional | Opaque cursor from the previous page's next_cursor. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].id` | string | required | | | `data[].title` | string | required | | | `data[].network_name` | string | optional | Default `null`. Nullable. | | `data[].time_range` | string | optional | Default `null`. Nullable. | | `data[].comparison_mode` | string | optional | Default `null`. Nullable. | | `data[].created_at` | string | optional | Default `null`. Nullable. | | `data[].updated_at` | string | optional | Default `null`. Nullable. | | `data[].created_by` | string | optional | Default `null`. Nullable. | | `data[].created_via` | string | optional | Default `null`. Nullable. | | `data[].is_automated` | boolean | optional | Default `false`. | | `data[].is_test` | boolean | optional | Default `false`. | | `data[].template_id` | string | optional | Default `null`. Nullable. | | `data[].element_count` | integer | optional | Default `0`. | | `data[].element_ids` | array of strings | optional | | | `data[].element_metrics` | array of strings | optional | | | `data[].element_types` | map of integer | optional | | | `data[].report_scope` | object | optional | Default `null`. Nullable. | | `data[].report_scope.is_single_show` | boolean | optional | Default `false`. | | `data[].report_scope.scope_label` | string | optional | Default `null`. Nullable. | | `data[].report_scope.detail_text` | string | optional | Default `null`. Nullable. | | `data[].report_scope.show_count` | integer | optional | Default `null`. Nullable. | | `data[].report_scope.show_ids` | array of strings | optional | | | `data[].report_scope.show_names` | array of strings | optional | | | `data[].headline` | object | required | | | `data[].headline.total` | number | optional | Default `null`. Nullable. | | `data[].headline.total_growth` | number | optional | Default `null`. Nullable. | | `data[].headline.youtube` | number | optional | Default `null`. Nullable. | | `data[].headline.youtube_growth` | number | optional | Default `null`. Nullable. | | `data[].headline.audio` | number | optional | Default `null`. Nullable. | | `data[].headline.audio_growth` | number | optional | Default `null`. Nullable. | | `data[].headline.social` | number | optional | Default `null`. Nullable. | | `data[].headline.social_growth` | number | optional | Default `null`. Nullable. | | `data[].has_data` | boolean | optional | Default `false`. | | `data[].urls` | object | required | | | `data[].urls.network` | string | optional | https://network.listener.com/report/{uid}/{id} Default `null`. Nullable. | | `data[].urls.public` | string | optional | https://listener.com/report/{uid}/{id} Default `null`. Nullable. | | `data[].object` | string | required | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | | `meta.kind` | string | optional | One of `reports`, `templates`. | | `meta.templates` | array of objects | optional | | | `meta.templates[].id` | string | required | | | `meta.templates[].name` | string | required | | | `meta.templates[].description` | string | optional | Default `null`. Nullable. | | `meta.templates[].icon` | string | optional | Default `null`. Nullable. | | `meta.templates[].built_in` | boolean | required | | | `meta.templates[].automation` | boolean | required | True when the template can drive a report automation. | | `meta.templates[].dynamic` | boolean | optional | Default `false`. | | `meta.templates[].element_count` | integer | optional | Default `0`. | | `meta.templates[].element_ids` | array of strings | optional | | | `meta.templates[].elements` | array of objects | optional | Custom templates only (built-ins carry element_ids). | | `meta.templates[].elements[].id` | string | optional | Default `null`. Nullable. | | `meta.templates[].elements[].instance_id` | string | optional | Default `null`. Nullable. | | `meta.templates[].elements[].type` | string | optional | Default `null`. Nullable. | | `meta.templates[].elements[].category` | string | optional | Default `null`. Nullable. | | `meta.templates[].elements[].name` | string | optional | Default `null`. Nullable. | | `meta.templates[].elements[].metric` | string | optional | Default `null`. Nullable. | | `meta.templates[].elements[].platform` | string | optional | Default `null`. Nullable. | | `meta.templates[].elements[].chart_type` | string | optional | Default `null`. Nullable. | | `meta.templates[].elements[].text_type` | string | optional | Default `null`. Nullable. | | `meta.templates[].elements[].content` | string | optional | Default `null`. Nullable. | | `meta.templates[].elements[].max_rows` | integer | optional | Default `null`. Nullable. | | `meta.templates[].elements[].has_episode_data` | boolean | optional | Default `false`. | | `meta.templates[].created_at` | string | optional | Default `null`. Nullable. | | `meta.templates[].updated_at` | string | optional | Default `null`. Nullable. | | `meta.filters` | object | optional | | | `meta.built_in_template_count` | integer | optional | Default `0`. | | `meta.custom_template_count` | integer | optional | Default `0`. | | `meta.computed_at` | string | optional | | | `meta.cache_age_seconds` | number | optional | | | `meta.note` | string | optional | Default `null`. Nullable. | ### Example request ```bash curl "https://api.listener.com/v1/reports" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/reports", "data": [ { "object": "report", "id": "yDOF1QDhp4fJsSahuI0L", "title": "Executive summary", "network_name": "Northwind Audio", "time_range": "30d", "comparison_mode": "previous_period", "created_at": "2026-10-14T07:57:01.185000+00:00", "updated_at": null, "created_by": "XXfzcGxkLsQaKAvRy1SwLsZCcH78", "created_via": "app", "is_automated": false, "is_test": false, "template_id": null, "element_count": 0, "element_ids": [], "element_metrics": [], "element_types": {}, "report_scope": { "is_single_show": false, "scope_label": "Multiple Shows", "detail_text": "13 shows included", "show_count": 13, "show_ids": [ "sLLP2LKo9LJHqRCtBjjWDsbyDImn", "jgDOYIsPG3c9Jk7mysodavhQxXhV" ], "show_names": [ "Dugout Daily", "The Overtime Report" ] }, "headline": { "total": 36123160, "total_growth": 9.720099772604962, "youtube": 1691211, "youtube_growth": null, "audio": 499050, "audio_growth": -73.63766889482642, "social": 33773406, "social_growth": 441.5721709893556 }, "has_data": true, "urls": { "network": "https://network.listener.com/report/XXfzcGxkLsQaKAvRy1SwLsZCcH78/yDOF1QDhp4fJsSahuI0L", "public": "https://listener.com/report/XXfzcGxkLsQaKAvRy1SwLsZCcH78/yDOF1QDhp4fJsSahuI0L" } }, { "object": "report", "id": "U408eAHMUK0pl7OlcvFU", "title": "Executive summary", "network_name": "Northwind Audio", "time_range": "30d", "comparison_mode": "previous_period", "created_at": "2026-10-14T07:54:14.099000+00:00", "updated_at": null, "created_by": "XXfzcGxkLsQaKAvRy1SwLsZCcH78", "created_via": "app", "is_automated": false, "is_test": false, "template_id": null, "element_count": 0, "element_ids": [], "element_metrics": [], "element_types": {}, "report_scope": { "is_single_show": false, "scope_label": "Multiple Shows", "detail_text": "13 shows included", "show_count": 13, "show_ids": [ "sLLP2LKo9LJHqRCtBjjWDsbyDImn", "jgDOYIsPG3c9Jk7mysodavhQxXhV" ], "show_names": [ "Dugout Daily", "The Overtime Report" ] }, "headline": { "total": 36120722, "total_growth": 9.74349297032353, "youtube": 1691211, "youtube_growth": null, "audio": 499050, "audio_growth": -73.63766889482642, "social": 33770968, "social_growth": 441.7254654737177 }, "has_data": true, "urls": { "network": "https://network.listener.com/report/XXfzcGxkLsQaKAvRy1SwLsZCcH78/U408eAHMUK0pl7OlcvFU", "public": "https://listener.com/report/XXfzcGxkLsQaKAvRy1SwLsZCcH78/U408eAHMUK0pl7OlcvFU" } } ], "has_more": true, "next_cursor": "eyJvIjoyfQ", "meta": { "kind": "reports", "templates": [], "filters": { "search": null }, "built_in_template_count": 0, "custom_template_count": 0, "computed_at": "2026-10-14T17:34:30.743194+00:00", "cache_age_seconds": 25.2, "note": null, "total": 125 } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Publish a shared report `POST /v1/reports` Scope: `reports:write` · needs `confirm=true` SDK: `client.reports.create()` (TypeScript) · `client.reports.create()` (Python) Publish a shared report with a frozen analytics snapshot and return its id and public URL. Needs `"confirm": true` because creating it publishes it: the page at `urls.public` is public and immutable (only `DELETE /v1/reports/{report_id}` changes it). `template_id` is a built-in (`network-overview`, `executive-summary`, `audio-template`, `social-reach`, `short-form`, `youtube-focused`, `cluster-overview`, `-focused` …) or a custom id from `GET /v1/report-templates`; `add_element_ids` / `remove_element_ids` adjust its elements. `time_range` is `7d`, `30d` or `90d`; on 90 days reach totals, trends and growth come from stored history while per-platform likes, comments and posts cover the last 30 days (noted in `warnings`). A dry run summarises the snapshot. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Requires `"confirm": true`; without it the API answers 409 `confirmation_required` with the exact `preview`. - Required scope: `reports:write`. ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `title` | string | required | Title. | | `template_id` | string | required | | | `time_range` | string | optional | 7d \| 14d \| 30d \| 90d \| 180d \| 365d \| all, or a quarter like 2026-Q2. Windows are UTC days anchored to today. One of `7d`, `30d`, `90d`. Default `"30d"`. | | `add_element_ids` | array of strings | optional | Default `null`. Nullable. | | `remove_element_ids` | array of strings | optional | Default `null`. Nullable. | | `comparison_mode` | string | optional | previous_period or none. One of `previous_period`, `none`. Default `"previous_period"`. | | `show_ids` | array of strings | optional | Limit to these show ids (default: every show in the workspace). Default `null`. Nullable. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | | `confirm` | boolean | optional | Must be true to apply destructive or metered changes; without it the API answers 409 with a preview. Default `false`. | ### Example request ```bash curl -X POST "https://api.listener.com/v1/reports" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"title": "TITLE", "template_id": "TEMPLATE_ID", "dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "report", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/XXfzcGxkLsQaKAvRy1SwLsZCcH78/sharedReports/p92xoYDOgoWiIvAGr0Pa", "id": "p92xoYDOgoWiIvAGr0Pa", "method": "add", "written": { "title": "Monthly performance recap", "elements": [ { "id": "kpi-audio-reach", "type": "kpi", "category": "kpis", "name": "Audio Consumption", "metric": "audioReach", "icon": "fas fa-headphones" }, { "id": "kpi-applePodcasts-reach", "type": "kpi", "category": "kpis", "name": "Apple Podcasts Reach", "metric": "platformReach", "platform": "applePodcasts", "icon": "fab fa-apple" } ], "data": { "totalReach": 35838955, "reachGrowth": -53.529957137007656, "totalYoutubeReach": 1699627, "youtubeGrowth": -36.5983046396352, "totalAudioReach": 354734, "audioGrowth": -79.67041374508213, "totalSocialReach": 33784594, "socialGrowth": -53.526879710025156, "reachData": [ { "date": "Sep 15", "value": 1546342 }, { "date": "Sep 16", "value": 10593158 } ], "youtubeReachData": [ { "date": "Sep 15", "value": 77798 }, { "date": "Sep 16", "value": 122746 } ], "audioReachData": [ { "date": "Sep 15", "value": 60331 }, { "date": "Sep 16", "value": 117799 } ], "socialReachData": [ { "date": "Sep 15", "value": 1408213 }, { "date": "Sep 16", "value": 10352613 } ], "showsRanking": [ { "id": "sLLP2LKo9LJHqRCtBjjWDsbyDImn", "name": "Dugout Daily", "image": "https://images.example.com/listener/09c3beb030eae9d0.jpg", "totalReach": 26429121, "youtubeReach": 0, "audioReach": 95919, "socialReach": 26333202, "growth": 83.47935920462142 }, { "id": "jgDOYIsPG3c9Jk7mysodavhQxXhV", "name": "The Overtime Report", "image": "https://images.example.com/listener/498a319e58218172.jpg", "totalReach": 5006355, "youtubeReach": 0, "audioReach": 29011, "socialReach": 4977344, "growth": -89.77629167629624 } ], "platformTrendData": { "youtube": [ { "date": "Sep 15", "value": 77798 }, { "date": "Sep 16", "value": 122746 } ], "instagram": [ { "date": "Sep 15", "value": 429254 }, { "date": "Sep 16", "value": 2492071 } ], "tiktok": [ { "date": "Sep 15", "value": 0 }, { "date": "Sep 16", "value": 23419 } ], "twitter": [ { "date": "Sep 15", "value": 978959 }, { "date": "Sep 16", "value": 7837123 } ], "spotify": [ { "date": "Sep 15", "value": 60159 }, { "date": "Sep 16", "value": 117457 } ], "applePodcasts": [ { "date": "Sep 15", "value": 172 }, { "date": "Sep 16", "value": 342 } ], "megaphone": [ { "date": "Sep 15", "value": 0 }, { "date": "Sep 16", "value": 0 } ], "omnystudio": [ { "date": "Sep 15", "value": 0 }, { "date": "Sep 16", "value": 0 } ] }, "platformGrowth": { "youtube": -36.5983046396352, "instagram": -47.940749688505804, "tiktok": 5.960717443343276, "twitter": -57.01242896991317, "spotify": -83.06823555689729, "applePodcasts": -79.36341378104233, "megaphone": -100, "omnystudio": 183.82319845163613 }, "platformTotals": { "youtube": 1699627, "instagram": 7666139, "tiktok": 2015820, "twitter": 24102635, "spotify": 291087, "applePodcasts": 590, "megaphone": 0, "omnystudio": 63057 }, "platformBreakdown": [ { "name": "X (Twitter)", "value": 24102635 }, { "name": "Instagram", "value": 7666139 } ], "avgPerformancePerPost": { "youtube": 7017.75, "instagram": 55551.73188405797, "tiktok": 25516.708860759492, "linkedin": 0, "twitter": 163963.50340136053, "facebook": 0, "threads": 0, "beehiiv": 0, "substack": 0 }, "youtubeMetrics": { "lifetimeViews": 55338008, "views": 1699627, "watchTime": 16504666, "subscribers": 200610, "subscribersGained": 4183, "avgViewDuration": 356.2857142857143, "viewsGrowth": 91.4220713034762, "watchTimeGrowth": 0, "subscribersGrowth": 0, "subsGainedGrowth": 25.42728635682159, "avgDurationGrowth": 0 }, "instagramMetrics": { "followers": 2530286, "plays": 7666139, "engagement": 343546, "likes": 333471, "comments": 10073, "shares": 2, "followersGrowth": 0, "playsGrowth": 914.4635853081446, "engagementGrowth": 1303.546186215631 }, "tiktokMetrics": { "followers": 183213, "plays": 2015820, "engagement": 157978, "likes": 136295, "comments": 4371, "shares": 17312, "followersGrowth": 0, "playsGrowth": 443.08275477462485, "engagementGrowth": 763.6925263790936 }, "facebookMetrics": { "followers": 7800, "views": 0, "engagement": 0, "likes": 0, "comments": 0, "shares": 0, "followersGrowth": 0, "viewsGrowth": 0, "engagementGrowth": 0 }, "twitterMetrics": { "followers": 1579885, "impressions": 24102635, "engagement": 174756, "likes": 152894, "retweets": 14811, "replies": 4576, "quotes": 2475, "followersGrowth": 0, "impressionsGrowth": 1602.857379030862, "engagementGrowth": 434.4056756674108 }, "spotifyMetrics": { "streams": 291087, "totalStreams": 80910297, "followers": 323863, "episodes": 15486, "avgStreams": 7702, "consumptionHours": 81505.23, "totalConsumptionHours": 18712443.50999994, "rating": 4.7, "topCountry": "United States", "streamsGrowth": -83.06823555689729, "followersGrowth": 0, "avgStreamsGrowth": -25.490954822482344, "consumptionHoursGrowth": -84.77568542704255 }, "applePodcastsMetrics": { "plays": 679, "totalPlays": 419206767, "followers": 355508, "listeners": 232, "totalListeners": 1605511, "engagedListeners": 1165291, "hoursListened": 37783544, "totalHoursListened": 37783544, "avgPlays": 283, "episodes": 1, "topCountry": "United States", "playsGrowth": -76.30844382414514, "followersGrowth": 0, "listenersGrowth": -74.7276688453159, "engagedListenersGrowth": 0, "hoursListenedGrowth": 0, "avgPlaysGrowth": -57.315233785822016 }, "threadsMetrics": { "reach": 0, "reachGrowth": 0, "daysWithData": 1, "engagement": 2, "engagementGrowth": 100, "followers": 9350 }, "megaphoneMetrics": { "downloads": 0, "downloadsGrowth": 0, "daysWithData": 0, "overviewDownloads": 0, "overviewStreams": 0, "overviewPlays": 0, "externalAnalyticsLoaded": false }, "omnystudioMetrics": { "downloads": 63057, "lifetimeDownloads": 2846561, "estimatedReach": 0, "hoursListened": 78347, "episodes": 872, "avgDownloads": 4504, "downloadsGrowth": 250.55036691127418, "avgDownloadsGrowth": 250.5058365758755, "lifetimeDownloadsGrowth": 0, "estimatedReachGrowth": 0, "hoursListenedGrowth": 0, "episodesGrowth": 0 }, "clusterMetrics": { "clusterCount": 12, "totalReach": 87263, "youtubeReach": 68228, "youtubeLongFormReach": 64066, "youtubeShortsReach": 4162, "audioReach": 4477, "socialReach": 14558, "longFormReach": 68543, "shortFormReach": 18720, "longFormCount": 11, "shortFormCount": 16, "clips": 27, "posts": 0, "totalEngagement": 1432, "engagementRate": 1.6410162382682236, "avgReachPerCluster": 7271.916666666667, "avgYoutubeReachPerCluster": 5685.666666666667, "avgYoutubeLongFormReachPerCluster": 5338.833333333333, "avgYoutubeShortsReachPerCluster": 346.8333333333333, "avgAudioReachPerCluster": 373.0833333333333, "avgSocialReachPerCluster": 1213.1666666666667, "avgLongFormReachPerCluster": 5711.916666666667, "avgShortFormReachPerCluster": 1560, "avgLongFormCountPerCluster": 0.9166666666666666, "avgShortFormCountPerCluster": 1.3333333333333333, "avgClipsPerCluster": 2.25, "avgPostsPerCluster": 0, "avgEngagementPerCluster": 119.33333333333333, "countGrowth": -33.33333333333333, "reachGrowth": -96.36663346812607, "youtubeGrowth": -65.88787616681083, "youtubeLongFormGrowth": -65.49748229528502, "youtubeShortsGrowth": -70.94792684629346, "audioGrowth": -72.67289263260696, "socialGrowth": -99.33382693045131, "longFormGrowth": -66.07924065166182, "shortFormGrowth": -99.14895319424416, "avgReachGrowth": -94.54995020218911, "avgYoutubeGrowth": -48.83181425021624, "avgAudioGrowth": -59.009338948910454, "avgSocialGrowth": -99.00074039567697, "avgLongFormGrowth": -49.11886097749272, "avgShortFormGrowth": -98.72342979136623, "hasReachDistributionData": true, "hasContentMixData": true, "clusterDocCount": 159, "sourceClusterCount": 144, "mergedClusterCount": 15, "manualClusterCount": 11, "emptyClusterCount": 22, "unlinkedClusterCount": 0, "undatedClusterCount": 1, "episodeCount": 134, "contentPieces": 27, "reachDistributionData": [ { "name": "Mailbag: your questions answered", "value": 68228, "color": "#FF0000" }, { "name": "What the numbers really say", "value": 4477, "color": "#8B5CF6" } ], "contentMixData": [ { "name": "The rookie who changed everything", "value": 68543, "color": "#2563EB" }, { "name": "Inside the locker room with a two-time champion", "value": 18720, "color": "#F59E0B" } ] }, "window": { "time_range": "30d", "kind": "rolling", "start": "2026-09-15", "end": "2026-10-14", "days": 30, "previous_start": "2026-08-16", "previous_end": "2026-09-14" }, "metricWindows": { "reach": "30d", "platformMetrics": "30d" }, "coverage": { "start": "2019-11-26", "embedded_boundary": "2026-09-15", "history_used": true, "days_with_data": 29, "previous_days_with_data": 30, "previous_window_coverage_days": 30, "previous_window_days": 30, "growth_basis": "total", "growth_platforms": null, "previous_platform_coverage": {}, "warnings": [] }, "generatedBy": "mcp" }, "networkName": "Northwind Audio", "timeRange": "30d", "comparisonMode": "previous_period", "reportScope": { "isSingleShow": false, "scopeLabel": "Multiple Shows", "detailText": "12 shows included", "showCount": 12, "logos": [ { "id": "sLLP2LKo9LJHqRCtBjjWDsbyDImn", "name": "Dugout Daily", "image": "https://images.example.com/listener/09c3beb030eae9d0.jpg" }, { "id": "jgDOYIsPG3c9Jk7mysodavhQxXhV", "name": "The Overtime Report", "image": "https://images.example.com/listener/498a319e58218172.jpg" } ] }, "createdAt": "2026-10-14T17:34:30.815Z", "createdBy": "XXfzcGxkLsQaKAvRy1SwLsZCcH78", "createdVia": "mcp", "templateId": "audio-template" }, "warnings": [ "Shared reports cannot be edited after creation; delete and recreate to change them." ], "urls": { "app": "https://network.listener.com/app/reports?uid=XXfzcGxkLsQaKAvRy1SwLsZCcH78", "public": "https://network.listener.com/report/XXfzcGxkLsQaKAvRy1SwLsZCcH78/p92xoYDOgoWiIvAGr0Pa" }, "error": null, "data": { "report_id": "p92xoYDOgoWiIvAGr0Pa", "template_name": "Audio Template", "element_ids": [ "kpi-audio-reach", "kpi-applePodcasts-reach" ], "report_scope": { "isSingleShow": false, "scopeLabel": "Multiple Shows", "detailText": "12 shows included", "showCount": 12, "logos": [ { "id": "sLLP2LKo9LJHqRCtBjjWDsbyDImn", "name": "Dugout Daily", "image": "https://images.example.com/listener/09c3beb030eae9d0.jpg" }, { "id": "jgDOYIsPG3c9Jk7mysodavhQxXhV", "name": "The Overtime Report", "image": "https://images.example.com/listener/498a319e58218172.jpg" } ] }, "confirm_required": true, "snapshot_summary": { "total_reach": 35838955, "reach_growth": -53.529957137007656, "shows": 12, "cluster_count": 12, "window": { "time_range": "30d", "kind": "rolling", "start": "2026-09-15", "end": "2026-10-14", "days": 30, "previous_start": "2026-08-16", "previous_end": "2026-09-14" } }, "snapshot_gaps": { "kpi-*-followers (social)": "follower deltas come from live socialStats; growth is reported as 0", "table-shows-ranking.image": "show images are the profile logo, not the analytics page's resolved artwork" } }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Retrieve a report `GET /v1/reports/{report_id}` Scope: `reports:read` SDK: `client.reports.retrieve()` (TypeScript) · `client.reports.retrieve()` (Python) One shared report: metadata, scope, headline numbers, every element (KPIs, charts, tables, text) and, with `include_data=true`, the stored snapshot the public page renders. The snapshot is what was computed when the report was published; arrays over 200 items are truncated (`data_truncated`). - Required scope: `reports:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `report_id` | string | required | Shared report id (see GET /v1/reports). | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `include_data` | boolean | optional | | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `id` | string | required | | | `title` | string | required | | | `network_name` | string | optional | Default `null`. Nullable. | | `time_range` | string | optional | Default `null`. Nullable. | | `comparison_mode` | string | optional | Default `null`. Nullable. | | `created_at` | string | optional | Default `null`. Nullable. | | `updated_at` | string | optional | Default `null`. Nullable. | | `created_by` | string | optional | Default `null`. Nullable. | | `created_via` | string | optional | Default `null`. Nullable. | | `is_automated` | boolean | optional | Default `false`. | | `is_test` | boolean | optional | Default `false`. | | `template_id` | string | optional | Default `null`. Nullable. | | `element_count` | integer | optional | Default `0`. | | `element_ids` | array of strings | optional | | | `element_metrics` | array of strings | optional | | | `element_types` | map of integer | optional | | | `report_scope` | object | optional | Default `null`. Nullable. | | `report_scope.is_single_show` | boolean | optional | Default `false`. | | `report_scope.scope_label` | string | optional | Default `null`. Nullable. | | `report_scope.detail_text` | string | optional | Default `null`. Nullable. | | `report_scope.show_count` | integer | optional | Default `null`. Nullable. | | `report_scope.show_ids` | array of strings | optional | | | `report_scope.show_names` | array of strings | optional | | | `headline` | object | required | | | `headline.total` | number | optional | Default `null`. Nullable. | | `headline.total_growth` | number | optional | Default `null`. Nullable. | | `headline.youtube` | number | optional | Default `null`. Nullable. | | `headline.youtube_growth` | number | optional | Default `null`. Nullable. | | `headline.audio` | number | optional | Default `null`. Nullable. | | `headline.audio_growth` | number | optional | Default `null`. Nullable. | | `headline.social` | number | optional | Default `null`. Nullable. | | `headline.social_growth` | number | optional | Default `null`. Nullable. | | `has_data` | boolean | optional | Default `false`. | | `urls` | object | required | | | `urls.network` | string | optional | https://network.listener.com/report/{uid}/{id} Default `null`. Nullable. | | `urls.public` | string | optional | https://listener.com/report/{uid}/{id} Default `null`. Nullable. | | `elements` | array of objects | optional | | | `elements[].id` | string | optional | Default `null`. Nullable. | | `elements[].instance_id` | string | optional | Default `null`. Nullable. | | `elements[].type` | string | optional | Default `null`. Nullable. | | `elements[].category` | string | optional | Default `null`. Nullable. | | `elements[].name` | string | optional | Default `null`. Nullable. | | `elements[].metric` | string | optional | Default `null`. Nullable. | | `elements[].platform` | string | optional | Default `null`. Nullable. | | `elements[].chart_type` | string | optional | Default `null`. Nullable. | | `elements[].text_type` | string | optional | Default `null`. Nullable. | | `elements[].content` | string | optional | Default `null`. Nullable. | | `elements[].max_rows` | integer | optional | Default `null`. Nullable. | | `elements[].has_episode_data` | boolean | optional | Default `false`. | | `data` | object | optional | The stored data snapshot (arrays truncated to 200 items when data_truncated). Default `null`. Nullable. | | `data_keys` | array of strings | optional | | | `data_truncated` | boolean | optional | Default `false`. | | `computed_at` | string | required | | | `cache_age_seconds` | number | required | | | `note` | string | optional | Default `null`. Nullable. | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/reports/REPORT_ID" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "report", "id": "yDOF1QDhp4fJsSahuI0L", "title": "Executive summary", "network_name": "Northwind Audio", "time_range": "30d", "comparison_mode": "previous_period", "created_at": "2026-10-14T07:57:01.185000+00:00", "updated_at": null, "created_by": "XXfzcGxkLsQaKAvRy1SwLsZCcH78", "created_via": "app", "is_automated": false, "is_test": false, "template_id": null, "element_count": 10, "element_ids": [ "kpi-cluster-count", "kpi-cluster-total-reach" ], "element_metrics": [ "clusterCount", "clusterLongFormReach" ], "element_types": { "chart": 1, "episode": 2, "kpi": 4, "table": 3 }, "report_scope": { "is_single_show": false, "scope_label": "Multiple Shows", "detail_text": "13 shows included", "show_count": 13, "show_ids": [ "sLLP2LKo9LJHqRCtBjjWDsbyDImn", "jgDOYIsPG3c9Jk7mysodavhQxXhV" ], "show_names": [ "Dugout Daily", "The Overtime Report" ] }, "headline": { "total": 36123160, "total_growth": 9.720099772604962, "youtube": 1691211, "youtube_growth": null, "audio": 499050, "audio_growth": -73.63766889482642, "social": 33773406, "social_growth": 441.5721709893556 }, "has_data": true, "urls": { "network": "https://network.listener.com/report/XXfzcGxkLsQaKAvRy1SwLsZCcH78/yDOF1QDhp4fJsSahuI0L", "public": "https://listener.com/report/XXfzcGxkLsQaKAvRy1SwLsZCcH78/yDOF1QDhp4fJsSahuI0L" }, "elements": [ { "id": "kpi-cluster-count", "instance_id": null, "type": "kpi", "category": "kpis", "name": "Cluster Count", "metric": "clusterCount", "platform": null, "chart_type": null, "text_type": null, "content": null, "max_rows": null, "has_episode_data": false }, { "id": "kpi-cluster-total-reach", "instance_id": null, "type": "kpi", "category": "kpis", "name": "Total Cluster Reach", "metric": "clusterTotalReach", "platform": null, "chart_type": null, "text_type": null, "content": null, "max_rows": null, "has_episode_data": false } ], "data": null, "data_keys": [ "applePodcastsMetrics", "rmm89Hrhvccj" ], "data_truncated": false, "computed_at": "2026-10-14T17:34:30.819895+00:00", "cache_age_seconds": 25.3, "note": null } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Delete a shared report `DELETE /v1/reports/{report_id}` Scope: `reports:write` · some changes need `confirm=true` SDK: `client.reports.delete()` (TypeScript) · `client.reports.delete()` (Python) Permanently delete a shared report so its public page stops resolving, and list report automations whose last report still points at it (left as is, listed in `warnings`). - Send `?dry_run=true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Requires `?confirm=true`; without it the API answers 409 `confirmation_required` with the exact `preview`. - Required scope: `reports:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `report_id` | string | required | Shared report id (see GET /v1/reports). | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). | | `confirm` | boolean | optional | Must be true to apply destructive or metered changes; without it the API answers 409 with a preview. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Example request ```bash curl -X DELETE "https://api.listener.com/v1/reports/REPORT_ID?dry_run=true" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "dry_run", "resource": "report", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/XXfzcGxkLsQaKAvRy1SwLsZCcH78/sharedReports/yDOF1QDhp4fJsSahuI0L", "id": "yDOF1QDhp4fJsSahuI0L", "method": "delete", "written": { "deleted": true, "report": { "id": "yDOF1QDhp4fJsSahuI0L", "createdAt": "2026-10-14T07:57:01.185Z", "timeRange": "30d", "title": "Executive summary" }, "automation_ids": [], "public_url": "https://network.listener.com/report/XXfzcGxkLsQaKAvRy1SwLsZCcH78/yDOF1QDhp4fJsSahuI0L" }, "warnings": [], "urls": { "app": "https://network.listener.com/app/reports?uid=XXfzcGxkLsQaKAvRy1SwLsZCcH78", "public": null }, "error": null, "data": { "report_id": "yDOF1QDhp4fJsSahuI0L", "automation_ids": [], "confirm_required": true }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## List report templates `GET /v1/report-templates` Scope: `reports:read` SDK: `client.reportTemplates.list()` (TypeScript) · `client.report_templates.list()` (Python) Report templates: the built-in ones plus the workspace's custom templates, with their element ids. Use a template's `id` as `template_id` in `POST /v1/reports`. - Required scope: `reports:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `search` | string | optional | Case-insensitive text filter. | | `limit` | integer | optional | Page size, 1–100 (default 25). | | `cursor` | string | optional | Opaque cursor from the previous page's next_cursor. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].id` | string | required | | | `data[].name` | string | required | | | `data[].description` | string | optional | Default `null`. Nullable. | | `data[].icon` | string | optional | Default `null`. Nullable. | | `data[].built_in` | boolean | required | | | `data[].automation` | boolean | required | True when the template can drive a report automation. | | `data[].dynamic` | boolean | optional | Default `false`. | | `data[].element_count` | integer | optional | Default `0`. | | `data[].element_ids` | array of strings | optional | | | `data[].elements` | array of objects | optional | Custom templates only (built-ins carry element_ids). | | `data[].elements[].id` | string | optional | Default `null`. Nullable. | | `data[].elements[].instance_id` | string | optional | Default `null`. Nullable. | | `data[].elements[].type` | string | optional | Default `null`. Nullable. | | `data[].elements[].category` | string | optional | Default `null`. Nullable. | | `data[].elements[].name` | string | optional | Default `null`. Nullable. | | `data[].elements[].metric` | string | optional | Default `null`. Nullable. | | `data[].elements[].platform` | string | optional | Default `null`. Nullable. | | `data[].elements[].chart_type` | string | optional | Default `null`. Nullable. | | `data[].elements[].text_type` | string | optional | Default `null`. Nullable. | | `data[].elements[].content` | string | optional | Default `null`. Nullable. | | `data[].elements[].max_rows` | integer | optional | Default `null`. Nullable. | | `data[].elements[].has_episode_data` | boolean | optional | Default `false`. | | `data[].created_at` | string | optional | Default `null`. Nullable. | | `data[].updated_at` | string | optional | Default `null`. Nullable. | | `data[].object` | string | required | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | | `meta.kind` | string | optional | One of `reports`, `templates`. | | `meta.reports` | array of objects | optional | | | `meta.reports[].id` | string | required | | | `meta.reports[].title` | string | required | | | `meta.reports[].network_name` | string | optional | Default `null`. Nullable. | | `meta.reports[].time_range` | string | optional | Default `null`. Nullable. | | `meta.reports[].comparison_mode` | string | optional | Default `null`. Nullable. | | `meta.reports[].created_at` | string | optional | Default `null`. Nullable. | | `meta.reports[].updated_at` | string | optional | Default `null`. Nullable. | | `meta.reports[].created_by` | string | optional | Default `null`. Nullable. | | `meta.reports[].created_via` | string | optional | Default `null`. Nullable. | | `meta.reports[].is_automated` | boolean | optional | Default `false`. | | `meta.reports[].is_test` | boolean | optional | Default `false`. | | `meta.reports[].template_id` | string | optional | Default `null`. Nullable. | | `meta.reports[].element_count` | integer | optional | Default `0`. | | `meta.reports[].element_ids` | array of strings | optional | | | `meta.reports[].element_metrics` | array of strings | optional | | | `meta.reports[].element_types` | map of integer | optional | | | `meta.reports[].report_scope` | object | optional | Default `null`. Nullable. | | `meta.reports[].report_scope.is_single_show` | boolean | optional | Default `false`. | | `meta.reports[].report_scope.scope_label` | string | optional | Default `null`. Nullable. | | `meta.reports[].report_scope.detail_text` | string | optional | Default `null`. Nullable. | | `meta.reports[].report_scope.show_count` | integer | optional | Default `null`. Nullable. | | `meta.reports[].report_scope.show_ids` | array of strings | optional | | | `meta.reports[].report_scope.show_names` | array of strings | optional | | | `meta.reports[].headline` | object | required | | | `meta.reports[].headline.total` | number | optional | Default `null`. Nullable. | | `meta.reports[].headline.total_growth` | number | optional | Default `null`. Nullable. | | `meta.reports[].headline.youtube` | number | optional | Default `null`. Nullable. | | `meta.reports[].headline.youtube_growth` | number | optional | Default `null`. Nullable. | | `meta.reports[].headline.audio` | number | optional | Default `null`. Nullable. | | `meta.reports[].headline.audio_growth` | number | optional | Default `null`. Nullable. | | `meta.reports[].headline.social` | number | optional | Default `null`. Nullable. | | `meta.reports[].headline.social_growth` | number | optional | Default `null`. Nullable. | | `meta.reports[].has_data` | boolean | optional | Default `false`. | | `meta.reports[].urls` | object | required | | | `meta.reports[].urls.network` | string | optional | https://network.listener.com/report/{uid}/{id} Default `null`. Nullable. | | `meta.reports[].urls.public` | string | optional | https://listener.com/report/{uid}/{id} Default `null`. Nullable. | | `meta.filters` | object | optional | | | `meta.built_in_template_count` | integer | optional | Default `0`. | | `meta.custom_template_count` | integer | optional | Default `0`. | | `meta.computed_at` | string | optional | | | `meta.cache_age_seconds` | number | optional | | | `meta.note` | string | optional | Default `null`. Nullable. | ### Example request ```bash curl "https://api.listener.com/v1/report-templates" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/report-templates", "data": [ { "object": "report_template", "id": "audio-template", "name": "Audio Template", "description": "Focus on audio content metrics and trends", "icon": "fas fa-headphones", "built_in": true, "automation": true, "dynamic": false, "element_count": 6, "element_ids": [ "kpi-audio-reach", "kpi-applePodcasts-reach" ], "elements": [], "created_at": null, "updated_at": null }, { "object": "report_template", "id": "social-reach", "name": "Social Reach", "description": "Comprehensive social media performance", "icon": "fas fa-share-alt", "built_in": true, "automation": true, "dynamic": false, "element_count": 8, "element_ids": [ "kpi-social-reach", "kpi-instagram-reach" ], "elements": [], "created_at": null, "updated_at": null } ], "has_more": true, "next_cursor": "eyJvIjoyfQ", "meta": { "kind": "templates", "reports": [], "filters": { "search": null }, "built_in_template_count": 13, "custom_template_count": 0, "computed_at": "2026-10-14T17:34:31.342564+00:00", "cache_age_seconds": 25.8, "note": null, "total": 13 } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Save a report template `POST /v1/report-templates` Scope: `reports:write` SDK: `client.reportTemplates.create()` (TypeScript) · `client.report_templates.create()` (Python) Save a custom report template from a list of element ids (see `GET /v1/report-templates` for the elements the built-ins use). Nothing is published; use the returned id as `template_id` in `POST /v1/reports`. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `reports:write`. ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `name` | string | required | Name. | | `element_ids` | array of strings | required | | | `description` | string | optional | Free-text description. Default `""`. | | `comparison_mode` | string | optional | previous_period or none. One of `previous_period`, `none`. Default `"previous_period"`. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | ### Example request ```bash curl -X POST "https://api.listener.com/v1/report-templates" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "NAME", "element_ids": [], "dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "report_template", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/XXfzcGxkLsQaKAvRy1SwLsZCcH78/reportsTemplates/YNYJCcbZLfiZ3hU9z9FI", "id": "YNYJCcbZLfiZ3hU9z9FI", "method": "add", "written": { "name": "API probe (dry run)", "description": "", "icon": "fas fa-user-edit", "elements": [ { "id": "kpi-audio-reach", "type": "kpi", "category": "kpis", "name": "Audio Consumption", "metric": "audioReach", "icon": "fas fa-headphones" }, { "id": "kpi-applePodcasts-reach", "type": "kpi", "category": "kpis", "name": "Apple Podcasts Reach", "metric": "platformReach", "platform": "applePodcasts", "icon": "fab fa-apple" } ], "createdAt": "2026-10-14T17:34:31.345Z", "elementIds": [ "kpi-audio-reach", "kpi-applePodcasts-reach" ], "comparisonMode": "previous_period" }, "warnings": [], "urls": { "app": "https://network.listener.com/app/reports?uid=XXfzcGxkLsQaKAvRy1SwLsZCcH78", "public": null }, "error": null, "data": { "element_ids": [ "kpi-audio-reach", "kpi-applePodcasts-reach" ] }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## List report automations `GET /v1/report-automations` Scope: `reports:read` SDK: `client.reportAutomations.list()` (TypeScript) · `client.report_automations.list()` (Python) Scheduled report automations: template, frequency, time range, shows, delivery (email masked, Slack configured or not, never the webhook), enabled flag, last and next run, last report URLs and delivery guardrails. - Required scope: `reports:read`. ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].id` | string | required | | | `data[].owner_uid` | string | optional | Default `null`. Nullable. | | `data[].title` | string | required | | | `data[].template_id` | string | optional | Default `null`. Nullable. | | `data[].template_name` | string | optional | Default `null`. Nullable. | | `data[].template_is_built_in` | boolean | optional | Default `false`. | | `data[].element_ids` | array of strings | optional | | | `data[].frequency` | string | optional | Default `null`. Nullable. | | `data[].time_range` | string | required | | | `data[].time_range_label` | string | required | | | `data[].comparison_mode` | string | required | | | `data[].selected_show_ids` | array of strings | optional | | | `data[].selected_show_names` | array of strings | optional | | | `data[].delivery_method` | string | optional | Default `null`. Nullable. | | `data[].recipient_email_masked` | string | optional | Default `null`. Nullable. | | `data[].slack_configured` | boolean | optional | Default `null`. Nullable. | | `data[].enabled` | boolean | optional | Default `true`. | | `data[].start_date` | string | optional | Default `null`. Nullable. | | `data[].created_at` | string | optional | Default `null`. Nullable. | | `data[].last_run` | string | optional | Default `null`. Nullable. | | `data[].next_run` | string | optional | Default `null`. Nullable. | | `data[].last_report_id` | string | optional | Default `null`. Nullable. | | `data[].last_report_urls` | object | optional | Default `null`. Nullable. | | `data[].last_report_urls.network` | string | optional | https://network.listener.com/report/{uid}/{id} Default `null`. Nullable. | | `data[].last_report_urls.public` | string | optional | https://listener.com/report/{uid}/{id} Default `null`. Nullable. | | `data[].last_error` | string | optional | Default `null`. Nullable. | | `data[].last_error_at` | string | optional | Default `null`. Nullable. | | `data[].last_delivery_blocked` | boolean | optional | Default `false`. | | `data[].last_delivery_block_reasons` | array of strings | optional | | | `data[].last_delivery_zero_kpi_count` | integer | optional | Default `0`. | | `data[].object` | string | required | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | | `meta.total` | integer | optional | | | `meta.enabled_count` | integer | optional | Default `0`. | | `meta.computed_at` | string | optional | | | `meta.cache_age_seconds` | number | optional | | | `meta.note` | string | optional | Default `null`. Nullable. | ### Example request ```bash curl "https://api.listener.com/v1/report-automations" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/report-automations", "data": [ { "object": "report_automation", "id": "H0tWUl7HNJQSkYF3QF5W", "owner_uid": "XXfzcGxkLsQaKAvRy1SwLsZCcH78", "title": "Executive summary", "template_id": "cluster-overview", "template_name": "Cluster Overview", "template_is_built_in": true, "element_ids": [ "kpi-cluster-total-reach", "kpi-cluster-youtube-reach" ], "frequency": "weekly", "time_range": "30d", "time_range_label": "Last 30 days", "comparison_mode": "previous_period", "selected_show_ids": [ "paAtKPJGo56md5VyoEQ9Las7qahL", "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H" ], "selected_show_names": [ "Hard Court", "The Bullpen" ], "delivery_method": "slack", "recipient_email_masked": null, "slack_configured": true, "enabled": true, "start_date": null, "created_at": "2026-04-24T14:01:10.490000+00:00", "last_run": "2026-10-10T08:00:21.920000+00:00", "next_run": "2026-10-16T18:05:12.879000+00:00", "last_report_id": "JiQNJsUwY0LlE4vESOXZ", "last_report_urls": { "network": "https://network.listener.com/report/XXfzcGxkLsQaKAvRy1SwLsZCcH78/JiQNJsUwY0LlE4vESOXZ", "public": "https://listener.com/report/XXfzcGxkLsQaKAvRy1SwLsZCcH78/JiQNJsUwY0LlE4vESOXZ" }, "last_error": null, "last_error_at": null, "last_delivery_blocked": false, "last_delivery_block_reasons": [], "last_delivery_zero_kpi_count": 0 }, { "object": "report_automation", "id": "y4AGCrBcehPQVzcmsLa7", "owner_uid": "XXfzcGxkLsQaKAvRy1SwLsZCcH78", "title": "Weekly network report", "template_id": "network-overview", "template_name": "Network Overview", "template_is_built_in": true, "element_ids": [ "kpi-network-reach", "kpi-youtube-reach" ], "frequency": "weekly", "time_range": "7d", "time_range_label": "Last 7 days", "comparison_mode": "previous_period", "selected_show_ids": [], "selected_show_names": [], "delivery_method": "email", "recipient_email_masked": "c***@mxsxmlhkfvuhc15", "slack_configured": null, "enabled": true, "start_date": null, "created_at": "2026-02-06T18:14:09.184000+00:00", "last_run": "2026-10-12T08:00:56.100000+00:00", "next_run": "2026-10-19T08:00:19.803000+00:00", "last_report_id": "RdLMz1J8JQ55WD2zhDee", "last_report_urls": { "network": "https://network.listener.com/report/XXfzcGxkLsQaKAvRy1SwLsZCcH78/RdLMz1J8JQ55WD2zhDee", "public": "https://listener.com/report/XXfzcGxkLsQaKAvRy1SwLsZCcH78/RdLMz1J8JQ55WD2zhDee" }, "last_error": null, "last_error_at": null, "last_delivery_blocked": false, "last_delivery_block_reasons": [], "last_delivery_zero_kpi_count": 0 } ], "has_more": false, "next_cursor": null, "meta": { "total": 3, "enabled_count": 3, "computed_at": "2026-10-14T17:34:31.347284+00:00", "cache_age_seconds": 25.8, "note": null } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Disable a report automation `POST /v1/report-automations/{automation_id}/disable` Scope: `reports:write` SDK: `client.reportAutomations.disable()` (TypeScript) · `client.report_automations.disable()` (Python) Disable a scheduled report automation so it stops emailing or posting reports. Creating, enabling and deleting automations send outbound email and are app-only. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `reports:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `automation_id` | string | required | Report automation id (see GET /v1/report-automations). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | ### Example request ```bash curl -X POST "https://api.listener.com/v1/report-automations/AUTOMATION_ID/disable" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "report_automation", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "reportAutomations/H0tWUl7HNJQSkYF3QF5W", "id": "H0tWUl7HNJQSkYF3QF5W", "method": "update", "written": { "enabled": false }, "warnings": [], "urls": { "app": "https://network.listener.com/app/reports?uid=XXfzcGxkLsQaKAvRy1SwLsZCcH78", "public": null }, "error": null, "data": { "automation_id": "H0tWUl7HNJQSkYF3QF5W", "title": "Executive summary" }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. --- # Sponsors > Sponsor matching, network bundle opportunities and sponsorship packages. Base URL: `https://api.listener.com` ## Match sponsors `GET /v1/sponsors/matches` Scope: `sponsors:read` SDK: `client.sponsors.matches()` (TypeScript) · `client.sponsors.matches()` (Python) Rank the brands this workspace already knows (campaign brands, invites, network sponsors, cluster sponsor tags, show collaborations) by fit for a show, an episode or a free-text `brief`, with the reason and proof points for each. Send at least one of `show_id`, `episode_id` (with `show_id`) or `brief`. Contact emails are never returned. - Required scope: `sponsors:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | optional | A show id in this workspace (see GET /v1/shows). | | `episode_id` | string | optional | Episode id (see GET /v1/shows/{show_id}/episodes). | | `brief` | string | optional | | | `limit` | integer | optional | Maximum number of items to return. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].sponsor_id` | string | required | | | `data[].name` | string | required | | | `data[].slug` | string | optional | Default `null`. Nullable. | | `data[].domain` | string | optional | Default `null`. Nullable. | | `data[].logo` | string | optional | Default `null`. Nullable. | | `data[].source` | string | required | Primary source: campaign_brand \| brand_invite \| brand_profile \| network_sponsor \| cluster_sponsor \| collaboration. | | `data[].sources` | array of strings | optional | | | `data[].fit_score` | integer | required | | | `data[].matched_terms` | array of strings | optional | | | `data[].reason` | string | required | | | `data[].proof_points` | array of strings | optional | | | `data[].campaign_count` | integer | optional | Default `0`. | | `data[].invite_status` | string | optional | Default `null`. Nullable. | | `data[].industry` | string | optional | Default `null`. Nullable. | | `data[].brand_public_url` | string | optional | Default `null`. Nullable. | | `data[].object` | string | required | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | | `meta.inventory_size` | integer | optional | | | `meta.inventory_sources` | map of integer | optional | Candidates per primary source. | | `meta.context` | object | optional | What was matched against: show, episode, brief. | | `meta.show_id` | string | optional | Default `null`. Nullable. | | `meta.episode_id` | string | optional | Default `null`. Nullable. | | `meta.computed_at` | string | optional | | | `meta.cache_age_seconds` | number | optional | | | `meta.note` | string | optional | Default `null`. Nullable. | ### Example request ```bash curl "https://api.listener.com/v1/sponsors/matches" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/sponsors/matches", "data": [ { "object": "sponsor_match", "sponsor_id": "brand-globex-sports", "name": "Globex Sports", "slug": "globex-sports", "domain": "globex-sports.example.com", "logo": "https://www.example.com/cd0528ea", "source": "campaign_brand", "sources": [ "campaign_brand", "brand_invite" ], "fit_score": 34, "matched_terms": [ "northwind" ], "reason": "New episode out now. We break down the week's biggest storylines and answer your questions.", "proof_points": [ "Ran campaign \"Globex Sports Campaign\" (active).", "Ran campaign \"Auto\" (active)." ], "campaign_count": 2, "invite_status": "accepted", "industry": null, "brand_public_url": "https://network.listener.com/northwind-audio/brands/globex-sports" }, { "object": "sponsor_match", "sponsor_id": "brand-acmecoffee", "name": "Umbrella Energy", "slug": "acmecoffee", "domain": "acme-coffee.example.com", "logo": "https://images.example.com/listener/a3504ff4873daa28.png", "source": "campaign_brand", "sources": [ "campaign_brand", "brand_invite" ], "fit_score": 32, "matched_terms": [ "northwind" ], "reason": "A deep dive into the numbers behind the season so far.", "proof_points": [ "Ran campaign \"Hard Court × Umbrella Energy Test\" (active).", "Brand account accepted the invitation." ], "campaign_count": 1, "invite_status": "accepted", "industry": null, "brand_public_url": "https://network.listener.com/northwind-audio/brands/acmecoffee" } ], "has_more": false, "next_cursor": null, "meta": { "inventory_size": 9, "inventory_sources": { "campaign_brand": 8, "collaboration": 1 }, "context": { "show": "The Long Game", "show_categories": [ "Sports", "Basketball" ], "episode_title": null, "brief": null }, "show_id": "i3KlnFag2DgIpykraVxjrIZwJYcL", "episode_id": null, "computed_at": "2026-10-14T17:34:31.504251+00:00", "cache_age_seconds": 26, "note": null } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Network bundle opportunities `GET /v1/sponsors/opportunities` Scope: `sponsors:read` SDK: `client.sponsors.opportunities()` (TypeScript) · `client.sponsors.opportunities()` (Python) Pairs of the workspace's shows worth bundling for a sponsor: shared categories, collaboration history, shared hosts and, with `include_reach=true`, their combined cluster reach and a score. Reach is skipped automatically on very large networks (`meta.reach_aware=false`). - Required scope: `sponsors:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `limit` | integer | optional | Maximum number of items to return. | | `include_reach` | boolean | optional | | | `include_empty_shows` | boolean | optional | | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].pair` | string | required | | | `data[].show_ids` | array of strings | required | | | `data[].show_names` | array of strings | required | | | `data[].reason` | string | required | | | `data[].overlap_categories` | array of strings | optional | | | `data[].combined_collaborations` | integer | optional | Default `0`. | | `data[].combined_reach` | number | optional | Sum of both shows' cluster reach (None when reach was not loaded). Default `null`. Nullable. | | `data[].shared_hosts` | array of strings | optional | | | `data[].score` | number | required | | | `data[].object` | string | required | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | | `meta.total_pairs_considered` | integer | optional | | | `meta.reach_aware` | boolean | optional | True when combined_reach / score used cluster reach. | | `meta.reach_source` | string | optional | 'cluster_summaries' when reach was loaded. Default `null`. Nullable. | | `meta.show_count` | integer | optional | | | `meta.missing_show_ids` | array of strings | optional | | | `meta.computed_at` | string | optional | | | `meta.cache_age_seconds` | number | optional | | | `meta.note` | string | optional | Default `null`. Nullable. | ### Example request ```bash curl "https://api.listener.com/v1/sponsors/opportunities" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/sponsors/opportunities", "data": [ { "object": "sponsor_opportunity", "pair": "The Bullpen x Box Score Breakdown", "show_ids": [ "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H", "Of9Els0RskCtlyZFPG8ujblH3NKI" ], "show_names": [ "The Bullpen", "Box Score Breakdown" ], "reason": "Highlights and analysis from the latest matchday.", "overlap_categories": [ "Sports News", "News" ], "combined_collaborations": 0, "combined_reach": 622011, "shared_hosts": [], "score": 63.18 }, { "object": "sponsor_opportunity", "pair": "The Bullpen x The Long Game", "show_ids": [ "0Hq6ZhayEg3a3GGHVwdzCtAkiV9H", "i3KlnFag2DgIpykraVxjrIZwJYcL" ], "show_names": [ "The Bullpen", "The Long Game" ], "reason": "The crew is back with rankings, hot takes and a listener mailbag.", "overlap_categories": [ "Sports News", "News" ], "combined_collaborations": 0, "combined_reach": 623242, "shared_hosts": [], "score": 53.18 } ], "has_more": false, "next_cursor": null, "meta": { "total_pairs_considered": 66, "reach_aware": true, "reach_source": "cluster_summaries", "show_count": 12, "missing_show_ids": [ "0Iush8rT5eCcD4ePHroKFDlQvKhT", "nCJuUMzGC6EE3dfruqjRHtYdlCTr" ], "computed_at": "2026-10-14T17:34:31.508349+00:00", "cache_age_seconds": 26, "note": null } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## List sponsorship packages `GET /v1/shows/{show_id}/sponsorship-packages` Scope: `sponsors:read` SDK: `client.shows.sponsorshipPackages.list()` (TypeScript) · `client.shows.sponsorship_packages.list()` (Python) A show's sponsorship packages (rate card) with each deliverable's projected reach from recent content: average listens per audio read, views per video integration or social clip, opens per newsletter placement. `window` (30, 90 or 365 days) overrides each package's trailing window; averages built from measured content are marked verified. - Required scope: `sponsors:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `window` | integer | optional | Reporting window. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].id` | string | optional | Default `null`. Nullable. | | `data[].name` | string | optional | Default `""`. | | `data[].description` | string | optional | Default `""`. | | `data[].price` | string | optional | Default `""`. | | `data[].trailing_window` | integer | required | | | `data[].window_days` | integer | required | | | `data[].format_averages` | array of strings | optional | | | `data[].featured` | boolean | optional | Default `false`. | | `data[].enabled` | boolean | optional | Default `true`. | | `data[].deliverables` | array of objects | optional | | | `data[].deliverables[].id` | string | optional | Default `null`. Nullable. | | `data[].deliverables[].type` | string | required | audio_read \| video_integration \| social_clip \| newsletter \| custom | | `data[].deliverables[].label` | string | optional | Default `""`. | | `data[].deliverables[].quantity` | integer | optional | Default `1`. | | `data[].deliverables[].manual_reach` | string | optional | Custom deliverables: the reach the seller typed. Default `""`. | | `data[].deliverables[].metric_label` | string | optional | Default `null`. Nullable. | | `data[].deliverables[].average` | number | optional | Average reach per unit over the trailing window (from the show's clusters). Default `0`. | | `data[].deliverables[].total` | number | optional | Default `0`. | | `data[].deliverables[].verified` | boolean | optional | True when the average comes from measured content, not a manual figure. Default `false`. | | `data[].deliverables[].count` | integer | optional | Content pieces the average was built from. Default `0`. | | `data[].projected_reach` | number | optional | Default `0`. | | `data[].verified_reach` | number | optional | Default `0`. | | `data[].object` | string | required | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | | `meta.show_id` | string | optional | | | `meta.show_name` | string | optional | | | `meta.window` | integer | optional | Override applied to every package (30 \| 90 \| 365); None keeps each package's own trailing window. Default `null`. Nullable. | | `meta.stats_by_window` | map of map of object | optional | Trailing window ('30' \| '90' \| '365') → average reach per format (audio, video, short_form, newsletter). | | `meta.stats_by_window.average` | number | optional | Default `0`. | | `meta.stats_by_window.count` | integer | optional | Default `0`. | | `meta.stats_by_window.open_rate` | number | optional | Default `null`. Nullable. | | `meta.clusters_considered` | integer | optional | Default `0`. | | `meta.clusters_available` | integer | optional | Default `0`. | | `meta.public_url` | string | optional | Default `null`. Nullable. | | `meta.computed_at` | string | optional | | | `meta.cache_age_seconds` | number | optional | | | `meta.note` | string | optional | Default `null`. Nullable. | ### Example request ```bash curl "https://api.listener.com/v1/shows/SHOW_ID/sponsorship-packages" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/shows/i3KlnFag2DgIpykraVxjrIZwJYcL/sponsorship-packages", "data": [], "has_more": false, "next_cursor": null, "meta": { "show_id": "i3KlnFag2DgIpykraVxjrIZwJYcL", "show_name": "The Long Game", "window": null, "stats_by_window": { "90": { "audio": { "average": 0, "count": 0, "open_rate": null }, "video": { "average": 0, "count": 0, "open_rate": null }, "short_form": { "average": 0, "count": 0, "open_rate": null }, "newsletter": { "average": 0, "count": 0, "open_rate": 0 } } }, "clusters_considered": 1, "clusters_available": 1, "public_url": "https://listener.com/the-long-game", "computed_at": "2026-10-14T17:34:31.511843+00:00", "cache_age_seconds": 26, "note": "This show has no sponsorship packages yet; add them in the show's profile." } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. --- # Library & research > Workspace library files, web research, podcast search and insertion-order parsing. Base URL: `https://api.listener.com` ## List library files `GET /v1/library/files` Scope: `library:read` SDK: `client.library.files.list()` (TypeScript) · `client.library.files.list()` (Python) Files in the workspace library, newest upload first: filename, type, size, parse status, whether extracted text is available and a short preview. - Required scope: `library:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `limit` | integer | optional | Page size, 1–100 (default 25). | | `cursor` | string | optional | Opaque cursor from the previous page's next_cursor. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].id` | string | required | | | `data[].filename` | string | required | | | `data[].media_type` | string | optional | Default `"application/octet-stream"`. | | `data[].size_bytes` | integer | optional | Default `0`. | | `data[].detected_kind` | string | optional | Default `"unknown"`. | | `data[].is_text_like` | boolean | optional | Default `false`. | | `data[].parse_status` | string | optional | Default `"unknown"`. | | `data[].parse_warnings` | array of strings | optional | | | `data[].status` | string | optional | Default `"ready"`. | | `data[].uploaded_at` | string | optional | Default `null`. Nullable. | | `data[].uploaded_by_uid` | string | optional | Default `null`. Nullable. | | `data[].has_extracted_text` | boolean | optional | Default `false`. | | `data[].extracted_text_chars` | integer | optional | Length of the stored extracted text (before the 12,000-character cap). Default `0`. | | `data[].has_structured_summary` | boolean | optional | Default `false`. | | `data[].preview` | string | optional | First ~280 characters of the stored preview text. Default `null`. Nullable. | | `data[].object` | string | required | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | | `meta.storage_configured` | boolean | optional | True when a Storage bucket is configured, so `GET /v1/library/files/{file_id}` can sign download URLs. | | `meta.computed_at` | string | optional | | | `meta.cache_age_seconds` | number | optional | | | `meta.note` | string | optional | Default `null`. Nullable. | ### Example request ```bash curl "https://api.listener.com/v1/library/files" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/library/files", "data": [ { "object": "library_file", "id": "eXfXrq21X1w6eEqepyvu", "filename": "document-1bd0fd.csv", "media_type": "text/csv", "size_bytes": 35314, "detected_kind": "csv", "is_text_like": true, "parse_status": "parsed", "parse_warnings": [], "status": "ready", "uploaded_at": "2026-04-28T11:33:04.257000+00:00", "uploaded_by_uid": null, "has_extracted_text": true, "extracted_text_chars": 7469, "has_structured_summary": true, "preview": "The crew is back with rankings, hot takes and a listener mailbag." }, { "object": "library_file", "id": "FpSVuQd6d0tFQjmSnKtE", "filename": "document-3e829c.pdf", "media_type": "application/pdf", "size_bytes": 4491186, "detected_kind": "pdf", "is_text_like": false, "parse_status": "parsed", "parse_warnings": [ "Extracted text was truncated to 12000 characters." ], "status": "ready", "uploaded_at": "2026-04-28T10:42:36.206000+00:00", "uploaded_by_uid": null, "has_extracted_text": true, "extracted_text_chars": 12001, "has_structured_summary": false, "preview": "Clip from this week's episode. Full episode on YouTube and Spotify." } ], "has_more": false, "next_cursor": null, "meta": { "storage_configured": true, "computed_at": "2026-10-14T17:34:31.513771+00:00", "cache_age_seconds": 26, "note": null, "total": 2 } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Retrieve a library file `GET /v1/library/files/{file_id}` Scope: `library:read` SDK: `client.library.files.retrieve()` (TypeScript) · `client.library.files.retrieve()` (Python) One library file: metadata, extracted text (up to 12,000 characters), the structured summary when one exists and a 1-hour signed download URL. `include_text=false` returns metadata and the link only. - Required scope: `library:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `file_id` | string | required | Library file id (see GET /v1/library/files). | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `include_text` | boolean | optional | | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `id` | string | required | | | `filename` | string | required | | | `media_type` | string | optional | Default `"application/octet-stream"`. | | `size_bytes` | integer | optional | Default `0`. | | `detected_kind` | string | optional | Default `"unknown"`. | | `is_text_like` | boolean | optional | Default `false`. | | `parse_status` | string | optional | Default `"unknown"`. | | `parse_warnings` | array of strings | optional | | | `status` | string | optional | Default `"ready"`. | | `uploaded_at` | string | optional | Default `null`. Nullable. | | `uploaded_by_uid` | string | optional | Default `null`. Nullable. | | `has_extracted_text` | boolean | optional | Default `false`. | | `extracted_text_chars` | integer | optional | Length of the stored extracted text (before the 12,000-character cap). Default `0`. | | `has_structured_summary` | boolean | optional | Default `false`. | | `preview` | string | optional | First ~280 characters of the stored preview text. Default `null`. Nullable. | | `extracted_text` | string | optional | Stored extracted text, capped at 12,000 characters. Default `null`. Nullable. | | `extracted_text_truncated` | boolean | optional | Default `false`. | | `structured_summary` | string | optional | Default `null`. Nullable. | | `storage_path` | string | optional | Default `null`. Nullable. | | `download_url` | string | optional | Signed GET URL (1 h) when the Storage bucket is configured. Default `null`. Nullable. | | `download_url_expires_at` | string | optional | Default `null`. Nullable. | | `computed_at` | string | required | | | `cache_age_seconds` | number | required | | | `note` | string | optional | Default `null`. Nullable. | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/library/files/FILE_ID" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "library_file", "id": "eXfXrq21X1w6eEqepyvu", "filename": "document-1bd0fd.csv", "media_type": "text/csv", "size_bytes": 35314, "detected_kind": "csv", "is_text_like": true, "parse_status": "parsed", "parse_warnings": [], "status": "ready", "uploaded_at": "2026-04-28T11:33:04.257000+00:00", "uploaded_by_uid": null, "has_extracted_text": true, "extracted_text_chars": 7469, "has_structured_summary": true, "preview": "The crew is back with rankings, hot takes and a listener mailbag.", "extracted_text": "The crew is back with rankings, hot takes and a listener mailbag.", "extracted_text_truncated": false, "structured_summary": "The crew is back with rankings, hot takes and a listener mailbag.", "storage_path": "workspace-library/XXfzcGxkLsQaKAvRy1SwLsZCcH78/eXfXrq21X1w6eEqepyvu/contacts-export.csv", "download_url": "https://images.example.com/listener/637cd7ad932cd90e.jpg", "download_url_expires_at": "2026-10-14T18:34:31.527234+00:00", "computed_at": "2026-10-14T17:34:31.516322+00:00", "cache_age_seconds": 26, "note": null } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Web research `GET /v1/research/web` Scope: `library:read` SDK: `client.research.web()` (TypeScript) · `client.research.web()` (Python) Search the public web and return titles, URLs and snippets plus a short summary of the top hits, for context outside Listener (a brand's news, a competitor's show). `configured=false` with a note when search is not set up on this server, never an error. - Required scope: `library:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `query` | string | required | Search text. | | `max_results` | integer | optional | | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `query` | string | required | | | `configured` | boolean | required | | | `summary` | string | optional | Default `null`. Nullable. | | `sources` | array of objects | optional | | | `sources[].title` | string | required | | | `sources[].url` | string | required | | | `sources[].snippet` | string | optional | Default `null`. Nullable. | | `sources[].display_link` | string | optional | Default `null`. Nullable. | | `total_results` | integer | optional | Default `null`. Nullable. | | `computed_at` | string | required | | | `cache_age_seconds` | number | required | | | `note` | string | optional | Default `null`. Nullable. | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/research/web?query=QUERY" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "web_research", "query": "podcast advertising trends", "configured": false, "summary": null, "sources": [], "total_results": null, "computed_at": "2026-10-14T17:34:31.530801+00:00", "cache_age_seconds": 26, "note": "Web research is not configured. Set GOOGLE_SEARCH_API_KEY and GOOGLE_SEARCH_ENGINE_ID to enable Google Custom Search lookups." } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Search podcasts `GET /v1/podcasts/search` Scope: `library:read` SDK: `client.podcasts.search()` (TypeScript) · `client.podcasts.search()` (Python) Search podcasts outside the workspace (prospects, competitors, a guest's own show) by name or keyword: title, publisher, artwork, categories, episode count, Apple / Spotify ids and RSS URL. `configured=false` with a note when the search provider is not configured. - Required scope: `library:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `query` | string | required | Search text. | | `limit` | integer | optional | Maximum number of items to return. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].id` | string | required | | | `data[].title` | string | required | | | `data[].publisher` | string | optional | Default `""`. | | `data[].image` | string | optional | Default `null`. Nullable. | | `data[].description` | string | optional | Default `""`. | | `data[].categories` | array of strings | optional | | | `data[].total_episodes` | integer | optional | Default `0`. | | `data[].audio_length_sec` | integer | optional | Default `0`. | | `data[].website` | string | optional | Default `null`. Nullable. | | `data[].podchaser_url` | string | optional | Default `null`. Nullable. | | `data[].apple_podcasts_id` | string | optional | Default `null`. Nullable. | | `data[].spotify_id` | string | optional | Default `null`. Nullable. | | `data[].rss_url` | string | optional | Default `null`. Nullable. | | `data[].extra` | object | optional | | | `data[].object` | string | required | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | | `meta.query` | string | optional | | | `meta.configured` | boolean | optional | | | `meta.total` | integer | optional | Default `0`. | | `meta.source` | string | optional | Default `"podchaser"`. | | `meta.computed_at` | string | optional | | | `meta.cache_age_seconds` | number | optional | | | `meta.note` | string | optional | Default `null`. Nullable. | ### Example request ```bash curl "https://api.listener.com/v1/podcasts/search?query=QUERY" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/podcasts/search", "data": [], "has_more": false, "next_cursor": null, "meta": { "query": "basketball", "configured": false, "total": 0, "source": "podchaser", "computed_at": "2026-10-14T17:34:31.532657+00:00", "cache_age_seconds": 26, "note": "Podcast search is not configured on this server: set LISTENER_INTERNAL_API_KEY (and LISTENER_APP_BASE_URL) so it can call the Listener app's Podchaser search." } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Parse an insertion order `POST /v1/insertion-orders/parse` Scope: `library:read` SDK: `client.insertionOrders.parse()` (TypeScript) · `client.insertion_orders.parse()` (Python) Read a PDF insertion order and return the extracted order, line items matched to workspace shows, rows that could not be matched, and ready-to-use `proposed_create_campaign_args` for `POST /v1/campaigns`. Writes nothing. Send exactly one source: `library_file_id` (from `GET /v1/library/files`), `file_url` (https, up to 4 MB) or `file_base64` (`upload_id` refers to a file attached in Listener's agent chat). `show_ids` narrows show matching. Review `unresolved_line_items` before creating the campaign; never guess a show or impressions. - Required scope: `library:read`. ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `upload_id` | string | optional | Default `null`. Nullable. | | `library_file_id` | string | optional | Default `null`. Nullable. | | `file_url` | string | optional | Default `null`. Nullable. | | `file_base64` | string | optional | Default `null`. Nullable. | | `file_name` | string | optional | Default `null`. Nullable. | | `show_ids` | array of strings | optional | Limit to these show ids (default: every show in the workspace). Default `null`. Nullable. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `ok` | boolean | optional | Default `true`. | | `configured` | boolean | required | False when this server cannot reach the app's extractor (internal key / app URL missing); nothing was parsed. | | `source` | object | optional | Default `null`. Nullable. | | `source.kind` | string | required | One of `upload`, `library`, `url`, `base64`. | | `source.id` | string | optional | The upload / library file id, or the URL for kind='url'. Default `null`. Nullable. | | `source.file_name` | string | required | | | `source.size_bytes` | integer | optional | Known for bytes the MCP handled itself; None when the app read Storage directly. Default `null`. Nullable. | | `source.storage_path` | string | optional | Where the PDF lives in Firebase Storage (the app stores URL/base64 sources under campaign-io/). Default `null`. Nullable. | | `io` | object | optional | The extracted InsertionOrder (ioNumber, advertiser, flight, lineItems[], confidence, sourceQuotes …). Default `null`. Nullable. | | `plan` | object | optional | The app's CampaignPlan as returned (camelCase); proposed_create_campaign_args is the same plan in `POST /v1/campaigns`'s shape. Default `null`. Nullable. | | `proposed_create_campaign_args` | object | optional | Exactly the argument names ``POST /v1/campaigns`` accepts, ready to pass through. Default `null`. Nullable. | | `proposed_create_campaign_args.title` | string | required | | | `proposed_create_campaign_args.description` | string | optional | Default `""`. | | `proposed_create_campaign_args.start_date` | string | optional | Default `null`. Nullable. | | `proposed_create_campaign_args.end_date` | string | optional | Default `null`. Nullable. | | `proposed_create_campaign_args.brand` | object | optional | Default `null`. Nullable. | | `proposed_create_campaign_args.brand.name` | string | required | | | `proposed_create_campaign_args.brand.domain` | string | optional | Default `null`. Nullable. | | `proposed_create_campaign_args.brand.contact` | string | optional | Default `null`. Nullable. | | `proposed_create_campaign_args.brand.notes` | string | optional | Default `null`. Nullable. | | `proposed_create_campaign_args.total_budget` | number | optional | Default `null`. Nullable. | | `proposed_create_campaign_args.show_goals` | map of integer | optional | | | `proposed_create_campaign_args.show_budgets` | map of number | optional | | | `proposed_create_campaign_args.planned_episodes` | array of objects | optional | | | `proposed_create_campaign_args.planned_episodes[].show_id` | string | required | | | `proposed_create_campaign_args.planned_episodes[].title` | string | optional | Default `null`. Nullable. | | `proposed_create_campaign_args.planned_episodes[].expected_impressions` | integer | optional | Default `null`. Nullable. | | `proposed_create_campaign_args.planned_episodes[].budget` | number | optional | Default `null`. Nullable. | | `unresolved_line_items` | array of objects | optional | Line items whose show could not be matched to a workspace show; ask the user, never guess. | | `unresolved_line_items[].line_item_index` | integer | optional | Default `null`. Nullable. | | `unresolved_line_items[].show_name` | string | optional | The show name as printed on the IO. Default `null`. Nullable. | | `unresolved_line_items[].units` | number | optional | Default `null`. Nullable. | | `unresolved_line_items[].impressions` | number | optional | Default `null`. Nullable. | | `unresolved_line_items[].cost` | number | optional | Default `null`. Nullable. | | `unresolved_line_items[].placement` | string | optional | Default `null`. Nullable. | | `unresolved_line_items[].candidates` | array of objects | optional | Workspace shows that may match, best first. | | `unresolved_line_items[].candidates[].show_id` | string | required | | | `unresolved_line_items[].candidates[].name` | string | optional | Default `null`. Nullable. | | `unresolved_line_items[].candidates[].score` | number | optional | Default `null`. Nullable. | | `warnings` | array of strings | optional | | | `model` | string | optional | The extraction model the app used. Default `null`. Nullable. | | `computed_at` | string | required | | | `cache_age_seconds` | number | required | | | `note` | string | optional | Default `null`. Nullable. | | `object` | string | required | | ### Example request ```bash curl -X POST "https://api.listener.com/v1/insertion-orders/parse" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "insertion_order", "ok": false, "configured": false, "source": null, "io": null, "plan": null, "proposed_create_campaign_args": null, "unresolved_line_items": [], "warnings": [], "model": null, "computed_at": "2026-10-14T17:34:31.533725+00:00", "cache_age_seconds": 26, "note": "Insertion-order parsing is not configured on this server: set LISTENER_INTERNAL_API_KEY (the same value as the Listener app's) and LISTENER_APP_BASE_URL so it can call the app's extractor." } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. --- # Refresh > Metered actions: platform refreshes and Spotify / Apple data requests. Base URL: `https://api.listener.com` ## Refresh a show's platform data `POST /v1/shows/{show_id}/platforms/{platform}/refresh` Scope: `refresh:write` · needs `confirm=true` SDK: `client.shows.platforms.refresh()` (TypeScript) · `client.shows.platforms.refresh()` (Python) Refresh one platform's data for one show now and return the status (`updated`, `skipped`, `requested`, `error`), last update before and after, and the provider-call estimate. Metered scraper platforms (tiktok, twitter, facebook, instagram, threads, linkedin) spend credits and need `"confirm": true`; spotify is asynchronous (`requested`); applePodcasts cannot be refreshed on demand (use `POST /v1/data-requests`). - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Requires `"confirm": true`; without it the API answers 409 `confirmation_required` with the exact `preview`. - Required scope: `refresh:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | | `platform` | string | required | A platform name (e.g. youtube, spotify, applePodcasts, instagram). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | | `confirm` | boolean | optional | Must be true to apply destructive or metered changes; without it the API answers 409 with a preview. Default `false`. | ### Example request ```bash curl -X POST "https://api.listener.com/v1/shows/SHOW_ID/platforms/PLATFORM/refresh" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "platform_refresh", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/i3KlnFag2DgIpykraVxjrIZwJYcL", "id": "i3KlnFag2DgIpykraVxjrIZwJYcL", "method": "http", "written": { "userId": "i3KlnFag2DgIpykraVxjrIZwJYcL", "platforms": [ "youtube" ], "forceUpdate": true }, "warnings": [], "urls": { "app": "https://network.listener.com/app/connect?uid=i3KlnFag2DgIpykraVxjrIZwJYcL", "public": null }, "error": null, "data": { "show_id": "i3KlnFag2DgIpykraVxjrIZwJYcL", "platform": "youtube", "kind": "server", "provider_calls_estimate": "7–9 API calls", "last_updated_before": "2026-10-13T09:34:22.044Z", "status": "would-refresh" }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## File a Spotify / Apple data request `POST /v1/data-requests` Scope: `refresh:write` · needs `confirm=true` SDK: `client.dataRequests.create()` (TypeScript) · `client.data_requests.create()` (Python) File a Spotify or Apple Podcasts data request with the Listener operations team and return the request written (status `pending`). Needs `"confirm": true` because it creates work for a person. `show_url` defaults to the URL stored on the show; a request already pending for the same show and platform is refused. Follow it in `GET /v1/integrations/status`. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Requires `"confirm": true`; without it the API answers 409 `confirmation_required` with the exact `preview`. - Required scope: `refresh:write`. ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | | `platform` | string | required | A platform name (e.g. youtube, spotify, applePodcasts, instagram). One of `spotify`, `applePodcasts`. | | `show_url` | string | optional | Default `null`. Nullable. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | | `confirm` | boolean | optional | Must be true to apply destructive or metered changes; without it the API answers 409 with a preview. Default `false`. | ### Example request ```bash curl -X POST "https://api.listener.com/v1/data-requests" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"show_id": "SHOW_ID", "platform": "spotify", "dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "data_request", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "admin_data_requests/JZw6iiMOmEUEYN3Mg4JC", "id": "JZw6iiMOmEUEYN3Mg4JC", "method": "add", "written": { "userId": "i3KlnFag2DgIpykraVxjrIZwJYcL", "userEmail": "No email available", "platform": "spotify", "showUrl": "https://open.spotify.com/show/2pGhC7Mvqu6L2VzmndqWLq", "requestedAt": "2026-10-14T17:34:32.408Z", "status": "pending", "createdBy": "XXfzcGxkLsQaKAvRy1SwLsZCcH78", "createdVia": "mcp", "spotifyShowUrl": "https://open.spotify.com/show/2pGhC7Mvqu6L2VzmndqWLq" }, "warnings": [ "Status transitions are owned by the ops Slack pipeline; poll `GET /v1/integrations/status`." ], "urls": { "app": "https://network.listener.com/app/connect?uid=i3KlnFag2DgIpykraVxjrIZwJYcL", "public": null }, "error": null, "data": { "show_id": "i3KlnFag2DgIpykraVxjrIZwJYcL", "platform": "spotify", "confirm_required": true }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. --- # Partner integrations > For hosting and ad-tech partners: discover and connect a show's accounts, set its podcast identity, look up episodes by your own ids, control auto-build, and record ad-server delivery on campaigns. Base URL: `https://api.listener.com` ## Retrieve a show's account discovery `GET /v1/shows/{show_id}/account-discovery` Scope: `shows:read` The social accounts Listener's discovery found for a show (YouTube, Instagram, TikTok, X, Facebook, Threads, LinkedIn): handle, profile URL, followers, confidence (`high`, `medium`, `low`) and why it was picked. Each candidate carries its connection state against the show's current integrations (`connected`, `connected_account`, `matches_candidate`) and `connect_via`: `api` (connect it with `PUT /v1/shows/{show_id}/integrations/{platform}`) or `app_sign_in` (a person signs in from `verify_url`). `verify_url` is the show's integrations page in the right Listener app (Listener Networks for network workspaces, Listener for creators); send people there to review and confirm. `status` is `none` until a run starts, then `running` or `done`. - Required scope: `shows:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | | | `show_name` | string | required | | | `status` | string | required | 'none' (never run), 'running', 'done' or 'failed'. | | `website` | string | optional | Default `null`. Nullable. | | `ran_at` | string | optional | Default `null`. Nullable. | | `runs` | integer | optional | Default `0`. | | `committed_at` | string | optional | When someone confirmed the accounts in onboarding, if they did. Default `null`. Nullable. | | `candidates` | array of objects | optional | | | `candidates[].platform` | string | required | instagram, tiktok, twitter, facebook, threads, linkedin or youtube. | | `candidates[].label` | string | required | | | `candidates[].status` | string | required | 'found' when discovery proposed an account, 'missing' when it found none. | | `candidates[].handle` | string | optional | Default `null`. Nullable. | | `candidates[].url` | string | optional | Default `null`. Nullable. | | `candidates[].display_name` | string | optional | Default `null`. Nullable. | | `candidates[].followers` | integer | optional | Default `null`. Nullable. | | `candidates[].verified` | boolean | optional | Default `null`. Nullable. | | `candidates[].confidence` | string | optional | 'high', 'medium' or 'low': how sure discovery is that the account is the show's. Default `null`. Nullable. | | `candidates[].reason` | string | optional | Why discovery picked it (plain English). Default `null`. Nullable. | | `candidates[].connected` | boolean | required | True when the show already has this platform connected. | | `candidates[].connection_state` | string | required | One of `connected`, `pending`, `disconnected`. | | `candidates[].connected_account` | string | optional | The handle or URL of the account currently connected, when known. Default `null`. Nullable. | | `candidates[].matches_candidate` | boolean | optional | True when the connected account is this candidate; false when a different account is connected; null when either side is unknown. Default `null`. Nullable. | | `candidates[].connect_via` | string | required | 'api': connect with PUT /v1/shows/{show_id}/integrations/{platform}; 'app_sign_in': the person signs in from verify_url. One of `api`, `app_sign_in`. | | `summary` | object | optional | | | `summary.found` | integer | optional | Default `0`. | | `summary.connected` | integer | optional | Default `0`. | | `summary.needs_review` | integer | optional | Found accounts whose confidence is not 'high'. Default `0`. | | `summary.missing` | integer | optional | Default `0`. | | `verify_url` | string | required | Deep link to the show's integrations page in the right Listener app, for a person to review and connect accounts. | | `note` | string | optional | Default `null`. Nullable. | | `computed_at` | string | required | | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/shows/SHOW_ID/account-discovery" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "account_discovery", "show_id": "pQ7sHt2Lw9XcV4bN1mRa", "show_name": "Dugout Daily", "status": "done", "website": "https://dugoutdaily.example", "ran_at": "2026-10-14T09:12:40.512Z", "runs": 1, "committed_at": null, "candidates": [ { "platform": "youtube", "label": "YouTube", "status": "found", "handle": "DugoutDaily", "url": "https://www.youtube.com/@DugoutDaily", "display_name": "Dugout Daily", "followers": 18400, "verified": false, "confidence": "high", "reason": "Linked from your other accounts", "connected": false, "connection_state": "disconnected", "connected_account": null, "matches_candidate": null, "connect_via": "app_sign_in" }, { "platform": "instagram", "label": "Instagram", "status": "found", "handle": "dugoutdaily", "url": "https://www.instagram.com/dugoutdaily/", "display_name": "Dugout Daily", "followers": 6120, "verified": false, "confidence": "high", "reason": "Listed on your website", "connected": true, "connection_state": "connected", "connected_account": "dugoutdaily", "matches_candidate": true, "connect_via": "api" }, { "platform": "tiktok", "label": "TikTok", "status": "found", "handle": "dugout.daily", "url": "https://www.tiktok.com/@dugout.daily", "display_name": "Dugout Daily Clips", "followers": 2350, "verified": false, "confidence": "medium", "reason": "Same name and avatar as the show", "connected": true, "connection_state": "connected", "connected_account": null, "matches_candidate": null, "connect_via": "api" }, { "platform": "twitter", "label": "X", "status": "missing", "handle": null, "url": null, "display_name": null, "followers": null, "verified": null, "confidence": null, "reason": null, "connected": false, "connection_state": "disconnected", "connected_account": null, "matches_candidate": null, "connect_via": "api" } ], "summary": { "found": 3, "connected": 2, "needs_review": 1, "missing": 1 }, "verify_url": "https://network.listener.com/app/integrations?show=pQ7sHt2Lw9XcV4bN1mRa", "note": null, "computed_at": "2026-10-15T09:30:00.000Z" } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Run account discovery for a show `POST /v1/shows/{show_id}/account-discovery` Scope: `shows:write` · needs `confirm=true` Run account discovery for a show, the same search Listener's onboarding uses: it finds the show's public accounts, checks each profile, stores the result on the show and returns the candidates with their connection state. Nothing is connected. Needs `"confirm": true`: every run is metered (web search, model and scraper credits) and capped per show, and a show that already has a result may get it back without a new search. A `running` status means the search is still going: read `GET /v1/shows/{show_id}/account-discovery` a minute later. A dry run returns the request without starting anything. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Requires `"confirm": true`; without it the API answers 409 `confirmation_required` with the exact `preview`. - Required scope: `shows:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | | `confirm` | boolean | optional | Must be true to apply destructive or metered changes; without it the API answers 409 with a preview. Default `false`. | ### Example request ```bash curl -X POST "https://api.listener.com/v1/shows/SHOW_ID/account-discovery" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "account_discovery", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/pQ7sHt2Lw9XcV4bN1mRa", "id": "pQ7sHt2Lw9XcV4bN1mRa", "method": "http", "written": { "uid": "pQ7sHt2Lw9XcV4bN1mRa" }, "warnings": [ "Discovery already ran for this show; the app may return its stored result instead of searching again." ], "urls": { "app": "https://network.listener.com/app/integrations?show=pQ7sHt2Lw9XcV4bN1mRa", "public": null }, "error": null, "data": { "show_id": "pQ7sHt2Lw9XcV4bN1mRa", "previous_status": "done", "previous_runs": 1, "verify_url": "https://network.listener.com/app/integrations?show=pQ7sHt2Lw9XcV4bN1mRa", "status": "would-run" }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Connect a platform to a show `PUT /v1/shows/{show_id}/integrations/{platform}` Scope: `shows:write` Connect a platform to a show the same way the Listener apps' Integrations page does: the account is seeded, its first data refresh runs and it is listed on the show's public page. The response says whether it connected, which account, and whether the first refresh finished (`refreshed`). Public-profile platforms (`instagram`, `tiktok`, `twitter`, `facebook`, `threads`, `linkedin`) take `handle` (e.g. `dugoutdaily`) or the profile `url`; LinkedIn needs the URL. `simplecast` takes `api_key` and optionally `podcast_id`; the key is never returned. `youtube`, `instagramBusiness`, `spotify` and `applePodcasts` need a person to sign in (Google or Meta sign-in, or the Listener browser extension) and answer 400 `oauth_required` with `connect_url`, the page to send them to. Connecting a platform that is connected to another account replaces it (a warning says so). Emits `integration.connected`. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `shows:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | | `platform` | string | required | A platform name (e.g. youtube, spotify, applePodcasts, instagram). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `handle` | string | optional | Public URL handle (lowercase, hyphens). Default `null`. Nullable. | | `url` | string | optional | Default `null`. Nullable. | | `api_key` | string | optional | Default `null`. Nullable. | | `podcast_id` | string | optional | Default `null`. Nullable. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | ### Example request ```bash curl -X PUT "https://api.listener.com/v1/shows/SHOW_ID/integrations/PLATFORM" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "integration", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/pQ7sHt2Lw9XcV4bN1mRa", "id": "instagram", "method": "http", "written": { "uid": "pQ7sHt2Lw9XcV4bN1mRa", "platform": "instagram", "handle": "dugoutdaily", "url": "https://www.instagram.com/dugoutdaily/" }, "warnings": [], "urls": { "app": "https://network.listener.com/app/integrations?show=pQ7sHt2Lw9XcV4bN1mRa", "public": null }, "error": null, "data": { "show_id": "pQ7sHt2Lw9XcV4bN1mRa", "platform": "instagram", "previous_state": "connected", "previous_account": "dugoutdaily", "status": "would-connect" }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Disconnect a platform from a show `DELETE /v1/shows/{show_id}/integrations/{platform}` Scope: `shows:write` · some changes need `confirm=true` Disconnect a platform from a show the same way the Integrations page does; the response includes the state and account it had. Needs `"confirm": true` (send `?confirm=true`): the show stops refreshing that platform until someone connects it again. Emits `integration.disconnected`. - Send `?dry_run=true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Requires `?confirm=true`; without it the API answers 409 `confirmation_required` with the exact `preview`. - Required scope: `shows:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | | `platform` | string | required | A platform name (e.g. youtube, spotify, applePodcasts, instagram). | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). | | `confirm` | boolean | optional | Must be true to apply destructive or metered changes; without it the API answers 409 with a preview. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Example request ```bash curl -X DELETE "https://api.listener.com/v1/shows/SHOW_ID/integrations/PLATFORM?dry_run=true" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "dry_run", "resource": "integration", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/pQ7sHt2Lw9XcV4bN1mRa", "id": "instagram", "method": "http", "written": { "uid": "pQ7sHt2Lw9XcV4bN1mRa", "platform": "instagram" }, "warnings": [], "urls": { "app": "https://network.listener.com/app/integrations?show=pQ7sHt2Lw9XcV4bN1mRa", "public": null }, "error": null, "data": { "show_id": "pQ7sHt2Lw9XcV4bN1mRa", "platform": "instagram", "previous_state": "connected", "previous_account": "dugoutdaily", "status": "would-disconnect" }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Set a show's podcast identity `PUT /v1/shows/{show_id}/podcast` Scope: `shows:write` Set the listing ids that identify the show: `rss_url`, `apple_podcasts_id`, `spotify_id`, `podchaser_id` and `simplecast_podcast_id`, the same fields Listener stores when a show is picked during onboarding. Only the fields you send change; an empty string clears one. Ids are validated and may be sent as the platform's own URLs (`podcasts.apple.com/…/id123…`, `open.spotify.com/show/…`, `podchaser.com/podcasts/…-123`). These are references used to match the show and its episodes. They do not connect Spotify, Apple Podcasts or Simplecast; connect Simplecast with `PUT /v1/shows/{show_id}/integrations/simplecast`. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `shows:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `rss_url` | string | optional | Default `null`. Nullable. | | `apple_podcasts_id` | string | optional | Default `null`. Nullable. | | `spotify_id` | string | optional | Default `null`. Nullable. | | `podchaser_id` | string | optional | Default `null`. Nullable. | | `simplecast_podcast_id` | string | optional | Default `null`. Nullable. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | ### Example request ```bash curl -X PUT "https://api.listener.com/v1/shows/SHOW_ID/podcast" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "podcast_identity", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/pQ7sHt2Lw9XcV4bN1mRa", "id": "pQ7sHt2Lw9XcV4bN1mRa", "method": "transaction", "written": { "podcastMetadata.rssUrl": "https://feeds.example.com/dugout-daily.xml", "podcastMetadata.applePodcastsId": "1580012345", "podcastMetadata.simplecastPodcastId": "0f9e8d7c-6b5a-4c3d-8e2f-1a0b9c8d7e6f", "podcastMetadata.updatedAt": "2026-10-15T09:30:00.000Z", "updatedAt": "2026-10-15T09:30:00.000Z" }, "warnings": [], "urls": { "app": "https://network.listener.com/app/connect?uid=pQ7sHt2Lw9XcV4bN1mRa", "public": null }, "error": null, "data": { "show_id": "pQ7sHt2Lw9XcV4bN1mRa", "podcast": { "rss_url": "https://feeds.example.com/dugout-daily.xml", "apple_podcasts_id": "1580012345", "simplecast_podcast_id": "0f9e8d7c-6b5a-4c3d-8e2f-1a0b9c8d7e6f" } }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Look up an episode by a partner's identifiers `GET /v1/shows/{show_id}/episodes/lookup` Scope: `episodes:read` Find the Listener episode behind your own identifiers. Send any of `simplecast_episode_id`, `guid` (the RSS item guid), `apple_episode_id`, `spotify_episode_id`, `title` and `published_on` (`YYYY-MM-DD`). Each match returns the Listener `episode_id` (use it with every episode endpoint), its cluster id and public cluster URL, and `match` with `by` (what matched) and `confidence`: - `exact`: an id or guid stored on the episode; - `high`: the same title within a day of the date (or a title unique in the show when no date is sent); - `medium`: the same title on another day, or a near-identical title within a day; - `low`: a similar title within a day. A Simplecast episode id is resolved through the show's Simplecast analytics to its title and publish date, then matched, so it is never better than `high`. `best` is set only for an `exact` or `high` match that no other episode ties; otherwise review `matches`. Nothing is guessed silently: `note` explains misses. - Required scope: `episodes:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `simplecast_episode_id` | string | optional | | | `guid` | string | optional | | | `apple_episode_id` | string | optional | | | `spotify_episode_id` | string | optional | | | `title` | string | optional | Title. | | `published_on` | string | optional | | | `limit` | integer | optional | Maximum number of items to return. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | | | `query` | map of string | optional | | | `best` | object | optional | Set only when the top match is exact or high confidence and unambiguous; otherwise null. Default `null`. Nullable. | | `best.episode_id` | string | required | The Listener episode id (a Spotify or Apple id) used by every other episode endpoint. | | `best.title` | string | required | | | `best.published_at` | string | optional | Default `null`. Nullable. | | `best.source_platform` | string | optional | Default `null`. Nullable. | | `best.cluster_id` | string | optional | Default `null`. Nullable. | | `best.cluster_url` | string | optional | Public share page of the episode's cluster. Default `null`. Nullable. | | `best.match` | object | required | | | `best.match.by` | string | required | What matched, e.g. 'spotify_episode_id', 'guid', 'simplecast_episode_id+title+date', 'title+published_on', 'title'. | | `best.match.confidence` | string | required | One of `exact`, `high`, `medium`, `low`. | | `best.match.score` | number | required | 0–1 similarity behind the confidence. | | `best.match.reasons` | array of strings | optional | | | `matches` | array of objects | optional | | | `matches[].episode_id` | string | required | The Listener episode id (a Spotify or Apple id) used by every other episode endpoint. | | `matches[].title` | string | required | | | `matches[].published_at` | string | optional | Default `null`. Nullable. | | `matches[].source_platform` | string | optional | Default `null`. Nullable. | | `matches[].cluster_id` | string | optional | Default `null`. Nullable. | | `matches[].cluster_url` | string | optional | Public share page of the episode's cluster. Default `null`. Nullable. | | `matches[].match` | object | required | | | `matches[].match.by` | string | required | What matched, e.g. 'spotify_episode_id', 'guid', 'simplecast_episode_id+title+date', 'title+published_on', 'title'. | | `matches[].match.confidence` | string | required | One of `exact`, `high`, `medium`, `low`. | | `matches[].match.score` | number | required | 0–1 similarity behind the confidence. | | `matches[].match.reasons` | array of strings | optional | | | `ambiguous` | boolean | optional | Default `false`. | | `simplecast_episode` | object | optional | The Simplecast episode the id resolved to (title and date come from the show's Simplecast analytics). Default `null`. Nullable. | | `simplecast_episode.id` | string | required | | | `simplecast_episode.title` | string | optional | Default `null`. Nullable. | | `simplecast_episode.published_at` | string | optional | Default `null`. Nullable. | | `simplecast_episode.listeners` | integer | optional | Default `null`. Nullable. | | `note` | string | optional | Default `null`. Nullable. | | `computed_at` | string | required | | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/shows/SHOW_ID/episodes/lookup" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "episode_lookup", "show_id": "pQ7sHt2Lw9XcV4bN1mRa", "query": { "simplecast_episode_id": "8a1f3c2e-5b6d-4e7f-9a0b-1c2d3e4f5a6b" }, "best": { "episode_id": "4rOoJ6Egrf8K2IrywzwOMk", "title": "Ep. 41: Trade Deadline Winners", "published_at": "2026-10-14", "source_platform": "spotify", "cluster_id": "cluster-4rOoJ6Egrf8K2IrywzwOMk", "cluster_url": "https://listener.com/cluster/pQ7sHt2Lw9XcV4bN1mRa_cluster-4rOoJ6Egrf8K2IrywzwOMk", "match": { "by": "simplecast_episode_id+title+date", "confidence": "high", "score": 1, "reasons": [ "same title", "same day" ] } }, "matches": [ { "episode_id": "4rOoJ6Egrf8K2IrywzwOMk", "title": "Ep. 41: Trade Deadline Winners", "published_at": "2026-10-14", "source_platform": "spotify", "cluster_id": "cluster-4rOoJ6Egrf8K2IrywzwOMk", "cluster_url": "https://listener.com/cluster/pQ7sHt2Lw9XcV4bN1mRa_cluster-4rOoJ6Egrf8K2IrywzwOMk", "match": { "by": "simplecast_episode_id+title+date", "confidence": "high", "score": 1, "reasons": [ "same title", "same day" ] } } ], "ambiguous": false, "simplecast_episode": { "id": "8a1f3c2e-5b6d-4e7f-9a0b-1c2d3e4f5a6b", "title": "Ep. 41: Trade Deadline Winners", "published_at": "2026-10-14T04:00:00Z", "listeners": 5400 }, "note": null, "computed_at": "2026-10-15T09:30:00.000Z" } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Retrieve a show's auto-build setting and statuses `GET /v1/shows/{show_id}/auto-build` Scope: `clusters:read` The show's auto-build setting and recent per-episode build statuses. `mode` is `off`, `suggest` (posts are recorded for review) or `auto` (the sure ones are added to the episode's cluster); `legacy` marks a mode read from the older on/off switch. `statuses` lists the latest episodes first (`building`, `ready`, `built`, `nothing`, `failed`, `accepted`, `dismissed`) with cluster ids and reasons; `counts` covers every stored status. New episodes are picked up by Listener's hourly auto-build run (episodes from the last 14 days without a cluster). To build an episode the moment you publish it, call `POST /v1/cluster-builds` with `scope: "episodes"` and its episode id. - Required scope: `clusters:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `limit` | integer | optional | Maximum number of items to return. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | | | `mode` | string | required | One of `off`, `suggest`, `auto`, `draft`. | | `legacy` | boolean | optional | True when the mode comes from the older on/off automation switch (reads as 'auto'). Default `false`. | | `enabled_at` | string | optional | Default `null`. Nullable. | | `updated_at` | string | optional | Default `null`. Nullable. | | `last_run` | object | optional | Default `null`. Nullable. | | `counts` | map of integer | optional | Status → number of episodes, across every stored status. | | `statuses` | array of objects | optional | Most recent per-episode statuses first. | | `statuses[].episode_id` | string | required | | | `statuses[].status` | string | required | building, ready, built, nothing, failed, accepted or dismissed. | | `statuses[].title` | string | optional | Default `null`. Nullable. | | `statuses[].published_at` | string | optional | Default `null`. Nullable. | | `statuses[].cluster_id` | string | optional | Default `null`. Nullable. | | `statuses[].cluster_url` | string | optional | Default `null`. Nullable. | | `statuses[].added_count` | integer | optional | Default `null`. Nullable. | | `statuses[].reason` | string | optional | Why nothing was built (status 'nothing'). Default `null`. Nullable. | | `statuses[].error` | string | optional | What went wrong (status 'failed'). Default `null`. Nullable. | | `statuses[].trigger` | string | optional | Default `null`. Nullable. | | `statuses[].mode` | string | optional | Default `null`. Nullable. | | `statuses[].updated_at` | string | optional | Default `null`. Nullable. | | `schedule` | string | required | How new episodes are picked up. | | `computed_at` | string | required | | | `object` | string | required | | ### Example request ```bash curl "https://api.listener.com/v1/shows/SHOW_ID/auto-build" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "auto_build", "show_id": "pQ7sHt2Lw9XcV4bN1mRa", "mode": "suggest", "legacy": false, "enabled_at": "2026-10-01T15:02:11.000Z", "updated_at": "2026-10-01T15:02:11.000Z", "last_run": { "run_id": "run_8Hq2Lm4Tz9", "trigger": "cron", "at": "2026-10-15T09:30:00.000Z", "episodes": 2 }, "counts": { "built": 1, "ready": 1 }, "statuses": [ { "episode_id": "4rOoJ6Egrf8K2IrywzwOMk", "status": "ready", "title": "Ep. 41: Trade Deadline Winners", "published_at": "2026-10-14T00:00:00.000Z", "cluster_id": null, "cluster_url": null, "added_count": null, "reason": null, "error": null, "trigger": "cron", "mode": "suggest", "updated_at": "2026-10-15T09:30:00.000Z" }, { "episode_id": "2bN8vC4xZ1qW7eR5tY3uIo", "status": "built", "title": "Ep. 40: Bullpen Math", "published_at": "2026-10-07T00:00:00.000Z", "cluster_id": "cluster-2bN8vC4xZ1qW7eR5tY3uIo", "cluster_url": "https://listener.com/cluster/pQ7sHt2Lw9XcV4bN1mRa_cluster-2bN8vC4xZ1qW7eR5tY3uIo", "added_count": 6, "reason": null, "error": null, "trigger": "cron", "mode": "auto", "updated_at": "2026-10-08T08:02:10.000Z" } ], "schedule": "In 'suggest' and 'auto' modes Listener's hourly auto-build run looks for episodes from the last 14 days that have no cluster yet: 'suggest' records posts to review, 'auto' adds the sure ones to a cluster. To build an episode the moment it is published, start a cluster build for that episode.", "computed_at": "2026-10-15T09:30:00.000Z" } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Set a show's auto-build mode `PUT /v1/shows/{show_id}/auto-build` Scope: `clusters:write` Turn auto-build on (`suggest` or `auto`) or `off` for a show, as the Episodes page does, and return the previous and new mode. The hourly auto-build run then picks up new episodes; for an immediate build after publishing call `POST /v1/cluster-builds`. `draft` is refused: it is a per-build policy the hourly run never uses. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `clusters:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `show_id` | string | required | A show id in this workspace (see GET /v1/shows). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `mode` | string | required | How the change applies (see the operation description). One of `off`, `suggest`, `auto`, `draft`. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | ### Example request ```bash curl -X PUT "https://api.listener.com/v1/shows/SHOW_ID/auto-build" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"mode": "off", "dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "auto_build", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/pQ7sHt2Lw9XcV4bN1mRa", "id": "pQ7sHt2Lw9XcV4bN1mRa", "method": "transaction", "written": { "profile.aiAutomationEnabled": false, "profile.autoBuild.mode": "auto", "profile.autoBuild.updatedAt": "2026-10-15T09:30:00.000Z", "users/pQ7sHt2Lw9XcV4bN1mRa/autoBuildSettings/current": { "mode": "auto", "updatedAt": "2026-10-15T09:30:00.000Z" } }, "warnings": [], "urls": { "app": "https://network.listener.com/app/episodes?uid=pQ7sHt2Lw9XcV4bN1mRa", "public": null }, "error": null, "data": { "show_id": "pQ7sHt2Lw9XcV4bN1mRa", "mode": "auto", "previous_mode": "suggest", "schedule": "In 'suggest' and 'auto' modes Listener's hourly auto-build run looks for episodes from the last 14 days that have no cluster yet: 'suggest' records posts to review, 'auto' adds the sure ones to a cluster. To build an episode the moment it is published, start a cluster build for that episode." }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## List a campaign's external ad-server delivery `GET /v1/campaigns/{campaign_id}/external-delivery` Scope: `campaigns:read` Delivery reported by external ad servers (AdsWizz, AudioServe or other) on a campaign: impressions and optional listeners per day or period, per slot and show, newest first, with `meta.totals_by_source`. These are true counts from the ad server, kept apart: they are never added to Listener reach, impressions or EMV. - Required scope: `campaigns:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `campaign_id` | string | required | Campaign id (see GET /v1/campaigns). | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `source` | string | optional | | | `limit` | integer | optional | Page size, 1–100 (default 25). | | `cursor` | string | optional | Opaque cursor from the previous page's next_cursor. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].id` | string | required | 'xd_…' | | `data[].source` | string | required | One of `adswizz`, `audioserve`, `other`. | | `data[].label` | string | optional | Default `null`. Nullable. | | `data[].slot_id` | string | optional | Default `null`. Nullable. | | `data[].show_id` | string | optional | Default `null`. Nullable. | | `data[].date` | string | optional | YYYY-MM-DD for a single day; null when the row covers start_date..end_date. Default `null`. Nullable. | | `data[].start_date` | string | optional | Default `null`. Nullable. | | `data[].end_date` | string | optional | Default `null`. Nullable. | | `data[].impressions` | integer | required | | | `data[].listeners` | integer | optional | Default `null`. Nullable. | | `data[].note` | string | optional | Default `null`. Nullable. | | `data[].created_at` | string | optional | Default `null`. Nullable. | | `data[].updated_at` | string | optional | Default `null`. Nullable. | | `data[].created_by` | string | optional | Default `null`. Nullable. | | `data[].object` | string | required | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | | `meta.campaign_id` | string | optional | | | `meta.campaign_title` | string | optional | Default `null`. Nullable. | | `meta.row_count` | integer | optional | Default `0`. | | `meta.totals_by_source` | map of object | optional | | | `meta.totals_by_source.rows` | integer | optional | Default `0`. | | `meta.totals_by_source.impressions` | integer | optional | Default `0`. | | `meta.totals_by_source.listeners` | integer | optional | Sum of the rows that report listeners; null when none do. Default `null`. Nullable. | | `meta.note` | string | optional | These are counts reported by the ad server; they are never added to Listener reach totals. | | `meta.computed_at` | string | optional | | ### Example request ```bash curl "https://api.listener.com/v1/campaigns/CAMPAIGN_ID/external-delivery" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/campaigns/Xb3kQ9LmT2vWc8Rz1PaE/external-delivery", "data": [ { "object": "external_delivery", "id": "xd_7Rk2Lq9ZpT4mW8cN1vBd", "source": "adswizz", "label": "Dugout Daily mid-roll", "slot_id": "ep_1760430000000_k2m9qa", "show_id": "pQ7sHt2Lw9XcV4bN1mRa", "date": "2026-10-14", "start_date": null, "end_date": null, "impressions": 18250, "listeners": 11900, "note": null, "created_at": "2026-10-15T09:30:00.000Z", "updated_at": "2026-10-15T09:30:00.000Z", "created_by": "api" }, { "object": "external_delivery", "id": "xd_3Hf6Xs1QaY9eK2uJ5rLm", "source": "audioserve", "label": "Week 41 network run", "slot_id": null, "show_id": "Hc4pWq8ZtLm2Xv7Rb1Ny", "date": null, "start_date": "2026-10-06", "end_date": "2026-10-12", "impressions": 64000, "listeners": null, "note": null, "created_at": "2026-10-15T09:30:00.000Z", "updated_at": "2026-10-15T09:30:00.000Z", "created_by": "api" } ], "has_more": false, "next_cursor": null, "meta": { "campaign_id": "Xb3kQ9LmT2vWc8Rz1PaE", "campaign_title": "Acme Coffee Q4 Fall Flight", "row_count": 2, "totals_by_source": { "adswizz": { "rows": 1, "impressions": 18250, "listeners": 11900 }, "audioserve": { "rows": 1, "impressions": 64000, "listeners": null } }, "note": "Counts reported by the ad server (AdsWizz, AudioServe …). They are stored as reported and are never added to Listener reach, impressions or EMV totals.", "computed_at": "2026-10-15T09:30:00.000Z", "total": 2 } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Record external ad-server delivery on a campaign `POST /v1/campaigns/{campaign_id}/external-delivery` Scope: `campaigns:write` Record ad-server delivery on a campaign. Each row has `source` (`adswizz`, `audioserve`, `other`), either `date` or `start_date` + `end_date`, `impressions`, and optionally `listeners`, `slot_id`, `show_id`, `label` and `note`. A row with the same source, slot, show and day (or period) as a stored row updates it instead of adding a duplicate, so re-sending a day is safe. Up to 500 rows per request and 2,000 per campaign. The response lists the row ids added and updated and the totals per source. Emits `campaign.updated`. These counts are never added to Listener reach totals. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `campaigns:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `campaign_id` | string | required | Campaign id (see GET /v1/campaigns). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `rows` | array of objects | required | | | `rows[].source` | string | required | Where the count comes from: adswizz, audioserve or other. One of `adswizz`, `audioserve`, `other`. | | `rows[].label` | string | optional | Your own label, e.g. the ad server's line item name (≤ 120 characters). Default `null`. Nullable. | | `rows[].slot_id` | string | optional | The campaign slot the delivery belongs to (its show is filled in when show_id is omitted). Default `null`. Nullable. | | `rows[].show_id` | string | optional | The show that ran the ads. Default `null`. Nullable. | | `rows[].date` | string | optional | YYYY-MM-DD for one day. Send either date or start_date + end_date. Default `null`. Nullable. | | `rows[].start_date` | string | optional | First day of the period (YYYY-MM-DD). Default `null`. Nullable. | | `rows[].end_date` | string | optional | Last day of the period (YYYY-MM-DD, inclusive). Default `null`. Nullable. | | `rows[].impressions` | integer | required | Impressions the ad server counted. | | `rows[].listeners` | integer | optional | Unique listeners the ad server counted, when it reports them. Default `null`. Nullable. | | `rows[].note` | string | optional | Free-text note (≤ 500 characters). Default `null`. Nullable. | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). Default `false`. | ### Example request ```bash curl -X POST "https://api.listener.com/v1/campaigns/CAMPAIGN_ID/external-delivery" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"rows": [], "dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "external_delivery", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/uJm8Fq1kT0bYwZ3xA9cD/campaigns/Xb3kQ9LmT2vWc8Rz1PaE", "id": "Xb3kQ9LmT2vWc8Rz1PaE", "method": "transaction", "written": { "externalDelivery": "<3 rows>", "rows_written": [ { "source": "adswizz", "label": "Dugout Daily mid-roll", "slot_id": "ep_1760430000000_k2m9qa", "show_id": "pQ7sHt2Lw9XcV4bN1mRa", "date": "2026-10-15", "start_date": null, "end_date": null, "impressions": 17480, "listeners": 11320, "note": null, "id": "xd_9Tn4Pw2ZcV7bQ1mR8sKe", "created_at": "2026-10-15T09:30:00.000Z", "updated_at": "2026-10-15T09:30:00.000Z", "created_by": "api" } ], "updatedAt": "" }, "warnings": [], "urls": { "app": "https://network.listener.com/app/campaign?id=Xb3kQ9LmT2vWc8Rz1PaE&uid=uJm8Fq1kT0bYwZ3xA9cD", "public": "https://network.listener.com/northwind-audio/campaigns/Xb3kQ9LmT2vWc8Rz1PaE" }, "error": null, "data": { "campaign_id": "Xb3kQ9LmT2vWc8Rz1PaE", "added": [ "xd_9Tn4Pw2ZcV7bQ1mR8sKe" ], "updated": [], "row_count": 3, "totals_by_source": { "adswizz": { "rows": 2, "impressions": 35730, "listeners": 23220 }, "audioserve": { "rows": 1, "impressions": 64000, "listeners": null } }, "note": "Counts reported by the ad server (AdsWizz, AudioServe …). They are stored as reported and are never added to Listener reach, impressions or EMV totals." }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Delete an external delivery row `DELETE /v1/campaigns/{campaign_id}/external-delivery/{row_id}` Scope: `campaigns:write` · some changes need `confirm=true` Delete one external delivery row (`xd_…`) from a campaign. Needs `"confirm": true` (send `?confirm=true`); there is no undo. Emits `campaign.updated`. - Send `?dry_run=true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Requires `?confirm=true`; without it the API answers 409 `confirmation_required` with the exact `preview`. - Required scope: `campaigns:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `campaign_id` | string | required | Campaign id (see GET /v1/campaigns). | | `row_id` | string | required | | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `dry_run` | boolean | optional | true returns exactly what would be written without writing anything (the API's test mode). | | `confirm` | boolean | optional | Must be true to apply destructive or metered changes; without it the API answers 409 with a preview. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Example request ```bash curl -X DELETE "https://api.listener.com/v1/campaigns/CAMPAIGN_ID/external-delivery/ROW_ID?dry_run=true" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "dry_run", "resource": "external_delivery", "would_write": { "ok": true, "dry_run": true, "auth_mode": "api_key", "path": "users/uJm8Fq1kT0bYwZ3xA9cD/campaigns/Xb3kQ9LmT2vWc8Rz1PaE", "id": "Xb3kQ9LmT2vWc8Rz1PaE", "method": "transaction", "written": { "removed": { "id": "xd_7Rk2Lq9ZpT4mW8cN1vBd", "source": "adswizz", "label": "Dugout Daily mid-roll", "slot_id": "ep_1760430000000_k2m9qa", "show_id": "pQ7sHt2Lw9XcV4bN1mRa", "date": "2026-10-14", "start_date": null, "end_date": null, "impressions": 18250, "listeners": 11900, "note": null, "created_at": "2026-10-15T09:30:00.000Z", "updated_at": "2026-10-15T09:30:00.000Z", "created_by": "api" }, "externalDelivery": "<1 row>", "updatedAt": "" }, "warnings": [], "urls": { "app": "https://network.listener.com/app/campaign?id=Xb3kQ9LmT2vWc8Rz1PaE&uid=uJm8Fq1kT0bYwZ3xA9cD", "public": "https://network.listener.com/northwind-audio/campaigns/Xb3kQ9LmT2vWc8Rz1PaE" }, "error": null, "data": { "campaign_id": "Xb3kQ9LmT2vWc8Rz1PaE", "deleted_row_id": "xd_7Rk2Lq9ZpT4mW8cN1vBd", "row_count": 1, "totals_by_source": { "audioserve": { "rows": 1, "impressions": 64000, "listeners": null } }, "confirm_required": true }, "replayed": false } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. --- # Webhooks & events > Webhook endpoints, deliveries and the Events API. Base URL: `https://api.listener.com` ## List webhook endpoints `GET /v1/webhook-endpoints` Scope: `webhooks:read` SDK: `client.webhookEndpoints.list()` (TypeScript) · `client.webhook_endpoints.list()` (Python) - Required scope: `webhooks:read`. ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].object` | any | required | | | `data[].id` | string | required | we_… | | `data[].url` | string (uri) | required | | | `data[].description` | string | optional | Nullable. | | `data[].enabled_events` | array of strings | required | | | `data[].status` | string | required | One of `enabled`, `disabled`. | | `data[].disabled_reason` | string | optional | Nullable. | | `data[].secret` | string | optional | whsec_… — returned only on create and roll-secret. | | `data[].secret_last4` | string | optional | | | `data[].expires_previous_at` | string (date-time) | optional | Nullable. | | `data[].api_version` | string | optional | | | `data[].created_at` | string (date-time) | optional | | | `data[].updated_at` | string (date-time) | optional | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | ### Example request ```bash curl "https://api.listener.com/v1/webhook-endpoints" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/webhook-endpoints", "data": [ { "object": "webhook_endpoint", "id": "we_3Eg9g0CxU0cF8kZ1wT1t", "url": "https://example.com/the-draft-room/webhooks", "description": "Production listener", "enabled_events": [ "campaign.updated", "cluster.nodes_added" ], "status": "enabled", "disabled_reason": null, "secret_last4": "Q2x9", "api_version": "2026-10-14", "created_at": "2026-10-14T09:30:00Z", "updated_at": "2026-10-14T09:30:00Z" } ], "has_more": false, "next_cursor": null, "meta": {} } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Create a webhook endpoint `POST /v1/webhook-endpoints` Scope: `webhooks:write` SDK: `client.webhookEndpoints.create()` (TypeScript) · `client.webhook_endpoints.create()` (Python) Returns the signing `secret` (whsec_…) once. Up to 16 endpoints per workspace. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `webhooks:write`. ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `url` | string | required | HTTPS URL that receives POSTed events. | | `enabled_events` | array of strings | required | Event types to deliver, or ["*"] for all. See GET /v1/event-types. | | `description` | string | optional | Free-text note shown in the dashboard. Nullable. | | `dry_run` | boolean | optional | Validate and return what would be written without calling the service. Default `false`. | ### Example request ```bash curl -X POST "https://api.listener.com/v1/webhook-endpoints" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "URL", "enabled_events": [], "dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "webhook_endpoint", "would_write": { "method": "POST", "path": "/v1/webhook-endpoints", "params": { "enabled_events": [ "*" ], "url": "https://example.com/the-overtime-report" } } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Retrieve a webhook endpoint `GET /v1/webhook-endpoints/{endpoint_id}` Scope: `webhooks:read` SDK: `client.webhookEndpoints.retrieve()` (TypeScript) · `client.webhook_endpoints.retrieve()` (Python) - Required scope: `webhooks:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `endpoint_id` | string | required | Webhook endpoint id (we_…). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | any | required | | | `id` | string | required | we_… | | `url` | string (uri) | required | | | `description` | string | optional | Nullable. | | `enabled_events` | array of strings | required | | | `status` | string | required | One of `enabled`, `disabled`. | | `disabled_reason` | string | optional | Nullable. | | `secret` | string | optional | whsec_… — returned only on create and roll-secret. | | `secret_last4` | string | optional | | | `expires_previous_at` | string (date-time) | optional | Nullable. | | `api_version` | string | optional | | | `created_at` | string (date-time) | optional | | | `updated_at` | string (date-time) | optional | | ### Example request ```bash curl "https://api.listener.com/v1/webhook-endpoints/ENDPOINT_ID" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "webhook_endpoint", "id": "we_3Eg9g0CxU0cF8kZ1wT1t", "url": "https://example.com/the-draft-room/webhooks", "description": "Production listener", "enabled_events": [ "campaign.updated", "cluster.nodes_added" ], "status": "enabled", "disabled_reason": null, "secret_last4": "Q2x9", "api_version": "2026-10-14", "created_at": "2026-10-14T09:30:00Z", "updated_at": "2026-10-14T09:30:00Z" } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Update a webhook endpoint `PATCH /v1/webhook-endpoints/{endpoint_id}` Scope: `webhooks:write` SDK: `client.webhookEndpoints.update()` (TypeScript) · `client.webhook_endpoints.update()` (Python) - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `webhooks:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `endpoint_id` | string | required | Webhook endpoint id (we_…). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `url` | string | optional | New HTTPS URL. Nullable. | | `enabled_events` | array of strings | optional | Event types to deliver, or ["*"]. Nullable. | | `description` | string | optional | Free-text note. Nullable. | | `disabled` | boolean | optional | true pauses deliveries; false re-enables the endpoint. Nullable. | | `dry_run` | boolean | optional | Validate and return what would be written without calling the service. Default `false`. | ### Example request ```bash curl -X PATCH "https://api.listener.com/v1/webhook-endpoints/ENDPOINT_ID" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "webhook_endpoint", "would_write": { "method": "PATCH", "path": "/v1/webhook-endpoints/we_probe", "params": { "endpoint_id": "we_probe", "description": "probe" } } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Delete a webhook endpoint `DELETE /v1/webhook-endpoints/{endpoint_id}` Scope: `webhooks:write` SDK: `client.webhookEndpoints.delete()` (TypeScript) · `client.webhook_endpoints.delete()` (Python) - Send `?dry_run=true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `webhooks:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `endpoint_id` | string | required | Webhook endpoint id (we_…). | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `dry_run` | boolean | optional | Validate and return what would be written without calling the service. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Example request ```bash curl -X DELETE "https://api.listener.com/v1/webhook-endpoints/ENDPOINT_ID?dry_run=true" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "dry_run", "resource": "webhook_endpoint", "would_write": { "method": "DELETE", "path": "/v1/webhook-endpoints/we_probe", "params": { "endpoint_id": "we_probe" } } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Roll a webhook signing secret `POST /v1/webhook-endpoints/{endpoint_id}/roll-secret` Scope: `webhooks:write` SDK: `client.webhookEndpoints.rollSecret()` (TypeScript) · `client.webhook_endpoints.roll_secret()` (Python) Returns the new secret once; the previous one keeps signing until `expires_previous_at`. - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `webhooks:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `endpoint_id` | string | required | Webhook endpoint id (we_…). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `expire_previous_in_hours` | integer | optional | Hours the previous secret keeps signing (0–168). Default `24`. | | `dry_run` | boolean | optional | Validate and return what would be written without calling the service. Default `false`. | ### Example request ```bash curl -X POST "https://api.listener.com/v1/webhook-endpoints/ENDPOINT_ID/roll-secret" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "webhook_endpoint", "would_write": { "method": "POST", "path": "/v1/webhook-endpoints/we_probe/roll-secret", "params": { "endpoint_id": "we_probe" } } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Reveal a webhook signing secret `GET /v1/webhook-endpoints/{endpoint_id}/secret` Scope: `webhooks:write` SDK: `client.webhookEndpoints.retrieveSecret()` (TypeScript) · `client.webhook_endpoints.retrieve_secret()` (Python) The endpoint's current signing secret (`whsec_…`), as the dashboard's "Reveal" shows it, plus `previous_secret_expires_at` while a rolled secret still signs. Needs `webhooks:write` because the secret lets its holder forge deliveries. Never cached (`Cache-Control: no-store`, no `ETag`). - Required scope: `webhooks:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `endpoint_id` | string | required | Webhook endpoint id (we_…). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | any | required | | | `id` | string | required | we_… | | `secret` | string | required | whsec_… — verify Listener-Signature with it. | | `secret_last4` | string | optional | Nullable. | | `previous_secret_expires_at` | string (date-time) | optional | Set while a rolled secret still signs deliveries. Nullable. | ### Example request ```bash curl "https://api.listener.com/v1/webhook-endpoints/ENDPOINT_ID/secret" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "webhook_endpoint_secret", "id": "we_3Eg9g0CxU0cF8kZ1wT1t", "secret": "whsec_EXAMPLEonlyNotARealSecret0000000000000", "secret_last4": "0000", "previous_secret_expires_at": null } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Send a test event `POST /v1/webhook-endpoints/{endpoint_id}/test` Scope: `webhooks:write` SDK: `client.webhookEndpoints.test()` (TypeScript) · `client.webhook_endpoints.test()` (Python) - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `webhooks:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `endpoint_id` | string | required | Webhook endpoint id (we_…). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `event_type` | string | optional | Event type to send. Default `"webhook_endpoint.test"`. | | `dry_run` | boolean | optional | Validate and return what would be written without calling the service. Default `false`. | ### Example request ```bash curl -X POST "https://api.listener.com/v1/webhook-endpoints/ENDPOINT_ID/test" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "webhook_delivery", "would_write": { "method": "POST", "path": "/v1/webhook-endpoints/we_probe/test", "params": { "endpoint_id": "we_probe" } } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## List recent deliveries `GET /v1/webhook-endpoints/{endpoint_id}/deliveries` Scope: `webhooks:read` SDK: `client.webhookEndpoints.deliveries.list()` (TypeScript) · `client.webhook_endpoints.deliveries.list()` (Python) - Required scope: `webhooks:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `endpoint_id` | string | required | Webhook endpoint id (we_…). | ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `limit` | integer | optional | Page size, 1–100 (default 25). | | `cursor` | string | optional | Opaque cursor from the previous page's next_cursor. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].object` | any | required | | | `data[].id` | string | required | wd_… | | `data[].endpoint_id` | string | required | | | `data[].event_id` | string | required | | | `data[].event_type` | string | optional | | | `data[].status` | string | required | One of `pending`, `succeeded`, `failed`. | | `data[].attempts` | array of objects | optional | | | `data[].next_attempt_at` | string (date-time) | optional | Nullable. | | `data[].response_status` | integer | optional | Nullable. | | `data[].response_body_excerpt` | string | optional | Nullable. | | `data[].duration_ms` | number | optional | Nullable. | | `data[].created_at` | string (date-time) | optional | | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | ### Example request ```bash curl "https://api.listener.com/v1/webhook-endpoints/ENDPOINT_ID/deliveries" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/webhook-endpoints/we_3Eg9g0CxU0cF8kZ1wT1t/deliveries", "data": [ { "object": "webhook_delivery", "id": "wd_6Ee1BpU8yE0zQ3sJ1pI6", "endpoint_id": "we_3Eg9g0CxU0cF8kZ1wT1t", "event_id": "evt_1Ch6EbG4uT9nJ2hZ4aO0", "event_type": "campaign.updated", "status": "succeeded", "attempts": [ { "at": "2026-10-14T09:30:01Z", "response_status": 200, "duration_ms": 182 } ], "next_attempt_at": null, "response_status": 200, "response_body_excerpt": "Our hosts react to the weekend's results and look ahead to next week.", "duration_ms": 182, "created_at": "2026-10-14T09:30:00Z" } ], "has_more": false, "next_cursor": null, "meta": {} } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Retry a delivery `POST /v1/webhook-deliveries/{delivery_id}/retry` Scope: `webhooks:write` SDK: `client.webhookDeliveries.retry()` (TypeScript) · `client.webhook_deliveries.retry()` (Python) - Send `"dry_run": true` to get `{"object": "dry_run", "would_write": …}` without writing anything. - Required scope: `webhooks:write`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `delivery_id` | string | required | Delivery id (wd_…). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `Idempotency-Key` | string | optional | Any unique string (≤ 255 chars). Replays within 24 h return the stored response with Idempotent-Replayed: true. | ### Body | Field | Type | | Description | | --- | --- | --- | --- | | `dry_run` | boolean | optional | Validate and return what would be written without calling the service. Default `false`. | ### Example request ```bash curl -X POST "https://api.listener.com/v1/webhook-deliveries/DELIVERY_ID/retry" \ -H "Authorization: Bearer $LISTENER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"dry_run": true}' ``` ### Example response ```json { "object": "dry_run", "resource": "webhook_delivery", "would_write": { "method": "POST", "path": "/v1/webhook-deliveries/wd_probe/retry", "params": { "delivery_id": "wd_probe" } } } ``` ### Errors - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `409` Confirmation required, or an Idempotency-Key request is still in flight. - `413` The request body exceeds 1 MB. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## List events `GET /v1/events` Scope: `webhooks:read` SDK: `client.events.list()` (TypeScript) · `client.events.list()` (Python) Newest first. Events are kept for 30 days. - Required scope: `webhooks:read`. ### Query parameters | Name | Type | | Description | | --- | --- | --- | --- | | `type` | string | optional | Only events of this type (e.g. campaign.updated). | | `created_gte` | string | optional | Only events created at or after this ISO 8601 time. | | `created_lte` | string | optional | Only events created at or before this ISO 8601 time. | | `limit` | integer | optional | Page size, 1–100 (default 25). | | `cursor` | string | optional | Opaque cursor from the previous page's next_cursor. | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].object` | any | required | | | `data[].id` | string | required | evt_… | | `data[].type` | string | required | | | `data[].api_version` | string | optional | | | `data[].created` | string (date-time) | required | | | `data[].workspace_id` | string | optional | | | `data[].source` | string | optional | One of `api`, `app`, `system`. | | `data[].data` | object | required | | | `data[].data.object` | object | optional | | | `data[].data.previous_attributes` | object | optional | Nullable. | | `data[].request` | object | optional | Nullable. | | `data[].request.id` | string | optional | Nullable. | | `data[].request.idempotency_key` | string | optional | Nullable. | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | ### Example request ```bash curl "https://api.listener.com/v1/events" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "list", "url": "/v1/events", "data": [ { "object": "event", "id": "evt_zfCWLlsXwZVK0oolPBa7Bfx0", "type": "webhook_endpoint.test", "api_version": "2026-10-14", "created": "2026-10-14T17:24:03Z", "workspace_id": "XXfzcGxkLsQaKAvRy1SwLsZCcH78", "source": "api", "data": { "object": { "object": "webhook_endpoint_test", "endpoint_id": "we_icrPegpM1Yvv80hXwUjoYQZs", "message": "This is a test event from Listener. Respond with any 2xx status to acknowledge it." } }, "request": null } ], "has_more": false, "next_cursor": null, "meta": {} } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## Retrieve an event `GET /v1/events/{event_id}` Scope: `webhooks:read` SDK: `client.events.retrieve()` (TypeScript) · `client.events.retrieve()` (Python) - Required scope: `webhooks:read`. ### Path parameters | Name | Type | | Description | | --- | --- | --- | --- | | `event_id` | string | required | Event id (evt_…). | ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | any | required | | | `id` | string | required | evt_… | | `type` | string | required | | | `api_version` | string | optional | | | `created` | string (date-time) | required | | | `workspace_id` | string | optional | | | `source` | string | optional | One of `api`, `app`, `system`. | | `data` | object | required | | | `data.object` | object | optional | | | `data.previous_attributes` | object | optional | Nullable. | | `request` | object | optional | Nullable. | | `request.id` | string | optional | Nullable. | | `request.idempotency_key` | string | optional | Nullable. | ### Example request ```bash curl "https://api.listener.com/v1/events/EVENT_ID" \ -H "Authorization: Bearer $LISTENER_API_KEY" ``` ### Example response ```json { "object": "event", "id": "evt_1Ch6EbG4uT9nJ2hZ4aO0", "type": "campaign.updated", "api_version": "2026-10-14", "created": "2026-10-14T09:30:00Z", "workspace_id": "XXfzcGxkLsQaKAvRy1SwLsZCcH78", "source": "api", "data": { "object": { "object": "campaign", "id": "B9GpElnJc2UQ1JLfav8j", "title": "Tyrell Travel — Holiday Promo" }, "previous_attributes": { "title": "Power rankings, week 6" } }, "request": { "id": "req_9m9ReX1wIa7Na0TpP6eG", "idempotency_key": "b7c1e2a4" } } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `401` Missing, invalid, expired or revoked credential. - `403` The credential lacks the required scope, or writes are disabled. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key. ## List event types `GET /v1/event-types` SDK: `client.eventTypes.list()` (TypeScript) · `client.event_types.list()` (Python) No authentication required. Every event type with a description and an example payload. No authentication. ### Header parameters | Name | Type | | Description | | --- | --- | --- | --- | | `Listener-Version` | string | optional | API version (YYYY-MM-DD). Omit for the latest (2026-10-14). | | `If-None-Match` | string | optional | An ETag from a previous response; unchanged results answer 304. | ### Returns (200) | Field | Type | | Description | | --- | --- | --- | --- | | `object` | string | required | | | `url` | string | required | | | `data` | array of objects | required | | | `data[].object` | any | required | | | `data[].name` | string | required | The event type, e.g. campaign.updated. | | `data[].domain` | string | optional | The resource family (campaign, cluster, report …). | | `data[].description` | string | optional | | | `data[].sources` | array of strings | optional | | | `data[].wildcard` | boolean | optional | Delivered to endpoints subscribed to "*". | | `data[].example` | object | optional | | | `data[].example.object` | any | required | | | `data[].example.id` | string | required | evt_… | | `data[].example.type` | string | required | | | `data[].example.api_version` | string | optional | | | `data[].example.created` | string (date-time) | required | | | `data[].example.workspace_id` | string | optional | | | `data[].example.source` | string | optional | One of `api`, `app`, `system`. | | `data[].example.data` | object | required | | | `data[].example.data.object` | object | optional | | | `data[].example.data.previous_attributes` | object | optional | Nullable. | | `data[].example.request` | object | optional | Nullable. | | `data[].example.request.id` | string | optional | Nullable. | | `data[].example.request.idempotency_key` | string | optional | Nullable. | | `has_more` | boolean | required | | | `next_cursor` | string | required | Pass as `cursor` to get the next page; null on the last page. Nullable. | | `meta` | object | required | The result's non-list fields (window, coverage, freshness, totals, note …). | ### Example request ```bash curl "https://api.listener.com/v1/event-types" ``` ### Example response ```json { "object": "list", "url": "/v1/event-types", "data": [ { "object": "event_type", "name": "campaign.created", "domain": "campaign", "description": "A campaign was created.", "sources": [ "api", "app" ], "wildcard": true, "example": { "object": "event", "id": "evt_8z4V7h1Z2d6U9p6S2y7LcCnJ", "type": "campaign.created", "api_version": "2026-10-14", "created": "2026-10-14T09:30:00Z", "workspace_id": "bPv3Ck9rB5eBiM8kE2yE", "source": "app", "data": { "object": { "object": "campaign", "id": "Hx9pD9OyG5uTw6Qu4GdM", "title": "Globex Sports — Q4 Push", "description": "Host-read mid-rolls plus two YouTube integrations per show.", "status": "active", "start_date": "2026-10-01", "end_date": "2026-12-15", "brand": { "name": "Hooli Mobile", "slug": "hooli-mobile", "domain": "hooli-mobile.example.com", "logo": "https://images.example.com/listener/81e8ed4c0aa082ba.jpg" }, "goals": { "total_views": 2500000, "total_budget": 85000 }, "cluster_ids": [ "cluster-1uE5pXhDoQ6dXjW8nIo8Po" ], "total_slots": 12, "created_at": "2026-09-22T15:04:11Z", "updated_at": "2026-10-14T09:29:58Z", "public_url": "https://network.listener.com/northwind-audio/campaigns/Hx9pD9OyG5uTw6Qu4GdM" } }, "request": null } }, { "object": "event_type", "name": "campaign.updated", "domain": "campaign", "description": "A campaign's title, description, dates, brand, goals, slots, linked clusters, exclusions or EMV settings changed.", "sources": [ "api", "app" ], "wildcard": true, "example": { "object": "event", "id": "evt_8z4V7h1Z2d6U9p6S2y7LcCnJ", "type": "campaign.updated", "api_version": "2026-10-14", "created": "2026-10-14T09:30:00Z", "workspace_id": "bPv3Ck9rB5eBiM8kE2yE", "source": "app", "data": { "object": { "object": "campaign", "id": "Hx9pD9OyG5uTw6Qu4GdM", "title": "Globex Sports — Q4 Push", "description": "Host-read mid-rolls plus two YouTube integrations per show.", "status": "active", "start_date": "2026-10-01", "end_date": "2026-12-15", "brand": { "name": "Hooli Mobile", "slug": "hooli-mobile", "domain": "hooli-mobile.example.com", "logo": "https://images.example.com/listener/81e8ed4c0aa082ba.jpg" }, "goals": { "total_views": 2500000, "total_budget": 85000 }, "cluster_ids": [ "cluster-1uE5pXhDoQ6dXjW8nIo8Po" ], "total_slots": 12, "created_at": "2026-09-22T15:04:11Z", "updated_at": "2026-10-14T09:29:58Z", "public_url": "https://network.listener.com/northwind-audio/campaigns/Hx9pD9OyG5uTw6Qu4GdM" }, "changed_fields": [ "goals", "slots" ], "previous_attributes": {} }, "request": null } } ], "has_more": false, "next_cursor": null, "meta": {} } ``` ### Errors - `304` Not modified (If-None-Match matched the ETag). - `400` Invalid request: missing, unknown or invalid parameters, malformed JSON, unknown version. - `404` The resource is not in this workspace. - `429` Rate limited. Retry after the Retry-After seconds. - `500` Something failed on Listener's side. Retry with the same Idempotency-Key.