Public Intake API
Reference for the five 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).
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 |
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"
}'
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). - 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.