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.
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.
- Issuer ID: a UUID, one per team, printed at the top of Users and Access → Integrations → App Store Connect API. Lost? Here is where it hides.
- Key ID: a 10-character code shown in the key's row. It is also baked into the download filename, AuthKey_<KEYID>.p8.
- .p8 file: offered once, at the moment the key is generated. The key creation walkthrough covers roles and that one-time download.
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.
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
- 401 NOT_AUTHORIZED: the token, always the token. Check in this order: exp more than 20 minutes after iat, your system clock drifting, aud not exactly appstoreconnect-v1, kid naming a key that did not sign the .p8 you loaded, or a token reused past its expiry.
- 403 FORBIDDEN: the token verified, the key's role does not reach that endpoint. Generate a second key with a broader role for that one job instead of upgrading the first.
- 429: you crossed the per-key hourly rate limit. Back off, then slow the loop. Apple does not publish the ceiling, so treat the first 429 as the number.
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
- Mint a fresh token per run, cache it for the lifetime you set, and never try to refresh it. There is no refresh; there is only signing again.
- Keep the .p8 out of git. Load it from a path in an environment variable or a secrets manager, and add *.p8 to .gitignore before the first commit, not after.
- Leaked it anyway? Revoke the key in App Store Connect and generate a new one. The Issuer ID stays the same; only the Key ID and .p8 change.
- Log the status code and the errors[].detail field from the JSON body. Apple's error text is short, but it does say which claim it disliked.
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 FunnelHoundData 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.