App Store Connect API for Revenue Data — From JWT ES256 to Vercel Cron
A hands-on Next.js tutorial for pulling iOS app revenue data via App Store Connect API. Covers JWT ES256 auth, Sales Reports API, gzip parsing, database ingestion, Vercel Cron, and the after() pattern for timeout workarounds.
On this page (11)
April 2026 · Dev Tutorial
Checking iOS app revenue means logging into App Store Connect every day. Fine with 2–3 apps; unmanageable at 10+. Switching between dashboards and copying numbers by hand isn't realistic. So you automate with the App Store Connect API.
This is a hands-on Next.js tutorial for pulling revenue data via App Store Connect API. JWT ES256 auth, Sales Reports API calls, gzip response handling, database ingestion, daily automation via Vercel Cron, solving the 10-second timeout — in order, with error logs and fixes for every choke point.
Bottom line: the build itself takes half a day. Three things usually trip people up — key file format, JWT signing, and Cron timeouts. Clear those and the rest is normal HTTP. If you don't want to deal with them, Apsity does the same work as a managed tool. That tradeoff is covered at the end.
- Generate ASC API key (.p8 file + Key ID + Issuer ID)
- Issue JWT ES256 tokens with 20-minute expiry (RS256/HS256 rejected)
- Call Sales Reports v1/salesReports — response is gzipped TSV
- Daily reports generate ~9 AM PT next day; plan for 24h+ delay
- Vercel Hobby default 10s, extendable to 60s via maxDuration → use after() for background within that window
- Ingest into Supabase, query from dashboard
- Managed alternatives exist — Apsity, Appfigures, Sensor Tower
1. Why Use the API
App Store Connect's web UI already shows revenue, installs, and retention. Why use the API? Three reasons. First, automation — opening 10 screens daily wastes time. Second, integration — consolidating multiple apps into one view requires the API. Third, customization — custom metrics like per-app ARPU or per-region conversion can't come from the web UI.
Web UI works fine at 1–2 apps. Gets painful at 3+ and impossible past 10. As an indie developer scaling your app portfolio, automation becomes the difference between "time to build more apps" and "time lost to ops." API automation is an investment in future build time.
App Store Connect API has been official since 2018. It exposes Sales Reports, Finance Reports, Analytics Reports, TestFlight, and more — 10+ resources. This post focuses on Sales Reports, the most-used one.
2. Generating the API Key — .p8 / Key ID / Issuer ID
Three pieces of info before calling the API:
- .p8 file — ECDSA private key (single download)
- Key ID — 10-char alphanumeric
- Issuer ID — UUID format, your account identifier
Path: App Store Connect → Users and Access → Integrations → App Store Connect API → Team Keys. Hit "Generate API Key" for a new one. Keep permissions minimal — if only Sales Reports are needed, give it the "Finance" role.
Apple doesn't let you re-download for security. If you lose it, revoke the key and generate a new one. Store the file in a secret manager like 1Password. For Vercel env vars, don't paste raw — base64 encode it for safer handling.
Drop the three values into .env.local:
ASC_KEY_ID=ABC1234567
ASC_ISSUER_ID=57246542-96fe-1a63-e053-0824d011072a
ASC_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
# Actual newlines replaced with \n in the env var
The key move is flattening the private key to a single line with \n escapes, then un-escaping it in code.
3. JWT ES256 Token Generation
Every ASC API request needs a JWT Bearer token. Apple accepts ES256 only (ECDSA using P-256 and SHA-256). RS256 and HS256 are rejected. In Node.js, jose is the cleanest choice.
import { SignJWT, importPKCS8 } from 'jose';
export async function generateToken() {
const keyText = process.env.ASC_PRIVATE_KEY!.replace(/\\n/g, '\n');
const privateKey = await importPKCS8(keyText, 'ES256');
const jwt = await new SignJWT({})
.setProtectedHeader({ alg: 'ES256', kid: process.env.ASC_KEY_ID!, typ: 'JWT' })
.setIssuedAt()
.setExpirationTime('20m')
.setIssuer(process.env.ASC_ISSUER_ID!)
.setAudience('appstoreconnect-v1')
.sign(privateKey);
return jwt;
}
Three critical points. Max expiry is 20 minutes (Apple's limit). aud must be the literal string appstoreconnect-v1. kid (header) is Key ID; iss (payload) is Issuer ID — easy to swap by accident.
Don't regenerate tokens per request. Cache for 20 minutes in memory or Redis. Next.js unstable_cache works.
4. Calling the Sales Reports API
With a JWT ready, hit the Sales Reports endpoint. Daily reports come back as a gzip-compressed TSV file.
export async function fetchDailySales(date: string) {
const token = await generateToken();
const url = 'https://api.appstoreconnect.apple.com/v1/salesReports';
const params = new URLSearchParams({
'filter[frequency]': 'DAILY',
'filter[reportSubType]': 'SUMMARY',
'filter[reportType]': 'SALES',
'filter[vendorNumber]': process.env.ASC_VENDOR_NUMBER!,
'filter[reportDate]': date,
});
const res = await fetch(`${url}?${params}`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok) throw new Error(`ASC API ${res.status}`);
return await res.arrayBuffer(); // gzip buffer
}
Response is gzipped TSV. Write the bytes to .tsv.gz and you can gunzip to verify. vendorNumber lives in App Store Connect → Payments and Financial Reports. One per account.
Daily reports generate around 9 AM PT. Give it at least 24 hours before requesting yesterday's data. Requesting a date whose report hasn't been generated yet returns 404.
5. Decompressing gzip and Parsing TSV
Use Node.js built-in zlib to decompress and tab-split to parse.
import { gunzipSync } from 'node:zlib';
export function parseSalesTsv(buffer: ArrayBuffer) {
const tsv = gunzipSync(Buffer.from(buffer)).toString('utf-8');
const [header, ...rows] = tsv.trim().split('\n').map(r => r.split('\t'));
return rows.map(row => Object.fromEntries(header.map((h, i) => [h, row[i]])));
}
The TSV header has 30+ columns, named in Apple-ese. The most-used ones:
| Column | Meaning |
|---|---|
| SKU | Developer-set app SKU |
| Apple Identifier | App ID (numeric) |
| Units | Downloads or purchases |
| Customer Price | User-paid price (local currency) |
| Developer Proceeds | Your actual payout (after Apple cut) |
| Country Code | ISO 2-letter country |
| Product Type Identifier | Product type (1F=app, 1I=IAP) |
Developer Proceeds is what actually reaches your account — Apple's 15–30% fee excluded. Confusing it with Customer Price inflates your apparent revenue.
6. Storing in Supabase
Parsed data goes into Supabase. Daily accumulation calls for a normalized schema.
CREATE TABLE daily_sales (
id bigserial primary key,
report_date date not null,
apple_id text not null,
country_code text not null,
product_type text not null,
units integer not null default 0,
proceeds_usd numeric(10,2),
created_at timestamptz default now(),
unique(report_date, apple_id, country_code, product_type)
);
CREATE INDEX idx_daily_sales_app ON daily_sales(apple_id, report_date);
The unique constraint gives you idempotency — re-importing the same report won't duplicate rows. Makes retry logic trivial.
7. Running Daily via Vercel Cron
Register the Cron in vercel.json. Runs at 3 AM KST daily to collect yesterday's data.
{
"crons": [{
"path": "/api/cron/sales",
"schedule": "0 18 * * *" // 18:00 UTC = 03:00 KST
}]
}
// app/api/cron/sales/route.ts
export async function GET(req: Request) {
const auth = req.headers.get('authorization');
if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response('unauthorized', { status: 401 });
}
const yesterday = new Date(Date.now() - 864e5).toISOString().slice(0, 10);
const buffer = await fetchDailySales(yesterday);
const rows = parseSalesTsv(buffer);
await supabase.from('daily_sales').upsert(rows);
return Response.json({ ok: true, rows: rows.length });
}
Vercel auto-generates CRON_SECRET. Keeps external callers from directly hitting /api/cron/sales.
8. Solving the 10-Second Timeout — after()
Vercel Hobby Functions/Cron default to 10s. Add export const maxDuration = 60 in the route and you get up to 60s (Hobby max). If 60s still isn't enough, use after() or upgrade to Pro (default 15s, max 300s).
after() returns the response first and finishes heavy work in the background. Stable in Next.js 15.1+. It still runs within the route's maxDuration — on Hobby with maxDuration=60, the after() work must also complete in 60s.
export async function GET(req: Request) {
// respond fast (within 10s)
after(async () => {
const buffer = await fetchDailySales(yesterday);
const rows = parseSalesTsv(buffer);
await supabase.from('daily_sales').upsert(rows);
});
return Response.json({ ok: true, scheduled: true });
}
The key idea is separating "what the user sees" from "what finishes in the background." Like a convenience store printing the receipt after you leave — customer goes fast, work finishes afterward. User-visible response takes milliseconds; actual data ingestion runs within the remaining maxDuration budget.
If the maxDuration itself is the bottleneck, upgrade to Pro. Hobby caps at 60s, Pro caps at 300s (5 minutes). Apps-heavy or multi-country analytics typically need Pro for stable runs.
after() work cannot exceed the route's maxDuration. Hobby max is 60s, Pro max is 300s. At high app counts with dense country/product rows, 60s gets tight. Split with a job queue (Inngest, Vercel Queues) or upgrade to Pro for 300s.
9. Build vs Buy
Follow the tutorial and your dashboard runs. Maintenance is the real story. Apple API spec changes, new vendor numbers, per-country exchange rates, in-app subscription rollovers — requirements keep growing. A half-day project becomes a microservice.
| Situation | Pick | Reason |
|---|---|---|
| Learning / portfolio | Build it | JWT + ASC API is valuable learning |
| Lots of custom metrics | Build it | SQL freedom on raw data |
| Indie, tight on time | Apsity Free | Zero setup, instant use |
| Team project / tax reporting | Appfigures | CSV + brand filtering |
| AI auto-recommendations | Apsity Pro | Claude-based keyword suggestions |
Building your own gets you data ownership and customization freedom. The cost is time — half-day build + 1–2 hours monthly maintenance realistically. Under 5 apps: build it. Past 10 apps and short on time: managed tools have better ROI. For tool selection, see the three-tool comparison and indie-tier three-tool comparison.
FAQ
Q. Where do I generate an App Store Connect API key?
App Store Connect web → Users and Access → Integrations → Team Keys. Download the .p8 file and note Key ID and Issuer ID. .p8 is single-download — store safely. Keep permissions minimal — "Finance" role suffices for Sales Reports.
Q. Why does the JWT have to use ES256?
Apple only accepts ES256 (ECDSA using P-256 and SHA-256). RS256 and HS256 are rejected. Use jose or jsonwebtoken in Node.js — jose pairs nicely with modern Node APIs.
Q. How often do Sales Reports update?
Daily reports generate around 9 AM PT next day. Weekly/monthly take several days after period end. Not real-time — build with a 24h+ delay in mind. Requesting a not-yet-generated date returns 404.
Q. How do I handle Vercel Cron timeouts?
Hobby functions default to 10s but can extend to 60s via export const maxDuration = 60. Cron inherits the same limit. If 60s still isn't enough, use after() to ship the response first and run background work inside the maxDuration window, or upgrade to Pro (default 15s, up to 300s).
Q. Should I build this or use a ready tool?
Apsity, Appfigures, and similar tools do this exact work for you. Indies with limited time should lean toward managed. Build it yourself if you need custom metrics, data ownership, or the learning experience.
Closing
The core of ASC API automation is three pieces: JWT ES256 tokens, Sales Reports gzip parsing, and managing Vercel timeouts (maxDuration + after()). Clear those and the rest is standard HTTP calls + DB ingestion. A half-day gives you a working dashboard.
But half a day is only the start. Currency handling, IAP subscription rollovers, multi-country tax calculations, API spec changes — the backlog grows. At scale, self-maintenance can outcost a managed subscription. That's when moving to Apsity or Appfigures makes sense. Having built it once, you'll pick a managed tool more intelligently — you'll know exactly what it handles and what it doesn't.
· App Store Connect API Reference
· Downloading Sales and Trends Reports
· jose — JWT library for Node.js
· Next.js after() API Reference
Tutorial based on App Store Connect API v1, Next.js 16, and Vercel official docs as of April 2026. API specs can change — verify latest from Apple's official documentation.
Code examples compact error handling and logging for brevity. Add retries and monitoring in production.
Related Posts
Vercel vs Netlify vs Cloudflare Pages — 2026 Deployment Platforms
Comparing Vercel Fluid Compute, Netlify credit-based billing, and Cloudflare Pages unlimited bandwidth on pricing, free tiers, Next.js compatibility, and edge performance. Numbers from April 2026 official pricing.
I Got Tired of Checking 12 App Revenue, So I Built My Own Dashboard
I Got Tired of Checking 12 App Revenue, So I Built My Own Dashboard
Drizzle ORM × Next.js Serverless Guide — Practical Prisma Replacement
Drizzle ORM bundle ~50KB, Edge runtime support out of the box, Neon/Supabase/Turso integration. A hands-on guide to replacing Prisma in Next.js App Router + serverless environments as of April 2026.