FunnelHound / Guides / API with Python

App Store Connect API with Python: auth + first request

Short version: read the .p8 file, sign a JWT with ES256 (kid = Key ID in the header, iss = Issuer ID, aud = appstoreconnect-v1, exp under 20 minutes), and send it as a Bearer header to api.appstoreconnect.apple.com. Three pip packages, about 25 lines, no SDK. The full script, the sales report call and the order to debug a 401 in are all below.

.p8 private key signs the token Key ID header: kid Issuer ID payload: iss JWT (ES256) aud appstoreconnect-v1 valid 20 min max api.appstoreconnect .apple.com Authorization: Bearer
Three credentials become one short-lived token. Apple checks the signature against the key named in kid.

What you need before line one

The API has no username and no password. It has a key pair: Apple keeps the public half, you hold the private half as a .p8 file, and two identifiers tell Apple which key signed the request.

Give the key the smallest role that covers reports. A read-only key cannot delete a build by accident; a script holding an Admin key can. If any of the three pieces is missing, stop here, because nothing below will sign.

The token: ES256, twenty minutes, one audience

Every call carries a JSON Web Token you mint yourself. The header names the key: {"alg":"ES256","kid":"<KEY_ID>","typ":"JWT"}. The payload names the team and the lifetime: iss is the Issuer ID, iat is now, exp is at most 1200 seconds later, and aud is the literal string appstoreconnect-v1. An optional scope claim, a list such as ["GET /v1/apps"], narrows what this particular token may touch.

Two footnotes. Individual (personal) keys have no Issuer ID at all; their payload carries "sub":"user" in place of iss. And Apple validates the signature against the public half of whatever key kid names, so a Key ID paired with the wrong .p8 fails exactly like a wrong password, with a 401 and no further commentary. Leave a few minutes of headroom under the 20 minute ceiling: the clock being checked is Apple's, not yours.

The complete example: list your apps

One install line, then a script you can run as-is after filling in the three constants.

pip install pyjwt cryptography requests
import time
import jwt
import requests

KEY_ID = "2X9R4HXF34"
ISSUER_ID = "57246542-96fe-1a63-e053-0824d011072a"
PRIVATE_KEY = open("AuthKey_2X9R4HXF34.p8").read()

def make_token():
    now = int(time.time())
    payload = {
        "iss": ISSUER_ID,
        "iat": now,
        "exp": now + 900,          # 15 min: headroom under Apple's 20 min cap
        "aud": "appstoreconnect-v1",
    }
    return jwt.encode(payload, PRIVATE_KEY, algorithm="ES256",
                      headers={"kid": KEY_ID})

headers = {"Authorization": f"Bearer {make_token()}"}
r = requests.get("https://api.appstoreconnect.apple.com/v1/apps", headers=headers)
r.raise_for_status()

for app in r.json()["data"]:
    a = app["attributes"]
    print(app["id"], a["name"], a["bundleId"])

The response is JSON:API, so everything interesting sits under data. Each data[].id is the numeric Apple ID of the app, the same number that appears in its App Store URL, and attributes.name and attributes.bundleId are what you would expect. Run it and you get one line per app on the team. That number is the handle every other endpoint wants, so keep it.

GET /v1/apps Authorization: Bearer <token> 200 OK data[] arrives parse, done 401 token rejected exp, aud, kid, clock 403 token fine key role too low 429 hourly limit hit wait, then retry
Same request, four answers. Only the 401 column is about your token; the other two "no"s are about the key and the clock.

Pulling a daily sales report

Units and proceeds live behind /v1/salesReports. The endpoint wants five filters and a specific Accept header, and it answers with a gzip-compressed TSV rather than JSON. The Vendor Number is the one value not on the API page: find it under Payments and Financial Reports.

import gzip, io, csv

params = {
    "filter[frequency]": "DAILY",
    "filter[reportDate]": "2026-09-30",
    "filter[reportSubType]": "SUMMARY",
    "filter[reportType]": "SALES",
    "filter[vendorNumber]": "88123456",
}
r = requests.get("https://api.appstoreconnect.apple.com/v1/salesReports",
                 headers={**headers, "Accept": "application/a-gzip"}, params=params)
r.raise_for_status()
tsv = gzip.decompress(r.content).decode("utf-8")
rows = list(csv.DictReader(io.StringIO(tsv), delimiter="\t"))
print(len(rows), "rows,", rows[0]["Title"], rows[0]["Units"])

Each row is one SKU in one country for one product type, with Units, Developer Proceeds and Country Code among the columns. A date with nothing to report comes back as an error, not as an empty file, so wrap the call if you loop over dates. Summing Units across rows gives you yesterday's downloads in about twelve lines.

Impressions, installs and deletions: the analytics side

Sales reports stop at the purchase. The funnel in front of it, impressions, product page views, installs and deletions, comes from the Analytics Reports API, which works in two beats: you POST /v1/analyticsReportRequests for an app (accessType ONGOING for a daily feed, ONE_TIME_SNAPSHOT for history), and later you list the reports, their instances and their segments, each of which carries a download URL for a gzip CSV. The first ONGOING request takes 24 to 48 hours before any data exists, so plan the cron for the day after. The full walk through those three list calls is in how to export App Store Connect analytics. And if you would rather not babysit a script, FunnelHound does the same ES256 signing on-device and shows the funnel on your iPhone.

When Apple says no: 401, 403, 429

Nine out of ten "the API is broken" messages are a 401 with a stale token. Print the status code before you print the body.

Habits that keep the script boring

Do those four and the script runs unattended for months. Skip the first one and it runs for exactly twenty minutes.

Prefer the numbers without the cron job?

FunnelHound signs the same requests on your iPhone, with the key held in Keychain, and draws impressions, installs, purchases and deletions as one funnel. Nothing to host, nothing to schedule.

Get FunnelHound

Share this on X

Data notes: JWT claims, the 20 minute token lifetime, the appstoreconnect-v1 audience, the sub: user form for individual keys and the salesReports filters are verified against Apple's App Store Connect API documentation as of 2026. The Key ID, Issuer ID and Vendor Number in the examples are documentation samples, not live credentials. Rate limits are enforced per key per hour and Apple does not publish the exact figure, so a 429 is a signal to slow down, not a bug report.