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
| Method | How to authenticate | Best 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
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
| Param | Type | Description |
|---|---|---|
q | string | Search subject and description. Combines with any of the filters below. |
scope | string | Admin only. unassigned — tickets with no assignee. all — every ticket. Omit for the caller's own queue. Staff or User receive 403. |
reporter_id | UUID | Admin only. Every ticket filed by that user. |
assignee_group_id | UUID | Staff and Admin. Tickets assigned to that group. With ticket scope enforced, a Staff member must be a member of the group. |
limit | int | Default 100, maximum 200. |
offset | int | Default 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.
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 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.
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.
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}.
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." }
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
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
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.
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.
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.
Ticket 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.
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.
Remove a link. The reverse link on the target ticket is also removed. Requires Staff or Admin role.
Categories, types, items & statuses
List all categories. Returns a flat list; types and items are not nested.
[ { "id": "3a7e...", "name": "Hardware", "sort_order": 1, "active": true } ]
List types for a category. Returns an empty array if the category has no types.
List items for a type. Returns an empty array if the type has no items.
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
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 }
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" }
Destroy the current session. The session cookie is cleared. Returns HTTP 204 with no body.
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.
Returns SAML Service Provider metadata XML. No authentication required. Import this URL into your IdP. Only available when SAML_ENABLED=true.
Me / profile
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.
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" }
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,..."
}
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.
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" }
]
}
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…" }
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
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/admin/users | List all users. Filter by role with ?role=staff. |
| POST | /api/v1/admin/users | Create 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
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/admin/groups | List all groups with member counts and scope entries. |
| POST | /api/v1/admin/groups | Create 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
| Method | Path | Description |
|---|---|---|
| POST | /api/v1/admin/categories | Create 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}/types | Create 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}/items | Create 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
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/admin/canned-responses | List all canned responses. |
| POST | /api/v1/admin/canned-responses | Create 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
| Method | Path | Description |
|---|---|---|
| POST | /api/v1/admin/statuses | Create 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/policies | List SLA policies. |
| POST | /api/v1/admin/sla/policies | Create 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/webhooks | List configured webhooks. |
| POST | /api/v1/admin/webhooks | Create 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/plugins | List installed plugins with status, version, and error count. |
| POST | /api/v1/admin/plugins | Install 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/settings | Get all settings as a flat key/value object. |
| PATCH | /api/v1/admin/settings | Update settings. Body is a partial key/value object — only included keys are updated. |
| POST | /api/v1/admin/settings/logo | Upload 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/logo | Remove the logo. Clears the stored file and the site_logo_url setting. Returns HTTP 204. |
Public endpoints
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/site | Returns { "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/logo | Serves 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/status | Returns { "needed": true|false }. No authentication required. Used to detect first-run setup. |
| POST | /api/v1/setup | Create 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.
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/tickets/{id}/tags | Tags on a ticket. |
| POST | /api/v1/tickets/{id}/tags | Add 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}/attachments | List attachments. |
| POST | /api/v1/tickets/{id}/attachments | Upload 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-fields | Custom field values on a ticket. |
| PUT | /api/v1/tickets/{id}/custom-fields | Replace them. Body: { "values": { "<field>": "<value>" } }. |
| GET | /api/v1/tickets/{id}/history | Status history, oldest first. |
| GET | /api/v1/tickets/fields | Which custom fields apply to a category, type and item, so a form can be built before the ticket exists. |
| GET | /api/v1/tags | Tags available to the caller. |
| GET | /api/v1/groups | Groups, for assignment pickers. |
Sign-in and provisioning
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/auth/providers | Which sign-in methods this instance offers. No authentication. |
| GET | /api/v1/auth/signup/status | Whether self-signup is on. No authentication. |
| POST | /api/v1/auth/signup | Request 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-email | Complete signup with the token from the verification email. |
| GET | /api/v1/auth/saml/login | Begin SAML sign-in. |
| POST | /api/v1/auth/saml/acs | Assertion consumer service. |
| GET | /api/v1/auth/saml/complete | Finish SAML sign-in and issue the session. |
| GET | /api/v1/auth/oidc/login | Begin OIDC sign-in. |
| GET | /api/v1/auth/oidc/callback | OIDC redirect target. |
| GET | /api/v1/setup/status | Whether first-run setup is still available. |
| POST | /api/v1/setup | Create 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.
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/admin/scopes | Every 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-warnings | Configuration problems worth an administrator's attention. |
| GET | /api/v1/admin/users/{id} | One user, disabled ones included. |
| POST | /api/v1/admin/users/{id}/password | Set a user's password directly. |
| GET | /api/v1/admin/oauth-clients | List OAuth clients. The hashed secret is never returned. |
| POST | /api/v1/admin/oauth-clients | Create 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}/members | Members of a group. |
| POST | /api/v1/admin/groups/{id}/members | Add a member. |
| DELETE | /api/v1/admin/groups/{id}/members/{userId} | Remove a member. |
| GET | /api/v1/admin/groups/{id}/scopes | The category and type pairs a group covers. |
| POST | /api/v1/admin/groups/{id}/scopes | Add a scope entry. |
| DELETE | /api/v1/admin/groups/{id}/scopes | Remove one. |
| GET | /api/v1/admin/categories | Categories including archived ones. |
| GET | /api/v1/admin/categories/{id} | One category. |
| GET | /api/v1/admin/categories/{id}/groups | Groups covering a category. |
| GET | /api/v1/admin/categories/{id}/types | Types under a category. |
| GET | /api/v1/admin/categories/{id}/types/{typeId}/groups | Groups covering a type. |
| GET | /api/v1/admin/categories/{id}/types/{typeId}/items | Items under a type. |
| GET | /api/v1/admin/statuses | Statuses including inactive ones. |
| GET | /api/v1/admin/tags | Tags including deleted ones. |
| POST | /api/v1/admin/tags | Create a tag. |
| DELETE | /api/v1/admin/tags/{id} | Soft-delete a tag. |
| POST | /api/v1/admin/tags/{id}/restore | Undo that. |
| GET | /api/v1/admin/custom-fields | Field definitions. |
| POST | /api/v1/admin/custom-fields | Create one. |
| PATCH | /api/v1/admin/custom-fields/{id} | Update one. |
| GET | /api/v1/admin/categories/{id}/fields | Which fields are attached at category level. |
| POST | /api/v1/admin/categories/{id}/fields | Attach 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}/fields | Fields attached at type level. |
| POST | /api/v1/admin/categories/{id}/types/{typeId}/fields | Attach 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}/fields | Fields attached at item level. |
| POST | /api/v1/admin/categories/{id}/types/{typeId}/items/{itemId}/fields | Attach 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/saml | SAML configuration. |
| PUT | /api/v1/admin/saml | Replace it. Refused for an API key or OAuth client — see below. |
| GET | /api/v1/admin/oidc | OIDC configuration. |
| PUT | /api/v1/admin/oidc | Replace 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
| Code | Meaning |
|---|---|
200 OK | Request succeeded. |
201 Created | Resource created. Returned for POST requests that create a new object. |
204 No Content | Request succeeded, no body (DELETE, logout). |
400 Bad Request | Missing or invalid fields. The error message describes the specific problem. |
401 Unauthorized | No valid session, token, or API key. The browser SPA redirects to /login on 401. |
403 Forbidden | Authenticated but insufficient role for the requested operation. |
404 Not Found | The resource does not exist, or you do not have permission to see it. |
409 Conflict | The operation would violate a constraint (e.g. deleting a category that has tickets). |
500 Internal Server Error | Unexpected 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).