✅ Verified — configured and posting for three brands.
Free to post, but the paperwork is real. Budget a day of calendar time, most of it waiting. Doing it also unlocks Instagram, which shares the same app and review.
What you get
Post text, images, video, Reels, and multi-photo posts to a Facebook Page you administer, via the Graph API, using a token that does not expire.
You cannot post to a personal profile programmatically. Pages only. This is a deliberate Meta policy, not a gap you can work around.
Prerequisites
- A Facebook Page (create one first — you need its id)
- A Facebook account with an admin role on that Page
- A business portfolio that owns the Page — see the next section
- For production access: a business that can pass Meta's Business Verification
Before the Page: the business portfolio
This is the step everybody skips, and it is the step that comes back later. Read Ownership & accounts for the full picture; the short version is here because getting it wrong costs a migration.
Meta has three layers, and only the middle one is the company:
| Layer | What it is | Who can hold it |
|---|---|---|
| Personal profile | A human's Facebook login | A person, always |
| Business portfolio | The container that owns Pages, Instagram accounts, ad accounts and apps | Administered by personal profiles |
| Asset | A Page, an Instagram account, an app | Owned by a portfolio |
There is no such thing as an account owned by a company email or a domain. "The company owns this" means, concretely: one named portfolio carrying the legal business name and address, that portfolio verified, and more than one human holding Full access to it.
Create the portfolio deliberately, before the first Page. If you don't, Meta will create one for you as a side effect the first time you link an Instagram account — unnamed, with no legal details, and named after whatever handle triggered it. That is exactly what happened here.
Put every Page in the same portfolio. Business Settings → Accounts → Pages → Add → Add an existing Page. A Page that sits in a different portfolio cannot be linked to an Instagram account from your side at all — the connect dialog replaces the button with "you need full control of the … business portfolio that the Page belongs to" and offers only to send a request to whoever does. Recovering from that means chasing a human, so check portfolio membership before you build on a Page.
Fill in the legal details early. Business Settings → Business info: legal business name, address, phone, website, primary Page. Business Verification checks these against real registration documents. An unnamed portfolio has nothing to verify, and verification is what gates Advanced Access — which is what gates Instagram publishing.
Steps
1. Create the Page
facebook.com → Pages → Create. Note its numeric id from Settings → Page transparency, or read
it later from the API. That is FACEBOOK_PAGE_ID.
Then add it to the portfolio (previous section) rather than leaving it owned by your personal profile.
2. Create a Business-type app
developers.facebook.com → My Apps → Create App.
When it asks what type of app, choose "Business". Read the trap below before clicking anything else — this choice is effectively irreversible and picking wrong wastes a day.
3. Add Pages permissions
In the app, request:
| Permission | Why |
|---|---|
pages_manage_posts | Create posts |
pages_read_engagement | Read the Page and its metrics |
pages_show_list | Enumerate which Pages you administer |
4. Get a Page access token
Use the Graph API Explorer:
- Select your app, then Get Token → Get Page Access Token
- Grant the permissions and pick your Page
- You now hold a short-lived user token — not what you want
Exchange it for a permanent Page token:
# 1. short-lived user token → long-lived user token (~60 days)
curl -s "https://graph.facebook.com/v21.0/oauth/access_token\
?grant_type=fb_exchange_token\
&client_id=<APP_ID>\
&client_secret=<APP_SECRET>\
&fb_exchange_token=<SHORT_LIVED_USER_TOKEN>"
# 2. long-lived user token → PAGE token (this one does not expire)
curl -s "https://graph.facebook.com/v21.0/me/accounts?access_token=<LONG_LIVED_USER_TOKEN>"
# → find your Page in the array; its `access_token` is the permanent oneConfirm it is genuinely permanent:
curl -s "https://graph.facebook.com/debug_token\
?input_token=<PAGE_TOKEN>&access_token=<APP_TOKEN>"
# → "expires_at": 0 ← 0 means never. Anything else means you skipped step 1.That token is FACEBOOK_PAGE_ACCESS_TOKEN.
5. Business Verification + App Review
While your app is in development mode it can only post to Pages owned by people with a role on the app. To post to a real Page in production you need Advanced Access, which requires Business Verification — submitting real business documents to Meta.
Start this early. It is the long pole, and it is waiting rather than working.
The traps
A Consumer-type app can never request Pages permissions. This is the expensive one. If you pick "Consumer" at app creation, the Pages permissions simply do not appear in the permissions list, and any OAuth attempt returns "Invalid Scopes: pages_manage_posts". Nothing in the error tells you the app type is the cause, and you cannot change the type after creation — you create a new Business app and start over. We lost a day to this.
expires_at: 0 is the only proof of a permanent token. It is easy to end up with a
60-day user token and believe you are done. Two months later, posting breaks with an
authentication error, long after you remember why. Always run debug_token.
Never log the request URL. Graph takes access_token as a query parameter, so any logging
middleware or error handler that echoes the URL writes a live Page token into your logs. Log the
error message only.
Text and photo posts are different endpoints with different field names. /{page-id}/feed
takes message; /{page-id}/photos takes url + caption. Posting a caption to /feed and an
image separately produces two posts.
Media is fetched by Meta, not uploaded. The url must be publicly reachable from Meta's
servers. localhost, signed-expiring URLs, and anything behind auth will fail. Put media on a
CDN first.
Photo posts take longer. Meta downloads the image before responding, so a timeout tuned for text posts will fire spuriously on media. We use 10s for text and 25s for photos.
Ownership debt is invisible until Instagram. Facebook posting works fine from a personal profile with a Page role and an unverified portfolio — the app posts to Pages its own admin controls, so nothing complains. Instagram gets no such grace: it needs Advanced Access → App Review → Business Verification → a real, named, registered business. So a Facebook setup that looks finished can still be a dead end for the rest of the pipeline. Fix ownership while it is cheap. See Ownership & accounts.
A single-admin portfolio is a bus factor of one. Full access includes billing and portfolio
deletion, and there is no recovery path if the only admin loses their account. Invite a second
human with Full access on day one: Business Settings → Users → People → Invite people. Note
the URL — /latest/settings/people silently redirects to the Business Suite home; the working
path is /latest/settings/business_users.
Environment variables
Per brand, with the product id uppercased:
| Variable | Example | Required |
|---|---|---|
FACEBOOK_PAGE_ID_<PRODUCT> | FACEBOOK_PAGE_ID_HOGWARTS | Yes |
FACEBOOK_PAGE_ACCESS_TOKEN_<PRODUCT> | FACEBOOK_PAGE_ACCESS_TOKEN_HOGWARTS | Yes |
FACEBOOK_PAGE_ID | unsuffixed legacy pair | Fallback for one brand only |
FACEBOOK_PAGE_ACCESS_TOKEN | " | " |
Each brand has its own Page and its own token. The unsuffixed pair is a legacy fallback for a single default brand — new brands should always use the suffixed form.
Verify
The Page name is the useful check, because it proves which Page the token actually resolves to — which is how you catch a token pasted into the wrong brand's variable:
curl -s "https://graph.facebook.com/v21.0/<PAGE_ID>?fields=name&access_token=<PAGE_TOKEN>"
# → {"name":"Your Page Name","id":"..."}Post a test:
curl -s -X POST "https://graph.facebook.com/v21.0/<PAGE_ID>/feed" \
-H 'Content-Type: application/json' \
-d '{"message":"pipeline test","access_token":"<PAGE_TOKEN>"}'In kun: the Social Hub's status dialog names the resolved Page for the selected brand. Check it before publishing — it is the only pre-publish signal that a token isn't crossed.
To check the portfolio side — that the Page is where you think it is, and what it is linked to:
https://business.facebook.com/latest/settings/pages/
?business_id=<BUSINESS_ID>
&selected_asset_id=<PAGE_ID>
&selected_asset_type=page
&detail_view_tab=CONNECTED_ASSETS
The Connected assets tab is the only trustworthy signal for whether a Page has an Instagram
account attached. Navigate straight to it with the detail_view_tab parameter — clicking the
tab in the UI frequently swallows the first click.
Limits
- Rate: generous for organic posting; Meta throttles per-app rather than per-post. Normal cadence never approaches it.
- Media: images fetched by URL; video supported; Reels have their own endpoint.
- Cost: free.
- AI disclosure: no organic API flag exists. Meta's self-disclosure duty applies to photorealistic synthetic video and realistic audio; C2PA metadata in an upload can auto-trigger an "AI Info" label, though most platforms strip manifests. Comply by policy rather than by flag.