# How I Mapped an Undocumented Vendor API in 2 Days With Claude Code

> Source: <https://dev.to/yureki_lab/how-i-mapped-an-undocumented-vendor-api-in-2-days-with-claude-code-1c4d>
> Published: 2026-08-28 14:32:31+00:00

A vendor handed us a sandbox key, a 6-page PDF, and no OpenAPI spec. I used Claude Code to turn ~40 exploratory requests into an inferred schema, a typed client, and a contract test suite in two days. The trick was never letting the agent write types from the docs — only from captured responses. Here's the loop, plus the three times it confidently made things up.

We had to integrate a partner's billing API. What we got was:

No OpenAPI spec. No Postman collection. No SDK. The PDF said `amount`

was "an integer," which turned out to mean *minor units as a string* in three of the seven endpoints. It listed six fields on the invoice object; the real payload had thirty-one.

I've been down this road before, and the usual failure mode is nasty: you write a client against the docs, it works in sandbox, and then production returns a nullable field the docs never mentioned and your parser explodes at 2 AM. The docs aren't the contract. **The responses are the contract.**

So my constraint going in was simple: I wanted an integration where every type, every enum, and every nullability decision could be traced back to a real HTTP response I had actually observed — not to prose in a PDF, and not to a language model's prior about what a billing API "usually" looks like.

That second one matters more than people expect. If you paste a vague doc into an agent and ask for a TypeScript client, you will get a *beautiful* client. It will have `status: 'pending' | 'paid' | 'failed'`

because that's what billing APIs usually have. The vendor's actual enum was `PENDING | SETTLED | REVERSED | PARTIAL_REVERSED`

. Everything compiles. Nothing works.

The whole thing is a four-stage loop. The agent is allowed to be creative in stages 1 and 3, and is aggressively constrained in stages 2 and 4.

``` php
flowchart LR
    A[Agent proposes<br/>probe requests] --> B[Harness executes<br/>+ records to disk]
    B --> C[Agent infers schema<br/>from captured JSON only]
    C --> D[Contract tests run<br/>against captures]
    D -->|gaps / mismatches| A
```

The first thing I asked for wasn't code. It was a *list of questions about the API*:

Read the vendor PDF at

`docs/vendor-billing.pdf`

. Don't write any client code. Produce a list of HTTP requests that would resolve ambiguity in the docs — especially anything where a field's type, nullability, or enum values are unstated. For each request, say what you expect to learn.

This produced 41 probes, and a good chunk of them were things I wouldn't have thought to try:

`null`

?)`amount`

as an integer where the PDF example used a stringThat last one saved us. It's a 409 with a body shape that appears nowhere else in the API.

This is the load-bearing part. The agent does not get to hold response data in its context and then "remember" it later — that's exactly how you get hallucinated fields. Every response lands on disk as a file, and every later stage reads from disk.

``` python
# probe.py — Python 3.13, stdlib only on purpose
import hashlib, json, pathlib, time, urllib.request, urllib.error, os

CAPTURES = pathlib.Path("captures")
CAPTURES.mkdir(exist_ok=True)

def probe(name: str, method: str, path: str, body: dict | None = None) -> dict:
    url = f"{os.environ['VENDOR_BASE_URL']}{path}"
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(
        url, data=data, method=method,
        headers={
            "Authorization": f"Bearer {os.environ['VENDOR_SANDBOX_KEY']}",
            "Content-Type": "application/json",
        },
    )
    started = time.monotonic()
    try:
        with urllib.request.urlopen(req) as res:
            status, payload = res.status, res.read().decode()
            headers = dict(res.headers)
    except urllib.error.HTTPError as e:            # errors are data, not failures
        status, payload = e.code, e.read().decode()
        headers = dict(e.headers)

    record = {
        "name": name, "method": method, "path": path,
        "request_body": body, "status": status, "headers": headers,
        "response_body": json.loads(payload) if payload.strip() else None,
        "elapsed_ms": round((time.monotonic() - started) * 1000),
    }
    slug = hashlib.sha1(f"{name}{method}{path}".encode()).hexdigest()[:8]
    (CAPTURES / f"{name}-{slug}.json").write_text(
        json.dumps(record, indent=2, sort_keys=True)
    )
    return record
```

Two decisions in there that I'd defend:

**Errors are captured, not raised.** A 409 or a 422 is the most information-dense response an API gives you. If your harness throws on non-2xx, you throw away half your schema.

**Sorted keys, stable filenames.** Captures get committed. When the vendor silently ships a change, the diff shows up in a pull request instead of in an incident channel. We caught a new `settlement_reference`

field this way three weeks later.

Now the agent gets to be clever again, but with a hard boundary:

Read every file in

`captures/`

. Produce a JSON Schema for each distinct response shape. Rules: a field is optional only if it is absent in at least one capture. A field is nullable only if it is literally`null`

in at least one capture. Enum values are the exact set of observed strings — do not add plausible extras. For any field where you have fewer than 3 samples, list it under`low_confidence`

instead of guessing.

That `low_confidence`

bucket is the single highest-value line in the prompt. It came back with eleven fields, and it was right to flag all of them. Four were genuinely ambiguous and needed more probes. Three were vendor-side bugs. Here's what shipped in the final schema versus what the PDF claimed:

| Field | PDF says | Reality |
|---|---|---|
`amount` |
integer | string, minor units |
`status` |
"pending / paid / failed" | 4 uppercase values, none matching |
`customer.tax_id` |
required | absent for non-EU customers |
`line_items` |
array |
`null` when empty, `[]` after first edit |
`created_at` |
ISO 8601 | ISO 8601, but no timezone offset |

That `line_items`

row is my favorite. `null`

on create, `[]`

after any update. No human would document that, because no human knows.

The generated client is only trustworthy if something keeps it honest. Every capture becomes a test case, so the parser is verified against real bytes rather than against a mock somebody wrote by hand.

``` js
// contract.test.ts — TypeScript 5.x + Vitest
import { describe, expect, it } from "vitest";
import { readdirSync, readFileSync } from "node:fs";
import { parseInvoice } from "../src/vendor/parse";

const captures = readdirSync("captures")
  .filter((f) => f.startsWith("invoice-"))
  .map((f) => JSON.parse(readFileSync(`captures/${f}`, "utf8")));

describe("invoice parser vs. captured responses", () => {
  it.each(captures)("$name -> $status", (capture) => {
    if (capture.status >= 400) {
      expect(() => parseInvoice(capture.response_body)).toThrow();
      return;
    }
    const parsed = parseInvoice(capture.response_body);
    // no silent field drops: every key we received survives the round trip
    for (const key of Object.keys(capture.response_body)) {
      expect(parsed).toHaveProperty(key);
    }
  });
});
```

The round-trip assertion catches the quiet failure mode where a parser drops an unrecognized field and nobody notices for a month.

Total elapsed: about two days, most of it waiting on sandbox rate limits.

The difference between "here's the doc, write me a client" and "here are 41 JSON files, write me a client" is the difference between fiction and engineering. Once responses live in files, every claim the agent makes is checkable with `grep`

. I now treat this as the default shape for any integration work: **capture first, generate second.**

The 409 body taught me more about their internal state machine than the entire PDF. If you're planning probes, spend at least a third of them deliberately breaking things — duplicate operations, empty payloads, wrong types, expired resources.

An agent asked for a schema will always produce a schema. An agent asked for *a schema plus a low-confidence list* will tell you where it's guessing. That one extra instruction turned eleven silent landmines into eleven tickets. Any time you request a confident artifact, request the uncertainty alongside it.

Worth naming specifically, because the pattern is consistent:

`page_size`

to the pagination params. Most APIs have it. This one doesn't — it's `limit`

.`currency`

as a 3-letter ISO enum. The vendor returns lowercase for two currencies.`metadata`

as `Record<string, string>`

. Nested objects are allowed, undocumented, and we use them.Every one of these is what a competent engineer would assume. None survived contact with the captures. The failures weren't random — they were the model regressing to the *industry average* API. **The more standard the domain, the harder you have to anchor to observed data.**

They're your regression suite for the vendor's changes, not just yours. Ours have caught two undocumented vendor-side changes since. Cost: 400 KB in the repo.

Three things on the list:

Stack for anyone reproducing this: Claude Code CLI (August 2026), Python 3.13 for the harness, Node.js 22.x with TypeScript 5.x and Vitest for the client and tests. Nothing exotic — the leverage is entirely in the loop shape, not the tools.

If you take one thing from this: **when you point an AI agent at an integration, make real responses the only thing it's allowed to read.** Docs are a hypothesis. Captures are evidence. The agent is excellent at turning evidence into types and terrible at knowing when it has none.

I'm writing up more of these build logs as I go — following me here on Dev.to is the easiest way to catch them. And if you've got a vendor API horror story, drop it in the comments. I collect them. 🚀
