REST API

Complete reference for all API endpoints, authentication methods, and data formats.

The REST API is mounted at /api/v1/. All request and response bodies are JSON (Content-Type: application/json). Timestamps are ISO 8601 strings in UTC. All resource IDs are UUIDs. Tickets additionally carry a human-readable tracking number such as GHD-2026-000001, which every ticket endpoint accepts in place of the UUID. The GHD part is an admin setting, so your instance may use something else.

Authentication methods

MethodHow to authenticateBest for
Session cookie POST to /api/v1/auth/local/login. The server sets an HttpOnly session cookie automatically. Browsers send it on subsequent requests. The built-in React SPA
API key Authorization: ApiKey <token> — its own scheme, not Bearer, which is reserved for OAuth2 JWTs. The token is 64 hex characters with no prefix, shown once when the key is created. Scripts, CLI tools, CI pipelines, monitoring
OAuth2 JWT POST to /api/v1/auth/oauth/token with client_id + client_secret to get a short-lived JWT. Send as Authorization: Bearer <jwt>. Server-to-server integrations (JIRA, chatbots)

From v1.2.0 every endpoint also checks the credential's scopes. Authenticating is no longer enough: an API key or OAuth client reaches only the resources it was granted, as read or write. A credential with no scopes reaches nothing — and the v1.1.1 admin UI created credentials with no scopes, so most existing keys stop working on upgrade. GET /api/v1/admin/scopes lists what can be granted. See Upgrading to 1.2.0.

The MCP server at /mcp/ accepts the same API key and OAuth2 JWT authentication as the REST API. See MCP Server for connection details.

Base URL

https://<BASE_URL>/api/v1

Quick example

curl https://helpdesk.example.com/api/v1/tickets \
  -H "Authorization: ApiKey 3f8a…"

The key needs the tickets:read scope. Without it the same request returns 403.

Tickets

GET/api/v1/tickets

List tickets. Scope is applied automatically: staff see tickets assigned to them or their groups; users see only their own tickets. Admins default to their own work queue and can broaden scope with the scope parameter.

Query parameters

ParamTypeDescription
qstringSearch subject and description. Combines with any of the filters below.
scopestringAdmin only. unassigned — tickets with no assignee. all — every ticket. Omit for the caller's own queue. Staff or User receive 403.
reporter_idUUIDAdmin only. Every ticket filed by that user.
assignee_group_idUUIDStaff and Admin. Tickets assigned to that group. With ticket scope enforced, a Staff member must be a member of the group.
limitintDefault 100, maximum 200.
offsetintDefault 0. Values are clamped rather than rejected.

Response

A JSON array of tickets. There is no envelope and no total count — page with limit and offset, and stop when a page comes back shorter than limit.

[
  {
    "id":              "9f1c2d84-5b6e-4a10-9c33-1f0e7b2a4d55",
    "tracking_number": "GHD-2026-000001",
    "subject":         "Laptop won't power on",
    "description":     "My ThinkPad X1 stopped...",
    "category_id":     "3a7e...",
    "type_id":         "5c21...",
    "priority":        "high",
    "status_id":       "b0d4...",
    "reporter_user_id":"77aa...",
    "created_at":      "2026-04-05T09:00:00Z",
    "updated_at":      "2026-04-05T09:00:00Z"
  }
]

Optional fields are omitted when unset rather than sent as null: type_id, item_id, assignee_user_id, assignee_group_id, reporter_user_id, guest_email, guest_name, guest_phone, resolution_notes, resolved_at, closed_at.

Names are not expanded. A ticket carries category_id and status_id, not a nested category or status object — resolve them against GET /api/v1/categories and GET /api/v1/statuses.

POST/api/v1/tickets

Create a new ticket. Returns the created ticket with HTTP 201.

Request body

{
  "subject":     "Laptop won't power on",        // required, 200 chars max
  "description": "My ThinkPad X1 stopped...",    // required
  "category_id": "3a7e...",                      // required
  "type_id":     "5c21...",                      // optional
  "item_id":     "8b93...",                      // optional
  "priority":    "high",                         // optional, default "medium"
  "guest_email": "sam@example.com",              // optional; makes this a guest ticket
  "guest_name":  "Sam Ortiz",                    // required when guest_email is set
  "guest_phone": "+1 555 0100",                  // optional
  "custom_fields": { "asset_tag": "X1-4471" }    // optional
}

Priority values are lower-case: critical, high, medium, low. A value that is present but not one of these is a 400.

guest_email must be a single bare address — no display name, one address only. From v1.2.0 it is validated and normalised; anything else is a 400.

Not usable yet. This endpoint requires a session, and an authenticated caller's guest fields are discarded rather than stored, so no guest ticket can currently be created. Tracked as issue #154.

GET/api/v1/tickets/{id}

Get a single ticket by UUID or tracking number. The response is the ticket on its own — replies are a separate request.

Response

{
  "id":               "9f1c2d84-5b6e-4a10-9c33-1f0e7b2a4d55",
  "tracking_number":  "GHD-2026-000001",
  "subject":          "Laptop won't power on",
  "description":      "My ThinkPad X1 stopped...",
  "category_id":      "3a7e...",
  "type_id":          "5c21...",
  "priority":         "high",
  "status_id":        "b0d4...",
  "assignee_user_id": "12ab...",
  "reporter_user_id": "77aa...",
  "resolution_notes": "Replaced the power adapter.",
  "resolved_at":      "2026-04-05T10:30:00Z",
  "created_at":       "2026-04-05T09:00:00Z",
  "updated_at":       "2026-04-05T10:30:00Z"
}

There is no sla_deadline on the ticket and no nested category, type, submitter or assignee object — only the ids. tracking_number is always present.

GET/api/v1/tickets/{id}

A tracking number works anywhere {id} is accepted, so /api/v1/tickets/GHD-2026-000001 and /api/v1/tickets/<uuid> reach the same ticket. There is no separate lookup endpoint, and no unauthenticated one: the same authentication and the same visibility rules apply either way. A ticket you may not see is reported as not found rather than refused, so tracking numbers cannot be probed for existence.

PATCH/api/v1/tickets/{id}

Update a ticket. All fields are optional — only included fields are modified (partial update).

Request body

{
  "status_id":          "b0d4...",   // change status
  "assignee_user_id":   "12ab...",   // assign to a staff member
  "assignee_group_id":  "44cd...",   // or to a group
  "clear_assignee":     true,        // unassign; see below
  "category_id":        "3a7e...",   // reclassify
  "type_id":            "5c21...",
  "item_id":            "8b93..."
}

Priority is not editable here. It is set when the ticket is created.

clear_assignee exists because an omitted key and an explicit null arrive identically, so there is no way to say "assign this to nobody" with the two assignee fields alone. Send {"clear_assignee": true}.

POST/api/v1/tickets/{id}/resolve

Mark a ticket Resolved. Requires Staff or Admin role. The ticket transitions to the system Resolved status and the reopen window starts.

{ "notes": "Replaced the power adapter. Confirmed working." }
POST/api/v1/tickets/{id}/reopen

Manually reopen a Resolved or Closed ticket. Staff and Admin only. The ticket transitions back to New.

{ "reason": "User says the issue has recurred." }  // optional
POST/api/v1/tickets/{id}/close

Permanently close a Resolved ticket. Admin only. Bypasses the reopen window — closes the ticket immediately rather than waiting for the window to expire. Returns the updated ticket.

Replies

GET/api/v1/tickets/{id}/replies

List all replies on a ticket in chronological order. Internal notes are included only for Staff and Admin. For User or guest access, only public replies are returned.

Response

[
  {
    "id":              "6d2f...",
    "ticket_id":       "9f1c...",
    "author_id":       "12ab...",
    "body":            "I've ordered the replacement part.",
    "internal":        false,
    "notify_customer": true,
    "created_at":      "2026-04-05T11:00:00Z"
  }
]

The flag is internal, not is_internal. author_id is omitted for a reply with no author. There is no edited_at and no nested author object.

POST/api/v1/tickets/{id}/replies

Add a reply or internal note to a ticket.

If the ticket is Resolved, the authenticated user is a User or guest, and they are within the reopen window, the ticket is automatically reopened and the reply is saved.

If internal is true, the request requires Staff or Admin. A User receives 403. An internal note never triggers a customer notification, whatever notify_customer says, and its body is not included in the webhook event either.

{
  "body":            "The replacement arrives Thursday morning.",
  "internal":        false,
  "notify_customer": true       // optional, defaults to true
}

Replies cannot be edited or deleted. There is no endpoint for either. A reply thread is the record of what was said to a customer and when, so it is append-only — correct a reply by posting another one.

GET/api/v1/tickets/{id}/canned-responses

List the canned responses in scope for this ticket — global templates, plus any scoped to the ticket's category, or category and type. Requires Staff or Admin role; returns 403 for a User or guest, since only staff compose replies with canned responses.

GET/api/v1/tickets/{id}/links

List all linked tickets for the given ticket.

[
  {
    "source_id": "9f1c...",
    "target_id": "4e77...",
    "link_type": "related_to"
  }
]

A link carries ids only; fetch the target ticket separately for its subject or status.

POST/api/v1/tickets/{id}/links

Create a link between two tickets. The reverse link is created automatically. Requires Staff or Admin role.

{
  "target_id": "4e77...",
  "link_type": "related_to"
}

Valid link_type values: related_to, parent_child (the source is the parent), caused_by (the source was caused by the target), duplicate_of. There is no parent_of or child_of — direction is carried by which ticket is the source.

Returns HTTP 201 with both the forward and reverse link objects.

DELETE/api/v1/tickets/{id}/links/{targetId}/{linkType}

Remove a link. The reverse link on the target ticket is also removed. Requires Staff or Admin role.

Categories, types, items & statuses

GET/api/v1/categories

List all categories. Returns a flat list; types and items are not nested.

[ { "id": "3a7e...", "name": "Hardware", "sort_order": 1, "active": true } ]
GET/api/v1/categories/{id}/types

List types for a category. Returns an empty array if the category has no types.

GET/api/v1/categories/{id}/types/{typeId}/items

List items for a type. Returns an empty array if the type has no items.

GET/api/v1/statuses

List all ticket statuses, including system and custom statuses, in their configured sort order.

[
  { "id": "b0d4...", "name": "New",         "kind": "system", "color": "#64748b", "sort_order": 1, "active": true, "ticket_count": 12 },
  { "id": "c1e5...", "name": "In Progress", "kind": "custom", "color": "#2563eb", "sort_order": 2, "active": true, "ticket_count": 4  },
  { "id": "d2f6...", "name": "Resolved",    "kind": "system", "color": "#16a34a", "sort_order": 3, "active": true, "ticket_count": 31 },
  { "id": "e3a7...", "name": "Closed",      "kind": "system", "color": "#475569", "sort_order": 4, "active": true, "ticket_count": 88 }
]

The field is kind — system or custom — not is_system.

Auth endpoints

POST/api/v1/auth/local/login

Authenticate with email and password. Sets an HttpOnly session cookie on success.

If MFA is enrolled, the session is created in a pending state and the response body includes "mfa_required": true. The session is not fully authenticated until POST /api/v1/auth/local/mfa/verify succeeds.

// Request
{ "email": "alex@example.com", "password": "hunter2" }

// Response — no MFA
{ "user": { "id": "...", "name": "Alex Chen", "role": "staff", "mfa_enrolled": false } }

// Response — MFA enrolled
{ "mfa_required": true }
POST/api/v1/auth/local/mfa/verify

Complete MFA verification after a successful password login. Accepts a 6-digit TOTP code or a recovery code. On success, the session is promoted to fully authenticated.

// TOTP code
{ "code": "123456" }

// Recovery code
{ "code": "AAAA-BBBB" }
POST/api/v1/auth/local/logout

Destroy the current session. The session cookie is cleared. Returns HTTP 204 with no body.

POST/api/v1/auth/oauth/token

OAuth2 client credentials flow. Exchange a client_id and client_secret for a short-lived JWT (1 hour).

// Request
{ "grant_type": "client_credentials", "client_id": "...", "client_secret": "..." }

// Response
{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "token_type":   "Bearer",
  "expires_in":   3600
}

Use the token as Authorization: Bearer <access_token>. Create OAuth2 client credentials from Admin → Settings → OAuth2 Clients.

GET/api/v1/auth/saml/metadata

Returns SAML Service Provider metadata XML. No authentication required. Import this URL into your IdP. Only available when SAML_ENABLED=true.

Me / profile

GET/api/v1/me

Get the currently authenticated user's profile.

{
  "id":           "12ab...",
  "email":        "alex@example.com",
  "display_name": "Alex Chen",
  "role":         "staff",
  "mfa_enabled":  true,
  "disabled":     false,
  "created_at":   "2026-01-15T08:00:00Z",
  "updated_at":   "2026-03-02T11:20:00Z"
}

The name field is display_name, and the MFA flag is mfa_enabled.

PATCH/api/v1/me/password

Change the authenticated user's password. Minimum 8 characters. Returns 204.

Every other session for this user is destroyed and the calling session is re-issued — changing your password is how you evict someone holding the old one, so the other sessions have to die, but signing yourself out of the tab you just used would be a bug. The current password is not required, because reaching this endpoint already means holding a valid session for the account.

{ "password": "a-new-password" }
POST/api/v1/me/mfa/enroll

Begin MFA enrollment. Returns a TOTP provisioning URI and a base64-encoded QR code PNG. Enrollment is not active until confirmed with POST /api/v1/me/mfa/enroll/confirm.

{
  "totp_uri": "otpauth://totp/Open%20Help%20Desk:alex%40example.com?secret=BASE32SECRET&issuer=...",
  "qr_code":  "data:image/png;base64,..."
}
POST/api/v1/me/mfa/enroll/confirm

Confirm MFA enrollment by submitting the first TOTP code from the authenticator app. On success, MFA is activated and recovery codes are returned. Store the recovery codes immediately.

// Request
{ "code": "123456" }

// Response
{ "recovery_codes": ["AAAA-BBBB", "CCCC-DDDD", "EEEE-FFFF", "GGGG-HHHH", "IIII-JJJJ",
                     "KKKK-LLLL", "MMMM-NNNN", "OOOO-PPPP"] }

You cannot switch off your own MFA. There is no endpoint for it. An administrator clears it with PATCH /api/v1/admin/users/{id} and {"reset_mfa": true}, which also ends every session that account holds — a cookie minted before the reset would otherwise let whoever holds it enrol a new authenticator.

GET/api/v1/admin/api-keys

List the current user's API keys. The key value is never returned after creation — only the name and metadata.

{
  "data": [
    { "id": "9f1c...", "name": "monitoring-script", "created_at": "2026-03-01T00:00:00Z", "last_used_at": "2026-04-05T08:00:00Z" }
  ]
}
POST/api/v1/admin/api-keys

Create a new API key. The token field in the response is the plaintext token and is returned only once. Store it immediately.

// Request
{ "name": "monitoring-script" }

// Response — HTTP 201
{ "id": "9f1c…", "name": "monitoring-script", "token": "3f8a…" }
DELETE/api/v1/admin/api-keys/{id}

Revoke an API key immediately. Returns HTTP 204. Any in-flight requests using this key will receive 401 Unauthorized after revocation.

Admin endpoints

All /api/v1/admin/* endpoints require the Admin role. Requests from Staff or User accounts receive 403 Forbidden.

Users

MethodPathDescription
GET/api/v1/admin/usersList all users. Filter by role with ?role=staff.
POST/api/v1/admin/usersCreate a user with a specified role. Body: { "name", "email", "password", "role" }.
PATCH/api/v1/admin/users/{id}Update name, email, or role. Set "force_password_reset": true to send a reset email. Set "reset_mfa": true to wipe their TOTP secret.
PATCH/api/v1/admin/users/{id}Disable/enable: { "disabled": true }.
DELETE/api/v1/admin/users/{id}Permanently delete a user. Tickets they submitted or are assigned to are preserved.

Groups

MethodPathDescription
GET/api/v1/admin/groupsList all groups with member counts and scope entries.
POST/api/v1/admin/groupsCreate a group. Body: { "name": "Networking", "scope": [{ "category_id": "...", "type_id": null }], "member_ids": ["..."] }.
PATCH/api/v1/admin/groups/{id}Update name, scope entries, or member list. Scope and members are replaced entirely — include the full desired state.
DELETE/api/v1/admin/groups/{id}Delete a group. Members are not deleted.

Categories, types, items

MethodPathDescription
POST/api/v1/admin/categoriesCreate a category. Body: { "name": "Hardware" }.
PATCH/api/v1/admin/categories/{id}Update name or sort order. Set "archived": true to hide from new tickets.
DELETE/api/v1/admin/categories/{id}Delete. Fails with 409 if any tickets reference this category.
POST/api/v1/admin/categories/{id}/typesCreate a type under a category.
PATCH/api/v1/admin/categories/{id}/types/{typeId}Update a type.
DELETE/api/v1/admin/categories/{id}/types/{typeId}Delete a type.
POST/api/v1/admin/categories/{id}/types/{typeId}/itemsCreate an item under a type.
PATCH/api/v1/admin/categories/{id}/types/{typeId}/items/{itemId}Update an item.
DELETE/api/v1/admin/categories/{id}/types/{typeId}/items/{itemId}Delete an item.

Canned responses

MethodPathDescription
GET/api/v1/admin/canned-responsesList all canned responses.
POST/api/v1/admin/canned-responsesCreate one. Body: { "name", "body", "category_id": null, "type_id": null, "sort_order": 0 }. Both scope fields are optional; type_id requires category_id to also be set.
PATCH/api/v1/admin/canned-responses/{id}Update name, body, scope, or sort order. Replaces the full record — send all fields.
DELETE/api/v1/admin/canned-responses/{id}Permanently delete. There's no soft-delete — the body was already copied into any replies that used it.

Statuses, SLA, webhooks, plugins, settings

MethodPathDescription
POST/api/v1/admin/statusesCreate a custom status. Body: { "name", "color", "sort_order" }.
PATCH/api/v1/admin/statuses/{id}Update name, color, or sort order. System statuses can be updated but not deleted.
DELETE/api/v1/admin/statuses/{id}Delete a custom status. Fails if any tickets currently have this status.
GET/api/v1/admin/sla/policiesList SLA policies.
POST/api/v1/admin/sla/policiesCreate an SLA policy. Body: { "name", "priority", "category_id", "response_hours", "resolution_hours" }.
PATCH/api/v1/admin/sla/policies/{id}Update an SLA policy.
DELETE/api/v1/admin/sla/policies/{id}Delete an SLA policy.
GET/api/v1/admin/webhooksList configured webhooks.
POST/api/v1/admin/webhooksCreate a webhook. Body: { "url", "secret", "events": [...], "enabled": true }.
PATCH/api/v1/admin/webhooks/{id}Update URL, secret, events, or enabled state.
DELETE/api/v1/admin/webhooks/{id}Delete a webhook and all its delivery history.
GET/api/v1/admin/pluginsList installed plugins with status, version, and error count.
POST/api/v1/admin/pluginsInstall a plugin. Multipart upload with file field, or JSON body { "url": "..." }.
PATCH/api/v1/admin/plugins/{id}Update plugin config or toggle enabled state. Body: { "enabled": true, "config": { ... } }.
DELETE/api/v1/admin/plugins/{id}Uninstall a plugin.
GET/api/v1/admin/settingsGet all settings as a flat key/value object.
PATCH/api/v1/admin/settingsUpdate settings. Body is a partial key/value object — only included keys are updated.
POST/api/v1/admin/settings/logoUpload a logo. Multipart form with a logo file field. Accepted: PNG, JPG, GIF, SVG (max 2 MB). Raster images are auto-scaled to fit within 320 × 64 px. SVGs are sanitized. Returns { "url": "/api/v1/logo" }.
DELETE/api/v1/admin/settings/logoRemove the logo. Clears the stored file and the site_logo_url setting. Returns HTTP 204.

Public endpoints

MethodPathDescription
GET/api/v1/siteReturns { "name", "logo_url", "version" }. No authentication required. Used by the SPA shell before login to render the correct site name and logo.
GET/api/v1/logoServes the uploaded logo file (PNG or SVG). No authentication required. Returns 404 if no logo has been uploaded. Cache-Control: public, max-age=300.
GET/api/v1/setup/statusReturns { "needed": true|false }. No authentication required. Used to detect first-run setup.
POST/api/v1/setupCreate the first admin account. Only available when no users exist. Body: { "email", "display_name", "password" }. Returns 409 once setup is complete.

Tags, attachments, custom fields & history

These live under a ticket and, like every other route under /tickets/{id}/, are refused for a ticket the caller may not see.

MethodEndpointDescription
GET/api/v1/tickets/{id}/tagsTags on a ticket.
POST/api/v1/tickets/{id}/tagsAdd a tag. Staff and Admin only.
DELETE/api/v1/tickets/{id}/tags/{tagId}Remove a tag. Staff and Admin only.
GET/api/v1/tickets/{id}/attachmentsList attachments.
POST/api/v1/tickets/{id}/attachmentsUpload one, as multipart. Images are checked by header and refused above 25 megapixels before they are decoded.
GET/api/v1/tickets/{id}/attachments/{attachId}Download one.
GET/api/v1/tickets/{id}/custom-fieldsCustom field values on a ticket.
PUT/api/v1/tickets/{id}/custom-fieldsReplace them. Body: { "values": { "<field>": "<value>" } }.
GET/api/v1/tickets/{id}/historyStatus history, oldest first.
GET/api/v1/tickets/fieldsWhich custom fields apply to a category, type and item, so a form can be built before the ticket exists.
GET/api/v1/tagsTags available to the caller.
GET/api/v1/groupsGroups, for assignment pickers.

Sign-in and provisioning

MethodEndpointDescription
GET/api/v1/auth/providersWhich sign-in methods this instance offers. No authentication.
GET/api/v1/auth/signup/statusWhether self-signup is on. No authentication.
POST/api/v1/auth/signupRequest an account. Body: { "email", "display_name", "password" }. Throttled per source address. A malformed address is 400; a disallowed domain is 422.
POST/api/v1/auth/verify-emailComplete signup with the token from the verification email.
GET/api/v1/auth/saml/loginBegin SAML sign-in.
POST/api/v1/auth/saml/acsAssertion consumer service.
GET/api/v1/auth/saml/completeFinish SAML sign-in and issue the session.
GET/api/v1/auth/oidc/loginBegin OIDC sign-in.
GET/api/v1/auth/oidc/callbackOIDC redirect target.
GET/api/v1/setup/statusWhether first-run setup is still available.
POST/api/v1/setupCreate the first administrator. Returns 409 once any user exists.

Remaining admin endpoints

All under /api/v1/admin/, all requiring the Admin role and the matching scope.

MethodEndpointDescription
GET/api/v1/admin/scopesEvery scope that can be granted to a credential. Needs the credentials scope itself — the list is a map of the surface.
GET/api/v1/admin/security-warningsConfiguration problems worth an administrator's attention.
GET/api/v1/admin/users/{id}One user, disabled ones included.
POST/api/v1/admin/users/{id}/passwordSet a user's password directly.
GET/api/v1/admin/oauth-clientsList OAuth clients. The hashed secret is never returned.
POST/api/v1/admin/oauth-clientsCreate one. The secret is shown once. Grant scopes explicitly.
DELETE/api/v1/admin/oauth-clients/{id}Revoke one.
GET/api/v1/admin/groups/{id}One group.
GET/api/v1/admin/groups/{id}/membersMembers of a group.
POST/api/v1/admin/groups/{id}/membersAdd a member.
DELETE/api/v1/admin/groups/{id}/members/{userId}Remove a member.
GET/api/v1/admin/groups/{id}/scopesThe category and type pairs a group covers.
POST/api/v1/admin/groups/{id}/scopesAdd a scope entry.
DELETE/api/v1/admin/groups/{id}/scopesRemove one.
GET/api/v1/admin/categoriesCategories including archived ones.
GET/api/v1/admin/categories/{id}One category.
GET/api/v1/admin/categories/{id}/groupsGroups covering a category.
GET/api/v1/admin/categories/{id}/typesTypes under a category.
GET/api/v1/admin/categories/{id}/types/{typeId}/groupsGroups covering a type.
GET/api/v1/admin/categories/{id}/types/{typeId}/itemsItems under a type.
GET/api/v1/admin/statusesStatuses including inactive ones.
GET/api/v1/admin/tagsTags including deleted ones.
POST/api/v1/admin/tagsCreate a tag.
DELETE/api/v1/admin/tags/{id}Soft-delete a tag.
POST/api/v1/admin/tags/{id}/restoreUndo that.
GET/api/v1/admin/custom-fieldsField definitions.
POST/api/v1/admin/custom-fieldsCreate one.
PATCH/api/v1/admin/custom-fields/{id}Update one.
GET/api/v1/admin/categories/{id}/fieldsWhich fields are attached at category level.
POST/api/v1/admin/categories/{id}/fieldsAttach a field.
PATCH/api/v1/admin/categories/{id}/fields/{assignmentId}Change an attachment.
DELETE/api/v1/admin/categories/{id}/fields/{assignmentId}Detach.
GET/api/v1/admin/categories/{id}/types/{typeId}/fieldsFields attached at type level.
POST/api/v1/admin/categories/{id}/types/{typeId}/fieldsAttach one.
PATCH/api/v1/admin/categories/{id}/types/{typeId}/fields/{assignmentId}Change it.
DELETE/api/v1/admin/categories/{id}/types/{typeId}/fields/{assignmentId}Detach.
GET/api/v1/admin/categories/{id}/types/{typeId}/items/{itemId}/fieldsFields attached at item level.
POST/api/v1/admin/categories/{id}/types/{typeId}/items/{itemId}/fieldsAttach one.
PATCH/api/v1/admin/categories/{id}/types/{typeId}/items/{itemId}/fields/{assignmentId}Change it.
DELETE/api/v1/admin/categories/{id}/types/{typeId}/items/{itemId}/fields/{assignmentId}Detach.
GET/api/v1/admin/samlSAML configuration.
PUT/api/v1/admin/samlReplace it. Refused for an API key or OAuth client — see below.
GET/api/v1/admin/oidcOIDC configuration.
PUT/api/v1/admin/oidcReplace it. Same restriction.

Identity settings need a signed-in administrator. SAML, OIDC, MFA and signup settings cannot be changed with an API key or OAuth client, whatever scopes it holds — the answer is 403 session_required. Each of them is a route to a session: repoint the identity provider, or switch on open registration, and an attacker signs in as somebody.

Errors & pagination

Error format

All error responses use the same JSON shape:

{
  "error": {
    "code":    "bad_request",
    "message": "subject is required"
  }
}

Match on code, not on message. Codes you will meet include bad_request, forbidden, not_found, session_required, reopen_window_closed and internal_error.

HTTP status codes

CodeMeaning
200 OKRequest succeeded.
201 CreatedResource created. Returned for POST requests that create a new object.
204 No ContentRequest succeeded, no body (DELETE, logout).
400 Bad RequestMissing or invalid fields. The error message describes the specific problem.
401 UnauthorizedNo valid session, token, or API key. The browser SPA redirects to /login on 401.
403 ForbiddenAuthenticated but insufficient role for the requested operation.
404 Not FoundThe resource does not exist, or you do not have permission to see it.
409 ConflictThe operation would violate a constraint (e.g. deleting a category that has tickets).
500 Internal Server ErrorUnexpected server error. Check the application logs.

Pagination

List endpoints return a bare JSON array and take limit and offset. There is no envelope, no page number and no total count.

GET /api/v1/tickets?limit=50&offset=100

limit defaults to 100 and is capped at 200. Both values are clamped rather than rejected, so an absurd offset returns an empty array rather than an error. Page until a response is shorter than limit.

Rate limiting

Credential endpoints are throttled: password login is limited per submitted email address, and signup per source address. Exceeding either returns 429 with a Retry-After header — honour it, since the header matches the budget that actually refused. TOTP attempts are counted on the user record instead, so they survive a restart and are not multiplied by the replica count.

Nothing else is throttled. If you need broader limits, configure them in a reverse proxy (nginx, Caddy, Cloudflare).

← Working with Tickets MCP Server →