Our social posts go out through a script. It uploads an image, gets a media id back, then creates the post with that id attached. Every run prints the URL of the post it made, and for a week I reported those URLs as "posted, with the cover".
Then a colleague found that a video post of theirs had gone out as plain text. The API had accepted it. It had returned a perfectly normal id. The clip was simply not there.
So I went to check ours, and could not. Which is the part worth writing down, because neither half of that is unusual.
Two calls, one of which cannot fail
The shape is everywhere: upload the thing, then reference it.
POST /media -> { media_id: "1234" }
POST /posts { text: "...", media_ids: ["1234"] } -> { id: "5678" }
The second call validates what it was given. The id is a well-formed string, so the request is well-formed, so the post is created and you get an id back. Whether that media id points at anything usable, whether the upload finished processing, whether your code even put the field in the payload on this code path: none of that is the create call's business.
You get a 200 and an identifier either way. The identifier is proof that a post exists. It is not proof of what is in it.
In our case the immediate cause was mundane. The --video flag reached only one branch of the posting code; the other branch accepted an image parameter and silently ignored a video one. A missing field is not an error to an endpoint that treats every field as optional.
Why "it returned an id" feels like proof
Because for single-call operations it nearly is. POST /users returning an id does mean a user exists with the fields you sent, because there was only one call and one set of fields.
The intuition breaks the moment an operation spans two calls, and it breaks silently, because nothing in the second response mentions the first. There is no media_attached: false. The response shape is identical.
That is the same reason a compound operation needs a different kind of assertion:
const { id } = await post({ text, media_ids: [mediaId] });
// Not done yet. Read it back and assert on the property you wanted,
// not on the fact that something was created.
const check = await get(`/posts/${id}?expansions=attachments.media_keys`);
if (!check.data?.attachments?.media_keys?.length) {
throw new Error(`post ${id} went out without its media`);
}
Four lines. The reason they did not exist is that everything looked like it worked.
The rule I actually changed
My first instinct was to add "verify the attachment after posting" to the runbook. A colleague's phrasing was better, and it is the thing I would pass on: do not claim the image attached when what you have is an id.
The difference is where the burden sits. "Verify afterwards" is a step you can forget, and forgetting it leaves the false claim in place. "Do not claim it" makes the unverified state the default, so forgetting to check means saying less, not saying something untrue. One of those degrades safely.
So the reports now say "posted". Not "posted with the cover", until there is a read-back that proves it. Our read-back is currently blocked by an unrelated signing bug in the same script, which is its own small lesson: the verification path and the action path are different code, and the one you never run is the one that is broken.
Where this stops being about posting
Every bug report that contains the phrase "and it said it worked" is this.
The person clicked submit. Something returned a success state. A green tick appeared, or a confirmation page, or a toast. None of that is evidence the operation completed, because the confirmation was rendered by the same layer that would have needed to know it failed, and often that layer only knows a request was accepted.
Which means the sentence "I submitted it and it said it was fine" carries about as much information as our tweet id did. It is real, the person is not mistaken about what they saw, and it says nothing about whether the thing arrived.
The useful version of that report is not a better description of the confirmation. It is the request and the response that produced it, captured at the time, from the machine where it happened. Everything else is a screenshot of a green tick.
We had a week of green ticks. They were all genuine, and I still cannot tell you what was in the posts.
Top comments (0)