SMU Master of Science in Business AI (MBAI) v1.0.0

MGMT 6110: Group 2 Task

A unified tool to walk through and unpack our Problem Set 2 together


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 URL no 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 card minutes

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 happen forms 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 these tested 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 do Throttle the network to slow 3G in devtools and reload the page.
Provider returns Nothing 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 sentence It 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 do Ask for a carpark that exists but has no data published, or a bus stop at 3am.
Provider returns 200 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 sentence This 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 do Rename the environment variable in Vercel and redeploy, while you watch.
Provider returns 401 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 sentence The 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 do Point /api at a hostname that does not resolve.
Provider returns Nothing 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 sentence Different 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
Reference responses paste 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 work Nothing saved yet — this page keeps nothing when you close it.

1 · What the team actually said

Verbatims, not summaries. The generated prompt quotes these back with attribution.

2 · Industry

Pick one. This is the lens the whole output is written through.

3 · The claim sentence

My screen tells the user , which right now is , and to be true it would have to come from .

4 · API service optional

Pick one or more — they combine — or skip this entirely. Choosing decides the endpoints, the cache, the licence lines and half the guardrails. Skipping still gives you a complete prompt, with the source left as a decision for whoever builds it.