{"slug": "how-i-mapped-an-undocumented-vendor-api-in-2-days-with-claude-code", "title": "How I Mapped an Undocumented Vendor API in 2 Days With Claude Code", "summary": "A developer used Anthropic's Claude Code to reverse-engineer an undocumented vendor billing API in two days, turning roughly 40 exploratory HTTP requests into an inferred schema, a typed client, and a contract test suite. The key constraint was that the agent inferred types only from captured responses, never from vendor documentation, to avoid hallucinated fields. The approach involved a four-stage loop where the agent proposed probes, the harness executed and recorded responses to disk, the agent inferred schemas from those captures, and contract tests validated against them.", "body_md": "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.\n\nWe had to integrate a partner's billing API. What we got was:\n\nNo OpenAPI spec. No Postman collection. No SDK. The PDF said `amount`\n\nwas \"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.\n\nI'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.**\n\nSo 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.\n\nThat 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'`\n\nbecause that's what billing APIs usually have. The vendor's actual enum was `PENDING | SETTLED | REVERSED | PARTIAL_REVERSED`\n\n. Everything compiles. Nothing works.\n\nThe 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.\n\n``` php\nflowchart LR\n    A[Agent proposes<br/>probe requests] --> B[Harness executes<br/>+ records to disk]\n    B --> C[Agent infers schema<br/>from captured JSON only]\n    C --> D[Contract tests run<br/>against captures]\n    D -->|gaps / mismatches| A\n```\n\nThe first thing I asked for wasn't code. It was a *list of questions about the API*:\n\nRead the vendor PDF at\n\n`docs/vendor-billing.pdf`\n\n. 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.\n\nThis produced 41 probes, and a good chunk of them were things I wouldn't have thought to try:\n\n`null`\n\n?)`amount`\n\nas 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.\n\nThis 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.\n\n``` python\n# probe.py — Python 3.13, stdlib only on purpose\nimport hashlib, json, pathlib, time, urllib.request, urllib.error, os\n\nCAPTURES = pathlib.Path(\"captures\")\nCAPTURES.mkdir(exist_ok=True)\n\ndef probe(name: str, method: str, path: str, body: dict | None = None) -> dict:\n    url = f\"{os.environ['VENDOR_BASE_URL']}{path}\"\n    data = json.dumps(body).encode() if body is not None else None\n    req = urllib.request.Request(\n        url, data=data, method=method,\n        headers={\n            \"Authorization\": f\"Bearer {os.environ['VENDOR_SANDBOX_KEY']}\",\n            \"Content-Type\": \"application/json\",\n        },\n    )\n    started = time.monotonic()\n    try:\n        with urllib.request.urlopen(req) as res:\n            status, payload = res.status, res.read().decode()\n            headers = dict(res.headers)\n    except urllib.error.HTTPError as e:            # errors are data, not failures\n        status, payload = e.code, e.read().decode()\n        headers = dict(e.headers)\n\n    record = {\n        \"name\": name, \"method\": method, \"path\": path,\n        \"request_body\": body, \"status\": status, \"headers\": headers,\n        \"response_body\": json.loads(payload) if payload.strip() else None,\n        \"elapsed_ms\": round((time.monotonic() - started) * 1000),\n    }\n    slug = hashlib.sha1(f\"{name}{method}{path}\".encode()).hexdigest()[:8]\n    (CAPTURES / f\"{name}-{slug}.json\").write_text(\n        json.dumps(record, indent=2, sort_keys=True)\n    )\n    return record\n```\n\nTwo decisions in there that I'd defend:\n\n**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.\n\n**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`\n\nfield this way three weeks later.\n\nNow the agent gets to be clever again, but with a hard boundary:\n\nRead every file in\n\n`captures/`\n\n. 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`\n\nin 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`\n\ninstead of guessing.\n\nThat `low_confidence`\n\nbucket 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:\n\n| Field | PDF says | Reality |\n|---|---|---|\n`amount` |\ninteger | string, minor units |\n`status` |\n\"pending / paid / failed\" | 4 uppercase values, none matching |\n`customer.tax_id` |\nrequired | absent for non-EU customers |\n`line_items` |\narray |\n`null` when empty, `[]` after first edit |\n`created_at` |\nISO 8601 | ISO 8601, but no timezone offset |\n\nThat `line_items`\n\nrow is my favorite. `null`\n\non create, `[]`\n\nafter any update. No human would document that, because no human knows.\n\nThe 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.\n\n``` js\n// contract.test.ts — TypeScript 5.x + Vitest\nimport { describe, expect, it } from \"vitest\";\nimport { readdirSync, readFileSync } from \"node:fs\";\nimport { parseInvoice } from \"../src/vendor/parse\";\n\nconst captures = readdirSync(\"captures\")\n  .filter((f) => f.startsWith(\"invoice-\"))\n  .map((f) => JSON.parse(readFileSync(`captures/${f}`, \"utf8\")));\n\ndescribe(\"invoice parser vs. captured responses\", () => {\n  it.each(captures)(\"$name -> $status\", (capture) => {\n    if (capture.status >= 400) {\n      expect(() => parseInvoice(capture.response_body)).toThrow();\n      return;\n    }\n    const parsed = parseInvoice(capture.response_body);\n    // no silent field drops: every key we received survives the round trip\n    for (const key of Object.keys(capture.response_body)) {\n      expect(parsed).toHaveProperty(key);\n    }\n  });\n});\n```\n\nThe round-trip assertion catches the quiet failure mode where a parser drops an unrecognized field and nobody notices for a month.\n\nTotal elapsed: about two days, most of it waiting on sandbox rate limits.\n\nThe 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`\n\n. I now treat this as the default shape for any integration work: **capture first, generate second.**\n\nThe 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.\n\nAn 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.\n\nWorth naming specifically, because the pattern is consistent:\n\n`page_size`\n\nto the pagination params. Most APIs have it. This one doesn't — it's `limit`\n\n.`currency`\n\nas a 3-letter ISO enum. The vendor returns lowercase for two currencies.`metadata`\n\nas `Record<string, string>`\n\n. 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.**\n\nThey'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.\n\nThree things on the list:\n\nStack 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.\n\nIf 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.\n\nI'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. 🚀", "url": "https://wpnews.pro/news/how-i-mapped-an-undocumented-vendor-api-in-2-days-with-claude-code", "canonical_source": "https://dev.to/yureki_lab/how-i-mapped-an-undocumented-vendor-api-in-2-days-with-claude-code-1c4d", "published_at": "2026-08-28 14:32:31+00:00", "updated_at": "2026-08-28 14:50:22.862725+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence", "large-language-models"], "entities": ["Claude Code", "Anthropic"], "alternates": {"html": "https://wpnews.pro/news/how-i-mapped-an-undocumented-vendor-api-in-2-days-with-claude-code", "markdown": "https://wpnews.pro/news/how-i-mapped-an-undocumented-vendor-api-in-2-days-with-claude-code.md", "text": "https://wpnews.pro/news/how-i-mapped-an-undocumented-vendor-api-in-2-days-with-claude-code.txt", "jsonld": "https://wpnews.pro/news/how-i-mapped-an-undocumented-vendor-api-in-2-days-with-claude-code.jsonld"}}