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.
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.
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.
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 design — 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.
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.
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.
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.
Q. Why were the documented operation names and fields wrong for a government open-data API?
Because the documentation, spec pages, and community sample code got some of these specs right and others wrong, and only a live call could tell which was which. In this case, the operation name I had inferred from the dataset description never existed; another dataset had been discarded entirely and replaced by a successor on a different gateway; and approval scope is a property of your key that no doc page can show.
Q. How do you get ground truth for an API where the key is held server-side and never exposed to the client?
Add a temporary, token-guarded debug probe to the same proxy that holds the key, so it fires real requests through the real credential path and returns the raw upstream response. That is the only way to see what the upstream actually does, because nothing outside the proxy can see it. Remove the probe once it has served its purpose — it should never ship in the production tree.
Q. Why did a 404 turn out to be an approval problem instead of a routing bug, and why did a 401 turn out to be the wrong gateway instead of a bad key?
Government open-data portals often approve API access per dataset, not per API key or per provider — so a key that works for one dataset from a provider can 404 on a structurally identical dataset from the same provider that was simply never approved. Separately, a 401 that looks like an authentication failure can actually mean the request landed on the wrong API gateway entirely, returning that gateway's own version of an unregistered-key error, while the correct endpoint works fine with the same key. Both errors look like their obvious cause and are not; only a live capture against the correct, known-good path distinguishes them.
Q. What does it mean for an API parameter to "mean the other endpoint of the flight"?
In a departures dataset, an airport-code parameter can filter by the destination airport rather than the airport the flight is departing from. Querying with the departure airport you already know, expecting it to return flights leaving from there, then returns an empty result by design — not a bug — because the dataset is answering a different question than the one implicitly assumed. The fix is not to "debug" the empty result but to confirm what the parameter actually filters by, and design the empty-result path as a legitimate degrade rather than an error.
Q. Why did wrapping every guess in a degrade path matter more than getting the guesses right?
Because it was impossible to know in advance which of the guesses were wrong, or in which of several different ways — wrong operation name, unapproved dataset, discarded dataset, or misunderstood parameter semantics. A degrade path that always falls back to unknown, empty, or a static estimate instead of asserting a fabricated value means being wrong costs only the wasted guess itself. No user ever saw an invented congestion grade or a fake transit time while the real integration was still being found.