API GuidesAugust 4, 20269 min readingMarcel Czuryszkiewicz

Facebook Posting API

Publishing to a Facebook Page is not one API. A text post goes to the Graph API, a video goes to a different host with a different auth header, and a Reel goes to a third. Covers all three upload paths with code, the native scheduling limit nobody documents, and why we never retry a Facebook video.

The Facebook posting API is not one API. A text post goes to graph.facebook.com. A video goes to graph-video.facebook.com with a Bearer header. A Reel goes to rupload.facebook.com with an OAuth header, which is a different word in the same position and will silently fail if you get it wrong. This covers all three paths with working requests, plus the scheduling limitation that catches every team building a scheduler.

A container ship photographed from directly above, its deck stacked with differently coloured freight containers, cutting a wake across open water
Same cargo. The route depends on what is inside.

Before you publish anything

Four things, in this order.

A Page access token, not a user token. Page posts are authored by the Page, and the token has to reflect that. Getting one, and keeping one that does not expire, is covered in Facebook Page access tokens.

pages_manage_posts, with Advanced Access. Standard Access works only on Pages your own developer account owns, which is why an integration that works perfectly in development returns empty arrays for customers. The full dependency chain and what review actually asks for is in Facebook API permissions.

The Page id. Not the vanity URL, not the username. GET /me/accounts with a user token returns the Pages a person manages along with their ids and per-Page tokens.

A decision about post type before you build the request, because the three types do not share a code path.

Text and link posts

The simplest case, and the only one that is a single request.

curl -X POST "https://graph.facebook.com/v26.0/${PAGE_ID}/feed" \
  -d "message=We are hiring two backend engineers." \
  -d "access_token=${PAGE_TOKEN}"
{ "id": "123456789_987654321" }

That composite id is {page-id}_{post-id}. Store the whole thing; most follow-up calls want it in that form.

A link post is the same call with a link parameter:

curl -X POST "https://graph.facebook.com/v26.0/${PAGE_ID}/feed" \
  -d "message=Details here." \
  -d "link=https://example.com/jobs" \
  -d "access_token=${PAGE_TOKEN}"

Facebook scrapes the URL to build the preview card. If the scrape fails, you get error 1609005, which is terminal: retrying will not make the page reachable. Common causes are a URL that requires authentication, a robots rule blocking Facebook's crawler, or missing Open Graph tags producing an empty card. Warm the scrape with the Sharing Debugger before a launch rather than discovering it at publish time.

Photos

One photo is direct:

curl -X POST "https://graph.facebook.com/v26.0/${PAGE_ID}/photos" \
  -d "url=https://cdn.example.com/office.jpg" \
  -d "message=New floor" \
  -d "access_token=${PAGE_TOKEN}"

Multiple photos in one post is a two-stage flow that is not obvious from the documentation. Upload each photo unpublished, collect the ids, then create a feed post that attaches them:

# For each photo: upload without publishing
curl -X POST "https://graph.facebook.com/v26.0/${PAGE_ID}/photos" \
  -d "url=https://cdn.example.com/1.jpg" \
  -d "published=false" \
  -d "access_token=${PAGE_TOKEN}"
# → { "id": "111111111" }

# Then one feed post attaching them all
curl -X POST "https://graph.facebook.com/v26.0/${PAGE_ID}/feed" \
  -d "message=Three from the opening" \
  -d "attached_media[0]={\"media_fbid\":\"111111111\"}" \
  -d "attached_media[1]={\"media_fbid\":\"222222222\"}" \
  -d "access_token=${PAGE_TOKEN}"

Two constraints that are easy to miss. A feed post accepts 4 photos or 1 video, never a mix. And Facebook's image limit is 4 MB, half of Instagram's 8 MB, which means a pipeline that resizes for Instagram is not automatically safe for Facebook. The full per-platform table is in media requirements.

Alt text is supported on photo uploads in a feed post, and only there. Not on Reels, not on Stories.

Video and Reels

Two old Finnish envelopes on a wooden table, each carrying a different postage stamp and a Tampere postmark, with an ink bottle behind them
Same envelope. Different stamp at each counter.

This is where the three-API thing becomes real.

Video goes to a different host.

curl -X POST "https://graph-video.facebook.com/v26.0/${PAGE_ID}/videos" \
  -H "Authorization: Bearer ${PAGE_TOKEN}" \
  -F "file_url=https://cdn.example.com/clip.mp4" \
  -F "description=Behind the scenes"

Reels go to a third host, with a different auth scheme. The header value is OAuth, not Bearer. The Reels flow is three steps: start an upload session, upload the bytes, then publish.

# 1. Start a session
curl -X POST "https://graph.facebook.com/v26.0/${PAGE_ID}/video_reels" \
  -d "upload_phase=start" \
  -d "access_token=${PAGE_TOKEN}"
# → { "video_id": "555555555", "upload_url": "https://rupload.facebook.com/..." }

# 2. Upload the bytes to the returned host
curl -X POST "https://rupload.facebook.com/video-upload/v26.0/555555555" \
  -H "Authorization: OAuth ${PAGE_TOKEN}" \
  -H "file_url: https://cdn.example.com/reel.mp4"

# 3. Publish
curl -X POST "https://graph.facebook.com/v26.0/${PAGE_ID}/video_reels" \
  -d "video_id=555555555" \
  -d "upload_phase=finish" \
  -d "video_state=PUBLISHED" \
  -d "description=Behind the scenes" \
  -d "access_token=${PAGE_TOKEN}"

Using Bearer in step 2 fails in a way that does not clearly say "wrong auth scheme". If your Reels upload is rejecting a token that works everywhere else, that is the first thing to check.

The media constraints differ per type too. A feed video runs up to 20 minutes at up to 45 Mbps with aspect ratio between 0.01 and 1.91. A Reel is 3 seconds to 20 minutes, needs at least 540 × 960, and caps aspect ratio at roughly 1.67, so a landscape video is not a valid Reel.

Stories

Stories are their own endpoint again, and they last 24 hours.

# Photo story: upload unpublished, then post it as a story
curl -X POST "https://graph.facebook.com/v26.0/${PAGE_ID}/photos" \
  -d "url=https://cdn.example.com/story.jpg" \
  -d "published=false" \
  -d "access_token=${PAGE_TOKEN}"
# → { "id": "777777777" }

curl -X POST "https://graph.facebook.com/v26.0/${PAGE_ID}/photo_stories" \
  -d "photo_id=777777777" \
  -d "access_token=${PAGE_TOKEN}"

Video stories use the Reels-style session flow against video_stories. Constraints are tighter than anywhere else: 3 to 60 seconds, at least 540 × 960, up to 25 Mbps, and images capped at 4 MB.

Native scheduling, and where it stops

A wall calendar with red pushpins stuck into several dates and the thirtieth circled in red marker
Works for one post type. Not the other two.

Facebook lets you schedule a post at creation time:

curl -X POST "https://graph.facebook.com/v26.0/${PAGE_ID}/feed" \
  -d "message=Doors open Monday." \
  -d "published=false" \
  -d "scheduled_publish_time=1754150400" \
  -d "access_token=${PAGE_TOKEN}"

The time is a Unix timestamp, and it has to be between 10 minutes and 6 months in the future.

Here is the part that is not in most guides:

Post typeNative scheduling
Feed post (text, link, photos, video)Yes
ReelNo
StoryNo

Meta does not support scheduled_publish_time for Reels or Stories. If you are building a scheduler that offers all three post types, you need your own scheduling layer for two of them regardless, which means native scheduling buys you less than it appears to. We reject the combination up front in validation rather than at publish time, because discovering it when the job fires is far worse than discovering it when the user saves.

There is a second argument against native scheduling even where it works: you lose visibility. A natively scheduled post lives on Meta's side. Your system does not know whether it published, whether it failed, or why. If your product shows customers a publishing history, scheduling yourself and publishing immediately gives you a result you can actually report on.

Why we never retry a Facebook video

The video and Reels upload flows have no idempotency key. Start, chunk, finish and create run as a sequence, and there is nothing in the protocol that lets a second attempt say "this is the same upload as before".

So a worker that times out mid-sequence and retries from the top produces a second video on the customer's Page.

Our policy is one attempt for Facebook video uploads. Not three, not two. The first failure is terminal, with a heartbeat so a dead worker is detected in minutes rather than after the full timeout, and the post surfaces as failed for a human to re-run deliberately.

That feels harsh until you weigh it against the alternative. A transient blip costing one failed post is recoverable in thirty seconds. A duplicate video on a brand's Page is a conversation. The reasoning behind this class of decision, and the platforms where it goes the other way, is in social media API error handling.

bundle.social

Three upload hosts, three auth headers, one scheduling trap. All behind a single call.

Three upload hosts and two auth header words, handled behind one endpoint.

The errors you will hit first

ErrorCauseFix
#200Missing pages_manage_posts or the permission was declinedCheck GET /me/permissions, re-request the specific scope
#190 subcode 463Page token expiredRefresh, or re-authorise if there is no path back
#190 subcode 492The user lost their role on the PageNot fixable by reconnecting. The customer needs an admin to re-add them
#10Permission denied, often Standard Access against a customer's PageAdvanced Access is required
#506Duplicate postTerminal. Facebook rejects identical content
#324Media rejected after uploadCheck the file against the media limits, not the network
1609005Link scrape failedThe URL is unreachable or blocked for Facebook's crawler
Empty array, no errorStandard Access against a Page you do not ownThe most confusing failure on the whole platform

That last row wastes more time than any actual error. It is not an error condition, and it looks identical to "this Page has no posts".

The shorter path

Three upload hosts, two auth header formats, scheduling that works for one post type out of three, an idempotency gap that forces a no-retry policy on video, and an access model where the most common failure is a silent empty array. Then Instagram, which shares a Graph API and disagrees with Facebook about half of it.

bundle.social's Facebook API is one call for all three post types. The upload host, the auth scheme, the unpublished-then-attach dance for multi-photo posts and the Reels session flow are behind it. Scheduling is ours, so you get a real status either way instead of handing the post to Meta and hoping.

For the wider platform, including reading, insights and the node-edge model, see the Facebook Graph API guide.

Frequently asked questions

How do I post to a Facebook Page with the API?

POST /{page-id}/feed with a Page access token and the pages_manage_posts permission. Photos go to /{page-id}/photos, video to graph-video.facebook.com, and Reels to a separate upload session on rupload.facebook.com.

Can I schedule Facebook Reels through the API?

No. scheduled_publish_time works for feed posts only. Reels and Stories have to be published at the moment you want them live, which means a scheduler needs its own timing layer for those two types.

Why does my multi-photo post only show one image?

Because each photo has to be uploaded with published=false first, then attached to a feed post through attached_media. Posting photos individually creates separate posts rather than one album.

How many photos can I attach to one Facebook post?

Four, and they cannot be combined with video in the same post. Each image is capped at 4 MB, which is half of Instagram's limit.

Why does the Reels upload reject a token that works elsewhere?

The upload step uses Authorization: OAuth {token}, not Bearer. It is the only place in the Facebook publishing surface that uses that scheme, and the failure does not name the cause.

Marcel Czuryszkiewicz
Written by

Marcel Czuryszkiewicz

Co-Founder

Marcel is one of the builders behind bundle.social, creating a social media API with a strong focus on developer experience, reliability, and real support. Before bundle.social, he worked at Samsung R&D, Reply AI, and Docplanner, building internal tools that helped companies grow. He brought that same practical, product-focused mindset to bundle.social, so you can trust that your project is in good hands.