A
?
← Handbook

SOP — Read-only Meta ad account access for the portal

Proving the token cannot write (24 Aug 2026)

Alex asked for evidence, not assurance, before granting access to the remaining clients. The naive test is misleading and worth writing down.

A write probe with an empty payload proves nothing. Meta validates parameters *before* it authorises the verb, so POST /act_<id>/campaigns with no body answers "the parameter special_ad_categories is required" — which reads exactly like a door that is open and merely needs the right form filled in.

Supply every required field except one. Creation is then impossible whatever the permission answer is, and authorisation becomes the only thing left to object:

GET  /act_1323702871980094            -> {"name": "AtlasandBone"}          reads fine
POST /act_1323702871980094/campaigns  -> Unsupported post request. Object   refused
     objective, status, special_ad_categories supplied; name withheld

The same object id that reads returns "does not exist, cannot be loaded due to missing permissions" for POST. It plainly does exist — the GET above returns it — so that is the authorisation layer refusing the verb.

Confirmed on both a client account and Atlas's own. POST /<page>/feed is refused the same way.

user_tasks on an ad account is deprecated (#12) and cannot be used to check this any more.

Reachable today

AtlasandBoneact_1323702871980094 · SGD · lifetime 22,300.37
ATLAS HOMESact_4312368265716310 · SGD · lifetime 219,476.46
Damien's Ad Accountact_932113312895834 · SGD · lifetime 26,304.12 — former client, should be removed
Atlas Media Creativeact_1034927652211085 · SGD · lifetime 15,273.50
PageAtlas Media (117658437929695), tasks MANAGE_LEADS + ANALYZE
Instagram@atlasmediasg (17841462301364328), 366 followers, 33 posts

Lead form fields arrive verbatim

136 leads, one form: full_name, email, phone_number, company_name, and the custom qualifying question marketing_budget_(excl._ad_spend) — 81 answered $3,000–$5,000, 23 >$10,000, 18 $5,000–$8,000, 14 the fourth band.

That fourth band stores as $,000_-_$10,000. Checked against the Graph API for the same lead id: Meta returns the same string. The missing digit is in the form's own answer option, not in anything this code does. Custom fields are kept verbatim under imported_from.fields, so a new client's questions need no schema change.

Settled: everything read-only, verified live (24 Aug 2026)

Token on monitor-readonly, never expires:

ads_read  leads_retrieval  pages_show_list  pages_read_engagement
read_insights  instagram_basic  instagram_manage_insights

No pages_manage_ads, no ads_management, no business_management.

Surface State Proof
Ad spend & resultsworking4 accounts syncing
Page organicworking7 metrics, Atlas Media Page
Instagram organicworkingatlasmediasg — reach 44,532, profile views 835, views 115,710 (28d)
Lead contentsworking136 leads, every one with name, email and phone
Client Pages / IGwaiting on clients1 Page assigned so far

Leads without a write permission — the thing that was not obvious

Earlier notes in this file said lead contents needed pages_manage_ads and App Review. That was half right and the wrong half mattered. There are two routes and only one of them wants the write scope:

GET /<form-id>/leads   requires pages_manage_ads   refused
GET /<ad-id>/leads     ads_read + leads_retrieval  works

The lead form id is on the ad creative, which ads_read already reads, so the Page's form listing — the only call that demanded pages_manage_ads — is never needed. src/lib/meta/leads.ts.

Also corrected: App Review was not required for any of this. Client assets shared into the portfolio fall under Standard Access.

Not doing: the leadgen webhook

Real-time delivery additionally requires ads_management, the one scope that could spend a client's budget. Polling every night costs a few minutes of speed-to-lead and keeps the guarantee.

Paging

Accounts with many lead ads refuse a page as too large rather than serving fewer rows, and the threshold moves with creative size. leadAdsIn backs off 25 → 10 → 5 → 2 on that error. Before this, ATLAS HOMES — the account with the most lead ads — was skipped silently: 89 lead ads found across the four accounts instead of 237.

What still needs clients to act

Paid data flows as each client grants. Organic needs the Page assigned as well; Instagram comes through the Page. Nothing else is outstanding on Atlas's side.

Leads: counts are free, contents are not (24 Aug 2026)

Two different things get called "leads" and only one of them is already working.

Lead counts already flow on today's ads_read scope, with no new permission and no client action. Verified 30d: ATLAS HOMES returns {"action_type": "lead", "value": "279"} alongside onsite_web_lead 48 and offsite_conversion.fb_pixel_lead 48. Volume, cost per lead and trend need nothing further.

Lead contents — the name, phone and email Zapier currently delivers — need leads_retrieval, and pages_manage_ads alongside it. Neither appears in the system user permission picker, so both require App Review *and* Business Verification. Listing forms fails today with (#200) Requires pages_manage_ads permission to manage the object.

Retrieval uses a Page access token, not the system user token.

Avoid the webhook path. Real-time leadgen webhooks additionally require pages_manage_metadata and ads_management — the one scope this project has ruled out, and the only one that could spend a client's budget. Poll GET /<form-id>/leads on a short interval instead. Speed-to-lead costs a few minutes versus Zapier; the alternative costs the read-only guarantee, which is not a trade this project makes.

pages_manage_ads is still write-capable in name. It is scoped to ad objects associated with a Page, and with no ad account carrying ads_management there is no path from it to spend — but it is a genuine widening and should be a deliberate decision, not a footnote.

PDPA: lead contents are the client's customers' personal data, not the client's own business data. Storing it in the portal is a different category of responsibility from storing spend figures, and needs deciding before it is built, not after.

Organic: verified working, and which metrics actually exist (24 Aug 2026)

Scopes on monitor-readonly after regeneration:

['read_insights', 'pages_show_list', 'ads_read', 'pages_read_engagement', 'public_profile']

Page insights need a Page access token, not the system user token. Get it from the same listing call — GET /me/accounts?fields=access_token — then read insights with that.

Most page_* metrics were removed in v21.0 and return (#100) The value must be a valid insights metric. Probed one at a time against the Atlas Media Page; these are what survive:

metric 28d
page_video_views4,526
page_post_engagements1,636
page_posts_impressions_organic819
page_views_total424
page_follows99
page_daily_follows_unique3
page_total_actions0

Dead in v21.0: page_impressions, page_impressions_unique, page_impressions_organic_v2, page_fans, page_fan_adds, page_posts_impressions, page_posts_impressions_unique, page_posts_impressions_paid.

Note there is no page-level paid/organic split any more — page_posts_impressions_organic survives but its paid twin does not. Paid numbers come from the ads side, so the split has to be assembled from the two sources rather than read off one.

GET /<page-id>/posts works and returns message text and timestamps, so post-level reporting is available independently of the page-level aggregates.

Instagram is a separate problem. instagram_basic and instagram_manage_insights are not offered in the system user permission picker at all, and the Atlas Media Page returns {} for instagram_business_account. IG needs the Instagram product added to app 868685572979139 before any client grant helps.

Gotcha: a missing Page scope looks exactly like an unassigned Page

GET /me/accounts and /me/assigned_pages return {"data": []} — not an error — when the token lacks pages_show_list. An empty result therefore means *either* "no Pages assigned" *or* "this token was minted without Page scopes", and nothing in the response distinguishes them.

Verified 24 Aug 2026: Alex assigned a Page to monitor-readonly, and both endpoints still returned [] while /me/adaccounts returned four accounts. The assignment was fine; the token carried ['ads_read', 'public_profile'] and nothing else.

A system user token's scopes are frozen when it is generated. Assigning an asset afterwards does not widen an existing token — it has to be regenerated with the new scopes ticked.

Before concluding organic is unavailable, check the token's scopes:

GET /debug_token?input_token=<tok>&access_token=<tok>   ->  data.scopes

An earlier read in this project recorded "0 Pages/Instagram assigned" from an empty array. That conclusion was not supported by the evidence.

Do not create an admin system user (decided 24 Aug 2026)

Alex asked whether atlas-portal-admin could delete the business portfolio or remove clients. Checking that turned up a better answer than a safer admin token: we do not need one at all.

Meta separates two things that are easy to conflate.

what it controls ours
Role (admin / employee)*which assets* the system user can reachpromote reader to admin
Scope (ads_read, business_management, …)*what it may do* to themads_read only

An admin-role system user reaches everything shared with the portfolio without per-asset assignment. Its scopes still gate every operation. So an admin-role reader with ads_read sees all 31 clients and can change none of them.

Verified against the live token on 24 Aug 2026:

type:   SYSTEM_USER      scopes: ['ads_read', 'public_profile']
GET /me/businesses    ->  (#100) Missing Permission

It cannot name the business it belongs to. Partner removal, asset unlinking and portfolio settings all sit behind business_management, which it has never held — so those are not things we decline to do, they are things we cannot do.

Therefore: no business_management token exists anywhere in the portal. If admin-role reach ever misses a partner-shared account, assign that account by hand in Business Settings — one tick — rather than minting a credential that can remove partners in order to save the click.

Portfolio deletion additionally requires 2FA in the Business Settings UI and is not exposed to any token.

Verified state — 24 Aug 2026

Checked directly against the Graph API, not from memory.

Token: SYSTEM_USER, app 868685572979139, never expires. Scopes: ads_read, public_profile. Nothing else.

Read-only is therefore structural. Meta requires ads_management for every mutation — pausing, budget changes, creating or editing anything — and the token does not carry it. There is no write path to disable because the capability was never granted.

No write probe was run to confirm this, deliberately. A probe that unexpectedly succeeded against a live client account is exactly the mistake the rule exists to prevent, and the scope list is the stronger evidence anyway: a capability Meta never issued cannot be exercised.

Reachable ad accounts: 4. Only one is a current client.

Account id What it is
AtlasandBoneact_1323702871980094Client
ATLAS HOMESact_4312368265716310The second business, not an agency client
Damien's Ad Accountact_932113312895834Former client — disconnected 22 Aug, should be removed from partner access
Atlas Media Creativeact_1034927652211085Our own account

Pages / Instagram assigned: 0. The organic side is not partially working, it is entirely unavailable — no Page insights, no Instagram insights, no reach or engagement for any client.

Per-account tasks cannot currently be read either, because that query needs business_management, which the token also lacks.

What to grant

Atlas manages three kinds of client and they need different things. Granting the ads permission to an organic client buys nothing, and it is the most common way this gets done twice.

Once, on our side — not per client

Regenerate the system user token with these scopes added:

Scope Unlocks Needed for
pages_read_engagementPage insights: reach, engagement, followsOrganic
pages_show_listEnumerating assigned PagesOrganic
instagram_basicThe IG account behind a PageOrganic
instagram_manage_insightsIG reach, saves, profile visitsOrganic
business_managementAuto-assignment of newly granted accounts, and reading tasks to verify a grant is read-onlyBoth

business_management is worth doing first: without it every new client is a manual assignment step, and we cannot verify that a grant came through as view-only rather than manage.

These are Advanced Access permissions and go through App Review. That is a one-time submission, not a per-client one.

Per client, by type

Paid ads. The client's Business Manager → *Partners* → add Atlas's Business ID → assign the ad account with View performance only. Never *Manage campaigns*: it is not needed for anything the portal does, and it is what would make the read-only guarantee a policy rather than a permission.

Organic. The client assigns their Facebook Page to Atlas with an insights/analyst task. If Instagram matters, the IG account must be linked to that Page first — Instagram insights are reached through the Page, never directly.

Both. Both grants. They are separate assignments even when they belong to the same business.

What each type gets in the portal

Paid Organic Both
Spend, CPM, cost per resultyes—yes
Purchases / leads, ROASyes—yes
Ad-level lineage via the naming conventionyes—yes
Reach, engagement, follows, saves—yesyes

A client with neither grant shows no performance anywhere, which is the current state for 31 of 32 active accounts.

The keystone credential: it turns on performance ingest, the pipeline's spend and cost-per-opportunity, "what worked", and everything downstream of results. Read-only by construction, matching the Drive pattern.

Status — 20 Aug 2026

The system user already exists: monitor-readonly (id 61591145263443) in the Atlas Business Manager, holding a non-expiring token with ads_read, assigned four ad accounts at View performance. It was created for agency-monitor; the portal reads the same accounts.

Two things remain:

  1. The portal does not hold the token. Numbers on the pipeline today were pushed as a one-off snapshot from a laptop. Nothing refreshes until the token is on the droplet — one command, below.
  2. business_management is missing, which is what auto-assignment needs. See *Auto-add*.

Handing the portal the token

Run this yourself — it reads the token you already have and pipes it to the droplet over ssh without printing it:

sed -n 's/^META_SYSTEM_USER_TOKEN=//p' ~/projects/agency-monitor/.env | tr -d "\"'" \
  | ~/projects/atlas-portal/scripts/set-secret.sh prod META_SYSTEM_USER_TOKEN

Then the same with dev. After that POST /api/meta?action=sync pulls live, and scripts/meta-sync.sh runs it daily from cron.

Auto-add: how new accounts assign themselves

The API call behind the "View performance" tick box is:

POST /act_<ID>/assigned_users   user=61591145263443   tasks=["ANALYZE"]

and discovery is GET /<business-id>/owned_ad_accounts and /client_ad_accounts. All three need business_management.

We do not do this. See the decision at the top of this file: promoting monitor-readonly to admin *role* gives it the same reach with none of the power, so the assign call never needs to be made and no business_management token is minted. reconcileAssignments remains in the code as a fallback only, and stays unconfigured.

If admin-role reach ever misses a partner-shared account, tick "View performance" for that one account by hand in Business Settings. One tick, no standing credential.

The design

A system user in Atlas's own Business Manager, assigned every managed ad account with View performance only, holding a non-expiring token scoped to ads_read + read_insights. It cannot edit a campaign, change a budget or touch a page — the permission and the scopes are both read-only, so the guarantee is structural.

Steps (Alex — needs BM admin)

  1. developers.facebook.com → My Apps → Create app → type Business → name atlas-portal → connect it to the Atlas Media Business Manager.
  2. In the app: Add product → Marketing API.
  3. business.facebook.com → Settings → Users → System users → Add → name atlas-portal-reader, role Employee (not admin — it reads, it does not manage).
  4. Select it → Assign assets → Ad accounts → tick every client ad account → permission View performance only → Save.
  5. Generate token → choose the atlas-portal app → tick ads_read and read_insights → token expiry Never → Generate. Copy it once, like the Drive key.
  6. Hand it over: set-secret.sh META_ACCESS_TOKEN, value on stdin.

Then each client's profile gets its ad account id (act_…), and the nightly ingest writes ad_results rows with source='api' — the same table the manual entries use, so nothing downstream changes when the API takes over.

Rotation / revocation

Business Settings → System users → the token list. Revoking kills the portal's read access and nothing else. The app can stay in development mode — reading your own business's assets does not require app review at this tier.

Doing it — 24 Aug 2026

Three jobs. Only the third is per-client, and it is the client's to do.

1. Atlas side, once: the two tokens stay separate

The reader must never be able to grant itself anything. Keep them apart:

System user Scopes Used for
reader (existing)ads_read, and the Page/IG read scopes belowEverything the portal reads
atlas-portal-adminbusiness_management onlyAssignment reconciliation, nothing else

Never ads_management, on either.

At business.facebook.com/settings:

  • Users → System users → Add → atlas-portal-admin, role Admin
  • Generate token → app atlas-monitor → tick business_management → expiry Never
  • Copy the Business ID from Business info — the reconcile needs it

Then, without the value touching a terminal history:

./scripts/set-secret.sh prod META_ADMIN_TOKEN     # paste, then Ctrl-D
./scripts/set-secret.sh prod META_BUSINESS_ID

2. Atlas side, once: the organic scopes

These are Advanced Access and go through App Review, which takes days — start them before the client conversations, not after.

App dashboard → App Review → Permissions and features, request:

pages_read_engagement · pages_show_list · instagram_basic · instagram_manage_insights

Meta asks what each is for. The honest answer is the one that gets approved: an agency reporting on Pages and Instagram accounts its clients have assigned to it. Record a screen capture of the portal's reporting page — they ask for it.

3. Per client: what to send them

Paste this. It is deliberately short, and it is the whole ask.

Granting Atlas view-only access 1. Go to business.facebook.com/settings/partners 2. Add → Give a partner access to your assets 3. Partner Business ID: <ATLAS_BUSINESS_ID> 4. Select the ad account → enable View performance only. Please do not tick Manage campaigns — we do not need it and cannot use it. 5. If we also handle your organic social, repeat for the Facebook Page, choosing the insights/analyst level. Instagram is reached through the Page, so link the IG account to it first if it is not already. 6. Save. Nothing else is needed, and access can be removed from the same screen at any time.

Once they save, the account lands in client_ad_accounts on our side by itself. The nightly reconcile assigns it at ANALYZE and name-matches it to the client record. Nobody at Atlas touches Business Manager.

While you are in there

Remove Damien's Ad Account (act_932113312895834) from partner access. Former client since 22 Aug, still readable.