---
title: "Support tickets — API v1 and MCP | Nodea Documentation"
description: "Full reference for support tickets: REST API v1 endpoints (list with status filter, detail with conversation excluding internal notes, create, reply, close), account:read/write + hosting-suite scopes, MCP tools and curl / JSON-RPC examples."
url: "https://nodea.io/en/api-docs/tickets"
lang: "en"
updated: "2026-07-16"
---

# Support tickets — API v1 and MCP | Nodea Documentation

> Support tickets reference — API v1 endpoints (list with status filter, detail + conversation without internal notes, create/reply/close), account:read/write scopes, hosting-suite flag, MCP tools and examples.

The **Support tickets** module covers the client support portal: listing the customer account's 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.

## Customer account, ownership and visibility

Tickets belong to a **customer account** (see [Customer accounts](https://nodea.io/en/api-docs/accounts)), not to a single user. A request acts in the current account (token pinning → X-Account-Id header → default account) and — next to the token scope — requires the tickets account permission (the owner always holds it):

EndpointAccount permissionGET /tickets, GET /tickets/{ticket}, POST /tickets, POST /tickets/{ticket}/reply, POST /tickets/{ticket}/closetickets

- The **list** (GET /tickets) returns the **current account's** tickets — opened by any member, not only by you.
- **Show, reply and close** work on any ticket of the current account. A ticket outside the current account — another customer's, one of your other accounts', or a guest ticket without an account — yields 404 (not 403), so the API never confirms the resource exists.
- **Creating** (POST /tickets) opens the ticket in the current account: account\_id = the current account, reporter (user\_id) = the token user.
- TicketResource carries an account\_id field (int|null — null for older tickets without an account).
- is\_by\_owner in replies means **“a customer-side reply”**: true when the author is not a support staff member (ADMIN role) — this includes replies by other members of the account; the reporter's replies are always customer-side, and a reply without an author counts as a staff reply, unless it comes from a guest sender (a customer without a login — e.g. a contact of a ticket imported from the previous customer panel), which is customer-side. Staff replies imported from the previous panel whose author has no staff account are authored by “Zespół Nodea (archiwum)” and are staff replies.

Missing the tickets permission returns 403 account\_permission\_denied with "permission": "tickets"; no resolvable account returns 403 no\_account\_context.

## 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

MethodPathScopeDescriptionGET/api/v1/ticketsaccount:readTickets of the current account (all members), 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, customer\_reply, in\_progress, on\_hold, escalated, client\_action\_required, closed, spam. Any other value → 422.

### Ticket statuses

The statuses mirror the WHMCS ticket statuses (tickets imported from WHMCS keep their status 1:1). The awaiting\_reply\_from field tells whose move it is: staff, client or null (closed/spam).

StatusMeaningawaiting\_reply\_fromopenNew ticket, no staff reply yet.staff pendingWaiting for staff (nodea status).staff customer\_replyThe client replied after a staff reply (WHMCS Customer-Reply).staff in\_progressStaff is working on it (set by an administrator).staff on\_holdPut on hold by staff (set by an administrator).staff escalatedEscalated — no staff reply within the configured window, or an administrator's decision.staff answeredStaff replied.client client\_action\_requiredStaff is waiting for an action by the client.client closedClosed (is\_closed = true).null spamMarked as spam — irreversible.null

- per\_page (int 1–100, default 20), page (int ≥1).

Response: a TicketResource collection — fields: id, number (T-YYYY-NNNNNN), subject, status, awaiting\_reply\_from (staff|client|null), 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 moves answered, client\_action\_required and on\_hold → customer\_reply; other states (e.g. open, in\_progress, escalated) are 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.
- 403 — account-context error: account\_permission\_denied (the permission field names the missing flag) — here always tickets, no\_account\_context, account\_forbidden / account\_mismatch (the X-Account-Id header or token pinning) — see [Customer accounts](https://nodea.io/en/api-docs/accounts).
- 404 — unknown ticket or a ticket outside the current account.
- 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.

ToolScopeTypeDescriptiontickets-listaccount:readread-onlyList the current account's tickets (all members); 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. answered/client\_action\_required/on\_hold → customer\_reply. 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, the move to customer\_reply on a client reply, 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.

**Customer accounts in MCP.** The tools act in the current account of the MCP connection (see [Customer accounts](https://nodea.io/en/api-docs/accounts)) and require the tickets account permission next to the scope. tickets-list returns the tickets of every member of the account; ticket-get, ticket-reply and ticket-close work on the current account's tickets; ticket-create opens the ticket in the current account with the caller as the reporter. is\_by\_owner means the same as in the API (customer side). Denials end with the suffix (code: account\_permission\_denied, permission: tickets) or (code: no\_account\_context); a ticket outside the account returns “Ticket not found or not accessible with your permissions.”.

### 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." } } }

## Related

- [Account profile — API v1 and MCP | Nodea Documentation](https://nodea.io/en/api-docs/profile)
- [Billing — API v1 and MCP | Nodea Documentation](https://nodea.io/en/api-docs/billing)
- [Customer accounts — API v1 and MCP | Nodea Documentation](https://nodea.io/en/api-docs/accounts)

---

Languages: [pl](https://nodea.pl/pl/dokumentacja-api/tickets.md)  
Site index: [llms.txt](https://nodea.io/en/llms.txt)
