@twentylabs/asa
v0.1.0
Published
Apple Ads from the terminal: read-only report pulls and approved change files, on top of the asc CLI.
Maintainers
Readme
asa
Apple Ads from the terminal, on top of asc (Rork's App Store Connect CLI). No runtime dependencies.
It does three jobs:
- Profiles. Each Apple Ads ad account + promoted app is one profile, and any project can use any of them.
asa pull. Fetches the campaign, keyword and search-term reports for a date range into CSV files. Always read-only.asa apply. Changes the account from a JSON change file ofasc ads …commands.- By default it prints the exact
asccommands and payloads, and the current state from the file'sverifyreads. Nothing is written. --yessends the writes, reads the state again, and saves before and after as evidence.
- By default it prints the exact
asa never talks to Apple itself. Every call goes through asc, with telemetry off and, outside apply --yes, asc's read-only mode on.
Install
Requires Node 20 or later and asc 5.8 or later (below 6.0).
brew install asc
npm i -g @twentylabs/asa
asa versionApple Ads API credentials (once per ad account)
Apple Ads credentials are separate from App Store Connect API keys: an AuthKey_*.p8 does not work here.
Create an EC P-256 key pair, outside any git repo:
mkdir -p ~/.asc && umask 077 openssl ecparam -genkey -name prime256v1 -noout -out ~/.asc/apple-ads-private-key.pem openssl ec -in ~/.asc/apple-ads-private-key.pem -pubout -out ~/.asc/apple-ads-public-key.pemInvite an API user. In Apple Ads → Account Settings → User Management, invite a new user with an API role:
| Role | Use it when | | --- | --- | | API Account Read Only | You only pull reports and run
doctor. Prefer this. Apple refuses every write, whatever runs with the key. | | API Campaign Manager | You needasa apply --yes. The key can then write, andasa's read-only mode is the only thing keepingpull,plananddoctorfrom writing. Do not use this key withasc adsby hand. |Upload the public key. Sign in as that API user → Account Settings → API, and paste the public key. Apple shows the client ID, team ID and key ID.
- Uploading a new public key replaces the old one; the old key returns
invalid_clientstraight away.
- Uploading a new public key replaces the old one; the old key returns
Find the ad account ID, if you do not know it: export the four
ASC_ADS_*values below, then runasc ads auth discover.
Profiles
asa auth login --profile acme \
--client-id SEARCHADS.xxxx --key-id xxxx \
--private-key ~/.asc/apple-ads-private-key.pem \
--ad-account 1234567 --app 1234567890
asa doctor--team-iddefaults to the client ID, which is what Apple issues in practice.--appis the App Store ID (adamId) of the promoted app. There is no default app or account.--time-zone ORTZ|UTCsets the report time zone. The default,ORTZ, is the ad account's own, so days match the Apple Ads UI.auth loginchecks the fields and that the key file exists. It does not call Apple;asa doctordoes.
Profiles live in ~/.config/asa/config.json (owner-only; ASA_CONFIG or XDG_CONFIG_HOME move it). The file stores the key's path, never the key.
asa profiles # list (* = default)
asa profiles use acme # set the default
asa auth logout --profile acmeChoose a profile with --profile NAME, then ASA_PROFILE, then the default. Any field can also come from the environment or a .env file, so CI can run without a config file. Later layers win: profile < environment < --env-file < flags.
| Field | Environment / .env | Flag |
| --- | --- | --- |
| client ID | ASC_ADS_CLIENT_ID | |
| team ID | ASC_ADS_TEAM_ID | |
| key ID | ASC_ADS_KEY_ID | |
| private key path | ASC_ADS_PRIVATE_KEY_PATH | |
| ad account ID | ASC_ADS_AD_ACCOUNT_ID | --ad-account |
| app ID | ASA_APP_ID (or ADAM_ID) | --app |
| report time zone | ASA_TIME_ZONE | --time-zone |
The credential names are asc's own, so an existing asc .env works with --env-file FILE (or ASA_ENV_FILE). asa passes the resolved credentials to asc and bypasses asc's keychain profiles, so no other stored key can be picked up by accident.
Read
asa doctor # asc version, credentials, role, the app's campaigns
asa plan # range, output files, request bodies — no API calls
asa pull # the last complete Monday–Sunday week
asa pull --end 2026-09-27 # choose the last day
asa pull --days 14 # longer range (1–90 days)
asa pull --out-dir /tmp/x # write elsewhere (or ASA_DATA_DIR); default ./data
asa pull --force # overwrite existing filesdoctorchecks thatascis installed and inside the supported range, validates the credentials, prints the API user's role on the ad account, and lists the campaigns promoting the app.- The default end date is the last complete Sunday: run on a Sunday, it takes the Sunday before, because the running week is not finished.
Output files
<out-dir>/YYYY-MM-DD-campaigns.csv, -keywords.csv and -search-terms.csv, named after the last day of the range. One row per campaign × country, keyword × country or search term × country, highest spend first:
| Column | Meaning |
| --- | --- |
| from, to | the range, inclusive |
| campaignId, campaignName, adGroupId, keywordId, keyword, matchType, keywordStatus, bid, searchTermText, countryOrRegion, … | metadata, with the API's own field names; nested fields joined with dots |
| impressions, taps, tapInstalls, totalInstalls, localSpend.amount, localSpend.currency | totals for the range |
| ttr, cpt, cpi | taps/impressions, spend/taps, spend/totalInstalls; empty when the denominator is 0 |
The columns are not the Apple Ads UI export's. Do not mix the two in one table without renaming.
How the Platform API behaves (learned against a live account), and what pull does about it:
- The campaign report rejects a
campaignIdfilter, so it runs once for the whole ad account.pullkeeps only the rows of the app's campaigns. - The keyword and search-term reports require a
campaignIdfilter, so they run once per campaign. - The keyword report carries only the keyword's ID.
pulladds the text, match type, ad group, bid and status fromasc ads targeting-keywords find. - A
nullsearch term is the UI's (Low volume terms) row, and is written with that label. - A campaign with only Search Match has no keywords, so an empty keyword report is correct.
Change
A change file is the exact list of asc commands to send, plus the context a reviewer needs. It is reviewed, approved, applied, and committed together with its result.
{
"asa": ">=0.1.0",
"adAccount": "1234567",
"title": "Pause the Generic campaign",
"reason": "CPA 3x the account average for two weeks running",
"steps": [
{ "args": ["ads", "campaigns", "pause", "--campaign", "1000000002"], "confirm": false }
],
"verify": [
{ "args": ["ads", "campaigns", "find"],
"body": { "filters": [{ "field": "id", "operator": "EQUALS", "value": 1000000002 }] } }
]
}argsis theasccommand withoutasc, and always starts withads.asc ads <command> --helpshows each command and its payload schema.bodyis the JSON payload;asapipes it toascas--file -.asaadds--ad-account,--output jsonand, on writes,--confirm. A file that sets them itself is refused."confirm": falsedrops--confirmfor a command that has no such flag or does not need it (for examplecampaigns pause).verifyruns read-only, before and after. Always include at least one read of the thing being changed.adAccount(optional) must match the active profile, so a change written for one account cannot run against another.asa(optional) is the version range ofasathe file needs; an olderasarefuses the file instead of misreading it.schemaVersion(default1) guards the format itself.
Common commands: campaigns update / pause / resume, ad-groups update, targeting-keywords create-bulk / update-bulk, negative-keywords create-bulk / update-bulk. A template is in examples/.
Workflow
- Write
YYYY-MM-DD-<what>.json. - Dry run:
asa apply 2026-10-02-pause.json. It prints eachasccommand exactly as it will run, the payloads, the file's SHA-256, and the current state fromverify. Nothing is written. - Approve that exact file. Approving the idea is not approving the payload; the SHA-256 identifies what was reviewed.
- Apply:
asa apply 2026-10-02-pause.json --yes. - Check the state after in the output and in the result file; do not trust a ✓ alone. Commit the change file and its result together.
Result files
--yes writes <name>.result.json next to the change file:
{ "change": "2026-10-02-pause.json", "sha256": "…", "adAccount": "1234567",
"appliedAt": "2026-10-02T09:15:00.000Z",
"before": [{ "args": [...], "response": {...} }],
"steps": [{ "args": [...], "response": {...} }],
"after": [{ "args": [...], "response": {...} }] }If a step fails, apply stops, writes the partial result with an error field, and exits. Read it before running again: re-applying a file that has a result needs --force.
Safety model
| Rule | Why |
| --- | --- |
| pull, plan and doctor run every asc call with ASC_READ_ONLY=1, set in code and not overridable | asc refuses every write before it leaves the machine (exit 6); find and report queries, which are read-only POSTs, still run. |
| plan makes no asc calls at all | Safe to run anywhere, without credentials. |
| Only the steps of apply --yes run without read-only mode; its verify reads keep it | One write path, for one reviewed file. |
| --read-only or ASA_READ_ONLY=1 refuses apply --yes before any asc call | Work on an account with no risk of changing it. Dry runs still work. |
| ASC_TELEMETRY_DISABLED=1 on every call | asc enables telemetry by default. |
| asc's keychain profiles are bypassed (ASC_ADS_BYPASS_KEYCHAIN=1) | Only the credentials asa resolved can be used. |
| Change files may only run ads … commands, never ads auth, ads api (raw requests, which bypass every per-command guard) or ads v5 | Keeps a change file to campaign-management commands asc validates. |
| delete steps are flagged in the dry run | Apple Ads has no undo. |
| A change with a result file is refused without --force | Prevents applying the same change twice. |
| asc must be inside the supported range (>=5.8.0 <6.0.0, declared in lib/asc.js) | A new major asc may change commands or flags. |
The strongest guard is still the role: with an API Account Read Only user, Apple itself refuses writes.
Exit codes
| Code | Meaning |
| --- | --- |
| 0 | OK |
| 1 | Usage error or refusal |
| 2 | asc failed (API error, bad credentials, unexpected output) |
| 6 | Refused by read-only mode |
Development
npm test # node --test; never calls AppleThe tests run the real CLI against a fake asc (test/fixtures/fake-asc), selected with ASA_ASC_BIN. To support a new asc release, test it, then widen SUPPORTED_ASC in lib/asc.js.
Releases: bump package.json, add the CHANGELOG entry, merge, then push a vX.Y.Z tag. The release workflow runs the tests and publishes to npm with trusted publishing (OIDC, no token).
License
MIT. asc is a separate project under its own MIT license; asa calls it as a program and includes none of its code.
