Analytics Measurement Methodology
show.fm download numbers are IAB Podcast Measurement v2.2-aligned. This page is the plain-English definition of what we count, exactly where we diverge from the strict spec, and how the numbers should be framed to users and (eventually) sponsors.
Source of truth: workers/analytics-rollup/src/lib/iab-filter.ts (the counting rules), workers/media-delivery/src/lib/client-hash.ts (the dedupe identity), src/lib/entitlements/analytics-retention.ts (read-time retention gating). Where the spec is ambiguous we mirror OP3's open reference implementation.
Aligned, never certified — wording verified against the official PDF
The claim is "IAB v2.2-ALIGNED", never "certified". Certification is a paid IAB Tech Lab audit we have not undergone — and the spec itself states compliance may only be claimed after certification (§5.4, p14). The byte-math wording on this page was verified against the official IAB v2.2 PDF on 2026-07-06 (resolving open item OI-1 in docs/planning/plans/2026-07-05-analytics-platform.md); page references below are to the PDF's printed page numbers.
What Counts as a Download
The rollup applies six rules, in order, to one fixed UTC day of raw request datapoints grouped by episode and client:
- Only successful audio fetches count. A request must be a
GETthat returned200(full file) or206(byte range). Conditional304s,HEADprobes, and errors never count. - A request with no user agent never counts. Empty or missing UA → dropped.
- Known bots never count. User agents matching the OPAWG bot lists (crawlers, monitoring services, archivers) are dropped before any counting. This also implements the spec's paired-Apple-Watch rule (step 1.2 rule 8, p16: UAs beginning
atc/withwatchOS, or containing(null)/(null) watchOS, duplicate the phone's download) — OPAWG's "Apple Podcasts (Watch)" entry covers both patterns, pinned by a regression test inopawg.test.ts. - The client must fetch at least 60 seconds' worth of audio. All of a client's bytes for an episode within the day are summed and must reach the byte-equivalent of one minute at the file's gross average bitrate (
ceil(file_size / duration × 60), capped at the file size — an episode shorter than 60 seconds requires the whole file, exactly as the spec prescribes). If we know the file size but not the duration, the fallback threshold is 5% of the file; if we know neither, anything more than 2 bytes qualifies. See divergence (c) for how this approximates the spec's "headers + one minute of content" math. - Apple probe exception. A client whose only traffic is
bytes=0-1two-byte probes, with an Apple user agent (AppleCoreMedia / Apple Podcasts / iTunes), counts as one download attributed to Apple Podcasts. See divergence (b) below for why. - One download per client per episode per UTC day. However many times the same client fetches the same episode in a day, it counts once. "Client" is the dedupe identity described next.
Alongside the filtered downloads, every aggregate row also carries raw_requests — the unfiltered request count — as an honesty metric so the filtering is inspectable.
The Dedupe Identity
A "client" is the pair (daily IP hash, user agent):
- The IP is never stored. Each datapoint carries
SHA-256(dailySalt + ':' + normalizedIp)wheredailySalt = HMAC-SHA-256(IP_HASH_SECRET, 'YYYY-MM-DD' UTC). - The salt derives deterministically from the UTC date (no storage, identical across isolates) and rotates at UTC midnight — the same instant the dedupe window closes, so the same listener produces unlinkable hashes on different days. Cross-day correlation is impossible by design (GDPR posture).
- IPv6 addresses are truncated to their /64 prefix before hashing (IAB rule — a /64 is one subscriber's delegation); IPv4 uses the full address.
- The dedupe window is the fixed UTC calendar day, not a rolling 24 hours (a spec-sanctioned option — see Disclosed Choice below).
Explicit Divergences from Strict IAB v2.2
Three deliberate divergences, each documented so a future certification audit is cheap:
(a) No IP-based filtering
Strict IAB filtering (step 1.2, p16) also drops requests by IP: known data-center/cloud ranges via the TAG data-center IP list — a licensed resource we do not carry in v1 — plus review of IPs with unrealistic download volumes, referrer-data checks, and malformed-UA correction (we do not log referrers at all). OPAWG UA-based bot filtering catches the well-behaved bots; unattributed data-center traffic with a plausible UA is currently counted. Revisit if bot noise shows up in real traffic (OP3-style open IP-range categorization is the fallback).
(b) The Apple bytes=0-1 probe exception
The spec (step 1.2 rule 7, p16) says to "disregard any 2 byte (0-1 byte) range requests". We count a probe-only Apple client as one download (rule 5) — following OP3's convention — because Apple's download pipeline frequently issues the bytes=0-1 probe against the origin while the full fetch lands on Apple's own cache infrastructure, invisible to us. The probe is often the only origin-visible signal that a real Apple Podcasts download happened. Excluding it would systematically undercount the largest podcast app.
(c) Threshold math ignores ID3/header bytes
The spec's threshold (step 2, p17–18) is "the header information plus enough of the podcast content to play for 1 minute", with a recommendation to measure each episode's ID3 tag size. Ours is 60 seconds at the file's gross average bitrate (ceil(file_size × 60 / duration)) with no ID3 measurement — more permissive by strictly less than one ID3-tag-size, which only becomes material for files with large embedded artwork. Our fallbacks are also laxer than the spec's stated alternative (100% of the file when sizes can't be computed): 5% of the file when only the duration is unknown, and >2 bytes when nothing is known — though the >2-byte floor still enforces the spec's rule-7 probe exclusion.
Disclosed Choice: Fixed UTC Calendar Day
Not a divergence — spec §5.4.1 (p15) explicitly permits either "a calendar day (fixed) or a rolling 24 hour window" and requires only that the choice be disclosed in a document of methodology; this page is that disclosure. We use the fixed UTC calendar day (OP3 precedent). A listener fetching at 23:50 UTC and again at 00:10 UTC counts twice; one fetching at 00:10 and 23:50 the same day counts once. The fixed day makes the daily aggregates exactly reproducible and lets the IP-hash salt rotation coincide with the window boundary.
Honest Metrics: Downloads Are Reach, Not Listens
A download measures delivery to an app or device — it is not a listen. Podcast apps auto-download subscribed episodes; an IAB-valid download can sit unplayed forever. This is true of every IAB-aligned hosting analytics product, and the UI copy says so explicitly.
Listening/engagement data (plays, listeners, listen-through) exists only inside Apple Podcasts and Spotify, and neither platform has an analytics API (verified 2026-07-05: Apple has none; Spotify is dashboard/CSV only; note Spotify redefined "plays" to ≥30 seconds in June 2026). Engagement metrics therefore enter show.fm only via guided CSV import of the Apple Podcasts Connect and Spotify for Creators exports, are stored separately (analytics_platform_metrics), and are always labelled "as reported by Apple/Spotify" — never blended into download counts.
The four data classes, kept distinct throughout the product:
| Class | What it is | Source |
|---|---|---|
| Delivery | Downloads (IAB-aligned), feed reach | media-delivery + rss-feed ingest → rollup |
| Audience | Geo, apps, devices | Same pipeline, breakdown maps |
| Engagement | Plays, listeners, listen-through | Apple/Spotify CSV imports only |
| Content & Ops | Cadence, funnel, talk-time | First-party tables (episodes, bookings, transcripts) |
Retention Is Read-Time Only — Collection Is Never Gated
Raw ingest and rollup run identically for every account from day one; plan tiering happens exclusively at read time. The API floors each requested range start at today − analytics_retention_days (free window: 90 days; null cap = unlimited) and reports { clamped, retentionDays } in the response instead of returning a 403 — the UI renders an "unlock full history" affordance rather than an error.
Because nothing is ever deleted or skipped at collection time, upgrading instantly reveals the full history that was accumulating all along. See Analytics API for the clamp contract and src/lib/entitlements/analytics-retention.ts for the fail-closed resolution rules.
Related Documentation
- Media Delivery Worker — ingest: what gets logged, the privacy hash
- Analytics Rollup Worker — where these rules execute
- Analytics API — the read layer and retention clamp
- Implementation plan (product decisions + open items):
docs/planning/plans/2026-07-05-analytics-platform.md(repo root)