Nodea — logo

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

MethodPathScopeDescription
GET/api/v1/ticketsaccount:readList of your own tickets, newest activity first (last_reply_at DESC, id DESC). status filter. Simple pagination.
GET/api/v1/tickets/{ticket}account:readTicket detail with the full conversation (public replies in chronological order, no internal notes).
POST/api/v1/ticketsaccount:writeOpen a new ticket (status open); the body becomes its first reply. Returns 201.
POST/api/v1/tickets/{ticket}/replyaccount:writeAppend a client reply. Returns 201 (TicketReplyResource).
POST/api/v1/tickets/{ticket}/closeaccount:writeClient-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 in ticket_departments with is_active = true).
  • priority (enum, optional: low|normal|high|urgent; defaults to normal).

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: body body (string, required, min 1). A client reply flips answeredopen (other states preserved).
  • A closed ticket rejects a reply: 422, code ticket_closed.
  • A spam ticket rejects a reply: 422, code ticket_spam.
  • close: closing an already-closed ticket is 422, code already_closed; a spam ticket cannot be closed by the client: 422, code ticket_spam. Success stamps closed_at and closed_by_id.

Error codes

  • 401 — missing/invalid token.
  • 403 — missing required scope or inactive hosting-suite flag.
  • 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.

ToolScopeTypeDescription
tickets-listaccount:readread-onlyList your own tickets; status filter, page, per_page (max 50, default 15). reply_count counts public replies only.
ticket-getaccount:readread-onlyOne ticket with the full conversation (public replies chronologically, no internal notes). Param: id.
ticket-createaccount:writewriteOpen a ticket; ticket_department_id, subject (max 255), body, optional priority. Status open, source = mcp.
ticket-replyaccount:writewriteClient reply; id, body. answeredopen. Closed/spam reject.
ticket-closeaccount:writewriteClose 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." }
  }
}