Public Intake API
Reference for the endpoints under /api/intake/* that an external system (or the intake page in this app) can call.
Conventions
- All endpoints accept and return JSON (
Content-Type: application/json). - Error responses have shape
{ "error": string }. Messages from public endpoints are deliberately user-safe (no Prisma stack traces or internal paths leaked). - Rate-limited endpoints set
Retry-After(seconds) on the 429 response. - Dates in responses are ISO 8601 strings (e.g.
2026-05-22T05:05:00.000Z); the response also includes adateFormat(date-fns pattern) so a UI can render them per the global setting. - Authentication: no API tokens. Sessions are NextAuth cookies. A client that needs to act as a specific user must complete the Google sign-in flow first; subsequent calls reuse the session cookie. Most intake endpoints don't require auth.
POST /api/intake
Submit a new ticket.
Auth
Optional. If a NextAuth session cookie is present, the submitter is locked to that user's identity — body-supplied createdByName / createdByEmail are overridden with the user's profile, and submittedByUserId is recorded. Without a session, the body fields are accepted as-is and the ticket is treated as anonymous (customer chat will be disabled).
Email verification (anonymous only): an anonymous submission must carry a valid intake_email_verified cookie proving ownership of createdByEmail. Obtain it via the verify-email flow (POST .../verify-email/send → POST .../verify-email/confirm, below) before submitting; otherwise the request is rejected 403. Signed-in submissions skip this (identity is already verified by the session).
Rate limit
5 submissions per hour per IP (in-process). Exceeding returns 429.
Request body
{
"deptId": "uuid", // required — destination department
"userId": "uuid", // optional — pre-assign to a user in that department
"kpiTypeId": "uuid", // required — must be intake-eligible and assigned to deptId
"ticketDate": "2026-05-22T05:05:00.000Z", // required — ISO timestamp
"description": "Cannot access shared drive", // required — non-empty
"createdByName": "Sandy", // required if not signed in
"createdByEmail": "sandy@example.com", // required if not signed in
"isHolidayWork": false // optional — count this ticket even if dated to a holiday/weekend
}
Responses
201 Created
{
"id": "uuid",
"status": "NEW" | "ONPROGRESS",
"assignmentEmail": { "status": "sent" | "skipped" | "error", "reason"?: string } | null
}
statusisONPROGRESSwhenuserIdwas provided (pre-assigned). OtherwiseNEW.assignmentEmailisnullunless a user was assigned and an email attempt was made.
400 Bad Request — one of:
| Error message | Why |
|---|---|
Department is required | deptId missing/invalid |
Invalid Department | deptId doesn't exist |
KPI Type is required | kpiTypeId missing |
Invalid KPI Type | kpiTypeId doesn't exist |
This KPI Type is not available for the selected department | DeptKpi mapping missing |
Ticket Date is required | ticketDate missing/unparseable |
Uptime KPI is calculated from other KPI tickets and cannot be entered manually | The chosen KPI Type is calculated |
Description is required | description missing |
Your name is required / Your email is required | Anonymous submission with missing fields |
Email format is invalid | createdByEmail doesn't look like an email |
Selected user is not assigned to that department or is inactive | userId invalid for this deptId |
403 Forbidden — { "error": "Please verify your email before submitting.", "code": "EMAIL_VERIFICATION_REQUIRED" } — anonymous submission whose createdByEmail hasn't been verified (no/expired intake_email_verified cookie).
429 Too Many Requests — { "error": "Too many submissions. Try again in N seconds." } + Retry-After header.
500 Internal Server Error — friendly message; raw error logged server-side only.
Example
curl -X POST https://your-app.example.com/api/intake \
-H 'Content-Type: application/json' \
-d '{
"deptId": "11111111-1111-1111-1111-111111111111",
"kpiTypeId": "22222222-2222-2222-2222-222222222222",
"ticketDate": "2026-05-22T05:05:00.000Z",
"description": "Cannot access shared drive",
"createdByName": "Sandy",
"createdByEmail": "sandy@example.com"
}'
Email verification (anonymous submitters)
A 6-digit one-time code proves an anonymous submitter owns createdByEmail before POST /api/intake will accept it. Stateless: signed (HMAC) cookies carry the challenge + the resulting proof; the plaintext code lives only in the email. Signed-in users don't use these.
POST /api/intake/verify-email/send
Emails a code to { "email": string }. Sets an intake_email_challenge cookie. Best-effort rate limit: a ~45s resend cooldown per browser.
200{ "ok": true, "cooldownMs": 45000, "expiresInMs": 600000 }— code sent. Or{ "alreadyVerified": true }if the email is already verified in this browser (no code sent — go straight to submit).400{ "error": "A valid email is required." }.429{ "error": "Please wait a moment before requesting another code.", "retryAfterMs": number }.502/503— the email couldn't be sent / SMTP isn't configured.
POST /api/intake/verify-email/confirm
Checks { "email": string, "code": "123456" } against the challenge cookie. On success sets the intake_email_verified cookie (remembered ~30 days) and clears the challenge.
200{ "verified": true }.400{ "verified": false, "reason": "invalid_code" | "expired" | "too_many_attempts", "remaining": number }(5 attempts, 10-min expiry).
GET /api/intake/verify-email/status?email={email}
Reports whether the current browser already holds a valid verification for that email (lets a returning customer skip the code step). 200 { "verified": boolean }.
GET /api/intake/options
Returns the lists needed to render the intake form: countries, departments (filtered to those with intake-eligible KPI types and at least one active user), KPI types grouped by department, and the global date format.
Auth
None. Public.
Response
200 OK
{
"countries": [
{ "id": "uuid", "name": "Indonesia", "abbreviation": "ID" }
],
"departments": [
{
"id": "uuid",
"name": "Information Technology",
"abbreviation": "IT",
"countryId": "uuid",
"country": { "id": "uuid", "name": "Indonesia", "abbreviation": "ID" },
"users": [
{ "id": "uuid", "name": "Sandy Rachman", "email": "sandy@example.com" }
]
}
],
"kpiTypesByDept": {
"<deptId>": [
{
"id": "uuid",
"name": "Ad Hoc",
"kgId": "uuid",
"ruleType": "rr",
"formulaType": "rr",
"metricSchema": [...],
"parameters": { "responseSlaMin": 30, "resolutionSlaMin": 480 },
"subGroup": null,
"displayOrder": 1,
"kpiGroup": { "id": "uuid", "name": "Request" }
}
]
},
"dateFormat": "dd/MM/yyyy"
}
Calculated KPI types (rule uptime) are excluded — those can't be submitted manually.
500 Internal Server Error — friendly message.
GET /api/intake/lookup?q={query}
Find tickets matching a UUID, exact email, or exact full name. Used by the public "Look up a ticket" widget on the intake page.
Auth
None. Public.
Rate limit
20 lookups per hour per IP.
Query
| Param | Required | Notes |
|---|---|---|
q | yes | UUID → match by id. Email regex match → match by createdByEmail (case-insensitive). Otherwise → match by createdByName exactly (case-insensitive). Partial name matches are not supported (enumeration deterrent). |
Response
200 OK
{
"dateFormat": "dd/MM/yyyy",
"tickets": [
{
"id": "uuid",
"status": "NEW" | "ONPROGRESS" | "CLOSED" | "REJECTED" | null,
"submittedAt": "2026-05-22T05:05:00.000Z",
"submittedBy": "Sandy",
"country": "Indonesia",
"department": "Information Technology",
"kpiType": "Ad Hoc"
}
]
}
Up to 10 most recent matches, newest first.
429 Too Many Requests — { "error": "Too many lookups. Try again in N seconds." } + Retry-After.
500 Internal Server Error — friendly message.
Example
curl 'https://your-app.example.com/api/intake/lookup?q=sandy@example.com'
GET /api/intake/status/{id}
Fetch the public-safe status of one ticket. Includes the chat history and a canMessage flag computed against the current viewer's session.
Auth
Optional. The session is read to decide canMessage. Without a session, the response still includes all fields except chat is read-only.
Response
200 OK
{
"id": "uuid",
"status": "NEW" | "ONPROGRESS" | "CLOSED" | "REJECTED", // 'NEW' falls back for self-logged tickets
"submittedAt": "2026-05-22T05:05:00.000Z",
"assignedAt": "2026-05-22T05:10:00.000Z" | null,
"firstAgentReplyAt": "2026-05-22T06:11:00.000Z" | null,
"closedAt": "2026-05-22T08:00:00.000Z" | null,
"firstResponseTimeMin": 5 | null,
"resolutionTimeMin": 120 | null,
"description": "Cannot access shared drive",
"rejectionReason": "Duplicate request" | null,
"submittedBy": "Sandy" | null,
"submittedByEmail": "sandy@example.com" | null,
"country": "Indonesia" | null,
"department": "Information Technology" | null,
"kpiType": "Ad Hoc" | null,
"assignedTo": "Sandy Rachman" | null,
"canMessage": false,
"messageDisabledReason": "Sign in to reply to this ticket." | null,
"dateFormat": "dd/MM/yyyy",
"messages": [
{
"id": "uuid",
"authorType": "AGENT" | "CUSTOMER",
"authorName": "Sandy Rachman" | null,
"authorEmail": "sandy@example.com" | null,
"body": "Licence have been renew",
"createdAt": "2026-05-22T06:11:00.000Z"
}
]
}
canMessage is true only when all of:
- The ticket has a status (i.e. it's an intake ticket, not a self-logged one).
ticket.submittedByUserIdis set (the submitter was signed in at submission).- The current viewer's user id equals
ticket.submittedByUserId.
When canMessage is false, messageDisabledReason explains why ("This ticket was submitted without sign-in…" / "Sign in to reply…" / "This ticket can only be replied to by the original submitter.").
400 Bad Request — { "error": "Ticket id is required" }.
404 Not Found — { "error": "Ticket not found" }.
500 Internal Server Error — friendly message.
Example
curl 'https://your-app.example.com/api/intake/status/2a58b5d1-668b-447d-8fc3-f52149d4fc71'
POST /api/intake/status/{id}/messages
Customer-side chat: append a message to the ticket's discussion.
Auth
Required. The viewer must be signed in and their user id must equal ticket.submittedByUserId. Tickets submitted anonymously (no submittedByUserId) can never receive customer messages; the agent must follow up via email.
Rate limit
20 messages per hour per IP per ticket.
Request body
{
"body": "Thanks — that resolved it." // required, max 2000 characters
}
The server ignores any authorName / authorEmail in the body — identity is taken from the verified session.
Responses
201 Created
{
"message": {
"id": "uuid",
"authorType": "CUSTOMER",
"authorName": "Sandy Rachman",
"authorEmail": "sandy@example.com",
"body": "Thanks — that resolved it.",
"createdAt": "2026-05-22T08:00:00.000Z"
}
}
400 Bad Request — one of:
| Error | Why |
|---|---|
Ticket id is required | URL missing the id segment |
Message is required | Body empty |
Message must be 2000 characters or fewer | Length limit |
Discussion is available only for intake tickets | Ticket has no status (self-logged) |
401 Unauthorized — Sign in to reply to this ticket.
403 Forbidden — one of:
| Error | Why |
|---|---|
This ticket was submitted without sign-in. Customer replies are not available — the agent will follow up via email. | submittedByUserId is null on the ticket |
Only the original submitter can reply to this ticket. | Session user differs from submittedByUserId |
404 Not Found — ticket id doesn't exist.
429 Too Many Requests — Retry-After header.
500 Internal Server Error — friendly message.
Example
# Assumes a session cookie has already been established via NextAuth.
curl -X POST 'https://your-app.example.com/api/intake/status/2a58b5d1.../messages' \
-H 'Content-Type: application/json' \
-b 'next-auth.session-token=...' \
-d '{ "body": "Thanks — that resolved it." }'
Typical integration flow
- Bootstrap:
GET /api/intake/options→ cache the country / department / KPI Type lists. Refresh occasionally (admins change them rarely). - (Anonymous only) Verify email:
POST .../verify-email/sendthen.../verify-email/confirmto set the verified-email cookie (skip if signed in, or ifstatusalready reports the email verified). - Submit:
POST /api/intakewith the picked IDs + customer details → store the returned ticketid. - (Optional) Sign-in flow: if you want the customer to chat, send them through your NextAuth Google sign-in before they reach the status page; the session cookie persists.
- Poll status:
GET /api/intake/status/{id}periodically (or use a refresh button). Observestatustransitions,messages, and the response/resolution metrics. - Send chat:
POST /api/intake/status/{id}/messageswhilecanMessageis true. - Find later: if the customer lost the URL,
GET /api/intake/lookup?q=....
Stability notes
- Endpoint shapes follow the source under
src/app/api/intake/. Breaking changes will be called out in repo commits but no versioning header is in place — pin to a known commit if you need a stable contract. friendlyErrorMessagemasks server stack traces in500responses for these public endpoints; expect generic prose, not structured details.- Schema fields beyond what's documented here may appear in responses (e.g. additional metadata on
tickets[].messages); consumers should ignore unknown fields.