Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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: instagram and instagram-standalone share the same Meta app family, but only instagram reuses FACEBOOK_APP_ID / FACEBOOK_APP_SECRET

Overview

PropertyValue
Identifierfacebook
Display nameFacebook Page
Connection modeoauth (Facebook Login for Business)
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true; extra entries are posted as POST /{post-id}/comments
Pending / async publishingYes for stories (supports_pending = true); ordinary feed posts are published synchronously inside post_pending() and return PostStatus::Success
AnalyticsYes. supports_analytics = true; Page Insights
Post analyticsYes. supports_post_analytics = true; feed post insights or video insights
MentionsNo. supports_mentions = false
is_between_stepstrue — the OAuth callback creates an unfinished channel; a Page must be selected with channelSetupTool
convert_to_jpegfalse
max_concurrent_job500 (the code comment: “Facebook has reasonable rate limits”)
Character limit (max_length)63,206 characters
Graph API versionv25.0 (META_GRAPH_API_VERSION)
API basehttps://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.

VariableRequiredDefaultPurpose
FACEBOOK_APP_IDYes—Meta app ID shown at the top of the App Dashboard. Used as client_id in the login dialog and token exchange.
FACEBOOK_APP_SECRETYes—Meta app secret. Used server-side for code → token and short-lived → long-lived token exchange.
FACEBOOK_BASE_URLNohttps://graph.facebook.comGraph API origin. Config::base_url("FACEBOOK", …).
FACEBOOK_WEB_BASE_URLNohttps://www.facebook.comOrigin used for the /v25.0/dialog/oauth login URL.
FRONTEND_URLYes in practicehttp://localhost:5000Base of the OAuth redirect URI. Trailing slash stripped.
JWT_SECRETYes in practiceemptyEncrypts pending OAuth state/client information.
RESTRICT_UPLOAD_DOMAINSNounsetGlobal upload guard; not read directly by facebook.rs, but media URLs must be reachable by Meta.
META_GRAPH_API_VERSIONNo—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

  1. Sign in at https://developers.facebook.com/apps/creation/.
  2. Choose Other → Business as the app type. A Business portfolio is strongly recommended; public apps need Business Verification.
  3. Enter the app name, contact email and (optionally) a Business portfolio.
  4. In the App Dashboard, add the product Facebook Login for Business.
  5. 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 the redirect_uri in 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.

ScopeWhy Rustiz needs it
pages_show_listList Pages in /me/accounts and Business Manager edges
business_managementRead /me/businesses, owned_pages, client_pages
pages_manage_postsCreate feed posts, photos, videos, stories
pages_manage_engagementCreate comments/replies as the Page
pages_read_engagementRead Page content and basic metadata
read_insightsPage Insights and post Insights

Missing publish_video: Meta’s Pages API documentation lists publish_video as 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, which handle_errors already maps to a friendly message. Add publish_video to 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:

  1. GET /v25.0/me/accounts?fields=id,username,name,access_token,picture.type(large)&limit=100
  2. GET /v25.0/me/businesses, then owned_pages and client_pages for each business, following paging.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_management and read_insights requires 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

  1. GET /v25.0/me/accounts returns the Page with a non-empty access_token.
  2. GET /v25.0/{page-id}?fields=name,fan_count works with that token.
  3. A text-only POST /{page-id}/feed succeeds.
  4. A photo post succeeds (JPEG/PNG < 4 MB).
  5. A video post succeeds only if publish_video (or the equivalent Page task) is granted.
  6. 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

  1. List the Pages the token can publish to:

    {
      "integrationId": "<unfinished channel id>",
      "methodName": "pages",
      "dataSchema": []
    }
    

    facebook exposes 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}}}, …].

  2. Choose a Page and call:

    {
      "integrationId": "<unfinished channel id>",
      "data": { "page": "<page id>" }
    }
    

    channelSetupTool calls Facebook::fetch_page_information(token, {"page": "<id>"}), which re-reads /me/accounts and Business Manager, then finish_integration_setup replaces the channel’s internalId, name, picture, profile and token with the Page values and clears inBetweenSteps.

  3. Verify with integrationList: the channel name is now the Page name and inBetweenSteps is false.

There is exactly one data key for Facebook: page. Do not send {"id": …} (that is the Instagram shape) or {"pageId": …}.

What not to send

  • customFields routes to connect_custom() and treats the object as an OAuth authorization code. Never send it to facebook.
  • externalUrl is not used by Facebook; it is ignored by connect.rs (only mastodon-custom requires 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
}
FieldTypeBehaviour
urlstringSent 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_idstringSent only for text-only posts of at most 130 UTF-16 code units. If Facebook rejects the preset, the provider retries once without it.
titlestringSent 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()

  • settings must be an object.
  • post_type, when non-null, must be exactly post or story.
  • 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 full http(s) URL or a scheme-less host containing a dot (the code retries with https:// prefixed). example.com/x is accepted; not a url is 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

MediaHow Rustiz sends itPlatform 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 attachmentFirst media only, sent as attachment_urlA link attachment, not an uploaded photo
Link previewlink field on /feedMust 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, then finalize_post() publishes exactly one item per call. A two-photo story produces two separate stories.
  • Video detection is .mp4 only. has_extension(video_path, "mp4") looks for the substring .mp4 anywhere in the path (case-insensitive). A .mov file 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 the permalink_url that 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.alt is never sent to Facebook.
  • A feed post with both link and media may be ignored or rejected. The code sends link even when attached_media is present. The schema says url is for text posts without media; treat that as the supported path.

Threads

supports_comments = true. A multi-entry post works as follows:

  1. The first entry is published as a feed post, photo post, video post or story.
  2. Each later entry is published by comment() against the previous post id (or the root post id for the first comment).
  3. comment() sends message plus, if the comment has media, the first media item as attachment_url.
  4. 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. Only channelSetupTool swaps 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_in is hard-coded to 59 * 24 * 3600. refresh_token() is a no-op (returns an empty access token with expires_in: 0), and refresh_cron is false. There is no proactive refresh. When the token expires, publishing gets a 190/Error validating access token, the engine marks the channel refreshNeeded, 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-authenticated AuthTokenDetails.id to equal the stored integration.internal_id. For a Page channel that stored id is the Page id, while authenticate() returns /me’s user id. The comparison fails with “Please refresh the channel that needs to be refreshed”. The provider’s reconnect() (which re-derives the Page token) is only called by the engine when root_internal_id is set, and Facebook channels have no root. Practically: reconnect by creating a new connection, then delete the old channel.
  • refresh_token is not a refresh token. authenticate() stores the long-lived access token in both access_token and refresh_token. Nothing rotates it. Do not treat it as a Meta fb_exchange_token input.
  • 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 ignores fields here; the code reads access_token only.
  • 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() and fetch_page_information() ignore errors from /me/businesses, owned_pages and client_pages and 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_token matters. A Page listed through a business the user has no role on comes back without access_token. The provider detects this and returns a specific message instead of a generic “not found”.
  • max_concurrent_job = 500 is 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 limit or 1366046/1390008 throttling. The code maps 1390008 to a retry (“You are posting too fast, please slow down”).
  • Feed posts are not pending. post_pending() for post_type != story calls post_non_story(), which performs the feed call and returns PostStatus::Success. supports_pending = true exists only for the story flow. The engine’s post() compatibility path still loops on the story pending data; ordinary posts return immediately.
  • Story handshake. Story publishing uses attempting (the item index) and confirmed. check_post_status() waits for every not-yet-published video to report upload_complete or ready, 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() publishes items[published_count], increments the counter and returns Pending if more items remain. Each story counts as a separate publication and has its own post_id.
  • Video processing is polled by GET /{video_id}?fields=status. Only upload_complete and ready count as done; error becomes “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_url makes 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 an error object; 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.
  • Percentage change is fake. Page analytics hard-code percentage_change: 5.0; post analytics hard-code 0.0. Do not present these as real period-over-period changes.
  • Insight metric names are version-sensitive. page_total_media_view_unique and page_media_view have 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_id retry. 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() <= 130 matches JavaScript string length, not Unicode scalar count. An emoji counts as 2.
  • Error mapping. Dozens of Meta subcodes are translated: 1366046 photos

    4 MB / not JPG-PNG, 1390008 posting too fast, 1346003 abusive content, 1404006 security check, 1404078 Page publishing authorization, 1366051 duplicate photos, 1609008 Facebook.com links, 4854002 identity confirmation, subcode 459 security check, subcode 492 lost Page role, subcode 33 deleted Page/post. A bare 401 is treated as an unknown error, not a refresh.

  • pages() exposes Page access tokens. The pages function returns the raw access_token for every Page, and triggerTool sends that array to the MCP client. Treat the tool output as secret; do not log it or paste it into shared transcripts. The channelSetupTool response 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 = 500 should be lowered for media-heavy workloads. The provider does not read X-Business-Use-Case-Usage or X-App-Usage headers 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