Publishing per channel
What each adapter actually calls — who moves the bytes, the container dances, and the failures worth recognising.
docs/CHANNEL_PUBLISH.md
POST_FLOW.md ends where the publisher hands a variant to its adapter. This is what happens after that, and it is different on every channel.
One split explains most of the differences.
Who moves the bytes
| Pattern | Channels |
|---|---|
| Pull — platform fetches our URL | Instagram · Facebook · Threads · Pinterest · TikTok |
| Push — we upload the file | LinkedIn · YouTube |
This is not trivia. Every pull channel fails silently on a local machine, because
localhost:4000 is not reachable from Meta's servers. That is one of the two reasons a publish
that looks fine in dev fails in production; the other is that a pull channel's error arrives
later, from a fetcher, not from the call you made.
It also decides where a slow upload hurts. On push channels the publish request carries the file and can take minutes; on pull channels the call returns immediately and the waiting happens somewhere you cannot see.
Instagram — three calls, and the middle one matters
The container is not ready when it is created — Meta downloads and transcodes asynchronously. Publishing too early returns 9007 / 2207027, "media is not ready for publishing", which reads like a caller bug and is really a race.
Post types map to media_type: STORIES for a story, REELS for a reel or any video,
CAROUSEL for 2–10 items (each child created first with is_carousel_item=true), and a plain
image_url otherwise. Stories are single-media — refused locally rather than at the API, whose
error names neither file.
Facebook — the edge depends on the media
| Content | Call |
|---|---|
| 1 image | POST /{page}/photos with url |
| 2–10 images | each to /photos with published=false, then POST /{page}/feed with attached_media[n] |
| video | POST /{page}/videos |
| text or link | POST /{page}/feed |
The multi-image path is the interesting one: the photos are uploaded unpublished, and the
feed post is what makes them visible as one album. Checking the order here matters — an image
check that ran before the video branch once routed videos to /photos.
LinkedIn — register, upload, then post
Two publish endpoints, and picking the wrong one fails loudly. ugcPosts carries text,
images, video and articles through shareMediaCategory. It has no vocabulary for a document —
sending "DOCUMENT" returns "DOCUMENT" is not an enum symbol (422). A PDF publishes through
the versioned /rest/posts API instead, the sibling of the API that minted its URN.
Three differences between those bodies are easy to miss and none survives a copy-paste:
commentary replaces shareCommentary.text, a distribution block is mandatory, and visibility
is a plain string rather than a nested map — the map is accepted and silently ignored, which
would publish a connections-only post to the whole feed while looking like success.
YouTube — a resumable upload
Video only. Google's resumable protocol: start a session, send 8 MiB chunks, resync from the
Range header if a chunk fails, retry 5xx with a status probe.
The real ceiling is not technical — an unaudited app forces every upload to private, and the
10,000-unit daily quota caps at roughly six publishes.
Threads, Pinterest, TikTok, X
| Channel | Shape | Notes |
|---|---|---|
| Threads | container → POST /me/threads_publish | text and image only; media_type: TEXT or IMAGE. Video is not supported by Meta yet |
POST /v5/pins with media_source: { source_type: "image_url" } | image only; needs a board | |
| TikTok | video/init or content/init with PULL_FROM_URL → poll publish/status/fetch | same container-then-poll shape as Instagram. One image only for now |
| X | POST /2/tweets | text works; image and carousel are stubbed. Media needs v1.1 media/upload with OAuth 1.0a HMAC signing, which the OAuth 2.0 flow here does not do |
What each supports
| Channel | text | image | carousel | video | reel | story | document |
|---|---|---|---|---|---|---|---|
| — | ✅ | ✅ | ✅ | ✅ | ✅ | — | |
| ✅ | ✅ | ✅ | ✅ | — | — | — | |
| ✅ | ✅ | ✅ | ✅ | — | — | ✅ | |
| YouTube | — | — | — | ✅ | — | — | — |
| Threads | ✅ | ✅ | — | — | — | — | — |
| — | ✅ | — | — | — | — | — | |
| TikTok | — | ✅ | — | ✅ | — | — | — |
| X | ✅ | ⚠️ stub | ⚠️ stub | — | — | — | — |
The composer offers a type only where the selected channels support it — which is why PDF appears for LinkedIn alone.
Permalinks
Every published row stores the link the platform returned, so published is checkable rather
than asserted. derivePermalink returns null rather than constructing a URL it cannot
verify: Instagram uses a shortcode unrelated to the media id, so its link has to be fetched, and
a guessed URL that 404s is worse than no link at all.
Failures worth recognising
| What you see | Channel | Cause |
|---|---|---|
9007 / 2207027 media is not ready | published before the container finished | |
"DOCUMENT" is not an enum symbol | a PDF sent to ugcPosts instead of /rest/posts | |
| Works in dev, fails in prod | any pull channel | the media URL is not reachable from the internet |
(#10) Application does not have permission | Meta | the scope was never granted to the token in hand |
| Publishes but only to you | YouTube | unaudited app — every upload is forced private |
| Image silently absent | X | media is stubbed; the text posts without it |