Facebook

Verified setup — a Business-type Meta app, Pages permissions, and a permanent Page token. Includes the Consumer-app trap that costs people a full day.

Facebook

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:

LayerWhat it isWho can hold it
Personal profileA human's Facebook loginA person, always
Business portfolioThe container that owns Pages, Instagram accounts, ad accounts and appsAdministered by personal profiles
AssetA Page, an Instagram account, an appOwned 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.comMy AppsCreate 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:

PermissionWhy
pages_manage_postsCreate posts
pages_read_engagementRead the Page and its metrics
pages_show_listEnumerate which Pages you administer

4. Get a Page access token

Use the Graph API Explorer:

  1. Select your app, then Get Token → Get Page Access Token
  2. Grant the permissions and pick your Page
  3. 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 one

Confirm 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:

VariableExampleRequired
FACEBOOK_PAGE_ID_<PRODUCT>FACEBOOK_PAGE_ID_HOGWARTSYes
FACEBOOK_PAGE_ACCESS_TOKEN_<PRODUCT>FACEBOOK_PAGE_ACCESS_TOKEN_HOGWARTSYes
FACEBOOK_PAGE_IDunsuffixed legacy pairFallback 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.