// guides
Download App Store sales reports with the API
Everything Apple knows about your daily revenue arrives as a gzipped TSV from one endpoint. This guide is the version we wish had existed before building a production sync on it: the exact request, the ZIP-inside-gzip surprise, per-unit money columns, the 365-day daily horizon, and which 404s are fine.
last updated 2026-07-12 · by revenuehog
One endpoint, several reports
Every report comes from GET /v1/salesReports on api.appstoreconnect.apple.com, distinguished by filter parameters. The ones a revenue pipeline cares about:
| reportType | what it contains | version we request |
|---|---|---|
SALES | The daily ledger: one row per product × country × price point, with units, per-unit proceeds and customer price. Covers paid apps, IAP and subscriptions alike. | 1_1 |
SUBSCRIPTION | A snapshot of currently-active subscriptions by state: standard price, free trial, pay-up-front / pay-as-you-go intro offers, promo offers, billing retry, grace period. The input for MRR. | 1_3 |
SUBSCRIPTION_EVENT | Lifecycle events per day: subscribe, renew, cancel, trial conversion, refund, billing retry, reactivate, with quantities. | 1_3 |
All three take filter[frequency]=DAILY (weekly, monthly and yearly variants exist), filter[reportSubType]=SUMMARY, your vendor number, and a report date. Keep the version configurable. Apple bumps report versions occasionally, and a hardcoded one is a silent future breakage.
Auth: the JWT and the vendor number
Two credentials, often confused. The API token is an ES256 JWT signed with your .p8 key: iss = Issuer ID, kid = Key ID, aud = appstoreconnect-v1, exp at most 20 minutes out. A Finance or Sales role key is enough to read reports; the full walkthrough is in the API key guide. The vendor number identifies whose reports you want: find it in App Store Connect under Payments and Financial Reports (a digit string, typically starting with 8).
The request
Two details that aren't obvious from the docs: the Accept: application/a-gzip header, and the fact that 404 is a normal answer: it means "no report for that date", which happens for days with no transactions and for dates Apple hasn't processed yet. Treat it as an empty day, not a failure:
# One daily SALES report. $TOKEN is the ES256 JWT (see the API-key guide).
curl -s -o report.gz \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/a-gzip" \
"https://api.appstoreconnect.apple.com/v1/salesReports?\
filter[frequency]=DAILY&\
filter[reportType]=SALES&\
filter[reportSubType]=SUMMARY&\
filter[version]=1_1&\
filter[vendorNumber]=8XXXXXXX&\
filter[reportDate]=2026-07-10"
# 200 → a gzipped TSV. 404 → no report for that date (NOT an error,
# see gotchas). Other statuses → a JSON error body worth logging.Decoding: the ZIP surprise
Most days the body is a single gzip stream. But when a date has more than one report file, Apple returns a ZIP archive of .txt.gz entries instead. Same Acceptheader, different container. A pipeline that always gunzips crashes on exactly those days with "incorrect header check", which is how we met this behavior in production. Sniff the magic bytes:
// decode.ts: Apple's report body is USUALLY one gzip stream, but when a
// date has more than one report file it's a ZIP of *.txt.gz entries. A
// plain gunzip throws "incorrect header check" on those days. Check the
// magic bytes and handle both, keeping only the first file's header row.
import { gunzipSync } from "node:zlib";
import { unzipSync } from "fflate"; // or any zip reader
export function decodeReportTsv(buffer: Buffer): string {
// gzip: 0x1f 0x8b
if (buffer[0] === 0x1f && buffer[1] === 0x8b) {
return gunzipSync(buffer).toString("utf8");
}
// zip: "PK" (0x50 0x4b)
if (buffer[0] === 0x50 && buffer[1] === 0x4b) {
const texts = Object.entries(unzipSync(buffer)).map(([name, bytes]) => {
const buf = Buffer.from(bytes);
return name.endsWith(".gz") ? gunzipSync(buf).toString("utf8") : buf.toString("utf8");
});
// Same columns in every entry: keep header 1, append the rest's data rows.
const [first, ...rest] = texts;
return [first, ...rest.map((t) => t.split(/\r?\n/).slice(1).join("\n"))]
.map((t) => t.trim())
.filter(Boolean)
.join("\n");
}
throw new Error("Unrecognized report encoding");
}When merging multiple entries, keep only the first file's header line. Every entry repeats the same columns, and a stray second header parses as a garbage data row.
Parsing the TSV (and the per-unit money trap)
The decoded report is tab-separated with a header row. Resolve columns by name. Apple adds columns across versions, so positional parsing is fragile. Then the three classics:
- ▸Money columns are per-unit.
Developer ProceedsandCustomer Priceare the amount for one unit; row revenue is the amount ×Units. Summing the proceeds column without multiplying is the most common wrong-dashboard bug we know of. - ▸Negative units are refunds(and cancellations). Keep them. They're how your totals stay honest.
- ▸Dates are MM/DD/YYYY, not ISO. Convert at the boundary before anything sorts or compares them.
// parse.ts: the TSV has a header row; resolve columns BY NAME, not index
// (Apple adds columns across report versions).
export function parseSalesTsv(tsv: string) {
const lines = tsv.split(/\r?\n/).filter((l) => l.trim());
const header = lines[0].split("\t").map((h) => h.trim());
const col = (name: string) => header.indexOf(name);
const c = {
appleId: col("Apple Identifier"),
sku: col("SKU"),
productType: col("Product Type Identifier"),
units: col("Units"),
proceeds: col("Developer Proceeds"), // PER UNIT (multiply by Units)
price: col("Customer Price"), // PER UNIT, in Customer Currency
date: col("Begin Date"), // MM/DD/YYYY, not ISO
proceedsCurrency: col("Currency of Proceeds"),
country: col("Country Code"),
};
return lines.slice(1).map((line) => {
const f = line.split("\t");
const units = parseInt(f[c.units], 10) || 0; // negative = refunds
return {
appleId: f[c.appleId],
sku: f[c.sku],
units,
// The money trap: report amounts are per-unit. Row revenue is × units.
proceeds: (parseFloat(f[c.proceeds]) || 0) * units,
proceedsCurrency: f[c.proceedsCurrency],
country: f[c.country],
date: toIso(f[c.date]), // "07/10/2026" → "2026-07-10"
};
});
}
const toIso = (mdY: string) => {
const m = mdY.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
return m ? `${m[3]}-${m[1]}-${m[2]}` : mdY;
};Two more columns worth keeping: Country Code (revenue arrives per territory in local currencies; Currency of Proceedsnames each row's currency, and converting them means daily FX rates, covered in the MRR guide) and Parent Identifier, which ties IAP and subscription rows back to their parent app's SKU.
The 365-day daily horizon
Apple serves dailyreports for roughly the trailing 365 days only. Ask for an older date and you get a 404 (or an error naming the limit). Older history exists only at coarser granularities, and yearly aggregates can't rebuild a daily series. Two practical consequences: backfill the full year on day oneof any pipeline (it's the most daily history you will ever be able to fetch), and sync daily from then on so your window never shrinks. This is also why RevenueHog imports "about a year of daily history": it's Apple's limit, not a product tier.
Gotchas checklist
- ▸404 ≠ failure. No report for that date. Also expected for today: daily reports typically appear the next day.
- ▸ZIP-inside-a-gzip-header days. Sniff magic bytes (
1f 8bvsPK), don't assume gunzip. - ▸Per-unit amounts. Multiply by
Unitsbefore summing anything. - ▸Developer Proceeds already carry your rate. The
Developer Proceedscolumn states your actual commission — 85% if you're a Small Business member — so don't re-adjust it. The standard-vs-SBP share only matters when you derive proceeds from a customer price yourself. - ▸Token expiry. 20-minute max lifetime; long backfills must re-mint. 401s mid-run usually mean an expired token, not revoked credentials.
- ▸Be polite on rate limits. A 365-day backfill is hundreds of requests; run them with bounded concurrency and back off on 429s.
- ▸Idempotent ingestion. Re-downloading a day should upsert, not duplicate. Apple occasionally reissues reports; your pipeline should survive fetching the same date twice.
Or skip the pipeline
This guide is the genericized version of RevenueHog's production sync: the same JWT auth, container sniffing, per-unit math, refund handling, daily FX conversion and 365-day backfill, running on a schedule with the results charted as MRR, revenue and trials. Connect the same API key and it's live in minutes.
sources
Apple: App Store Connect API — Sales and Finance reports (salesReports endpoint, report types, versions) — developer.apple.com/documentation/appstoreconnectapi/get-v1-salesreports.
Apple: Download and view reports / report availability — developer.apple.com/help/app-store-connect (Sales and Trends).
Container/column behavior (gzip vs ZIP days, per-unit amounts, MM/DD/YYYY dates, 404 semantics) — observed in RevenueHog's production syncs.
Checked 2026-07-12.
// related