← Back to blog
9 min readApp Store Connect · TypeScript · Indie

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

  1. App Store Connect → Users and Access Keys
  2. App Store Connect API tab → generate a new key (Access: Sales or higher)
  3. Download the .p8 file — you can only do this once. Lose it and you regenerate the key
  4. Note the Issuer ID and Key ID — both go into the JWT
  5. Grab your Vendor Number from Payments and Financial Reports
The .p8 is a secret. Store the PEM (or base64) in your env vars. Never commit it. On Vercel, paste the raw PEM including newlines into Environment Variables.

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.

typescript
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.

typescript
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. fetch won’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_1 for column stability

Parsing the TSV

Apple ships TSV, not CSV. Tabs as separators, first line is the header, UTF-8 encoded.

typescript
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

  1. ES256, not RS256 — check your JWT library’s default
  2. 20-minute token cap — cache and refresh, don’t sign per request
  3. vendorNumber ≠ appId — found in Payments and Financial Reports
  4. TSV, not CSV — tab-separated, handle empty columns
  5. D+1 lag — today’s revenue lands tomorrow afternoon
  6. Sandbox rows mixed in — filter out rows where Provider is sandbox
  7. 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.

Horog pulls revenue and time from 8 integrations and shows you the real $/hour per side project. Direct App Store Connect sync, AI weekly insights, Korean-first UX. Beta runs 2026-06 to 2026-09, first 100 makers get Pro free for 3 months — join the beta.
Pulling App Store Connect sales reports without RevenueCat — Horog · Horog