Facebook Page (facebook)
Facebook Page publishing uses the Meta Graph API with a Facebook Login for
Business token. Rustiz stores the user’s long-lived token at connect time, then
the is_between_steps setup step exchanges it for a Page access token for
the chosen Page. All publishing happens as the Page, never as the personal
profile.
- Rust source:
crates/rustiz-providers/src/facebook.rs - Upstream port:
facebook.provider.ts - Editor: Normal
- Connection mode:
oauth(Facebook Login for Business) - Related providers:
instagramandinstagram-standaloneshare the same Meta app family, but onlyinstagramreusesFACEBOOK_APP_ID/FACEBOOK_APP_SECRET
Overview
| Property | Value |
|---|---|
| Identifier | facebook |
| Display name | Facebook Page |
| Connection mode | oauth (Facebook Login for Business) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; extra entries are posted as POST /{post-id}/comments |
| Pending / async publishing | Yes for stories (supports_pending = true); ordinary feed posts are published synchronously inside post_pending() and return PostStatus::Success |
| Analytics | Yes. supports_analytics = true; Page Insights |
| Post analytics | Yes. supports_post_analytics = true; feed post insights or video insights |
| Mentions | No. supports_mentions = false |
is_between_steps | true — the OAuth callback creates an unfinished channel; a Page must be selected with channelSetupTool |
convert_to_jpeg | false |
max_concurrent_job | 500 (the code comment: “Facebook has reasonable rate limits”) |
Character limit (max_length) | 63,206 characters |
| Graph API version | v25.0 (META_GRAPH_API_VERSION) |
| API base | https://graph.facebook.com (FACEBOOK_BASE_URL) |
Server Environment Variables
required_env() returns exactly:
#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
&["FACEBOOK_APP_ID", "FACEBOOK_APP_SECRET"]
}
}
required_env_alternatives() is not overridden.
| Variable | Required | Default | Purpose |
|---|---|---|---|
FACEBOOK_APP_ID | Yes | — | Meta app ID shown at the top of the App Dashboard. Used as client_id in the login dialog and token exchange. |
FACEBOOK_APP_SECRET | Yes | — | Meta app secret. Used server-side for code → token and short-lived → long-lived token exchange. |
FACEBOOK_BASE_URL | No | https://graph.facebook.com | Graph API origin. Config::base_url("FACEBOOK", …). |
FACEBOOK_WEB_BASE_URL | No | https://www.facebook.com | Origin used for the /v25.0/dialog/oauth login URL. |
FRONTEND_URL | Yes in practice | http://localhost:5000 | Base of the OAuth redirect URI. Trailing slash stripped. |
JWT_SECRET | Yes in practice | empty | Encrypts pending OAuth state/client information. |
RESTRICT_UPLOAD_DOMAINS | No | unset | Global upload guard; not read directly by facebook.rs, but media URLs must be reachable by Meta. |
META_GRAPH_API_VERSION | No | — | Not configurable at runtime; the constant is compiled in. The table value is documentation only. |
rustiz providers --status checks only that FACEBOOK_APP_ID and
FACEBOOK_APP_SECRET are non-empty. It does not verify the app mode, the
registered redirect URI, or that the app has the Page permissions.
Step-by-Step Developer App Setup
Facebook Page and Instagram (Facebook Business) can share one Meta app. If
you already followed instagram, skip the app creation and
reuse the same credentials.
1. Create the Meta app
- Sign in at https://developers.facebook.com/apps/creation/.
- Choose Other → Business as the app type. A Business portfolio is strongly recommended; public apps need Business Verification.
- Enter the app name, contact email and (optionally) a Business portfolio.
- In the App Dashboard, add the product Facebook Login for Business.
- Open Facebook Login for Business → Settings and add the exact Valid OAuth Redirect URI from step 2.
2. Redirect URI (exact)
Facebook::redirect_uri(None) is:
#![allow(unused)]
fn main() {
format!("{}/integrations/social/facebook", self.ctx.config.frontend_url())
}
Examples:
https://social.example.com/integrations/social/facebook # production
http://localhost:5000/integrations/social/facebook # Docker / rustiz serve
http://localhost:4200/integrations/social/facebook # upstream Postiz dev UI
There is no redirectmeto.com wrapper and no trailing slash. Facebook
matches the URI exactly, including scheme, host, path and any query string.
Reconnect quirk: when the OAuth start is called with
?refresh=<channel id>,authenticate()sends…/integrations/social/facebook?refresh=<id>as theredirect_uriin the token exchange, while the login dialog itself used the URI without the query. If Meta rejects the exchange with a redirect-URI mismatch, reconnect by starting a fresh connection instead of using the refresh link. Also see the page-id comparison note in the gotchas.
3. Permissions requested and checked
Facebook::scopes() returns, in this order:
#![allow(unused)]
fn main() {
vec![
"pages_show_list",
"business_management",
"pages_manage_posts",
"pages_manage_engagement",
"pages_read_engagement",
"read_insights",
]
}
They are joined with commas in the login URL. After the token exchange,
authenticate() calls:
GET /v25.0/me/permissions?access_token=…
and collects every entry whose status is "granted", then calls
check_scopes() with the comma-joined list. All six must be granted or the
connect fails with NotEnoughScopes. Users can decline individual Page
permissions in the login dialog, so this is a common failure.
| Scope | Why Rustiz needs it |
|---|---|
pages_show_list | List Pages in /me/accounts and Business Manager edges |
business_management | Read /me/businesses, owned_pages, client_pages |
pages_manage_posts | Create feed posts, photos, videos, stories |
pages_manage_engagement | Create comments/replies as the Page |
pages_read_engagement | Read Page content and basic metadata |
read_insights | Page Insights and post Insights |
Missing
publish_video: Meta’s Pages API documentation listspublish_videoas a permission needed “if you are publishing a video to the Page”. The Rust provider does not request it. Video posts can therefore fail with(#100) No permission to publish the video, whichhandle_errorsalready maps to a friendly message. Addpublish_videoto the app’s requested permissions and re-authorize if you need video.
4. Page tasks and Business Manager
The app user must be able to perform the Page tasks CREATE_CONTENT, MANAGE
and MODERATE on the target Page. In practice:
- The user must be a Page admin or have full control.
- If the Page belongs to a Business portfolio, the user must have the Page assigned in Business Manager.
- The Page must be published; an unpublished Page cannot be posted to.
- A Page that requires Page Publishing Authorization (PPA) must complete it before API publishing works.
pages() tries both sources:
GET /v25.0/me/accounts?fields=id,username,name,access_token,picture.type(large)&limit=100GET /v25.0/me/businesses, thenowned_pagesandclient_pagesfor each business, followingpaging.next.
Pages are deduplicated by id. A Page discovered through Business Manager but without a Page token is treated as “no permission” and produces the message “Your Facebook user has no permission to manage this page. Ask a page admin for full content access, then reconnect the channel.”
5. App roles and Development mode
- Development mode (all new apps): only users with a role on the app (Admin, Developer, Tester) can grant permissions and see content published by the app. This is the normal setup for a single-user/self-hosted install.
- Add testers under App roles → Roles → Testers. They must accept the invitation before they can log in.
- In Development mode, posts with media are visible only to app role users; other people see the post without the image. This is the single most common “my image is missing” report in the Postiz docs.
- Data created in Development mode becomes visible to everyone after switching to Live.
6. Live mode and App Review
Switch the app to Live only after development is complete.
- Live mode can request permissions from anyone, but only permissions approved through App Review.
- Advanced Access for
pages_manage_posts,pages_manage_engagement,pages_read_engagement,pages_show_list,business_managementandread_insightsrequires App Review and Business Verification. - For an app used only by its owner and role users, Standard Access in Development mode is enough; no App Review is required.
- App Review submissions need a screencast showing the complete Facebook login, the Page selection, and a real post published as the Page. Missing or vague use-case descriptions are a common rejection reason.
- Permissions unused for 90 days may expire and must be re-granted.
7. Testing checklist
GET /v25.0/me/accountsreturns the Page with a non-emptyaccess_token.GET /v25.0/{page-id}?fields=name,fan_countworks with that token.- A text-only
POST /{page-id}/feedsucceeds. - A photo post succeeds (JPEG/PNG < 4 MB).
- A video post succeeds only if
publish_video(or the equivalent Page task) is granted. - A story appears in the Page’s story tray and disappears after 24 hours.
There is no Facebook sandbox environment for Pages. Test against a real test Page and a test app in Development mode.
Connecting a Channel (CLI & MCP)
CLI
rustiz channels connect facebook
rustiz channels connect facebook --open
rustiz channels connect facebook --timeout 600
The CLI prints the login URL to stderr and, when FRONTEND_URL points at this
machine, serves the callback and waits for the channel to appear. With a remote
FRONTEND_URL it prints the URL and exits; the server handles the callback.
The OAuth callback creates the channel with inBetweenSteps = true. The CLI
has no setup command: finish the Page selection with the MCP
channelSetupTool (or the corresponding web UI). Until then the channel exists
but cannot publish; the scheduler refuses it with “channel … is not set up yet,
finish it with channelSetupTool”.
MCP connectChannelTool
{ "provider": "facebook" }
Typical response:
{
"connected": false,
"pending": true,
"connectionId": "…",
"url": "https://www.facebook.com/v25.0/dialog/oauth?client_id=…&redirect_uri=…&state=…&scope=pages_show_list,business_management,pages_manage_posts,pages_manage_engagement,pages_read_engagement,read_insights",
"message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}
Poll with:
{ "connectionId": "…" }
After the callback, integrationList shows the channel with
"inBetweenSteps": true.
Finishing setup: triggerTool then channelSetupTool
-
List the Pages the token can publish to:
{ "integrationId": "<unfinished channel id>", "methodName": "pages", "dataSchema": [] }facebookexposes one function:{ "name": "pages", "description": "Pages the connected account can publish to", "input_schema": { "type": "object", "properties": {} } }The result is the same array
pages()builds:[{id, username, name, access_token, picture:{data:{url}}}, …]. -
Choose a Page and call:
{ "integrationId": "<unfinished channel id>", "data": { "page": "<page id>" } }channelSetupToolcallsFacebook::fetch_page_information(token, {"page": "<id>"}), which re-reads/me/accountsand Business Manager, thenfinish_integration_setupreplaces the channel’sinternalId,name,picture,profileandtokenwith the Page values and clearsinBetweenSteps. -
Verify with
integrationList: the channel name is now the Page name andinBetweenStepsisfalse.
There is exactly one data key for Facebook: page. Do not send
{"id": …} (that is the Instagram shape) or {"pageId": …}.
What not to send
customFieldsroutes toconnect_custom()and treats the object as an OAuth authorization code. Never send it tofacebook.externalUrlis not used by Facebook; it is ignored byconnect.rs(onlymastodon-customrequires it).
Post Settings & Media Validation
settings_schema()
{
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "Link to attach as a preview card (image, title, domain), used only for text posts without media. A URL inside the content alone shows as plain text with no card"
},
"post_type": {
"type": "string",
"enum": ["post", "story"],
"description": "Publish a regular page post or a story. Stories need at least one photo or video attachment, and each media becomes a separate story."
},
"text_format_preset_id": {
"type": "string",
"description": "Optional Facebook background preset for text-only posts (max ~130 characters)."
},
"title": {
"type": "string",
"description": "Title of the video, used only for video (non-story) posts"
}
},
"additionalProperties": true
}
| Field | Type | Behaviour |
|---|---|---|
url | string | Sent as link on POST /{page}/feed. Empty string means “no card”. Only meaningful for posts without media. |
post_type | "post" / "story" | Defaults to post when absent. story switches to the story container flow. |
text_format_preset_id | string | Sent only for text-only posts of at most 130 UTF-16 code units. If Facebook rejects the preset, the provider retries once without it. |
title | string | Sent as title only on a non-story video post (/videos). Ignored for photos and stories. |
additionalProperties: true — unknown keys are accepted and ignored. This is
intentional for forward compatibility.
validate_settings()
settingsmust be an object.post_type, when non-null, must be exactlypostorstory.text_format_preset_id, when non-null, must be a string.title, when non-null, must be a string.url, when non-null, must be a string. An empty string is allowed and means “no link”. A non-empty value must be a web URL: either a fullhttp(s)URL or a scheme-less host containing a dot (the code retries withhttps://prefixed).example.com/xis accepted;not a urlis not.- No length or presence checks are performed here. Facebook itself enforces the 63,206-character limit and the 130-character preset limit.
check_validity()
Only stories are checked:
if post_type == "story" and the first post has no media:
"Story should have at least one media"
There is no check for:
- maximum 10 photos per feed post (Facebook’s own limit);
- video duration/size;
- photo dimensions or 4 MB size limit;
- mixing photos and video in one feed post.
Those fail at the Graph API and are mapped by handle_errors().
Media requirements
| Media | How Rustiz sends it | Platform constraints |
|---|---|---|
| Photo (feed) | POST /{page}/photos with {"url": <public URL>, "published": false}, then POST /{page}/feed with attached_media: [{media_fbid: …}] | JPEG/PNG, < 4 MB per photo; up to 10 photos per post |
| Video (feed) | POST /{page}/videos with {"file_url": <public URL>, "description", "title?", "published": true} | MP4 (the code only detects .mp4), public URL; processing continues after the API returns |
| Photo (story) | Same unpublished /photos upload, then POST /{page}/photo_stories?photo_id=… | One photo per story; 24-hour lifetime |
| Video (story) | POST /{page}/video_stories?upload_phase=start → POST upload URL with Authorization: OAuth <token> and file_url header → upload_phase=finish&video_id=… | Minimum 540p; one video per story; 24-hour lifetime |
| Comment attachment | First media only, sent as attachment_url | A link attachment, not an uploaded photo |
| Link preview | link field on /feed | Must be a public URL; Facebook scrapes Open Graph metadata |
Important details:
- Everything is URL-based. Rustiz never uploads bytes to Facebook; it gives
Meta a URL and Meta fetches it. The media must be on a public HTTPS server at
publish time. A Rustiz install behind a private network needs a public
UPLOAD_DIRECTORY/CDN or a tunnel. - Stories are one story per media item.
story_pending()uploads every attachment, thenfinalize_post()publishes exactly one item per call. A two-photo story produces two separate stories. - Video detection is
.mp4only.has_extension(video_path, "mp4")looks for the substring.mp4anywhere in the path (case-insensitive). A.movfile takes the photo path and fails. - Feed video release URL is always a Reel URL. The video branch returns
https://www.facebook.com/reel/{video_id}and ignores thepermalink_urlthat the API returns. A normal Page video is not necessarily a Reel; use the API response if you need the canonical link. - Alt text is ignored.
MediaContent.altis never sent to Facebook. - A feed post with both
linkand media may be ignored or rejected. The code sendslinkeven whenattached_mediais present. The schema saysurlis for text posts without media; treat that as the supported path.
Threads
supports_comments = true. A multi-entry post works as follows:
- The first entry is published as a feed post, photo post, video post or story.
- Each later entry is published by
comment()against the previous post id (or the root post id for the first comment). comment()sendsmessageplus, if the comment has media, the first media item asattachment_url.- Comments are Page comments (
POST /{post-id}/comments), so they appear under the post in Facebook’s comment UI, not as separate feed posts.
For stories, comments target the story post_id; Facebook’s story comments are
limited and may not appear in all clients.
Plugs
facebook.rs has no @Plug or @PostPlug methods. run_plug() and
run_post_plug() use the trait defaults and return
ProviderError::Unsupported. The pages helper is a dashboard function, not a
plug.
Implementation Notes & Gotchas
- The channel is unusable until setup is finished.
authenticate()stores a user token and a user id. OnlychannelSetupToolswaps in the Page token and Page id. Publishing before setup posts as the user and fails with “Unpublished posts must be posted to a page as the page itself”, which is mapped to a refresh error telling you to reconnect. - Page token lifecycle. The initial long-lived user token is valid about
60 days;
expires_inis hard-coded to59 * 24 * 3600.refresh_token()is a no-op (returns an empty access token withexpires_in: 0), andrefresh_cronisfalse. There is no proactive refresh. When the token expires, publishing gets a190/Error validating access token, the engine marks the channelrefreshNeeded, and the user must reconnect. Page tokens derived from a long-lived user token can outlive the user token for Page actions, but Rustiz still gates on the stored expiration. - Reconnect page-id mismatch.
save()with?refresh=<id>requires the re-authenticatedAuthTokenDetails.idto equal the storedintegration.internal_id. For a Page channel that stored id is the Page id, whileauthenticate()returns/me’s user id. The comparison fails with “Please refresh the channel that needs to be refreshed”. The provider’sreconnect()(which re-derives the Page token) is only called by the engine whenroot_internal_idis set, and Facebook channels have no root. Practically: reconnect by creating a new connection, then delete the old channel. refresh_tokenis not a refresh token.authenticate()stores the long-lived access token in bothaccess_tokenandrefresh_token. Nothing rotates it. Do not treat it as a Metafb_exchange_tokeninput.- Long-lived exchange has no redirect URI. The second token call is
GET /oauth/access_token?grant_type=fb_exchange_token&client_id=…&client_secret=…&fb_exchange_token=…&fields=access_token,expires_in. Meta ignoresfieldshere; the code readsaccess_tokenonly. - Graph version is pinned to
v25.0. Meta supports each Graph version for roughly two years. Check the App Dashboard’s “API version” notice before a long-lived deployment; upgrading requires editing the constant. - Business Manager failures are swallowed.
pages()andfetch_page_information()ignore errors from/me/businesses,owned_pagesandclient_pagesand continue with/me/accounts. A permission error on Business Manager therefore shows up as “Page not found in your accounts”, not as a Graph error. found_without_tokenmatters. A Page listed through a business the user has no role on comes back withoutaccess_token. The provider detects this and returns a specific message instead of a generic “not found”.max_concurrent_job = 500is optimistic. Meta rate limits are per app/user pair and Pages have their own publishing limits. A 500-job burst can hit(#32) Page request limitor1366046/1390008throttling. The code maps1390008to a retry (“You are posting too fast, please slow down”).- Feed posts are not pending.
post_pending()forpost_type != storycallspost_non_story(), which performs the feed call and returnsPostStatus::Success.supports_pending = trueexists only for the story flow. The engine’spost()compatibility path still loops on the story pending data; ordinary posts return immediately. - Story handshake. Story publishing uses
attempting(the item index) andconfirmed.check_post_status()waits for every not-yet-published video to reportupload_completeorready, then witnesses the armed item. If a run dies after the confirm, the provider refuses to retry with “Facebook may have already published part of the story” rather than duplicate a story. - One story per call.
finalize_post()publishesitems[published_count], increments the counter and returns Pending if more items remain. Each story counts as a separate publication and has its ownpost_id. - Video processing is polled by
GET /{video_id}?fields=status. Onlyupload_completeandreadycount as done;errorbecomes “Video processing failed”. There is no byte-level upload progress because the file is fetched by Meta from a URL. - Comment media is a URL attachment.
attachment_urlmakes Facebook scrape the URL as a link preview. It does not upload the image as a comment photo. A comment with a video URL is unlikely to render as a video. - Analytics swallow failures.
analytics()logs and returns an error only when the insights response contains anerrorobject;post_analytics()catches feed errors and returns[]. Video insights are used whenever the post id has no underscore:- feed post id
{pageid}_{postid}→/insights; - Reel/video id (bare numeric) →
/video_insights. A Page post id that happens to lack an underscore is misclassified as video.
- feed post id
- Percentage change is fake. Page analytics hard-code
percentage_change: 5.0; post analytics hard-code0.0. Do not present these as real period-over-period changes. - Insight metric names are version-sensitive.
page_total_media_view_uniqueandpage_media_viewhave been renamed/deprecated by Meta over time. If the insights call returns an error, the whole analytics request fails (page analytics) or returns empty (post analytics). text_format_preset_idretry.is_preset_rejection()treats an error as a preset rejection unless the detail mentions access token, re-authenticate, revoked, code 190, or the message is not “Unknown Error”. It then retries once without the preset. This can mask a real error as a successful plain post.- Preset length is UTF-16.
message.encode_utf16().count() <= 130matches JavaScript string length, not Unicode scalar count. An emoji counts as 2. - Error mapping. Dozens of Meta subcodes are translated:
1366046photos4 MB / not JPG-PNG,
1390008posting too fast,1346003abusive content,1404006security check,1404078Page publishing authorization,1366051duplicate photos,1609008Facebook.com links,4854002identity confirmation, subcode459security check, subcode492lost Page role, subcode33deleted Page/post. A bare401is treated as an unknown error, not a refresh. pages()exposes Page access tokens. Thepagesfunction returns the rawaccess_tokenfor every Page, andtriggerToolsends that array to the MCP client. Treat the tool output as secret; do not log it or paste it into shared transcripts. ThechannelSetupToolresponse itself only returns{success, name}.- Rate limits. Meta Graph API limits are per app/user pair and depend on
Page impressions; Pages also have daily posting limits.
max_concurrent_job = 500should be lowered for media-heavy workloads. The provider does not readX-Business-Use-Case-UsageorX-App-Usageheaders to back off. - No sandbox. There is no Facebook Pages sandbox. Use a test Page, a test app in Development mode, and role users. Switching the app to Live exposes Development-mode content to everyone.
References
- Meta app creation: https://developers.facebook.com/apps/creation/
- Facebook Login for Business: https://developers.facebook.com/docs/facebook-login/facebook-login-for-business
- Permissions reference: https://developers.facebook.com/docs/permissions/reference
- App modes (Development vs Live): https://developers.facebook.com/docs/development/build-and-test/app-modes
- Access levels (Standard vs Advanced): https://developers.facebook.com/docs/graph-api/overview/access-levels
- App Review: https://developers.facebook.com/docs/app-review
- Access tokens (user, Page, long-lived): https://developers.facebook.com/docs/facebook-login/guides/access-tokens
- Pages API posts: https://developers.facebook.com/docs/pages-api/posts
- Page Stories API: https://developers.facebook.com/docs/page-stories-api
- Page Insights: https://developers.facebook.com/docs/graph-api/reference/page/insights
- Postiz Facebook setup: https://docs.postiz.com/providers/facebook
- Postiz Facebook settings: https://docs.postiz.com/public-api/providers/facebook