A scheduled Reel is not an on-time Reel

AI

Environment: Instagram Graph API v21.0 on graph.instagram.com (Instagram Login, not a Facebook Page), Python 3.12, GitHub Actions on a 30-minute cron. Measured July 2026 on seven published Reels.

We post short vertical videos for exam-prep books — one Reel, a few times a week, at an hour we actually chose. The Graph API will take a video from a public HTTPS URL, wrap it in a container, transcode it, and publish it. That part works.

What does not work is reading the clock on the schedule as the clock on the post.

We timed seven Reels from the minute we had asked for to the minute they appeared. The delay was 5 to 115 minutes, median 50. The API returned 200. The container finished. Nothing in the logs said “late”. The post was simply later than the time in our YAML.

Diagram: intended 20:30, a 30-minute job window, Instagram transcode, an optional retry on a later run, and the published time landing 5 to 115 minutes late; below, the one-hour pull-forward that puts the median landing back on the hour we wanted
Three clocks, one publish. The number in the schedule is the start of a window, not the time on the post.

We do not use Instagram’s own scheduler

The Graph API has a scheduled_publish_time field. We do not set it.

A Reel cannot be published from a file on disk. Instagram fetches a URL. So the pipeline is already three steps before anyone talks about a clock: upload the file to a throwaway HTTPS host, create a media container with that URL, wait until status_code is FINISHED, then media_publish. The wait is not optional, and it is not short.

If we handed Instagram a future timestamp as well, we would still own the upload, the container, and the retry when the container lies. The only thing we would give away is the right to decide what “late” means. We kept the clock.

GRAPH = "https://graph.instagram.com/v21.0"
CONTAINER_TIMEOUT = 420   # seconds. Transcode is often a few minutes
POLL_INTERVAL = 10
CONTAINER_RETRIES = 3
RETRY_WAIT = 60

The job that calls this runs every thirty minutes. GitHub’s cron is not a cron. It is allowed to drift by several minutes, and a queued run can sit behind other work. Thirty minutes on the YAML is already a window, not a time.

Advertisement

Three clocks, one publish

The delay is not one bug. It is three durations stacked, and only the first is ours.

The job cadence. A Reel whose time is 19:31 waits for the next run. On a quiet day that is a few minutes. On a busy one it is most of the half hour, plus whatever GitHub added. This alone can look like “about thirty minutes late” with no Instagram involved.

The transcode. POST /{ig-user-id}/media with media_type=REELS returns a container id immediately. The video is not ready. We poll status_code every ten seconds for up to seven minutes.

payload = {
    "media_type": "REELS",
    "video_url": video_url,
    "caption": caption,
    "share_to_feed": "true",
}
# POST {GRAPH}/{ig-user-id}/media  →  container id
# GET  {GRAPH}/{container-id}?fields=status_code
#      IN_PROGRESS | FINISHED | ERROR
# POST {GRAPH}/{ig-user-id}/media_publish  creation_id=…

A clean file often finishes in under two minutes. A file that will eventually succeed can sit in IN_PROGRESS for most of the timeout. The timeout is not a quality signal. It is a budget.

A retry that is not in the same run. Instagram will mark a perfectly valid vertical MP4 as ERROR after a few minutes of IN_PROGRESS. We measured this on the same file, same host, same account: two failures, then FINISHED on the third try about a minute later. The video was not the problem. The host was not the problem. Waiting and creating a new container was.

Inside one run we retry three times, a minute apart. If the run still fails, the next job thirty minutes later picks the same row up again — it is due, it is not marked posted. That second run is how a 50-minute median becomes a 115-minute outlier. Nothing in the API distinguishes “please try later” from “this file is broken”.

Schedule the landing, not the request

Once you treat the YAML time as the opening of a window, the fix is arithmetic, not a better poller.

We wanted posts on the screen around 20:30, on weekday evenings, for the people who actually watch them. A median delay of 50 minutes would have put the typical Reel at 21:20. The worst of the seven would have landed at 22:25. That is past the hour we will accept.

So the YAML says 19:30 when we mean 20:30. One hour early. The median landing comes back to about 20:20. The bad night still clears 21:30. We did not make Instagram faster. We stopped asking the schedule to mean what the field is named.

A second Reel on the same day has to be placed against that window, not against the other Reel’s YAML time. Two posts an hour apart on paper are, on a bad night, almost on top of each other; on a median night they are still closer than they look. We leave a gap of two YAML hours when two must share a day.

None of this is in the Graph API docs. The docs describe the happy path in which FINISHED means finished and a cron means on the minute.

Two other things the API will not tell you

A URL in the caption breaks the embed. We paste Instagram posts onto a WordPress page. A caption that contains https://… makes that embed fail to render. The Graph API accepts the caption. The damage is downstream. We strip every URL at publish time, and we refuse to generate a caption that still has one. Instagram is not a place we put links. The profile is.

Local files do not exist. The publishing call has no multipart upload for Reels. If the throwaway URL 404s, or the file is still uploading, the container errors in the same way as a corrupt MP4. The retry loop cannot see the difference. Stage the file, wait until a HEAD on the public URL is 200, then create the container. Asking Instagram to fetch a URL you have not finished writing is a way to spend seven minutes learning nothing.

What we actually trust

The Graph API is the publisher. It is not the clock, and it is not the verifier.

We trust a row in our own state file that says posted, with a media_id we can open on the phone. We do not trust scheduled_at. We do not trust FINISHED on the first try. We do not trust a 30-minute cron to fire at minute zero.

That is a small, boring architecture: a job that is allowed to be late, a container poll that is allowed to fail, a retry that is allowed to happen in the next window, and a schedule written for the time we want the audience to see, minus the delay we actually measured.

The number we measured — five to 115 minutes, median fifty, n = 7, July 2026 — is the whole reason the YAML looks an hour wrong.


Next: why the diagrams in these posts are HTML, and the drawings are an image model, and we do not let the model write the labels.

タイトルとURLをコピーしました