- date
- entry
- 014
- topic
- integration
- rev
- —
The Threads API's two-phase publish: why a thread cannot be batched
Creating the container and publishing it are two calls with a wait in between, and replying to the previous post needs its published id, not the container id. Stack those constraints and a ten-post thread becomes a chain that cannot be parallelised and has no transaction protecting it.
Posting on Threads happens in two phases. First create a media container, then publish it:
# 1. create the container
curl -s -X POST "https://graph.threads.net/v1.0/$USER_ID/threads" \
--data-urlencode "media_type=TEXT" \
--data-urlencode "text=first post in the thread" \
--data-urlencode "access_token=$TOKEN"
# → {"id":"<CONTAINER_ID>"}
# 2. publish it
curl -s -X POST "https://graph.threads.net/v1.0/$USER_ID/threads_publish" \
--data-urlencode "creation_id=$CONTAINER_ID" \
--data-urlencode "access_token=$TOKEN"
# → {"id":"<POST_ID>"}
The split makes sense: a container with an image needs the server to fetch that image, validate it and transcode it. None of that finishes before your POST returns, so the API hands you a container id and lets you come back later.
Three constraints, stacked
Each is reasonable alone. Together they force a strict sequence:
- You cannot publish immediately after creating. The server needs processing time — thirty seconds in practice
reply_to_idtakes a post id, not a container id. Pass the container id and it fails- Therefore you cannot pre-create all the containers and publish them together — the second post's container needs an id that only exists after the first is published
So every post walks the whole path before the next one starts:
create → wait 30s → publish → get post id → next post carries it → ...
curl -s -X POST "https://graph.threads.net/v1.0/$USER_ID/threads" \
--data-urlencode "media_type=TEXT" \
--data-urlencode "text=second post in the thread" \
--data-urlencode "reply_to_id=$PREV_POST_ID" \
--data-urlencode "access_token=$TOKEN"
A ten-post thread spends three hundred seconds waiting, minimum. That is not a performance problem — it is what dictates the shape your script has to take.
The real problem: the chain has no transaction
A five-minute loop can fail at any step: an expired token, an image host briefly down, a rate limit, a dropped connection.
The point is that when it fails, the posts already published do not roll back. The sixth failing leaves the first five publicly visible on someone's feed as a paragraph with no ending. This is nothing like a database batch, where a failure rolls the whole thing back. Here each publish is an externally visible fact.
So the script cannot be shaped as "run once, succeed or fail". It has to be a resumable state machine:
- Record the post id after every successful publish, somewhere that survives the process
- On rerun, continue from the last recorded post, never from the top
- On failure, print how far it got — that is the only thing a rerun needs to know
STATE=.thread-state
LAST_ID=$(tail -n1 "$STATE" 2>/dev/null | cut -f2)
START=$(( $(wc -l < "$STATE" 2>/dev/null || echo 0) + 1 ))
for i in $(seq "$START" "$TOTAL"); do
... create / wait / publish ...
printf '%s\t%s\n' "$i" "$POST_ID" >> "$STATE" # only on success
done
This is the other face of why payment callbacks need idempotency: there the problem is one thing delivered twice, here it is a series of things half-done. Both come from the same fact — the external system offers no transaction, so you supply one at the boundary.
Images have to be publicly reachable first
The API does not take local files. An image container needs a publicly accessible URL that the server fetches itself:
curl -s -X POST "https://graph.threads.net/v1.0/$USER_ID/threads" \
--data-urlencode "media_type=IMAGE" \
--data-urlencode "image_url=https://example.com/pic.jpg" \
--data-urlencode "text=caption" \
--data-urlencode "access_token=$TOKEN"
Limits: JPEG or PNG, under 8 MB, 320–1440 px wide, sRGB.
That design has an easily missed consequence: your image host becomes a dependency of publishing. If it is unavailable at that moment the container is created but publishing fails — and the error usually just says media processing failed, not that the image could not be fetched. Free hosts deserve particular caution; the way they shut down is usually by quietly disabling uploads.
Smaller traps: text caps at 500 characters, so longer content has to be split; and blank lines only
survive if you pass the text with --data-urlencode — build the URL by hand and the
\n\n disappears.
The token expires after sixty days
A long-lived token lasts sixty days. Unremarkable on its own; drop it into a scheduled auto-posting setup and it becomes a timer. It breaks long after you have forgotten the script exists, and the symptom is silent: the schedule ran, nothing was posted.
Two cheap mitigations:
- Record the date you obtained it and put a reminder before expiry
- Verify the token is alive at the top of the script before entering the loop, and fail loudly if not — the same habit as verifying a token before editing DNS
And the token is a credential; it should not be riding along in a .env that travels with
the project directory. The rule from that same piece applies: once it exists somewhere that gets
copied, treat it as leaked.
If you remember one thing
Any API shaped "create → wait → confirm" has to be written as a state machine, not as a function call. The wait exists because the work is happening on someone else's server, and that server will not hold consistency for the sake of your loop. All you can do is record each completed step and make a rerun able to pick up from it.
Revision history
- First published