The post that went to the wrong account
A publication identifier is an optional field that silently defaults. The API reports success either way, so the only way to know is to verify the response.
A post is created through an API with every field correct except one that was omitted. The call succeeds, returns a URL, and the post exists.
It is on a personal profile rather than the publication, and nothing in the response says so in words anybody reads.
The case this post is about
One optional field, omitted:
if (!expectPublication) {
throw new Error("publicationId is empty — refusing; this would post to the personal profile");
}
Without that refusal the call succeeds. It returns 201, it returns a URL, and the post is on a
personal profile rather than the publication it was written for — because an absent publication
identifier is not an error at the API, it is a different valid destination.
There is no update endpoint and no delete endpoint, so the post stays where it landed.
This post follows that one field, because "absent" and "chosen" are indistinguishable to the receiver and the local check is the only place they can be told apart.
The symptom
A post that is live and in the wrong place. Not failed, not pending — published, correctly formatted, with the right title and body, under an account that has none of the publication's followers, none of its archive, and none of its standing with the platform's recommendation system.
The second thing is that the response looks like success because it is success. A created resource, a two-hundred status, a URL. Everything a caller checks is fine, and the only trace of the problem is inside the URL itself — a path segment that differs between the two destinations and that nobody is comparing, because nobody expected there to be two destinations.

The third is that the field is optional, which is correct API design and a trap for this specific case. Optional means "may be omitted", and a reasonable reading of that is "omitting it is one of the supported ways to use this". It is — the support is that the post goes to the personal profile, which is a sensible default for most callers and the wrong one for this one.
The asymmetry is worth noticing because it decides who gets caught. A default serves the majority case by construction, so the callers it fails are exactly the ones doing something slightly less common — and those callers are the least likely to find a warning in the documentation, because the documentation was written for everybody else.
The fourth is how it gets omitted. Not by somebody deciding to leave it out: by a code path that builds the payload from a post's frontmatter, encountering a post whose frontmatter lacks the key, and constructing a valid request without it. The omission is in the data, the request is correct, and no layer between them has an opinion.
That distribution of responsibility is what makes it survive review. The code is right, the API is right, the schema permits it, and the fault lives in one post's frontmatter — a file nobody reviews as infrastructure because it is content.
Why the obvious fix failed
Set the field. Of course, and it is already set on almost every post. The failure is the one where it is not — a post created from a template that predates the field, or hand-written, or copied from an example. "Set the field" is a rule about every future post forever, which is not a mechanism.
Make the field required in the local schema. Better, and it catches posts missing the key entirely while passing anything with an empty or wrong value. A key present and blank is the common shape, because that is what a scaffold produces.
Check the response status. Checks nothing relevant. The status is identical for both destinations, which is the entire difficulty — a successful call to the wrong place is not distinguishable from a successful call to the right one by any status code.
Move it afterwards. The platform I use has no API for this, and the manual path in the interface does not reliably preserve the URL — so a post moved after the fact may acquire a new address, breaking any link already shared and orphaning the canonical relationship that was the point of syndicating in the first place.
The actual mechanism
Verify the destination in the response, not the request you sent.
send the payload, with the publication identifier present and non-empty
read the response's own account or publication field, or the returned URL
compare it against the destination you intended
fail loudly, before anything else proceeds, if they differ
The second line is the whole idea and it is a general discipline rather than a trick for this API. What you sent is a statement of intent; what came back describes what happened. When those can differ silently, only the second one is evidence — and checking it costs one comparison against a value you already have.

The sub-case worth handling explicitly is the empty string, because it is the one a schema check usually misses. A frontmatter key present with no value serialises to a field that is technically supplied and semantically absent, and most validation treats presence as sufficient. The check has to be non-empty rather than present, and the distinction is exactly where the scaffolded posts land.
There is a related property of this failure that makes verification worth more than usual: it is not reversible. A post on the wrong account cannot be moved without cost, and the cost lands on whatever link has already been shared. Verification is most valuable precisely where the recovery is poor, which is also where it is least often implemented.
There is a cheap partial that is worth taking even when the full check feels like too much: record the destination you observed, per item, at the time. Even without comparing it to anything, having it written down converts "we think they all went to the right place" into a column somebody can scan later — which is how the backfill below became possible at all.
The reason for that inversion is not mysterious. Verification gets built where failures are frequent and visible, because that is where somebody is annoyed often enough to act. A failure that happens rarely and cannot be undone produces no steady annoyance at all — just one bad afternoon, long after the decision not to check.
The fix
require the identifier non-empty, locally, before any call
verify the destination from the response, every time
halt the run on a mismatch, rather than recording and continuing
check existing posts by reading their URLs, once, as a backfill
The destination is verified from the response rather than from what was requested — those differ exactly when it matters:
export async function createPost(body: PostBody, expectPublication: string) {
// 1. REQUIRE locally. An empty id silently means "personal profile" at the API,
// which is a successful call to the wrong place.
if (!expectPublication) {
throw new Error("publicationId is empty — refusing; this would post to the personal profile");
}
const res = await api.post({ ...body, publicationId: expectPublication });
// 2. VERIFY from the RESPONSE. The request said where we wanted it; only the
// response says where it went.
const landed = publicationOf(res.url);
if (landed !== expectPublication) {
throw new Error(
`post landed on ${landed ?? "the personal profile"}, expected ${expectPublication}\n` +
` url: ${res.url}\n` +
` this post exists and is in the wrong place — move or delete it by hand.`,
);
}
return res;
}
The failure it catches, which the API reports as success:
$ create --publication "" # a config read that returned nothing
error: publicationId is empty — refusing; this would post to the personal profile
$ create --publication c7d41f24be0f # id valid but membership lapsed
error: post landed on the personal profile, expected c7d41f24be0f
url: https://medium.com/@therajanmaurya/a-post-9f2c1b05
this post exists and is in the wrong place — move or delete it by hand.
The second case is the one worth the code. The call returned 201, the post exists, and nothing except reading the returned URL distinguishes it from a correct publish.
The fourth line is the one that found the problem. Reading the account segment out of every previously-created URL is a few lines over a list you already have, and it answers "has this already happened" — a question nobody asks until they have a reason to, by which time there is a backlog rather than an incident.
What makes the forward check cheap is that both halves are already in hand. The intended destination is in the post's frontmatter, the actual one is in the response, and the comparison is a string equality. There is nothing to build beyond deciding to look.

That phrase covers most of what I have added to this pipeline over a year. Almost none of it was a mechanism that did not exist; it was comparing two values that were both already present and that nothing had been asked to compare.
How we proved it
The two destinations are distinguishable in the returned URL, which is what makes the check possible at all. The path differs, consistently, between a publication post and a personal one — so the verification is reading a value that is already being returned and already being ignored.
The empty-value case is reproducible deliberately. Create a post from a scaffold whose publication key is present and blank, send it, and read the result. The call succeeds and the post is personal. That takes a minute and it demonstrates why presence checks are insufficient without anybody needing to argue about schemas.
What I cannot tell you is how many posts this affected before I looked. The backfill check tells me the current state; it does not tell me whether anything was moved, deleted or re-created in the past, because none of that was recorded at the time. The honest answer is the present state is correct and the history is unknown.
What I have not solved: the move. A post already on the wrong account stays there, because moving it is a manual operation that may change the URL, and a changed URL breaks the canonical link and any share that already went out. The least-bad option I have found is to leave it and fix the process, which is unsatisfying and is what I did.
Writing that down was itself useful. An unfixed thing with a stated reason is a different object from an unfixed thing nobody has looked at, and the difference shows up the next time somebody asks why the archive is not uniform — there is an answer, and it is not "we never noticed".
And a limitation of response verification generally: it tells you what the platform says it did, which is better than what you asked for and is still a report rather than an observation. If the response's own field were wrong, this check would confirm the error confidently — which has not happened to me and is the residual risk in trusting any self-report, including this one.
The only stronger evidence available is to fetch the created resource back and look at it, which is a second request and a different trust assumption rather than none. At some point every check bottoms out in believing something; the useful distinction is between believing a system's description of what it did and believing your own description of what you asked for.
The thing that reframed it was noticing I had been treating "the request was correct" and "the right thing happened" as the same claim. They are different claims with different evidence, and every API that defaults silently makes the gap between them real. Once that was clear, checking the response stopped feeling like paranoia and started feeling like the only thing that was actually being asked.
What this generalizes to
An optional field with a sensible default is a silent fork in behaviour, and the fork is invisible from the caller's side.
The pattern is everywhere in integration work. An environment that defaults to production. A region that defaults to the provider's home. A visibility that defaults to public. A currency that defaults to the account's. In each case omitting the field is legal, the call succeeds, and the behaviour you got is one the API considers correct — which means no error will ever tell you.
So the question worth asking of any call that matters is what does this do if I leave each field out, answered deliberately rather than discovered. Where the answer is "something reasonable and not what you want", the field is not optional for you, and the response is the only place the truth is written down.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".