AI-Driven Development

Read the status code before you believe the README

Six documented endpoints, two that work. Probing an API's real surface takes a minute and changes the architecture you build on top of it.

MobileByteSensei 8 min read
Six documented doors, two of them open, the rest answering with a number
aimcpagentsapi

I built an integration against six documented endpoints. Four of them do not exist. Finding that out took about a minute and would have taken a week if I had discovered it feature by feature.

The case this post is about

Six requests, under a minute, against a documented API:

POST   /v1/publications/{id}/posts   201
GET    /v1/me                        200
GET    /v1/users/{id}/posts          405   Method Not Allowed
GET    /v1/posts/{id}                404
PUT    /v1/posts/{id}                404
DELETE /v1/posts/{id}                404

The third line is the interesting one. 405 rather than 404 means the path is routed and nothing answers GET on it — the documentation was not wrong about the URL, and no amount of re-reading it would have revealed that nothing is listening.

The bottom three mean there is no read-back, no update and no delete. This post follows those six status codes, because every design decision downstream of them follows from facts that took one minute to establish and would otherwise have arrived in month two.

The symptom

The documentation described a normal REST surface: create a post, list your posts, update one, delete one. I wrote code against all of it. Then I probed it:

POST   /v1/publications/{id}/posts   201
GET    /v1/me                        200
GET    /v1/users/{id}/posts          405   Method Not Allowed
GET    /v1/posts/{id}                404
PUT    /v1/posts/{id}                404
DELETE /v1/posts/{id}                404

Two work. The API can create a post and tell me who I am. It cannot list, read, update, or delete anything it created.

The two codes say different things and both matter. 405 means the path is routed but the method is not allowed — the server knows about /v1/users/{id}/posts and refuses GET on it. 404 on a path the docs describe usually means the route was removed entirely, not that my particular id was missing.

Why the obvious fix failed

My first instinct was that I had it wrong — bad token scope, wrong id format, a trailing slash. All reasonable, all cheap to eliminate:

  • The same token creates posts successfully, so it is not authentication.
  • GET /v1/me returns my real user id, so it is not an id-format problem.
  • A deliberately invalid id returns the same 404 as a valid one, which is the tell. If the route existed, a bad id and a good id would answer differently.

That last check is the one worth keeping. A route that returns the same status for a valid and an invalid identifier is not checking the identifier. It is not there.

The second instinct — that the docs were merely stale — was also wrong in an important way. They were not stale; they were describing an API that had been frozen years earlier while the product moved on. Nothing in the document says so. Documentation has no obligation to announce its own obsolescence, and it usually cannot, because whoever stopped maintaining the API also stopped maintaining the page.

The actual mechanism

The probe is the same shape every time and takes a minute:

for m in GET PUT DELETE; do
  printf '%-6s %s ' "$m" "$URL"
  curl -s -o /dev/null -w '%{http_code}\n' -X "$m" \
       -H "Authorization: Bearer $TOKEN" "$URL"
done

Read the results against a small table rather than by feel:

Code What it usually means
200/201 works
401/403 exists; your credential is the problem
404 on a valid id route is gone — not your id
405 path is routed, this method is not allowed
410 explicitly withdrawn, which is rare and honest
501 advertised and unimplemented

Run it with a valid identifier and an invalid one. Identical responses mean the identifier is not being consulted, which means the handler is not there.

The fix

The point of the probe is not the list; it is that the result changes what you build.

Knowing the API was create-only moved three things:

  • No read-back. I had planned to create, then fetch the result to confirm. There is no fetch. Confirmation had to come from the create response and a local record written immediately.
  • No cleanup. With no DELETE, a mistaken create is permanent. Every guard moved to before the request, and batches became bounded and serial rather than fire-and-check.
  • Discovery moved out of band. With no list endpoint, "what have I published?" had to be answered another way entirely — a different protocol, a different surface, a different set of failure modes. That is a substantial piece of work, and it was better to know on day one than in month two.

None of those are workarounds for a bug. They are the correct design for the API that exists, and I could only reach them by finding out what exists.

The probe itself is worth writing down, because the thing that makes it useful is that it records what it found rather than printing it:

type Probe = { verb: string; path: string; body?: unknown };
type Result = { verb: string; path: string; status: number; verdict: string };

const VERDICT: Record<number, string> = {
  200: "exists",
  201: "exists (created)",
  401: "exists — auth rejected, not absent",
  403: "exists — forbidden for this token",
  404: "NO SUCH RESOURCE",
  405: "PATH EXISTS, VERB DOES NOT",
};

async function probe(base: string, token: string, probes: Probe[]): Promise<Result[]> {
  const out: Result[] = [];
  for (const p of probes) {
    const res = await fetch(`${base}${p.path}`, {
      method: p.verb,
      headers: { authorization: `Bearer ${token}` },
      body: p.body ? JSON.stringify(p.body) : undefined,
    });
    // 404 and 405 are DIFFERENT facts and the distinction is the whole point:
    // 405 means the path is routed and this verb is not implemented.
    out.push({ ...p, status: res.status, verdict: VERDICT[res.status] ?? `unmapped ${res.status}` });
  }
  return out;
}

Run against the publishing API, with a known-good id and no destructive bodies:

const results = await probe(BASE, token, [
  { verb: "GET",    path: `/v1/me` },
  { verb: "GET",    path: `/v1/users/${userId}/posts` },
  { verb: "GET",    path: `/v1/posts/${knownId}` },
  { verb: "PUT",    path: `/v1/posts/${knownId}`, body: { title: "probe" } },
  { verb: "DELETE", path: `/v1/posts/${knownId}` },
]);
for (const r of results) console.log(`${r.verb.padEnd(6)} ${r.path.padEnd(34)} ${r.status}  ${r.verdict}`);
GET    /v1/me                             200  exists
GET    /v1/users/1a2b3c/posts             405  PATH EXISTS, VERB DOES NOT
GET    /v1/posts/9f2c1b05                 404  NO SUCH RESOURCE
PUT    /v1/posts/9f2c1b05                 404  NO SUCH RESOURCE
DELETE /v1/posts/9f2c1b05                 404  NO SUCH RESOURCE

Five requests, under a minute, and the shape of the whole integration is in those status codes. The second line is the interesting one: 405 rather than 404 means the documentation was not wrong about the path — a list route is there and nothing answers GET on it, which is a different and more permanent fact than a typo in a URL. Lines three to five are why there is no read-back and no cleanup, and the DELETE returning 404 on a real id is the one I re-ran twice before believing.

How we proved it

The probe is falsifiable, so here is it failing to be wrong. With a valid post id:

GET    /v1/posts/{real-id}     404

With an id I invented:

GET    /v1/posts/deadbeef      404

Same code. The server is not looking at the id. Compare with the endpoint that does work, where a bad publication id produces a different failure than a good one — that is what a live handler looks like.

And the create, for contrast, on the same token in the same minute:

POST   /v1/publications/{id}/posts   201    post_id=8d5201c9c6bb

So it is not credentials, not scope, not the base URL. Four routes are gone and two are not.

Where the probe misleads you

It is a cheap check, not an infallible one, and three cases will lie to you.

A gateway in front of the API. Some proxies return 404 for routes the caller is not entitled to reach, so an endpoint you lack scope for looks identical to one that does not exist. The tell is whether any method on that path behaves differently — if OPTIONS answers and GET 404s, there is something there.

Rate limiting mid-probe. Six requests in a loop is enough to trip an aggressive limiter, and a 429 partway through turns the rest of your results into noise. If codes start changing between runs, space the requests out and re-probe rather than believing the first table you got.

Methods with side effects. DELETE against a real id is not a probe — if the route does exist, you have just used it. Probe destructive methods against an id you know to be invalid, and accept that a 404 there tells you less. The asymmetry is worth it: a wrong guess about GET costs nothing, a wrong guess about DELETE costs an object.

There is also a limit worth naming: the probe tells you a route responds, not that it does what the documentation says. An endpoint can return 200 and quietly ignore half the fields you send. That is the failure I hit next — a create that returned 201 having silently dropped the field routing it to the right destination — and no amount of status-code reading would have caught it. Response codes map the surface; only reading the created object back tells you the semantics, and on an API with no read endpoint you find out from a human noticing.

What this generalizes to

Treat a documented endpoint as a claim with a cost attached: the cost of finding out it is false at the moment you need it most. The verification is one loop over the methods you intend to use, compared against a valid and an invalid identifier, and it belongs at the start of the integration rather than in the middle. A minute spent reading status codes is the cheapest architectural decision available.

Record what you find somewhere the next person will trip over it, with the codes attached. I wrote the six lines above into the integration's own documentation, and the value was not that I would forget — it was that the next reader arrives believing the vendor's page, and a table of observed responses is the only thing that argues back. Undocumented removals do not announce themselves twice.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS