The NYSC staging API is live. This page is everything you need to point your app at it. Each step is short; technical detail sits inside the grey boxes.
Updated 5 October 2026 · staging runs main as it stood on 1 October (up to ECDS-18). Staff accounts (ECDS-49) are on main but not on staging yet
Staging is up.The health check answers {"status":"ok"}.
Four steps to get going
Add the API address to your app
Put this line in your .env.local file. Every request your app makes starts with this address.
The browser only lets your app call the API from these two addresses. Anything else is blocked by CORS (see the glossary).
http://localhost:3000http://localhost:5173
Deploying a preview (for example on Vercel or Netlify)? Send the backend team the exact address so it can be added. Until then, calls from that address will fail in the browser even though the API is up.
Use the contract, not Swagger
Swagger at /docs is turned off on purpose, because staging runs in production mode. The reviewed contract lives in the repo:
Every endpoint and field
docs/api/openapi.v1.json
How to use it
docs/api/README.md
User stories S1 to S11
docs/api/user-stories.md
Tip: drop openapi.v1.json into editor.swagger.io, Postman or Insomnia to browse it, or generate a typed client from it.
Before you start testing
Rules for staging
This is staging, not production. Use test accounts and test data only.
Never enter a real corps member's state code, phone number or email.
16 of 24 endpoints work today. The other 8 answer 501, which means "not built yet". See the API reference.
Check-in only works for members matched to the official roster, and roster upload isn't built yet (ECDS-8). On staging every check-in is refused with a reason, which is useful for testing your error screens.
A member can only have one phone registered for check-in at a time.
The login is locked after 5 wrong passwords in a row. Use a fresh test account if you lock yourself out.
What the backend team checked
Public HTTPS with a valid Let's Encrypt certificate.
Plain HTTP is sent to HTTPS automatically.
The API has its own PostgreSQL database and storage volume on TNT.
Database migrations ran successfully.
CORS allows localhost:3000 and localhost:5173 only. Other web addresses are refused.
Existing routes on the shared proxy were left alone; only this hostname was added.
The deployed code (main, up to ECDS-18) passed 312 tests, with 8 skipped.
A starter API helper
One small function that every screen can use. It reads the address from your env file and sends JSON.
// lib/api.ts
const BASE = process.env.NEXT_PUBLIC_API_BASE_URL!;
export async function api<T>(path: string, init: RequestInit = {}, token?: string): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init.headers,
},
});
if (res.status === 501) throw new Error(`${path} is not built yet (501)`);
if (!res.ok) throw await res.json().catch(() => new Error(res.statusText));
return res.status === 204 ? (undefined as T) : res.json();
}
// usage
const health = await api<{ status: string }>("/health");
The helper assumes the access token is sent as Authorization: Bearer …. Confirm how tokens are sent and the exact error shape in docs/api/README.md.
What is finished, what we are building now, and what the app can already call. Read it top to bottom: the plan, what is next, each stage in detail, then the full list of endpoints.
As of 5 October 2026 · this board shows main only · main has ECDS-3, ECDS-4, ECDS-5, ECDS-6, ECDS-17, ECDS-18 and ECDS-49 · staging runs main up to ECDS-18 · next up: ECDS-19
16 of 24endpoints the app can call today
323automated checks on main, all passing (8 more set aside for now)
6 of 12areas of work built
Stagingis live for the frontend team
1 · Roadmap
Six stages, in the order we build them
DoneBuilt, not closedPartly builtNot started
2 · Open items
What needs doing next
Taken from the unfinished tasks below. The order in each column is a suggestion.
Finish what is built
ECDS-17Tell the frontend team that sign-up now asks for three more thingsCovered on the Start here and API pages
Start next
ECDS-31Confirm a member's email and phoneNow ticket ECDS-31
ECDS-49Staff accounts for the LGI and NYSC staff are done (PR #16, merged 5 October). Next: permissions (ECDS-19), the LGI managing officials (ECDS-48), roster upload (ECDS-20) and matching (ECDS-21)Decisions needed first. Until matching lands nobody can check in for real
Platform and housekeeping
ECDS-50Connect a real email providerBlocks the LGI setting a password, and members' password resets, on staging
ECDS-16Write down how we back up and restore the database
ECDS-15Finish the one-command setup for a developer's machine
HulyTidy up Huly: add these stages and group the loose tickets
3 · Milestones
The work in each stage
4 · Endpoints
Every endpoint the app can use
Grouped by the epic that delivers it. Payloads are on the API reference page. Paths are under /api/v1.
Method
Path
Story
State
Story by story
How each flow works
One diagram per user story. Read each one top to bottom. Dashed green boxes are checks the server makes; a red box to the side is what happens when a check fails.
Member (the person)App (your frontend)Server (the API)Official
Endpoints and payloads
API reference
Every endpoint in version one. Open a row to see what it does, what to send and what comes back. All paths start with your NEXT_PUBLIC_API_BASE_URL.
Field names are examples. The JSON below shows the shape of each request and reply so you know what to expect. The exact field names, types and error bodies are in docs/api/openapi.v1.json, which is the source of truth. If anything here differs from that file, the file wins.
How passkeys work in the app (WebAuthn)
In plain words: a passkey is a secret key that lives inside the member's phone. The phone only uses it after the member unlocks it with their PIN, fingerprint or face. The server sends a random "challenge", the phone signs it, and the server checks the signature. No password crosses the network, and a passkey made on one phone can't be copied to a friend's.
WebAuthn is the browser feature that does this. Your app never sees the fingerprint or PIN; the browser and phone handle that part. Your app only passes data between the server and the browser.
Get a challenge from the server
POST /auth/passkey/challenge returns the options the browser needs.
Hand it to the browser
First time on this phone: navigator.credentials.create() makes a new passkey. Every check-in: navigator.credentials.get() signs the challenge. The browser shows the PIN or fingerprint prompt.
Send the browser's answer back
To POST /users/me/passkeys (register) or POST /attendance/current/check-in (check in). The server checks it.
// npm i @simplewebauthn/browser (handles the base64url conversions for you)
import { startRegistration, startAuthentication } from "@simplewebauthn/browser";
// Register this phone (once)
const regOptions = await api("/auth/passkey/challenge", { method: "POST", body: JSON.stringify({ purpose: "register" }) }, token);
const credential = await startRegistration({ optionsJSON: regOptions });
await api("/users/me/passkeys", { method: "POST", body: JSON.stringify(credential) }, token);
// Every check-in
const authOptions = await api("/auth/passkey/challenge", { method: "POST", body: JSON.stringify({ purpose: "check_in" }) }, token);
const assertion = await startAuthentication({ optionsJSON: authOptions });
// send `assertion` with the location readings and request id to /attendance/current/check-in
The request fields here (such as purpose) are examples; take the exact names from openapi.v1.json.
Things that trip people up
Passkeys only work on HTTPS or on localhost. A plain-HTTP address on your network (for example http://192.168.x.x:3000) will fail.
A passkey belongs to one website address, called the RP ID, which the server sets. If it doesn't match the address your app runs on, the browser refuses. Ask the backend team which RP ID staging uses before testing on a phone.
If the member cancels the prompt, the browser throws NotAllowedError. Treat it as "cancelled", not as an error.
Each challenge works once and expires. Get a new one for every attempt.
Only one phone per member. Registering a second phone is refused.
Who uses ECDS
User roles
Four kinds of people use the system. Today only visitors and corps members exist in the API; officials and administrators arrive with ECDS-8 and ECDS-9.
Who can do what
✓ Works now◔ Planned— Not allowed
Action
Visitor
Corps member
Official / LGI
Administrator
* Needs a roster match first, which arrives with ECDS-8. The exact split of powers between a CDS official and an LGI is still to be decided inside ECDS-8, so they share a column for now.
States a member moves through
Account
RegisteredSigned up with a state code
→
VerifiedMatched to the official roster
Roster matching is part of ECDS-8, which is not built yet, so accounts on staging stay Registered for now.
Attendance record
PendingChecked in
→
Confirmed
→
Completed
A record only ever moves forward one step at a time. It can't skip a step or go back.
Why it works the way it does
Backend decisions
The choices the backend team has made, in plain words. Each card says what was decided, why, and what it means for the screens you build.
Plain-language meanings
Glossary
Every term used on these pages, explained simply. Type to search.