DEV Community

John
John

Posted on • Originally published at hexisteme.github.io

Government Open-Data APIs: Every Guess I Made Was Wrong Until I Probed It Live

Originally published on hexisteme notes.

I'm building a travel app on top of Korea's government open-data portal, data.go.kr — arrival and departure congestion, airport transit time, flight schedules. I shipped the integration the honest way the first time around: I couldn't get every operation name and field confirmed before launch, so I marked the uncertain ones as an unconfirmed best-effort guess in the code, and wrapped every one of them in a degrade path, so a wrong guess could only fall back to unknown or empty, never assert a false value to a user. That discipline turned out to be the only thing that saved the release, because once the proxy went live and I could finally test the guesses against the real upstreams, every one of them was wrong somewhere. Not "slightly off." Wrong in four structurally different ways, and each one taught a different lesson about what it means to build on a government API you don't control.

Live capture is the only ground truth

The proxy — a Worker sitting between the app and data.go.kr, holding the API key server-side — was deployed and live. That mattered, because it meant I could finally test against reality instead of documentation. I added a temporary, token-guarded /debug/raw probe to the Worker so I could fire real requests through the same server-held key the app itself uses, capture the raw upstream response, and see exactly what came back. It was removed again in the same change — never shipped in the tree — but for the short time it existed it did more for correctness than every research pass I'd done up to that point combined. The key insight: the key is server-held, so there's no way to test the real upstream from outside the Worker. A live capture through that key was the only ground truth available. Documentation, spec pages, and even other developers' GitHub clients were all downstream of that same live behavior — some of them were current, some weren't, and nothing but a live call could tell me which.

Beat one: the right-shaped guess, the wrong operation name

The arrival congestion signal (dataset 15095061) is the clearest case, because it looked like a small mistake and turned out to be two mistakes stacked on each other. The guessed operation was B551177/arrivalCongestion/getArrivalCongestionRT — a plausible name, matching the dataset's own description. It returned HTTP 500. The real operation, found only by capturing a working live call, is B551177/StatusOfArrivals/getArrivalsCongestion. Called correctly, it returns 200 NORMAL SERVICE with per-flight, per-entry-gate rows — terno, entrygate, korean, foreigner, scheduletime, estimatedtime, airport, gatenumber, flightid — where korean and foreigner are waiting-passenger counts as strings, like "42.0" for flight OM309 at gate B. There is no congestion-grade field anywhere in that response. The field my proxy code was reading, item.congestion, never existed. It wasn't a typo or an off-by-one on a real field — it was a field I had invented because a "congestion" dataset sounded like it should return a congestion grade. It doesn't. It returns raw per-gate passenger counts, and the grade — if you want one — is something you compute yourself, on thresholds you pick and document as policy, not something you read off the wire.

Beat two: a 404 that had nothing to do with the key

Departure congestion (15095066, the "승객예고" forecast dataset) looked, on paper, like a sibling of the arrival dataset that had just worked. Research had already confirmed the real operation name, getfPassengerNoticeIKR, and the real fields (t1sumset2 / t2sumset2, passenger counts per time slot per terminal). So this one should have been the easy fix — same key, same gateway, correct path this time. It returned 404 "API not found", on the correct path, using the exact same key that had just made 15095061 succeed. The natural assumption — the one baked into my own earlier ADR — was that one approved key unlocks every dataset a provider publishes. It doesn't. data.go.kr approval is granted per dataset, not per key or per provider. The 404 wasn't a routing bug or a stale cache; it was the portal telling me, as plainly as a 404 can, that this particular dataset had never been approved for this key, even though a structurally identical dataset from the same provider had been. I ended up not fixing that dataset at all — I switched departure congestion to a different one entirely, 15148225 ("출국장 혼잡도 조회"), which the owner had been approved for, and which turned out to be a materially better signal anyway: live per-gate waitTime in minutes plus waitLength queue headcount, on a roughly one-minute cadence, instead of a forecast count. But the lesson isn't "always have a backup dataset." It's that "the key works" and "the key is approved for this dataset" are two different facts, and a government portal will let you discover the gap with a 404 that looks exactly like every other kind of 404.

Beat three: the dataset that quietly stopped existing

Transit time (15095478) was the strangest one, because the guess wasn't even wrong in the usual sense — it was aimed at a dataset that no longer existed. 15095478 had been discarded on data.go.kr. Its live successor is a different dataset entirely, 15158950 ("한국공항공사_공항 소요시간 정보_GW", provider 한국공항공사, auto-approved), with a different resource prefix, B551178, on the standard apis.data.go.kr gateway. An earlier research draft had guessed the endpoint lived on api.odcloud.kr/…/aprtWaitTimeV2 instead — a plausible guess, since some data.go.kr datasets really do live on that gateway — and that guess returned 401 등록되지 않은 인증키. A 401 reads like an authentication failure. It wasn't one. It was the wrong gateway entirely, returning the specific error an unregistered key produces on that gateway, while the correct endpoint on apis.data.go.kr/B551178/airport-process-time/v1 worked fine with the same key the whole time. That 401 was a pure red herring — chasing it as a credentials problem would have burned time on the wrong layer of the stack. The correct endpoint returns walk-time as STY_TCT_AVG_ALL, a field in seconds, not minutes — an early capture returned 1339.0 for Gimpo, and the value moves with real airport conditions since it's a live measurement, not a static number. Once wired to the right gateway and the right field, GMP and CJU transit time upgraded from a static walk-time guess to a measured fact, live-verified at the correct endpoint.

Beat four: a parameter that means the other end of the flight

The flight schedule dataset (15095059, PaxFltSched) is the one case where the field mapping was actually correct on the first guess — flightid, airline, st, airportcode, and monday through sunday as Y/N flags, verified live with an ICN-to-NRT query returning 205 rows. What was wrong was an assumption about what the airport parameter means. I'd assumed airport meant the airport the flight departs from — the one the app already knows it's at. It doesn't. For a departures dataset, airport filters by the destination — the other end of the flight. Query with airport=GMP expecting "flights out of Gimpo" and you get an empty array, not because anything is broken, but because the dataset is asking "which departures are headed to Gimpo," and this particular dataset only covers Incheon's board in the first place — it can never enumerate GMP or Jeju's own domestic departures no matter what you pass. That's not a bug to fix; it's a dataset that structurally cannot answer the question the feature wanted answered, and the honest fix was to stop filtering by that parameter for Incheon at all — serve the full ICN board (live-verified at 2,887 departures) and do the destination and weekday filtering locally in the app — while leaving GMP/CJU domestic autocomplete as the empty-array degrade it already was, and recording plainly that answering it for real needs a different dataset from a different provider, deferred rather than faked.

What this looks like generalized past Korea

None of these four failures were caused by carelessness, and only some of them were the kind of thing a closer reading of the docs would have caught — the docs described a system that had moved on in places and hadn't in others: a guessed operation name that reality never matched, a dataset discarded and replaced on a different gateway, an approval scope that documentation can't represent because it's a property of your key, not of the API. This is not a Korea-specific problem, and it's not a data.go.kr-specific problem. It's what building on any government open-data API is like, anywhere: the portal is not the ground truth, the docs are not the ground truth, and the sample code from three years ago on GitHub is not the ground truth. The only ground truth is a live call, made with the real credentials, captured and read literally.

Two things followed from treating that as a design constraint rather than a one-time debugging exercise. First, build the temporary probe as a first-class, disposable tool — token-guarded so it can't be abused, routed through the same server-held key the app uses so it tests the real path, and removed once it's done its job. It is not a debugging convenience; for a server-held-key architecture it is the only way to see what the upstream actually does, because nothing outside the proxy can see it. Second — and this is the part that made shipping the guesses safe in the first place — every one of these four integrations was already wrapped in a degrade path before a single guess was verified. Wrong operation name degrades to empty. Unapproved dataset degrades to empty. Discarded dataset degrades to a static fallback. Wrong parameter semantics degrades to an honest empty array instead of a plausible-looking but fabricated result. Because every guess could only ever fall back and never assert, being wrong four times in four different ways cost nothing except the wasted guesses themselves — no user ever saw a fabricated congestion grade or an invented transit time in the meantime. That's the actual takeaway: you cannot know in advance which of your API integrations are wrong, or in which of the several distinct ways they'll be wrong, but you can build every one of them so that being wrong degrades instead of lies.

More notes at hexisteme.github.io/notes.

Top comments (1)

Collapse
 
fromzerotoship profile image
FromZeroToShip

"You cannot know in advance which of your API integrations are wrong — but you can build every one so that being wrong degrades instead of lies." I read that the same week I built the health-check version of that exact sentence, and I want it on a wall. Degrading is honest; lying-while-green is the whole disease.

I pull from the same portal you did — data.go.kr, for a couple of public-info features in my tools — and I've been burned by the same gap between the spec page and the live behavior. Your point that documentation and spec pages "were all downstream of that same live behavior" took me the longest to accept: the doc is a claim about the API, the live call is the API. I now treat every integration doc as a hypothesis and the first real response as the only ground truth — exactly your /debug/raw.

The part I'm stealing is the design goal, not just the debugging trick. "Being wrong degrades instead of lies" is a spec, not a mood. For me it meant a fetch that, when the upstream shape changes, records why it fell back instead of quietly returning a cheerful empty result that looks identical to success. A wrong answer that announces itself is recoverable; a wrong answer wearing a green checkmark is the one that costs you a day. One of the best framings of live-vs-assumed I've read.