Skip to content

Home/Blog/An AI invented an endpoint for our API. We shipped it.

An AI invented an endpoint for our API. We shipped it.

A model wrote a working-looking demo against a URL, a field name, and a response shape that did not exist. You cannot patch a model, so we made the guess true — and kept one rule about which guesses to honour.

A user sent us a bug report that was really a screenshot of someone else’s chat. They had asked an AI assistant to build a web page that erases text from an image using our API, and it produced a complete, confident little demo: file input, preview pane, fetch call, error handling. It did not work. The error it reported was CORS.

It was not CORS.

What the model got wrong

The generated page called this:

POST https://erasetext.com/api/mcp/erase
Authorization: Bearer et_…
FormData: image = <File>
→ reads JSON { output_url }

Our API lives on a different host. The path is /v1/erase. The documented field is image_file. A success returns image bytes, not JSON. And /api/mcp/erase did not exist anywhere: the model had welded “MCP” — which we do run, as JSON-RPC for agent runtimes — onto an HTTP path, which is not what MCP is. It also invented x/y/w/h crop parameters, then closed by recommending a Node proxy to work around the CORS it had diagnosed.

What it got right

Everything structural. POST, multipart FormData, a file field, a key in a header, and a JSON body carrying a URL you can drop straight into an img tag. That is the average of ten thousand image APIs, and the average is a reasonable prior. The model was not being careless. It filled the parts of our API it could not see with the parts almost every other API has.

The error was not CORS

Preflight passed, the POST failed, and the browser surfaced an opaque network error with no readable status. In a console that looks exactly like CORS, and every assistant will tell you it is CORS. What actually happened is duller: the marketing domain is a static site, so a POST to an invented path under it never reached an API at all. Once you see that, the fix stops being about headers.

You cannot patch the model

We could have replied with the correct snippet, and that would have fixed one person. The next assistant, tomorrow, generates the same URL — because it is generating from the same priors, not from our docs.

Which reframes the problem: a wrong guess that a thousand people will make is not a support ticket, it is an unclaimed route on your own domain. URLs are cheap. So we made the guess true.

What we shipped

  • The invented paths — /api/mcp/erase, /api/erase, /mcp/erase, and a bare /erase — map to the same upstream as /v1/erase.
  • Authorization: Bearer et_… is copied into X-Api-Key, so either header works.
  • The form field image is accepted next to image_file.
  • The loose paths answer with JSON, including output_url as a data URL — the exact shape the generated code was already trying to read.
  • The marketing apex routes /api/* to the API worker, because that is the host models pick.

The router, in the Cloudflare Worker that fronts the API:

export function apiUpstreamPath(pathname) {
  const p = pathname.replace(/\/+$/, "") || "/";
  if (
    p === "/erase" ||
    p === "/v1/erase" ||
    p === "/api/erase" ||
    p === "/api/mcp/erase" ||
    p === "/mcp/erase"
  ) {
    return "/erase";
  }
  if (p === "/account" || p === "/v1/account") return "/account";
  if (p.startsWith("/v1/") && p.length > 4) return p.slice(3);
  return null;
}

/** Hallucinated "MCP HTTP" paths: return JSON { output_url } for <img src>. */
export function wantsDemoJson(pathname) {
  const p = pathname.replace(/\/+$/, "") || "/";
  return p === "/api/mcp/erase" || p === "/api/erase" || p === "/mcp/erase";
}

Header aliasing is three lines, and worth more than it looks:

const bearer = /^Bearer\s+(et_\S+)/i.exec(
  request.headers.get("Authorization") || "",
);
if (bearer && !proxied.headers.get("X-Api-Key")) {
  proxied.headers.set("X-Api-Key", bearer[1]);
}

The JSON envelope is the part worth explaining. Our real success response is raw image bytes, which is right for a pipeline and wrong for a generated demo whose very next line is res.json(). So on the compatibility paths only, the bytes get wrapped:

const dataUrl = `data:${mime};base64,${b64}`;

return Response.json({
  output_url: dataUrl,     // what the generated code read
  output_base64: dataUrl,  // …and its second guess
  image_base64: b64,
  content_type: mime,
});

One deploy later, the demo that had been pasted at us ran unchanged apart from the API key:

curl -i -X POST https://erasetext.com/api/mcp/erase \
  -H 'Authorization: Bearer et_…' \
  -F '[email protected]'
# 200 application/json  { "output_url": "data:image/webp;base64,…" }

The rule we did not break

Aliasing a URL costs nothing and changes no behaviour. Accepting an invented parameter is a different animal. Those x/y/w/h crop fields are still a 400 with a machine-readable code, because pretending to support them would mean silently ignoring what a caller asked for.

That is the line we drew: honour guesses about where the thing is, refuse guesses about what the thing does.

Then tell the next model the truth

Aliases are a safety net, not a strategy. In the same week we made the contract machine-readable where assistants actually look: an OpenAPI document, an llms.txt with a plain-language section for browser demos, and a hosted playground that can be copied wholesale. Our MCP handshake now returns an instruction that amounts to: if you are generating a web page, do not call this JSON-RPC endpoint — POST FormData to the HTTP path instead.

A handshake string is one of the few chances you get to correct a model at the moment it is deciding.

If you run an API

  1. Read failing generated code as traffic data. It tells you which URL the world believes you have.
  2. Claim those guesses on your own domains — especially the marketing apex, before someone else’s 404 becomes your bug report.
  3. Never let “CORS” in an assistant’s diagnosis end the investigation. Check the status code on the real host first.
  4. Alias paths and headers freely. Do not invent semantics to match a hallucination.
  5. Publish the contract where machines read it: OpenAPI, llms.txt, an MCP handshake string, and a demo page worth copying.

Our docs still name one canonical call. The aliases exist so that being wrong about it is survivable. Contract, playground, and llms.txt are linked from /docs/api/ — same paths the assistants are meant to read next time.

Ready to erase text on your own image?

Free upload, no sign-in — results in a few seconds. See all features or Help & FAQs if you need the tool map first.

Upload Image