This reference covers every route handler under src/app/api. All endpoints send and receive JSON. For how the endpoints fit together, see FLOWS.md.
- Auth. Sessions use an httpOnly cookie named
schemeai_session(SameSite Lax, Secure in production, 14-day expiry). It is set by register and login and checked withgetSessionUser()insrc/lib/auth.ts. "Session" in the tables below means the route returns401without a valid session. "Admin" means the route callsrequireAdmin(), which accepts only a session whose user hasrole = "admin". - Errors. Errors are returned as
{ "error": string }. Unexpected exceptions return500with the exception message. Most routes catch them; saved, tracker and admin stats do not use try/catch, so their exceptions surface as the framework's default500. - Validation. Request bodies are validated with
zod(v4) where noted. - Serialized scheme. Many responses contain
serializeScheme()output fromsrc/lib/serialize.ts, in one of two shapes, described below.
{
id, slug, name, nameHi, shortDescription, shortDescriptionHi,
category, ministry, department, jurisdiction, state, beneficiaryType,
benefits, benefitAmount, applicationUrl, infoUrl,
verificationStatus, // "REFERENCE" | "LIVE_CHECKED" | "STALE"
extractionMethod, // "SEED_REFERENCE" | "DIRECT_FETCH" | "TINYFISH_LIVE"
confidence, // "HIGH" | "MEDIUM"
dataCompiledAt, lastLiveCheckedAt,
ruleCount, sourceName, sourceDomain
}SchemeSummary plus:
{
description, applicationWindow, applicationDeadline,
documents: string[], process: string[], keywords,
rules: [{ attribute, operator, value /* JSON string */, required, label, labelHi, ruleGroup }],
evidence: [{ sourceUrl, excerpt, evidenceType, note, retrievedAt }], // [] if evidence not loaded
sourceMeta: { name, domain, department, lastCheckedAt, lastCheckStatus, lastCheckTitle } | null
}SchemeMatchResult (evaluateScheme() in src/lib/eligibility.ts)
{
status: "LIKELY_ELIGIBLE" | "POSSIBLY_ELIGIBLE" | "NOT_ELIGIBLE" | "UNABLE_TO_DETERMINE",
relevanceScore: number, // 0–100
relevanceBand: "HIGH_RELEVANCE" | "POTENTIAL_MATCH" | "EXPLORE",
reasons: string[], reasonsHi: string[],
toVerify: string[], toVerifyHi: string[],
rules: [{ label, labelHi, status: "MET"|"FAILED"|"UNKNOWN", explanation, explanationHi,
required, ruleGroup, userValue, requirement }],
infoCompleteness: number // 0–1
}{
age?, state?, district?, gender? /* male|female|other */, educationLevel?, employmentStatus?,
occupation?, annualIncome?, residence? /* rural|urban */, socialCategory?,
isFarmer?, hasDisability?, disabilityPercent?, isMinority?, isWidow?
}Every field is optional and nullable.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /api/health |
none | Database and TinyFish health |
| GET | /api/schemes |
none | List, search, filter, paginate |
| GET | /api/schemes/[id] |
none | Scheme detail by id or slug, optional match |
| POST | /api/schemes/match |
none | Run the eligibility engine over all schemes |
| POST | /api/schemes/compare |
none | Compare 1–3 schemes |
| POST | /api/assistant |
none | Grounded Q&A over scheme records |
| GET | /api/sources |
none | Official source registry |
| POST | /api/sources/verify |
none with schemeId, otherwise admin |
Live HTTP check of one source, plus a TinyFish refresh of one scheme when schemeId is sent |
| POST | /api/discovery |
admin | Live discovery run over up to 5 sources (direct check + TinyFish extraction) |
| GET | /api/discovery |
none | Last 30 fetch jobs |
| POST | /api/auth/register |
none | Create account and session |
| POST | /api/auth/login |
none | Start session |
| POST | /api/auth/logout |
none | End session |
| GET | /api/auth/me |
optional | Current user, saved IDs, tracker |
| GET / POST / DELETE | /api/saved |
session | Saved schemes |
| GET / PUT | /api/tracker |
session | Self-reported application status |
| GET | /api/admin/stats |
admin | System metrics |
| POST | /api/setup |
setup secret | Seed sources, schemes and demo admin |
Source: src/app/api/health/route.ts
Auth: none. Params: none.
200
{ "status": "ok", "database": "connected", "schemes": 0, "sources": 0,
"tinyfish": { "status": "NOT_CONFIGURED", "message": "..." }, "time": "ISO-8601" }503 if either count query throws:
{ "status": "degraded", "database": "error", "error": "...", "time": "ISO-8601" }Calls: db.scheme.count, db.governmentSource.count, tinyFishStatus().
Source: src/app/api/schemes/route.ts
Auth: none.
| Query | Default | Notes |
|---|---|---|
q |
"" |
Trimmed and lowercased. Matched case-insensitively (contains with mode: "insensitive", OR) against name, nameHi, shortDescription, description, keywords, ministry, beneficiaryType, benefits. |
category |
— | Exact match. |
state |
— | Exact match on state. |
level |
— | national sets state = null. state sets state != null. Either one overrides the state filter. |
sort |
relevance |
name (asc), category (asc), recent (dataCompiledAt desc). Anything else sorts by verificationStatus asc (so LIVE_CHECKED comes before REFERENCE), then name asc. |
page |
1 |
At least 1. |
perPage |
12 |
Clamped to 1–48. |
200
{ "total": 0, "page": 1, "perPage": 12, "results": [ /* SchemeSummary */ ] }500 { "error": "..." }
Calls: db.scheme.count, db.scheme.findMany (include eligibilityRules, source), serializeScheme.
Note: the relevance sort is not a text-relevance ranking. It is a string sort on verificationStatus that lists live-checked schemes first, then sorts by name.
Source: src/app/api/schemes/[id]/route.ts
Auth: none.
| Param | Notes |
|---|---|
id (path) |
Matches Scheme.id or Scheme.slug. |
profile (query, optional) |
URL-encoded JSON UserProfile. The route runs decodeURIComponent and then JSON.parse. It is not schema-validated, and a parse error is silently ignored. |
200
{ "scheme": { /* SchemeDetail */ }, "match": { /* SchemeMatchResult */ } }match is omitted when there is no profile or when it fails to parse.
404 { "error": "Scheme not found" } · 500
Calls: db.scheme.findFirst (include eligibilityRules, evidence, source), evaluateScheme, serializeScheme(s, true).
Source: src/app/api/schemes/match/route.ts
Auth: none.
Body: either { "profile": UserProfile } or a bare UserProfile. The route reads body.profile ?? body. The strict zod schema applies these limits:
| Field | Constraint |
|---|---|
age |
int, 0–120 |
state, district |
string, max 80 |
gender |
male | female | other |
educationLevel, employmentStatus |
string, max 40 |
occupation |
string, max 60 |
annualIncome |
int, 0–100,000,000 |
residence |
rural | urban |
socialCategory |
string, max 20 |
isFarmer, hasDisability, isMinority, isWidow |
boolean |
disabilityPercent |
int, 0–100 |
Every field is .nullable().optional(). Unknown keys are stripped.
200
{
"results": [ { "scheme": { /* SchemeSummary */ }, "match": { /* SchemeMatchResult */ } } ],
"summary": { "total": 0, "likely": 0, "possibly": 0, "notEligible": 0, "unable": 0 }
}results covers every scheme, sorted by match.relevanceScore descending.
400 { "error": "Invalid profile", "details": [ /* up to 5 zod issues */ ] } · 500
Calls: db.scheme.findMany (include eligibilityRules, source), evaluateScheme, serializeScheme.
Source: src/app/api/schemes/compare/route.ts
Auth: none.
Body
{ "ids": ["scheme-id"], "profile": { } }ids: 1–3 strings. These must beScheme.idvalues; slugs are not resolved here. Seeded schemes use the slug as their id, so in practice both work.profile: optionalrecord<string, unknown>, cast toUserProfilewithout per-field validation.
200
{ "results": [ { "scheme": { /* SchemeDetail */ }, "match": { /* SchemeMatchResult */ } } ] }- Results come back in the order of
ids. - IDs that are not found are dropped.
matchisnullwhen no profile is sent.
400 { "error": "Send 1–3 scheme ids" } · 500
Calls: db.scheme.findMany (include eligibilityRules, evidence, source), serializeScheme(s, true), evaluateScheme.
Source: src/app/api/assistant/route.ts
Auth: none.
Body
| Field | Type | Notes |
|---|---|---|
question |
string, 2–600 | required |
lang |
en | hi |
default en |
schemeId |
string | optional. Gives the matching scheme +100 in retrieval scoring. |
profile |
record<string, unknown> |
optional. Included in the prompt as raw JSON. |
200
{
"answer": "string",
"mode": "AI_GROUNDED",
"cited": [ { "id": "", "name": "", "slug": "", "infoUrl": "" } ],
"disclaimer": "string (en or hi)"
}modeisAI_GROUNDEDorDETERMINISTIC.citedholds the retrieved schemes, up to 5.
400 { "error": "Invalid question" } · 500
Calls:
db.scheme.findMany(loads all schemes, witheligibilityRulesandsource)serializeScheme(s, true)z-ai-web-dev-sdk:ZAI.create()thenzai.chat.completions.create({ messages: [system, user] }), with no explicit model or parametersdeterministicAnswer()as the fallback when the SDK throws or returns empty content
The prompt construction and fallback behaviour are described in FLOWS.md section 8.
Source: src/app/api/sources/route.ts
Auth: none. Params: none.
200
{
"sources": [ {
"id": "", "name": "", "domain": "", "jurisdiction": "", "department": "",
"accessMethod": "DIRECT_FETCH", "accessStatus": "UNKNOWN",
"automationSupported": false, "reliability": "HIGH", "notes": null,
"lastCheckedAt": null, "lastCheckStatus": null, "lastCheckTitle": null,
"lastSuccessAt": null, "lastFailureAt": null,
"schemeCount": 0, "jobCount": 0
} ],
"tinyfish": { "status": "NOT_CONFIGURED", "message": "" }
}accessMethodisDIRECT_FETCH,TINYFISH_AUTOMATIONorOPEN_DATA.accessStatusisUNKNOWN,PERMITTEDorRESTRICTED.- Sources are ordered by
jurisdictiondesc, thennameasc.
500
Calls: db.governmentSource.findMany (with _count of schemes and fetchJobs), tinyFishStatus().
Source: src/app/api/sources/verify/route.ts. Exports maxDuration = 180.
Body
| Field | Type | Notes |
|---|---|---|
sourceId |
string | required |
schemeId |
string | optional. Also refreshes this scheme from its official page with TinyFish. |
Auth
- With
schemeId: none. Any visitor can run a scheme refresh; this is what the scheme page's Verify button sends. - Without
schemeId: admin. Otherwise 401{ "ok": false, "error": "Admin sign-in required", "message": "..." }.
The scheme must exist (404 otherwise) and belong to sourceId (400 otherwise), so the public path cannot be used to start arbitrary source checks.
What runs (the two steps run in parallel):
verifySourceAndRecord(sourceId)insrc/lib/live-check.ts:- Creates a
FetchJobwith typeSOURCE_VERIFYand modeDIRECT_FETCH. - Calls
performLiveCheck(domain): a HEAD request tohttps://<domain>, followed by a GET if HEAD returns 2xx, 403 or 405. Each request has a 12 s timeout, and the page<title>is extracted from the GET. - Updates the job and the
GovernmentSourcefieldslastChecked*,lastSuccessAt/lastFailureAtandaccessStatus(PERMITTEDorRESTRICTED). - On success, sets
verificationStatus = "LIVE_CHECKED"for every scheme linked to the source.
- Creates a
- Only when
schemeIdis sent and TinyFish isREADY,refreshSchemeWithTinyFish(schemeId):- If the scheme has a
SUCCEEDEDSCHEME_REFRESHjob completed in the last 6 hours, that job's stored result is returned and no new run starts. - Otherwise it creates a
FetchJob(SCHEME_REFRESH, modeTINYFISH, withschemeId) and callstinyFishExtractSchemePage(scheme.infoUrl, scheme.name)(see TINYFISH.md). - Stores the compacted run result in
FetchJob.resultJson. - On success, replaces the scheme's
LIVE_FETCHSourceEvidencerow with an excerpt built from the extracted deadline, application window, benefit, eligibility, documents and page-updated date (the note carries the TinyFish run ID), and sets the scheme toLIVE_CHECKED. Curated scheme fields are not changed.
- If the scheme has a
200: returned whether the checks pass or fail.
{
"ok": true,
"mode": "TINYFISH+DIRECT",
"domain": "example.gov.in",
"httpStatus": 200, "title": "Page title", "durationMs": 812, "error": null,
"tinyfish": {
"ok": true, "cached": false, "runId": "", "status": "COMPLETED", "error": null,
"result": {
"scheme_name": "", "benefit_amount": null, "eligibility": [], "documents_required": [],
"application_window": null, "deadline": null, "application_url": null,
"last_updated": null, "blocked": false
}
},
"message": "Live check succeeded: <name> responded with HTTP 200. TinyFish extracted current details from the official scheme page."
}modeisTINYFISH+DIRECTwhen a TinyFish refresh ran or was served from cache, otherwiseDIRECT_FETCH.okis true when either the direct check or the TinyFish refresh succeeded.tinyfishisnullwhen noschemeIdwas sent or TinyFish isNOT_CONFIGURED. A cached result hascached: trueandcachedAt. A failed run hasok: falsewith anerrorand astatussuch asBLOCKED,TIMEOUT,FAILEDorHTTP_401.
400 { "error": "sourceId required" }
500 { "error": "Source not found" } or { "error": "Scheme not found" }: unknown IDs throw inside the library functions and come back as 500, not 404.
Source: src/app/api/discovery/route.ts. Exports maxDuration = 300.
Auth: admin. Otherwise 401 { "error": "Admin sign-in required to run live discovery." }.
Body (optional): { "sourceIds": ["id", "..."], "useTinyFish": true }
sourceIdstakes at most 5 entries. Without it (or with an invalid body), the 5 sources with the oldestlastCheckedAtare checked.useTinyFishdefaults totrue; sendfalsefor direct checks only. It has no effect while TinyFish isNOT_CONFIGURED.
For each source the route creates a FetchJob (type LIVE_DISCOVERY, mode TINYFISH or DIRECT_FETCH) and runs, in parallel:
performLiveCheck(domain), always;tinyFishExtractSourcePage("https://<domain>"), when TinyFish is used.
When TinyFish is used, at most 2 sources are processed at a time (otherwise up to 5). A job is SUCCEEDED when either check succeeded. Its resultJson stores httpStatus, title, durationMs, tinyFishUsed and the compacted TinyFish result (ok, runId, status, steps, durationMs, error, result). The source and its linked schemes are updated as for verify; when only TinyFish succeeded, lastCheckStatus is TINYFISH_OK.
200
{
"tinyfish": { "status": "READY", "message": "" },
"checked": 1,
"jobs": [ {
"jobId": "", "source": "<source name>", "mode": "TINYFISH+DIRECT",
"status": "SUCCEEDED", "httpStatus": 200, "title": "", "durationMs": 0, "error": null,
"tinyfish": {
"ok": true, "runId": "", "status": "COMPLETED", "error": null,
"schemeNames": [], "announcements": [ { "text": "", "date": null } ],
"deadlines": [ { "label": "", "date": "" } ]
}
} ],
"note": "string"
}- Job
modeisTINYFISH+DIRECTorDIRECT_FETCH;tinyfishisnullfor direct-only jobs. - Job
statusisSUCCEEDEDorFAILED.errorjoins the direct and TinyFish errors (Direct: ... | TinyFish: ...). titleis the direct-check page title, or TinyFish'spage_titlewhen the direct check found none.durationMsis the longer of the two checks.
500
Calls: requireAdmin, tinyFishStatus, performLiveCheck, tinyFishExtractSourcePage, compactTinyFishResult, and Prisma updates on FetchJob, GovernmentSource and Scheme.
Auth: none. Params: none.
200
{ "jobs": [ { "id": "", "type": "", "mode": "", "status": "", "source": null,
"targetUrl": "", "error": null, "startedAt": "", "completedAt": null } ] }Returns the 30 most recent jobs by startedAt desc. No client component calls this endpoint.
500
Calls: db.fetchJob.findMany (include source.name, source.domain).
Session helpers are in src/lib/auth.ts:
hashPasswordandverifyPassworduse scrypt with a 16-byte salt and a 64-byte key, stored assalt:hash. Verification usestimingSafeEqual.createSessionstores the sha256 of a random 32-byte token and sets the cookie.destroySessiondeletes the session row and the cookie.getSessionUserlooks up the session and lazily deletes expired ones.requireAdminreturns the session user only when itsroleisadmin. It guardsPOST /api/discovery,POST /api/sources/verifywithoutschemeId, andGET /api/admin/stats.
Source: src/app/api/auth/register/route.ts
Body: { "email": "string (email, max 200)", "password": "string (8–100)", "name": "string (max 100, optional)" }
The email is lowercased before lookup and storage.
200 (also sets schemeai_session):
{ "user": { "id": "", "email": "", "name": null, "role": "user" } }400 { "error": "Enter a valid email and a password of at least 8 characters." }
409 { "error": "An account with this email already exists." }
500
Calls: db.user.findUnique, hashPassword, db.user.create, createSession, db.auditLog.create (action USER_REGISTER).
Source: src/app/api/auth/login/route.ts
Body: { "email": "string (email, max 200)", "password": "string (1–100)" }
200 (sets schemeai_session): { "user": { "id", "email", "name", "role" } }
400 { "error": "Enter your email and password." }
401 { "error": "Incorrect email or password." }
500
Calls: db.user.findUnique, verifyPassword, createSession.
There is no rate limiting or lockout, and logins are not written to the audit log.
Source: src/app/api/auth/logout/route.ts
Body: none. 200 { "ok": true } (always).
Calls: destroySession().
Source: src/app/api/auth/me/route.ts
Auth: optional. This route never returns 401.
200 without a session: { "user": null }
200 with a session:
{
"user": { "id": "", "email": "", "name": null, "role": "user" },
"savedIds": ["scheme-id"],
"tracker": [ { "schemeId": "", "status": "INTERESTED", "updatedAt": "" } ],
"profile": null
}profile is the UserProfile row or null. No endpoint writes that table, so it is always null in practice.
Calls: getSessionUser, db.savedScheme.findMany, db.applicationTracker.findMany, db.userProfile.findUnique.
Source: src/app/api/saved/route.ts. Auth: session for all methods. Without a session, every method returns 401 { "error": "Sign in required" }.
200 { "saved": [ { "schemeId": "", "scheme": { /* SchemeSummary */ } } ] }, newest first.
Calls: db.savedScheme.findMany (include scheme with rules and source), serializeScheme (dynamically imported). No client component calls this endpoint.
Body: { "schemeId": "string" }
200 { "ok": true, "schemeId": "" } (idempotent upsert)
400 { "error": "schemeId required" } · 404 { "error": "Scheme not found" }
Calls: db.scheme.findUnique, db.savedScheme.upsert.
Body: { "schemeId": "string" }. The ID goes in the JSON request body, not the URL.
200 { "ok": true } (also when nothing was deleted) · 400 { "error": "schemeId required" }
Calls: db.savedScheme.deleteMany.
Source: src/app/api/tracker/route.ts. Auth: session. Without one: 401 { "error": "Sign in required" }.
200
{ "tracker": [ { "schemeId": "", "status": "", "updatedAt": "", "scheme": { "id": "", "name": "", "slug": "" } } ] }Entries are ordered by updatedAt desc. No client component calls this endpoint; the client gets tracker data from /api/auth/me.
Body: { "schemeId": "string", "status": "INTERESTED" | "PREPARING_DOCUMENTS" | "APPLIED_EXTERNALLY" | "UNDER_REVIEW" | "COMPLETED" }
200 { "ok": true }
400 { "error": "Invalid tracker update" } · 404 { "error": "Scheme not found" }
Calls: db.scheme.findUnique, db.applicationTracker.upsert.
Source: src/app/api/admin/stats/route.ts
Auth: admin. Without a session the route returns 401; a signed-in user without the admin role gets 403.
200
{
"isAdmin": true,
"stats": { "totalSchemes": 0, "totalSources": 0, "liveChecked": 0,
"totalJobs": 0, "failedJobs": 0, "totalUsers": 0 },
"tinyfish": { "status": "", "message": "" },
"sources": [ { "id": "", "name": "", "domain": "", "jurisdiction": "", "accessStatus": "",
"lastCheckedAt": null, "lastCheckStatus": null, "lastCheckTitle": null, "schemeCount": 0 } ],
"recentJobs": [ { "id": "", "type": "", "mode": "", "status": "", "source": null,
"startedAt": "", "completedAt": null, "error": null } ]
}401 { "error": "Sign in required" } · 403 { "error": "Admin access required" }
Calls: getSessionUser, requireAdmin, db.scheme.count (x2), db.governmentSource.count, db.fetchJob.count (x2), db.user.count, db.fetchJob.findMany (take 10), db.governmentSource.findMany, tinyFishStatus().
Source: src/app/api/setup/route.ts
Auth: shared secret. The request needs both:
- the query parameter
setup=1 - the header
x-setup-secret: <SETUP_SECRET>
Body: none.
200
{ "ok": true, "created": 0, "updated": 0,
"counts": { "sources": 0, "schemes": 0, "rules": 0, "admin": "admin@schemeai.in" } }401 { "error": "Unauthorized." }
500 { "error": "SETUP_SECRET is not set. Add it in Vercel env vars first." }, or any seeding exception.
Calls:
ALL_SEED_SOURCESandALL_SEED_SCHEMESfromsrc/lib/schemesgovernmentSource.upsert(bydomain)scheme.updateorscheme.create(byslug, withid = slugon create)schemeEligibilityRuledeleteMany then createsourceEvidencedeleteMany (GUIDELINE_EXCERPTonly) then createuser.createfor the demo adminadmin@schemeai.inwith the fixed passwordAdmin@1234, only if that user does not already exist
The route is idempotent. The CLI equivalents are scripts/seed.ts (npm run db:seed) and scripts/create-admin.ts (npm run db:seed:admin).
| Variable | Used in | Purpose |
|---|---|---|
POSTGRES_PRISMA_URL / DATABASE_URL |
db.ts |
Database connection. The first takes precedence. |
SETUP_SECRET |
/api/setup |
Gates seeding. |
TINYFISH_API_KEY |
tinyfish.ts |
Required for TinyFish READY. Sent as the X-API-Key header. |
TINYFISH_API_URL |
tinyfish.ts |
Optional. Default https://agent.tinyfish.ai. |
NODE_ENV |
auth.ts |
production sets the Secure flag on the cookie. |
The assistant's LLM credentials are not read by route code. z-ai-web-dev-sdk's ZAI.create() resolves them itself.