Support tickets — API v1 and MCP | Nodea Documentation
The Support tickets module covers the client support portal: listing your own tickets, viewing a conversation, opening a new ticket, appending a reply and closing. This document describes the REST API v1 layer (/api/v1/tickets) and the module's MCP tools.
Authentication and scopes
API v1 authenticates with a Passport personal access token via the Authorization: Bearer <token> header.
account:read— listing and detail (index,show).account:write— create, reply and close (store,reply,close).
The whole module is additionally gated by the Pennant flag features:hosting-suite — it mirrors the web /app/account/* groups. Rate limit: throttle:api-v1 = 240/min per token user.
Ownership and visibility
Tickets have no sharing model and no policy class — row-level access is enforced inline (ensureOwned): only the owner may see or act on their ticket. A foreign or unknown id yields 404 (not 403), so the API doesn't confirm that another tenant's resource exists.
Internal notes — never visible
The conversation is always loaded through the public() scope on TicketReply, so internal admin notes never reach the payload — a security requirement, not an optimisation. A client can never create an internal note: is_internal_note is hardcoded to false on every reply created via the API. Attachments are not supported over the API (no upload, no download URLs) — only reply-attachment metadata is exposed (id, original_name, size, mime).
API v1 — endpoints
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/tickets | account:read | List of your own tickets, newest activity first (last_reply_at DESC, id DESC). status filter. Simple pagination. |
| GET | /api/v1/tickets/{ticket} | account:read | Ticket detail with the full conversation (public replies in chronological order, no internal notes). |
| POST | /api/v1/tickets | account:write | Open a new ticket (status open); the body becomes its first reply. Returns 201. |
| POST | /api/v1/tickets/{ticket}/reply | account:write | Append a client reply. Returns 201 (TicketReplyResource). |
| POST | /api/v1/tickets/{ticket}/close | account:write | Client-initiated close. Returns the updated ticket. |
GET /tickets — query parameters
status(enum, optional) — exact match:open,pending,answered,closed,spam.per_page(int 1–100, default 20),page(int ≥1).
Response: a TicketResource collection — fields: id, number (T-YYYY-NNNNNN), subject, status, priority, department (id/name/slug), last_reply_at, last_reply_by_admin, is_closed, closed_at, created_at, updated_at. The replies[] key appears only in the detail (when the controller eager-loaded the relation). Each reply: id, body, source, is_by_owner, author (id/name), attachments[] (metadata), created_at.
POST /tickets — body parameters
subject(string, required, min 5, max 255).body(string, required, min 10) — persisted as the first public reply.ticket_department_id(required, must exist inticket_departmentswithis_active = true).priority(enum, optional:low|normal|high|urgent; defaults tonormal).
Creation is atomic (transaction): a temporary number satisfies the unique index, then generateNumber() derives the real T-YYYY-NNNNNN, and the body is stored as the first reply (source = api). The department mailbox is notified as an anonymous notifiable. Attachments are not accepted over the API.
POST /reply and /close — business rules
reply: bodybody(string, required, min 1). A client reply flipsanswered→open(other states preserved).- A
closedticket rejects a reply:422, codeticket_closed. - A
spamticket rejects a reply:422, codeticket_spam. close: closing an already-closed ticket is422, codealready_closed; aspamticket cannot be closed by the client:422, codeticket_spam. Success stampsclosed_atandclosed_by_id.
Error codes
401— missing/invalid token.403— missing required scope or inactivehosting-suiteflag.404— unknown or foreign ticket (inline ownership).422— validation or business rule (codes:ticket_closed,ticket_spam,already_closed).
Examples — API
List tickets filtered by status
curl -s "https://app.nodea.io/api/v1/tickets?status=open&per_page=20" \
-H "Authorization: Bearer $NODEA_TOKEN" \
-H "Accept: application/json"
Open a new ticket
curl -s -X POST https://app.nodea.io/api/v1/tickets \
-H "Authorization: Bearer $NODEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"ticket_department_id": 1,
"subject": "DNS issue",
"body": "The A record has not propagated for an hour.",
"priority": "high"
}'
Reply to a ticket
curl -s -X POST https://app.nodea.io/api/v1/tickets/42/reply \
-H "Authorization: Bearer $NODEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "body": "Thanks, it works now." }'
Close a ticket
curl -s -X POST https://app.nodea.io/api/v1/tickets/42/close \
-H "Authorization: Bearer $NODEA_TOKEN" \
-H "Accept: application/json"
MCP — tools
MCP tools share the auth and scope model with API v1 (Bearer + scope checked in ensureScope) and the features:hosting-suite flag — a tool on an inactive flag disappears from tools/list and every call fails with "Tool [...] not found.". Ticket ids are integers (id). A missing and a foreign id raise the same error (AuthorizationException: "Ticket not found or not accessible with your permissions.") — no cross-tenant enumeration.
| Tool | Scope | Type | Description |
|---|---|---|---|
| tickets-list | account:read | read-only | List your own tickets; status filter, page, per_page (max 50, default 15). reply_count counts public replies only. |
| ticket-get | account:read | read-only | One ticket with the full conversation (public replies chronologically, no internal notes). Param: id. |
| ticket-create | account:write | write | Open a ticket; ticket_department_id, subject (max 255), body, optional priority. Status open, source = mcp. |
| ticket-reply | account:write | write | Client reply; id, body. answered → open. Closed/spam reject. |
| ticket-close | account:write | write | Close a ticket; id. Already-closed or spam return a tool error. |
Notes: tickets-list, ticket-get carry the IsReadOnly/IsIdempotent annotations. ticket-create, ticket-reply, ticket-close are writing (no read-only annotation). The business rules (closed/spam, reopen from answered, hardcoded is_internal_note = false, department-mailbox notification) are identical to the API; on failure a Response::error is returned instead of an HTTP 422. ticket-create and ticket-close load reply_count from public replies only.
Example — MCP (JSON-RPC)
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "ticket-create",
"arguments": {
"ticket_department_id": 1,
"subject": "DNS issue",
"body": "The A record has not propagated for an hour.",
"priority": "high"
}
}
}
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "ticket-reply",
"arguments": { "id": 42, "body": "Thanks, it works now." }
}
}