A unified tool to walk through and unpack our Problem Set 2 together
Zi Ming, Yi Ming, Keziah, Gillian 16 September 2026
Visit our Product Here
Why we're showing you this
Last week we built a screen. Looks nice. This week, we'll put something true behind it. That sounds like a small step but it isn't—so let's be honest about why.
Up to now, everything our product claimed was something we decided. We typed the number, or the agent invented it, and either way it was ours.
From this week, our product depends on something we don't control (yet, but hopefully soon when we build our own A-a-a-S). A
service run by people who have never heard of you, who can be slow, or empty, or down, or can decide your credential expired at three in the morning (hopefully not to us).
That changes what 'finished' means. A screen either renders or it doesn't. A service that calls another service has states, and we have to decide about them in advance.
So here's the thing we most want you to take from this set: most of the work is not entirely about the code. Most of the work is deciding what your product says when the data isn't there
We have the data now. We need to reverse engineer our approach to build a UI. Not just any random UI, but someone that will serve our users well.
Well since we are in business school, we will focus on monetization. How about selling some convenience to our users. We will start from there.
Our bet for today: speed of experimentation beats speed of execution. We'd rather rule out five bad
directions as a team, rather than polish one we never tested. This is the start of the questions, not the start
of the answers—so if something we try today falls apart, that's not a bad demo, that's the point. We will walk you through this—rest assured.
~ Group 2 · Week 4 Problem Solving Leadership Session
Each tab at a glance
Item
Input
Output
Best Use
1. Reveal
Google AI Studio -> Github -> Vercel
Preview of Build -> Management of Codebase -> Hosted Deployment (with API KEY control)
Showing a live product where the credential is provably server-side and absent from the repo
2. BREAK IT
We will intentionally break it to test the resilience of our backend
Four distinguishable screen states: loading, empty, refused, unreachable—plus the /api/health verdict
Proving the back end fails in sentences a user can act on, not in a blank screen
3. Application
The same product, re-pointed at HR, Operations and Finance & Accounting
What changes in data, risk and oversight—and the one industry we would not ship in
Testing whether the design survives a change of stakes, or only worked because nothing was riding on it
The four states every back end has
A screen either renders or it doesn't. A service that calls another service has four
situations, and your user needs a different sentence for each one. Three of the four are things
they can actually act on—which is why a spinner isn't an answer.
01. Loading
no status yet
The request is in flight. The only state where "wait" is the right advice—and the only one
most products ever bother to design. Some take some time to get approved.
02. Empty (but OK)
200 OK
The source answered and there is nothing to show. Not an error. A Singapore feed at midnight
is quiet, not broken, and the screen has to say so in words.(like there should not be buses or trains at 3am right—so rightfully so)
03. Refused
401 / 403 / 404
A credential was sent and rejected. The user can't fix this, but they can tell us—so the
screen's job is to say plainly that nothing on it is current.
04. Unreachable
no response · we return 503
We couldn't reach the provider at all. Different cause, different sentence, same rule:
never a blank space where a number belongs.
THE ONE RULE WITH A CONSEQUENCE OUTSIDE THIS ROOM
THE KEY LIVES IN A VERCEL ENVIRONMENT VARIABLE. THE CALL HAPPENS INSIDE /api.
THE PAGE ONLY EVER TALKS TO OUR OWN ADDRESS. NO VARIABLE NAME BEGINS VITE_—THAT
PREFIX WRITES THE VALUE INTO THE PAGE EVERY VISITOR DOWNLOADS, WITH NO WARNING, PUBLIC FROM
THE MOMENT YOU DEPLOY.
How the next 25 minutes go
Reveal: We run the product live on the real URL, end to end—Google
AI Studio to GitHub to Vercel. Then we show the credential is configured without ever showing
its value.
Break it: We point it at something that doesn't exist, then pull
the credential, while you watch. Four states, four sentences. If it falls apart in a way we
didn't rehearse, oh boy... but apparently Prof said that's the better demo.
Application: The same product re-pointed at HR, Operations and
Finance & Accounting—what changes in data, in risk, in oversight. Including the one where
we would not ship it, and why.
Reveal—what the key is, and where it may be read
Every service below was called by hand before it was written about. The tiers are not
about quality; they are about what the service asks of you before it answers, and what that means for where
the credential is allowed to live.
Key reference
Tier one · nothing but a URLno signup
Open-Meteo (weather and geocoding), Frankfurter (ECB exchange
rates), Nager.Date (public holidays), World Bank (country
indicators), Wikipedia (summaries). No key, no account, no card — you paste the URL
into a browser and the data comes back.
Route them through /api anyway. Not for secrecy — there is nothing to hide — but so
every feed in the product is cached in one place, fails in one shape, and is read by one piece of
code. The day a keyless service starts asking for a key, you change one file.
Tier two · a key, by email, no cardminutes
LTA_ACCOUNT_KEY (Singapore bus, train and traffic feeds),
NASA_API_KEY, GUARDIAN_API_KEY, PEXELS_API_KEY,
ALPHAVANTAGE_API_KEY. You fill in a form, the key arrives by email, and you are working
inside ten minutes. No payment details at any point.
All five are read inside /api only. The browser asks your own route for
data; your route asks the provider and attaches the key. A key that reaches the browser has been
published, whether or not anyone has looked yet.
Tier three · making something happenforms and email
WEB3FORMS_KEY is the one exception that lives in browser code, because it can do
exactly one thing: deliver a form submission to the inbox that owns it. It cannot read your data,
spend your money, or be turned into anything else. That narrowness is the whole reason it is safe to
expose — not convenience.
RESEND_API_KEY is the opposite and is server-side only. It sends mail as you. On the
shared sender you can only write to your own account address; sending to anyone else requires a
domain you have verified.
Do not build on thesetested and rejected
NewsAPI.org — the free plan forbids production use, so it works in development and
stops at the deployed URL. REST Countries — returns success codes on failure, which
means your error states never fire and the screen lies quietly.
Also: anything served over http-only, which a browser on an https page will refuse
to load; and any endpoint a tutorial names without naming its host, because you cannot check the
terms of a service you cannot identify.
Variable Name
Service
Where It May Be Read
LTA_ACCOUNT_KEY
LTA DataMall — bus, train, traffic
Inside /api only
NASA_API_KEY
NASA open APIs — imagery, APOD
Inside /api only
GUARDIAN_API_KEY
The Guardian — articles and search
Inside /api only
PEXELS_API_KEY
Pexels — stock photography
Inside /api only
ALPHAVANTAGE_API_KEY
Alpha Vantage — market and FX series
Inside /api only
RESEND_API_KEY
Resend — transactional email
Inside /api only
ONEMAP_TOKEN
OneMap SG — expires every three days
Inside /api only
WEB3FORMS_KEY
Web3Forms — form delivery
Browser — the single exception
ATTENTION: NO VARIABLE NAME BEGINS VITE_
THE VITE_ PREFIX IS AN INSTRUCTION TO THE BUILD TOOL TO WRITE THE VALUE INTO THE BUNDLE THAT EVERY
VISITOR DOWNLOADS. THERE IS NO WARNING AND NO ERROR. THE KEY IS PUBLIC FROM THE MOMENT YOU DEPLOY, AND
REMAINS PUBLIC IN EVERY CACHE AND CLONE OF THAT BUILD AFTER YOU ROTATE IT.
Three worked examples you can try right now.
01. Singapore, with a key
carparks + buses
What it proves: the credential is provably server-side, and "no lots
data" reads differently on screen from "we were refused".
data.gov.sg carpark availability — keyless, the main feed
LTA DataMall bus arrival — needs LTA_ACCOUNT_KEY, so something in the demo
can actually be refused in front of an audience
loading
Checking the carpark feed — usually about two seconds.
200 OK
The feed answered, but no lot counts were published for this carpark in the last update.
401 / 403
Our bus key was refused, so arrival times are not current. Carpark lots below are unaffected.
503
We could not reach LTA at all. Nothing on this panel is live — try again in a minute.
Prompt
ROLE
You are building a small production web app on Vercel, not a snippet. Assume a public URL.
GOAL
One page for a driver heading into town: for a chosen carpark, show how many lots are free
right now and when the next buses reach the stop outside it, so they can decide drive or bus
before leaving the house.
OUTPUT
/api/carparks.js fetch data.gov.sg carpark availability, return {updatedAt, lots:[...]}
/api/bus.js fetch LTA DataMall bus arrival, read LTA_ACCOUNT_KEY from process.env
/api/health.js return {ok, keyConfigured: Boolean(process.env.LTA_ACCOUNT_KEY)}
index.html two panels, each rendering its own four states independently
Endpoints:
https://api.data.gov.sg/v1/transport/carpark-availability?date_time=2026-09-12T09:00:00
https://datamall2.mytransport.sg/ltaodataservice/v3/BusArrival?BusStopCode=83139
LTA requires the request header AccountKey: <LTA_ACCOUNT_KEY>
GUARDRAILS
- LTA_ACCOUNT_KEY is read only inside /api. It never appears in client code, in a query
string, or anywhere in a response body.
- No environment variable name begins VITE_.
- Cache each upstream response for 60 seconds and print "as of HH:MM" beside the numbers.
- Four states, four distinct sentences. Never a spinner that has no end condition.
- /api/health reports keyConfigured as true or false and never the value itself.
CHECKS
1. curl both endpoints by hand before writing any UI code.
2. View source on the deployed page and search for the key. Expect zero hits.
3. Open /api/health. Expect {"ok":true,"keyConfigured":true}.
4. Unset LTA_ACCOUNT_KEY and redeploy: the bus panel must say refused in words, and the
carpark panel must keep working.
02. Overseas, keyless
zero credentials
What it proves: a genuinely useful product with no credentials at all —
and that "empty" is a real state, not a bug. A two-character search or an unlisted country returns
nothing, and the screen has to say so.
Open-Meteo geocoding — city name to coordinates
Open-Meteo forecast — seven days, daily highs, lows and rain probability
Nager.Date — public holidays for the destination country
For an HR audience: this is the tool an HR team reaches for when
planning an offsite, or checking whether a long weekend in one market collides with a working week in
another.
loading
Looking up that city and pulling the seven-day outlook.
200 OK
No city matched that search. Try the full name — two letters is not enough to match on.
4xx
That country is not in the holiday list, so the calendar below is blank on purpose.
503
The forecast service did not respond. Nothing here is current — try again shortly.
Prompt
ROLE
You are building a small production web app on Vercel with no credentials of any kind.
GOAL
A quick-getaway planner for an HR team: type a city, see the next seven days of weather and
any public holidays in that country, so they can pick an offsite date or spot a long-weekend
clash across markets before booking anything.
OUTPUT
/api/geocode.js Open-Meteo geocoding, city name to {name, country, lat, lon, countryCode}
/api/forecast.js Open-Meteo 7-day daily forecast for those coordinates
/api/holidays.js Nager.Date public holidays for that country code and year
index.html search box, 7-day strip, holiday list, all four states per panel
Endpoints:
https://geocoding-api.open-meteo.com/v1/search?name=Penang&count=5&language=en
https://api.open-meteo.com/v1/forecast?latitude=5.41&longitude=100.34&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max&timezone=auto&forecast_days=7
https://date.nager.at/api/v3/PublicHolidays/2026/MY
GUARDRAILS
- There is no key here, so there is nothing to leak — but the calls still happen in /api, so
caching and error handling live in one place if a key ever appears.
- No environment variable name begins VITE_.
- Cache geocoding for 24 hours and the forecast for 30 minutes.
- Four states, four distinct sentences. An empty result is a sentence, never a blank panel.
- /api/health reports {ok:true, keyConfigured:false} — this product needs no key.
- Footer must credit Open-Meteo with a link, CC-BY 4.0, non-commercial use.
CHECKS
1. Call all three endpoints by hand first and read the raw JSON shapes.
2. Search "Pe" — confirm the empty state renders as a sentence, not a spinner.
3. Ask for a country Nager.Date does not list and confirm the holiday panel explains itself.
4. Open /api/health and confirm it answers without any key being set.
03. Example 1 plus a form
the one browser key
What it proves: the difference between the single key that may sit in
browser code and every other key that may not — and that the form owns four states of its own, separate
from the data panel's.
Everything from example 1, unchanged
Web3Forms submit — called directly from the browser with
WEB3FORMS_KEY
submitting
Sending your address — do not close this tab yet.
success
You are on the list. The first digest arrives tomorrow at 7am.
rejected
That address was not accepted. Check for a typo and send it again.
unreachable
We could not reach the form service. Nothing was saved — please try again.
Prompt
ROLE
You are extending the carpark app from example 1 with a signup form, on Vercel.
GOAL
A commuter who checked the carpark once wants it daily: one email field under the lots panel
subscribes them to a 7am digest for the carpark they were looking at, and tells them plainly
whether the subscription worked.
OUTPUT
index.html the example 1 panels, plus a form posting to Web3Forms from the browser
/api/bus.js unchanged, still reads LTA_ACCOUNT_KEY server-side
/api/health.js {ok, keyConfigured} for the LTA key only
Endpoints:
https://api.web3forms.com/submit POST JSON {access_key, email, carpark}
https://api.data.gov.sg/v1/transport/carpark-availability?date_time=2026-09-12T09:00:00
GUARDRAILS
- WEB3FORMS_KEY is the single exception: it belongs in browser code because it can only
deliver a message to the inbox that owns it. Say so in a comment beside it.
- LTA_ACCOUNT_KEY stays inside /api and is never sent to the browser for any reason.
- No environment variable name begins VITE_.
- Cache the carpark feed for 60 seconds; the form is never cached.
- The form has its own four states — submitting, success, rejected, unreachable — rendered
separately from the data panel's four, as four distinct sentences.
- /api/health reports keyConfigured for LTA without revealing the value.
CHECKS
1. POST to Web3Forms by hand with curl before wiring the form.
2. View source: WEB3FORMS_KEY is visible by design, LTA_ACCOUNT_KEY must not appear at all.
3. Submit an invalid address and confirm the rejected sentence renders, not a silent reset.
4. Block api.web3forms.com in devtools and confirm the unreachable sentence appears.
Gotcha: Rotating a leaked key does not un-publish it. Anything already
scraped from a deployed bundle keeps working until the old key is revoked at the provider — which is a
different action from issuing a new one.
Break it — what the screen says when it doesn't work
Four breaks, performed live, in this order. Each one is a deliberate act with a
predicted sentence attached.
The premise
Reveal proved the thing works. That is the least interesting claim we can make — anyone can demo a
product working, and every demo you have ever seen was a demo of something working.
The only interesting question is what the screen says when it doesn't. A spinner that never resolves, a
blank panel where a number belongs, a silent fallback to stale data: none of those are bugs in the
ordinary sense. The code ran. Someone simply never decided what the user should be told.
So this tab is not a testing exercise. It is a product decision, made four times, out loud.
~ Four breaks, four sentences
The four breaks
Each card opens. Inside is the prompt that engineers that failure on purpose, what
is actually happening underneath, and the guard that prevents it.
We doThrottle the network to slow 3G in devtools and reload the page.
Provider returnsNothing yet — there is no status code to read, because no response has
arrived.
User sees"Checking the carpark feed — usually about two seconds."
Why that sentenceIt is the only state where "wait" is honest advice — and the only one most
products bother to design.
Engineer this failure
ROLE
You are a senior full-stack developer. The project is a static site deployed to Vercel from
GitHub — no build step, no framework. Serverless functions live at the project root in api/,
as siblings of package.json, never inside src/. package.json needs "type": "module", or the
handlers are named .mjs.
GOAL
Two halves, in one pass — you need a working app before you can break it.
1. The build: a panel showing live carpark availability for a short watched list of
CarParkIDs, read from data.gov.sg through our own api/ route.
2. The break: make the response artificially slow so the loading state is visible. Add a
query parameter ?delay=8000 that, when present, awaits that many milliseconds before
returning. Load /api/sites?delay=8000 and confirm the loading SENTENCE appears rather
than a spinner.
OUTPUT
api/sites.js GET https://api.data.gov.sg/v1/transport/carpark-availability
api/health.js {ok, keyConfigured: false, checkedAt} — this service takes no key
index.html one panel, and these four states as four literal sentences:
loading "Checking the carpark feed, usually about two seconds."
empty "The feed answered, but no lots are listed for this carpark right now."
refused does not exist for this service — it takes no credential. Do not write it.
unreachable "We couldn't reach the carpark feed. Nothing here is live — try again in a
minute."
GUARDRAILS
- The ?delay parameter applies ONLY when process.env.NODE_ENV !== 'production'. Put the
reason in a comment beside the guard: a production endpoint that accepts a client-supplied
delay is a denial of service handed to anyone who reads the URL.
- data.gov.sg is keyless, so this feed can be UNREACHABLE but never REFUSED. Do not add an
environment variable and do not emit a refused sentence.
- Check response.ok BEFORE reading any body.
- Cast every number at the boundary. Never render NaN, null or undefined as a lot count.
- No variable name starts with VITE_. No new npm packages, no database, no login.
CONTEXT
No environment variable — this service takes no credential.
Cache-Control: s-maxage=60, stale-while-revalidate=120
Real response shapes: see "Reference responses" below on this page. Copy the block for this
service and paste it here before sending.
We doAsk for a carpark that exists but has no data published, or a bus stop at
3am.
Provider returns200 OK, with an empty array in the body.
User sees"The feed answered, but no lots are listed for this carpark right
now."
Why that sentenceThis is the dangerous one. A success code, nothing in the body, nothing
thrown. It reaches real users precisely because nothing anywhere reports it as a problem.
Engineer this failure
ROLE
You are a senior full-stack developer. The project is a static site deployed to Vercel from
GitHub — no build step, no framework. Serverless functions live at the project root in api/,
as siblings of package.json, never inside src/. package.json needs "type": "module", or the
handlers are named .mjs.
GOAL
Two halves, in one pass — you need a working app before you can break it.
1. The build: a panel showing live carpark availability for a short watched list of
CarParkIDs, read from data.gov.sg through our own api/ route.
2. The break: add a ?force=empty parameter that returns a valid 200 response with an empty
array in the body — same envelope, same headers, no records. Then confirm the screen
distinguishes "the feed answered and there is nothing to show" from "the feed failed".
Also test the real version: request a CarParkID that is in the watched list but absent
from today's payload.
OUTPUT
api/sites.js GET https://api.data.gov.sg/v1/transport/carpark-availability
api/health.js {ok, keyConfigured: false, checkedAt} — this service takes no key
index.html one panel, and these four states as four literal sentences:
loading "Checking the carpark feed, usually about two seconds."
empty "The feed answered, but no lots are listed for this carpark right now."
refused does not exist for this service — it takes no credential. Do not write it.
unreachable "We couldn't reach the carpark feed. Nothing here is live — try again in a
minute."
GUARDRAILS
- The ?force=empty parameter applies ONLY when process.env.NODE_ENV !== 'production'. Put
the reason in a comment beside the guard: a production endpoint that lets any caller force
an empty response is a denial of service handed to anyone who reads the URL.
- A carpark reporting zero available lots is FULL. A carpark absent from the response is
UNKNOWN. These are different states and must never share a sentence.
- data.gov.sg is keyless, so this feed can be UNREACHABLE but never REFUSED. Do not add an
environment variable and do not emit a refused sentence.
- Check response.ok BEFORE reading any body, and check array length before rendering.
- No variable name starts with VITE_. No new npm packages, no database, no login.
CONTEXT
No environment variable — this service takes no credential.
Cache-Control: s-maxage=60, stale-while-revalidate=120
Real response shapes: see "Reference responses" below on this page. Copy the block for this
service and paste it here before sending.
We doRename the environment variable in Vercel and redeploy, while you
watch.
Provider returns401 with an empty body. Our /api catches it and returns
503.
User sees"Our key was refused, so nothing on this panel is current — please
tell us if it stays this way."
Why that sentenceThe user cannot fix this, but they can tell us — so the screen's job is to
say plainly that nothing on it is current.
Engineer this failure
ROLE
You are a senior full-stack developer. The project is a static site deployed to Vercel from
GitHub — no build step, no framework. Serverless functions live at the project root in api/,
as siblings of package.json, never inside src/. package.json needs "type": "module", or the
handlers are named .mjs.
GOAL
Two halves, in one pass — you need a working app before you can break it.
1. The build: a panel showing the next buses at a chosen stop, read from LTA DataMall
through our own api/ route, with the credential server-side.
2. The break: rename the Vercel environment variable LTA_ACCOUNT_KEY to LTA_ACCOUNT_KEY_OLD
and redeploy. Confirm the refused sentence appears, /api/health reports
keyConfigured: false, and no key material appears anywhere in any response. Then rename
it back and redeploy again.
Faster for a live demo: keep a second preview deployment with the variable absent and
switch browser tabs — a redeploy takes 30 to 60 seconds and you do not want dead air.
OUTPUT
api/bus.js GET https://datamall2.mytransport.sg/ltaodataservice/v3/BusArrival
with the request header AccountKey:
api/health.js {ok, keyConfigured, checkedAt} — reports WHETHER, never WHAT
index.html one panel, and these four states as four literal sentences:
loading "Checking the bus feed, usually about two seconds."
empty "The feed answered, but no buses are listed for this stop right now."
refused "Our key was refused, so nothing on this panel is current — please tell us
if it stays this way."
unreachable "We couldn't reach LTA at all. Nothing here is live — try again in a minute."
GUARDRAILS
- Guard BEFORE the fetch: if LTA_ACCOUNT_KEY is missing or empty, return 503 and do not call
LTA at all. An unset variable is interpolated as the string "undefined" and LTA answers 401
exactly as it would for a wrong key.
- LTA returns an EMPTY BODY on 401, so response.json() on a failed reply throws. Check
response.ok BEFORE reading any body.
- The header name is exactly AccountKey. Any other capitalisation returns 404, not 401.
- NextBus2 and NextBus3 arrive as objects whose fields are empty strings when there is no
such bus. Treat an empty EstimatedArrival as no bus and omit it. Never emit NaN as a minute.
- Read the key via process.env inside api/ only, never in browser code. No variable name
starts with VITE_. Never print the key, or any part of it, in a response or a log.
- No new npm packages, no database, no login.
CONTEXT
Environment variable: LTA_ACCOUNT_KEY, set only in Vercel, passed as the AccountKey header.
Cache-Control: s-maxage=20, stale-while-revalidate=40
Real response shapes: see "Reference responses" below on this page. Copy the block for this
service and paste it here before sending.
We doPoint /api at a hostname that does not resolve.
Provider returnsNothing at all. A timeout, or a DNS failure before any connection is
made.
User sees"We couldn't reach the provider at all. Nothing here is live — try
again in a minute."
Why that sentenceDifferent cause, different sentence, same rule — never a blank space where a
number belongs.
Engineer this failure
ROLE
You are a senior full-stack developer. The project is a static site deployed to Vercel from
GitHub — no build step, no framework. Serverless functions live at the project root in api/,
as siblings of package.json, never inside src/. package.json needs "type": "module", or the
handlers are named .mjs.
GOAL
Two halves, in one pass — you need a working app before you can break it.
1. The build: a panel showing the next buses at a chosen stop, read from LTA DataMall
through our own api/ route, with the credential server-side.
2. The break: temporarily point the hostname at a domain that does not resolve —
datamall2.mytransport.invalid — and redeploy. Confirm the unreachable sentence appears
and names the time of the last good reading. Then test the timeout path separately with
an AbortController set to a 2000ms ceiling against the real host.
No-deploy alternative: open devtools, set the network to Offline, and reload.
OUTPUT
api/bus.js GET https://datamall2.mytransport.sg/ltaodataservice/v3/BusArrival
with the request header AccountKey:
api/health.js {ok, keyConfigured, upstream:{reachable, status, ms}, checkedAt}
index.html one panel, and these four states as four literal sentences:
loading "Checking the bus feed, usually about two seconds."
empty "The feed answered, but no buses are listed for this stop right now."
refused "Our key was refused, so nothing on this panel is current — please tell us
if it stays this way."
unreachable "We couldn't reach LTA at all. Last good reading was at HH:MM — nothing on
this screen has updated since."
GUARDRAILS
- Wrap the fetch in try/catch with an explicit AbortController timeout. Distinguish a
timeout from a connection failure in the log, even though the user-facing sentence is the
same for both.
- Always keep and display the time of the last good reading. A user who knows the data is
40 minutes old can still act on it; a user staring at a blank panel cannot.
- There is no status code when nothing answers. The only code in play is the one our own
function returns — return 503, not 500.
- Guard BEFORE the fetch on a missing LTA_ACCOUNT_KEY, and check response.ok BEFORE reading
any body.
- Read the key via process.env inside api/ only, never in browser code. No variable name
starts with VITE_. Never print the key, or any part of it, in a response or a log.
- No new npm packages, no database, no login.
CONTEXT
Environment variable: LTA_ACCOUNT_KEY, set only in Vercel, passed as the AccountKey header.
Cache-Control: s-maxage=20, stale-while-revalidate=40
Real response shapes: see "Reference responses" below on this page. Copy the block for this
service and paste it here before sending.
Check your own credential
Pick your service and your machine. We'll give you the exact line to paste, what a good
answer looks like, and what each failure means.
Service
Platform
Pick a service and a platform first.
Paste this in your terminal
What to check
Paste this into your coding tool
Reference responsespaste into a prompt
Each prompt above asks for the real response shape. Capture it by hand, paste it into the
matching block below, and it is there for every prompt afterwards. Do not invent a
response body. A fabricated shape here is worse than an empty one, because
someone will build on it and lose an hour to field names that do not exist.
LTA DataMall · CarParkAvailabilityv2
Note whether AvailableLots comes back quoted as a string or as a number.
[PASTE THE REAL RESPONSE HERE — captured by hand on DATE]
LTA DataMall · the 401
Headers plus an EMPTY body — which is why response.ok is checked before .json().
[PASTE THE REAL RESPONSE HERE — captured by hand on DATE]
data.gov.sg · two-hr-forecast
Wrapped as {"code":0,"data":{...},"errorMsg":""}, and area_metadata key order VARIES between entries — some list longitude first.
[PASTE THE REAL RESPONSE HERE — captured by hand on DATE]
Alpha Vantage · TIME_SERIES_DAILY
Closes are STRINGS, and dates are object keys rather than array entries.
[PASTE THE REAL RESPONSE HERE — captured by hand on DATE]
Alpha Vantage · rate-limited
200 OK carrying an "Information" key and no data at all.
[PASTE THE REAL RESPONSE HERE — captured by hand on DATE]
Guardian · search
The envelope is nested under "response", with "total" alongside the results.
[PASTE THE REAL RESPONSE HERE — captured by hand on DATE]
401 vs 404 — the pair that looks identical
401
rejected
A value arrived and was rejected. The request reached the provider, carried something in the key
position, and the provider said no to that something.
404
never arrived
The header never arrived at all. As far as the provider is concerned you asked for a route that does
not exist, because an unauthenticated request is routed nowhere.
Both return empty bodies. From the outside they are identical: no message, no hint, no
difference in what your catch block receives. And an unset environment variable does not throw
— it is interpolated as the string "undefined", which lands in exactly the same place a wrong
key would, and produces exactly the same silence.
Read the headers
# -i prints the response headers, -s hides the progress meter
curl -is "https://datamall2.mytransport.sg/ltaodataservice/v3/BusArrival?BusStopCode=83139" \
-H "AccountKey: $LTA_ACCOUNT_KEY" | head -12
# key correct
HTTP/2 200
content-type: application/json;charset=utf-8
# key wrong — a value arrived, and was rejected
HTTP/2 401
content-length: 0
# header missing entirely — the variable was never set
HTTP/2 404
content-length: 0
# Run this in the terminal, not the browser console. The console collapses the
# response to its body, and the body is empty in both failures — the status line
# is the only thing that tells the two apart.
/api/health, live
One endpoint, called on stage, answering the only question the room actually cares about: is the
credential configured, and is the provider answering us right now?
GET /api/health
{
"status": "ok",
"keyConfigured": true,
"upstream": {
"reachable": true,
"status": 200,
"ms": 412
},
"message": "Key is configured and the provider answered in 412ms.",
"checkedAt": "2026-09-12T09:31:04.118Z"
}
Open /api/health: it reports keyConfigured: true. The key
exists, and the provider is answering with it.
Search the repository for the variable name: nothing. Not in a config file,
not in a commit, not in the history.
View source on the live page: nothing. Search the bundle, search the network
tab, search every response body — the value is in none of them.
ATTENTION: THE HEALTH ENDPOINT REPORTS WHETHER, NEVER WHAT
NO PREFIX. NO LAST FOUR CHARACTERS. NO LENGTH, NO HASH, NO MASKED STRING. A FINGERPRINT FEELS HELPFUL
WHILE YOU ARE DEBUGGING AND IS EXACTLY WHAT A SCREENSHOT OF THIS PAGE LEAKS — INCLUDING THE SCREENSHOT
SOMEONE IN THIS ROOM IS ABOUT TO TAKE.
The working sequence
Follow along. Tick each one as you go.
0 of 5 verified
Each card is a complete build. You do not need the other three to run this one.
What we got wrong
Fix it — the guard that catches each one
Four breaks, four root causes, four guards. Each fix is a few lines, and each one
turns a silent failure into a sentence.
01. Loading
no status yet
Root cause
Nothing has gone wrong. There is no status code because no response has arrived. The failure is
that most products show a spinner here, which tells the user nothing about which of four situations
they are in.
Solution
// Loading is a sentence, not a spinner. Name the source and a rough duration.
function renderLoading(el, source = 'the carpark feed', expectSec = 2) {
el.textContent = `Checking ${source}, usually about ${expectSec} seconds.`;
// If it runs long, say so rather than implying it is about to arrive
return setTimeout(() => {
el.textContent = `Still waiting on ${source}. It has been longer than usual — nothing on this screen has updated yet.`;
}, expectSec * 1000 * 3);
}
// Always clear the timer when the request settles, whichever way it settles
const slowTimer = renderLoading(panelEl);
try {
const data = await load();
render(data);
} finally {
clearTimeout(slowTimer);
}
How you know it worked
The panel names the feed and a rough duration instead of spinning, and says so again if it runs long.
02. Empty
200 OK
Root cause
This is the dangerous one. HTTP 200, response.ok is true, nothing throws, and the body is
empty. Every layer of the stack reports success. The screen goes blank because nobody wrote a sentence
for the case where the request worked and the answer was nothing.
Solution
// 200 OK with an empty body is a success, not an error. It needs its own sentence.
function toPanelState(rows) {
if (!Array.isArray(rows) || rows.length === 0) {
return {
state: 'empty',
message: 'The feed answered, but no lots are listed for this carpark right now.'
};
}
return { state: 'ok', rows };
}
// FULL and UNKNOWN are different facts and must never share a sentence
function lotsSentence(carparkId, rows) {
const row = rows.find(r => r.CarParkID === carparkId);
if (!row) {
return `${carparkId}: not in this update — we do not know how full it is.`; // UNKNOWN
}
const lots = Number(row.AvailableLots); // arrives quoted, cast at the boundary
if (!Number.isFinite(lots)) {
return `${carparkId}: the feed sent a lot count we could not read.`;
}
if (lots === 0) {
return `${carparkId}: full — zero lots available.`; // FULL
}
return `${carparkId}: ${lots} lots available.`;
}
How you know it worked
A carpark with zero lots reads "full"; a carpark missing from the update reads "we do not know" — two different sentences where there was one blank space.
03. Refused
401 / 403 / 404
Root cause
401 means a value arrived and was rejected. 404 means the header never arrived at all. Both come back
from LTA with empty bodies, so from the outside they are identical — and an unset
variable is sent as the string "undefined", which lands in exactly the same place as a
wrong key. This is why the guard fires before the fetch, and why /api/health exists at
all.
Solution
// The guard fires BEFORE the fetch. An unset variable is sent as the string
// "undefined" and LTA answers 401 exactly as it would for a wrong key.
const key = process.env.LTA_ACCOUNT_KEY;
if (typeof key !== 'string' || key.trim() === '') {
return res.status(503).json({
state: 'refused',
error: 'LTA_ACCOUNT_KEY is not set. Add it in Vercel and redeploy.'
});
}
const r = await fetch(url, { headers: { AccountKey: key } });
// LTA returns an EMPTY BODY on 401, so .json() here would throw and crash the function
if (!r.ok) {
return res.status(503).json({
state: 'refused',
upstream: r.status // 401 rejected a value, 404 never saw the header
});
}
const data = await r.json(); // only now is this safe
// health reports WHETHER, never WHAT
export function health() {
return { ok: true, keyConfigured: Boolean(process.env.LTA_ACCOUNT_KEY) };
}
How you know it worked
Pull the variable and the panel says it was refused instead of showing a crashed function, and /api/health flips to keyConfigured: false.
04. Unreachable
no response · we return 503
Root cause
There is no status code to read, because nothing answered. DNS failure, timeout, or the provider is
simply down. The only code in play is the one our own function decides to return.
Solution
// Nothing answered, so there is no status code to read. The only code in play is
// the one this function decides to return.
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 4000);
try {
const r = await fetch(url, { headers, signal: ctrl.signal });
if (!r.ok) return res.status(503).json({ state: 'refused', upstream: r.status });
const data = await r.json();
lastGood = { at: new Date().toISOString(), data }; // keep the last good reading
return res.status(200).json({ state: 'ok', ...lastGood });
} catch (err) {
// Same sentence for the user, different cause in the log
return res.status(503).json({
state: 'unreachable',
reason: err.name === 'AbortError' ? 'timeout' : 'connect',
lastGoodAt: lastGood?.at ?? null
});
} finally {
clearTimeout(timer);
}
How you know it worked
Go offline in devtools and the panel names the time of the last good reading rather than going blank.
Guards, not fixes
Every one of these is a guard, not a fix. The provider still fails. What changes is whether the
screen says so.
Application — the same product, somewhere it matters more
Say what you said out loud, pick the industry and the service, and this hands back a
complete build instruction. Nothing here calls anything — it is string templating in your browser.
The ask
Move your product into another industry and say concretely what changes — in the data, in the risk, and
in the oversight. Not "it would be similar but for HR". Concretely.
The benchmark: a commuter board becomes a ward-supplies board. The data stops being bus timings and
becomes patient-adjacent. The risk stops being a missed bus and becomes a missed dose. The oversight moves
from nobody at all to a named clinician who signs off. Three sentences, three real changes.
That is the level of specificity being asked for. If your version of those three sentences would survive
being read back to you by someone who works in that industry, you have done the exercise.
~ Data, risk, oversight — name all three
Your RGOGC prompt
Paste this into your coding tool
Industry lens
What the generator reasoned from — and every line is editable. Change one and the
three blocks on this page rewrite themselves to match.