Guide
Apple Ads API authentication: the client secret, the access token, and the errors in between
The Apple Ads API does not take an API key. Every request carries a one-hour OAuth access token, and the only way to get one is to sign a short JWT — the client secret — with a private key you generated yourself, then trade it at appleid.apple.com.
Nothing about this flow changes when v5 shuts down: the same credentials and the same token work against the Apple Ads Platform API. Below is the whole path from an empty account to a working request, the exact claims the secret needs, and the errors that cost us time.
Setting up the credentials
- In Apple Ads, an account administrator opens Account Settings → User Management and invites a user with an API role (API Account Manager to write, API Account Read Only to report). Use a separate user for the API, not a person's login.
- Generate a P-256 key pair on your own machine with the two commands below. The private key never leaves your side; Apple only sees the public half.
- Sign in to Apple Ads as the API user, open Account Settings → API, and paste the contents of public-key.pem.
- Apple then shows three values: clientId, teamId and keyId. Store them next to the private key. These four together are the whole credential.
Key pair (run once, keep private-key.pem secret):
openssl ecparam -genkey -name prime256v1 -noout -out private-key.pem
openssl ec -in private-key.pem -pubout -out public-key.pemThe client secret
The client secret is a JWT you sign with the private key. It is checked field by field; a wrong value anywhere comes back as invalid_client.
| Field | Value |
|---|---|
header.alg | ES256 |
header.kid | keyId from the API settings |
sub | clientId |
iss | teamId |
aud | https://appleid.apple.com |
iat | Now, in seconds |
exp | Up to 180 days after iat. Signing a fresh one per token request is simpler than tracking expiry |
Then POST it to https://appleid.apple.com/auth/oauth2/token as a form with grant_type=client_credentials and scope=searchadsorg. Node 18+, no dependencies:
import { createSign, randomUUID } from "node:crypto";
const b64url = (v) => Buffer.from(v).toString("base64url");
function clientSecret({ clientId, teamId, keyId, privateKeyPem }) {
const now = Math.floor(Date.now() / 1000);
const head = b64url(JSON.stringify({ alg: "ES256", kid: keyId }));
const body = b64url(JSON.stringify({
sub: clientId,
iss: teamId,
aud: "https://appleid.apple.com",
iat: now,
exp: now + 3600,
jti: randomUUID(),
}));
const sig = createSign("sha256")
.update(`${head}.${body}`)
.sign({ key: privateKeyPem, dsaEncoding: "ieee-p1363" });
return `${head}.${body}.${b64url(sig)}`;
}
export async function accessToken(creds) {
const res = await fetch("https://appleid.apple.com/auth/oauth2/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: creds.clientId,
client_secret: clientSecret(creds),
scope: "searchadsorg",
}),
});
if (!res.ok) throw new Error(`token: ${res.status} ${await res.text()}`);
return res.json(); // { access_token, token_type, expires_in }
}Making a request
- Send Authorization: Bearer <access_token>. The token lasts expires_in seconds (one hour); request a new one rather than refreshing — there is no refresh token.
- GET /acls needs no account context and lists what the API user can reach. Use it to find the ids for the context header.
- Every other call needs X-AP-Context: orgId=… on v5, or adAccountId=… on the Platform API.
Errors that look like something else
Each of these comes back as a bare invalid_client or a parse error, with nothing pointing at the cause.
What you see: invalid_client, although every field is right
What is actually wrong: The signature is DER-encoded. ES256 in a JWT is the raw 64-byte r‖s form. Node's crypto signs DER by default — pass dsaEncoding: "ieee-p1363". Python's cryptography library also returns DER; JWT libraries convert it for you, hand-rolled code does not.
What you see: The private key will not load in production, but works locally
What is actually wrong: The PEM was stored in an environment variable or secret manager with its line breaks written as the two characters \n. Turn them back into newlines before signing.
What you see: invalid_client right after rotating the key
What is actually wrong: The keyId changes with every public key you upload. A new key pair needs the new keyId in the header, or Apple looks up the old public key.
What you see: invalid_client from one server only
What is actually wrong: That machine's clock is off and iat lands in the future or exp in the past. Sync the clock before blaming the key.
What you see: A token is issued, but every campaign call fails
What is actually wrong: The context header is missing or names an org the API user was not given. Read GET /acls with the same token and use an id from there.
How Loop Marketing handles it
Loop Marketing asks for the same four values, signs a fresh secret for every token, and stores the private key encrypted per workspace. It then reads spend and installs from your Apple Ads account and changes budgets and bids only inside limits you set.
Free trial. No credit card to begin.