Pulling App Store Connect sales reports without RevenueCat
How to fetch App Store revenue numbers directly from Apple — ES256 JWT signing, the Sales Reports endpoint, gzipped TSV parsing. ~150 lines of TypeScript.
Horog talks to App Store Connect directly — no RevenueCat in the middle. The reason is simple: the revenue data is ours, Apple gives it to us for free, and there’s no need to stack another SaaS on top.
This is a tactical write-up of how we did it — ES256 JWT signing, the Sales Reports endpoint, gzipped TSV parsing. About 150 lines of TypeScript.
Why we skipped RevenueCat
RevenueCat is a good product. It’s just heavy for our use case:
- Free up to $10K MTR, then 1% of MTR — at 5 apps you’re paying $200-500/mo
- Its real value is entitlement management (iOS + Android + web subs tied to one user) — Horog doesn’t need that
- Apple’s own API is more than enough if you only need to read revenue numbers
- One less SaaS dependency = less availability and privacy surface area
For pure revenue tracking, calling Apple directly is the answer. If you need subscription state, receipt validation, or paywall A/B tests, RevenueCat is still the right call.
What you need from Apple
- App Store Connect →
Users and Access→Keys App Store Connect APItab → generate a new key (Access: Sales or higher)- Download the
.p8file — you can only do this once. Lose it and you regenerate the key - Note the
Issuer IDandKey ID— both go into the JWT - Grab your
Vendor NumberfromPayments and Financial Reports
JWT signing — ES256, not RS256
First gotcha. Most JWT libraries assume RS256 by default. The App Store Connect API requires ES256 (ECDSA on P-256). Any other algorithm and Apple returns a 401.
jose is the cleanest library for this and runs on both Node and Edge runtimes.
import { SignJWT, importPKCS8 } from "jose";
const ISSUER_ID = process.env.APP_STORE_ISSUER_ID!;
const KEY_ID = process.env.APP_STORE_KEY_ID!;
const P8_KEY = process.env.APP_STORE_P8_KEY!; // -----BEGIN PRIVATE KEY----- ...
export async function getAppStoreToken(): Promise<string> {
const privateKey = await importPKCS8(P8_KEY, "ES256");
return new SignJWT({})
.setProtectedHeader({ alg: "ES256", kid: KEY_ID, typ: "JWT" })
.setIssuer(ISSUER_ID)
.setIssuedAt()
.setExpirationTime("19m") // Apple max is 20 min
.setAudience("appstoreconnect-v1")
.sign(privateKey);
}Tokens max out at 20 minutes. Signing on every request burns CPU, so cache the token and refresh a minute before expiry.
Calling Sales Reports
Endpoint: GET /v1/salesReports. Five required filters — vendor number, reportType, reportSubType, frequency, reportDate.
import { gunzipSync } from "node:zlib";
const VENDOR_NUMBER = process.env.APP_STORE_VENDOR_NUMBER!;
export async function fetchDailySalesReport(reportDate: string) {
const token = await getAppStoreToken();
const url = new URL("https://api.appstoreconnect.apple.com/v1/salesReports");
url.searchParams.set("filter[vendorNumber]", VENDOR_NUMBER);
url.searchParams.set("filter[reportType]", "SALES");
url.searchParams.set("filter[reportSubType]", "SUMMARY");
url.searchParams.set("filter[frequency]", "DAILY");
url.searchParams.set("filter[reportDate]", reportDate); // YYYY-MM-DD
url.searchParams.set("filter[version]", "1_1");
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/a-gzip",
},
});
if (res.status === 404) {
// No report for this date yet — Apple publishes D+1 afternoon
return null;
}
if (!res.ok) {
throw new Error(`App Store ${res.status}: ${await res.text()}`);
}
const buf = Buffer.from(await res.arrayBuffer());
const tsv = gunzipSync(buf).toString("utf8");
return parseSalesTsv(tsv);
}Four things to know:
- Response is
application/a-gzip— you have to gunzip it.fetchwon’t decompress this MIME type automatically - Today’s date returns
404— Apple publishes daily reports D+1 in the afternoon (US Pacific) - WEEKLY, MONTHLY, YEARLY follow the same pattern
- Pin
filter[version]=1_1for column stability
Parsing the TSV
Apple ships TSV, not CSV. Tabs as separators, first line is the header, UTF-8 encoded.
interface SalesRow {
sku: string;
units: number;
developerProceeds: number;
currency: string;
countryCode: string;
}
function parseSalesTsv(tsv: string): SalesRow[] {
const [header, ...lines] = tsv.split("\n").filter(Boolean);
const cols = header.split("\t");
const idx = (name: string) => cols.indexOf(name);
const SKU = idx("SKU");
const UNITS = idx("Units");
const PROCEEDS = idx("Developer Proceeds");
const CURRENCY = idx("Currency of Proceeds");
const COUNTRY = idx("Country Code");
return lines.map((line) => {
const c = line.split("\t");
return {
sku: c[SKU],
units: Number(c[UNITS]),
developerProceeds: Number(c[PROCEEDS]),
currency: c[CURRENCY],
countryCode: c[COUNTRY],
};
});
}Look up columns by name, not by hardcoded index. Apple occasionally reorders columns — if you index by position, one day your revenue ends up in the SKU column.
7 gotchas we actually hit
- ES256, not RS256 — check your JWT library’s default
- 20-minute token cap — cache and refresh, don’t sign per request
- vendorNumber ≠ appId — found in Payments and Financial Reports
- TSV, not CSV — tab-separated, handle empty columns
- D+1 lag — today’s revenue lands tomorrow afternoon
- Sandbox rows mixed in — filter out rows where
Provideris sandbox - Multi-currency — reports come per currency. Convert to your reporting currency at end-of-day with an FX source (we use ECB)
Where to store it
Horog writes each row into a revenue_events table in Supabase — SKU, date, amount, currency. The $/hour calc converts currencies and aggregates per project. RLS keyed on user_id so nobody can ever see anyone else’s revenue.
Cost comparison
- RevenueCat at $50K MTR: about $400/mo = $4,800/year
- Apple direct: free. ~150 lines + token cache (~50) + cron (~50) = ~250 lines total
- Build time: one day. Payback: first month
When RevenueCat is the right call
- Cross-platform entitlement management (iOS + Android + web)
- Receipt validation + restore-purchase UI
- Paywall A/B tests
- Subscription state webhooks (cancel, refund, grace period)
Horog hits none of these — we just read revenue numbers, so Apple direct was right for us. If your use case is entitlements, just ship RevenueCat.