Syncing at scale
Backfill thousands of shows with batch reads and big pages, stay in sync with webhooks and the Events API, refresh only on demand, and stay inside your rate limit.
5 min read
This guide is for platforms that mirror Listener data for many shows at once: a hosting platform syncing every show it hosts, or a network pulling clusters for its whole catalog. It covers the first backfill, staying in sync afterwards, what refreshes cost, and how rate limits apply.
| Step | Endpoint | Per request |
|---|---|---|
| Map your shows | GET /v1/connections (or GET /v1/shows) | 100 shows |
| Map your episodes | POST /v1/shows/{show_id}/episodes/lookup/batch | 100 episode ids |
| List clusters | GET /v1/clusters?show_ids=… | 100 clusters |
| Read cluster metrics | POST /v1/clusters/metrics/batch | 100 clusters |
| Stay in sync | webhooks, or GET /v1/events | 100 events |
Batch reads are read-only: they return the figures Listener already stores and never call a data provider, so they cost nothing beyond your rate limit.
1. Initial backfill
#Map your shows
#Each connection links one of your shows to its Listener show. List them 100 at a time and store show_id next to your own show id (simplecast_show_id):
curl -G https://api.listener.com/v1/connections \
-H "Authorization: Bearer lsk_…" \
--data-urlencode "limit=100"Follow next_cursor until has_more is false (see Pagination). Pages are ordered by creation time, newest first, so connections created while you page never shift a page. For a network workspace without connections, page through GET /v1/shows?limit=100 instead.
Map your episodes
#Send up to 100 lookups for one show in a single request. Each lookup takes the same identifiers as the single lookup: simplecast_episode_id, guid, apple_episode_id, spotify_episode_id, or title with published_on.
curl https://api.listener.com/v1/shows/$SHOW_ID/episodes/lookup/batch \
-H "Authorization: Bearer lsk_…" \
-H "Content-Type: application/json" \
-d '{
"lookups": [
{"simplecast_episode_id": "8a1f3c2e-5b6d-4e7f-9a0b-1c2d3e4f5a6b"},
{"guid": "your-platform-episode-8a1f3c2e"},
{"title": "Draft night", "published_on": "2026-10-01"}
]
}'The response is a list with one batch_result per lookup, in the order you sent them:
{
"object": "list",
"data": [
{
"object": "batch_result",
"index": 0,
"query": {"simplecast_episode_id": "8a1f3c2e-5b6d-4e7f-9a0b-1c2d3e4f5a6b"},
"status": 200,
"result": {
"best": {"episode_id": "4rOoJ6Egrf8K2IrywzwOMk", "cluster_id": "cluster-4rOoJ6Egrf8K2IrywzwOMk", "match": {"by": "simplecast_episode_id", "confidence": "exact"}},
"matches": [ … ],
"ambiguous": false,
"note": null
},
"error": null
},
{
"object": "batch_result",
"index": 1,
"query": {"guid": "your-platform-episode-8a1f3c2e"},
"status": 200,
"result": {"best": null, "matches": [], "ambiguous": false, "note": "No stored episode carries that guid …"},
"error": null
},
…
],
"has_more": false,
"meta": {"requested": 3, "succeeded": 3, "failed": 0, "matched": 2}
}- One bad lookup never fails the batch. Each item has its own
status:200, or400when the lookup itself is invalid (no identifier, a malformed date). Itserrorhas the sametype,code,messageandparamas the error object, for example{"code": "parameter_invalid", "param": "lookups[2].published_on", "message": "published_on must be a date as YYYY-MM-DD."}. - Not found is not an error. An episode Listener doesn’t know answers
status: 200withresult.best: nulland anote.bestis only set for an unambiguous exact or high-confidence match; reviewmatchesotherwise. limit(1–5, default 3) caps the matches returned per lookup.
Store the global cluster id {show_id}_{cluster_id} from best next to your episode.
List clusters
#GET /v1/clusters returns each cluster’s summary (total, YouTube, audio and social reach, clip and post counts, last_refreshed_at) 100 at a time. Narrow it to the shows you sync with show_ids:
curl -G https://api.listener.com/v1/clusters \
-H "Authorization: Bearer lsk_…" \
--data-urlencode "show_ids=$SHOW_ID" \
--data-urlencode "limit=100"Read cluster metrics
#For the per-platform and per-post breakdown, send up to 100 global cluster ids at once:
curl https://api.listener.com/v1/clusters/metrics/batch \
-H "Authorization: Bearer lsk_…" \
-H "Content-Type: application/json" \
-d "{\"cluster_refs\": [\"$CLUSTER_REF\"]}"Each batch_result has id (the ref as you sent it), status and either result (the fields of GET /v1/clusters/{id}/metrics: totals, platforms, items, last_refreshed_at, warnings, definitions) or error: 400 parameter_invalid for a malformed id, 404 resource_not_found for an unknown show or cluster. meta counts requested, succeeded and failed.
A request with more than 100 items, or with something other than an array, fails as a whole with 400 parameter_invalid and param naming the field (cluster_refs or lookups). Split the batch.
A backfill loop
#const API = 'https://api.listener.com/v1';
const headers = { Authorization: `Bearer ${process.env.LISTENER_API_KEY}`, 'Content-Type': 'application/json' };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function call(path, init = {}) {
for (let attempt = 0; ; attempt++) {
const res = await fetch(`${API}${path}`, { ...init, headers });
if (res.status === 429 && attempt < 5) {
await sleep(Number(res.headers.get('Retry-After') || 1) * 1000);
continue;
}
if (!res.ok) throw new Error(`${path}: ${res.status}`);
return res.json();
}
}
// clusterRefs: every global cluster id you stored while mapping episodes
for (let i = 0; i < clusterRefs.length; i += 100) {
const page = await call('/clusters/metrics/batch', {
method: 'POST',
body: JSON.stringify({ cluster_refs: clusterRefs.slice(i, i + 100) }),
});
for (const item of page.data) {
if (item.status === 200) await saveMetrics(item.id, item.result);
else console.warn(item.id, item.error.code, item.error.message);
}
}The SDKs (0.6.0) have methods for both batch endpoints, client.clusters.metricsBatch({ cluster_refs }) and client.shows.episodes.lookupBatch(showId, { lookups }) in TypeScript (clusters.metrics_batch(cluster_refs=…) and shows.episodes.lookup_batch(show_id, lookups=…) in Python), and retry 429 for you.
2. Staying in sync
#After the backfill, don’t re-read everything on a timer. Listen for changes:
- Webhooks (recommended): subscribe to
cluster.created,cluster.updatedandcluster.deleted.cluster.updatedfires when a cluster’s numbers move, with the changed metric names indata.changed_fieldsand the new totals indata.object.metrics. Re-read only the clusters an event names, in batches of up to 100. See Webhooks. - Events API (no public endpoint, or to reconcile after downtime): keep the newest
created_atyou processed and page forward from it. Events are kept for 30 days.
curl -G https://api.listener.com/v1/events \
-H "Authorization: Bearer lsk_…" \
--data-urlencode "type=cluster.*" \
--data-urlencode "created_gte=2026-10-14T00:00:00Z" \
--data-urlencode "limit=100"The events cursor is a keyset position, so events created while you page never shift or repeat a page. See Events API.
3. Refresh economics
#Cluster numbers come from the platforms (YouTube, Instagram, TikTok and the rest) through paid data providers. A refresh re-fetches every post of a cluster and spends one provider call per post, which is why:
- Refreshes happen on demand only. Listener refreshes a cluster when its public page or embed is viewed, and when you ask. There is no bulk “refresh everything” endpoint and no scheduled refresh, by design.
- Refresh what someone is looking at. When a creator opens an episode in your product, call
POST /v1/clusters/{id}/refreshwithconfirm: true(scoperefresh:write), show the stored numbers right away, and update them whencluster.updatedarrives. - Read freely. Batch reads, lists and single reads return stored figures and never spend provider calls. Each figure carries
last_refreshed_at;warningsflags figures older than 36 hours.
curl https://api.listener.com/v1/clusters/$CLUSTER_REF/refresh \
-H "Authorization: Bearer lsk_…" \
-H "Content-Type: application/json" \
-d '{"confirm": true}'See Data sources, freshness & retention for how often each platform’s figures move.
4. Rate limits at scale
#Every credential has its own token bucket, sized by its rate tier:
| Tier | Requests / minute | Writes / minute | Concurrent |
|---|---|---|---|
default | 120 | 30 | 10 |
partner | 1,200 | 300 | 20 |
- Batches are weighted. A batch request costs one token per 10 items, rounded up: 100 items cost 10 tokens, so batching is 10× cheaper than single reads. At the default tier that is up to 1,200 clusters a minute; at the partner tier, 12,000.
- OAuth connections each get their own budget. If you connect creators with OAuth, every creator’s connection has its own 120 a minute.
- The
partnertier is for one credential that syncs many shows (for example a network’s API key). Listener assigns it to an API key or a whole workspace; it can’t be set from the API or the console. Email tech@listener.com with your use case and expected volume. - Check your tier in the
Listener-Rate-Tierheader,RateLimit-Limit(your per-minute limit) orcredential.rate_limitinGET /v1/me. The Developers console shows each key’s tier. - Limits are per server. Buckets are kept in memory on each API server. Listener runs one today; if it runs more, a credential’s budget applies on each, so treat the published limit as your budget, not a guarantee of more.
- Back off on
429. Wait forRetry-Afterseconds, then retry. Keep concurrency to a handful of parallel requests per credential.
See Rate limits for the headers and error shape.