REST API

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

The REST API is mounted at /api/. All request and response bodies are JSON (Content-Type: application/json). Timestamps are ISO 8601 strings in UTC. All resource IDs are ULIDs (lexicographically sortable UUIDs).

Authentication methods

MethodHow to authenticateBest for
Session cookie POST to /api/auth/login. The server sets an HttpOnly session cookie automatically. Browsers send it on subsequent requests. The built-in React SPA
API key Authorization: Bearer ohd_sk_... Scripts, CLI tools, CI pipelines, monitoring
OAuth2 JWT POST to /api/auth/token with client_id + client_secret to get a short-lived JWT. Send as Authorization: Bearer <jwt>. Server-to-server integrations (JIRA, chatbots)

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

Quick example

curl https://helpdesk.example.com/api/tickets \
  -H "Authorization: Bearer ohd_sk_xxxxxxxxxxxxxxxxxxx"

Tickets

GET/api/tickets

List tickets. Scope is applied automatically based on the authenticated user's role and group membership. Admins see all tickets.

Query parameters

ParamTypeDescription
statusstringFilter by status name. Repeatable: ?status=New&status=In+Progress
prioritystringFilter by priority: Critical, High, Medium, Low
assignee_idULIDFilter by assigned staff user ID. Use none for unassigned tickets.
group_idULIDFilter by assigned group.
category_idULIDFilter by category.
type_idULIDFilter by type.
qstringFull-text search on subject and description.
sortstringupdated_at (default), created_at, priority, sla_deadline
orderstringdesc (default) or asc
pageintPage number, 1-indexed. Default: 1.
per_pageintResults per page. Default: 25, maximum: 100.

Response

{
  "data": [
    {
      "id":          "01JQXYZ...",
      "subject":     "Laptop won't power on",
      "status":      "New",
      "priority":    "High",
      "category":    { "id": "...", "name": "Hardware" },
      "type":        { "id": "...", "name": "Laptop" },
      "item":        null,
      "submitter":   { "id": "...", "name": "Sam Ortiz" },
      "assignee":    null,
      "created_at":  "2026-04-05T09:00:00Z",
      "updated_at":  "2026-04-05T09:00:00Z"
    }
  ],
  "page": 1, "per_page": 25, "total": 1, "total_pages": 1
}
POST/api/tickets

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

Request body

{
  "subject":     "Laptop won't power on",        // required
  "description": "My ThinkPad X1 stopped...",    // required
  "category_id": "01JQABC...",                   // required
  "type_id":     "01JQDEF...",                   // optional
  "item_id":     "01JQGHI...",                   // optional
  "priority":    "High"                          // optional, default: "Medium"
}
GET/api/tickets/:id

Get a single ticket by ULID. Returns the ticket with the reply thread attached.

Response

{
  "id":               "01JQXYZ...",
  "subject":          "Laptop won't power on",
  "description":      "My ThinkPad X1 stopped...",
  "status":           "In Progress",
  "priority":         "High",
  "category":         { "id": "...", "name": "Hardware" },
  "type":             { "id": "...", "name": "Laptop" },
  "item":             null,
  "submitter":        { "id": "...", "name": "Sam Ortiz", "email": "sam@example.com" },
  "assignee":         { "id": "...", "name": "Alex Chen" },
  "group":            null,
  "resolution_notes": null,
  "tracking_number":  null,
  "sla_deadline":     "2026-04-06T09:00:00Z",
  "created_at":       "2026-04-05T09:00:00Z",
  "updated_at":       "2026-04-05T10:30:00Z",
  "replies": [ ... ]
}
GET/api/tickets/tracking/:number

Look up a ticket by guest tracking number (e.g. TRK-2849). Returns the ticket and public replies only — no internal notes. No authentication required, but the tracking number must be valid.

PATCH/api/tickets/:id

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

Request body

{
  "status_id":   "01JQJKL...",   // change status
  "priority":    "Critical",     // change priority
  "assignee_id": "01JQMNO...",   // assign to staff member (null to unassign)
  "group_id":    "01JQPQR...",   // assign to group (null to unassign)
  "category_id": "01JQSTU...",   // reclassify
  "type_id":     "01JQVWX...",
  "item_id":     null
}
POST/api/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.

{ "resolution_notes": "Replaced the power adapter. Confirmed working." }
POST/api/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

Replies

GET/api/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

{
  "data": [
    {
      "id":          "01JQABC...",
      "body":        "I've ordered the replacement part.",
      "is_internal": false,
      "author":      { "id": "...", "name": "Alex Chen", "role": "staff" },
      "attachments": [],
      "created_at":  "2026-04-05T11:00:00Z",
      "edited_at":   null
    }
  ]
}
POST/api/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 is_internal is true, the request requires Staff or Admin role. A 403 is returned if a User or guest attempts to post an internal note.

{
  "body":        "The replacement arrives Thursday morning.",
  "is_internal": false
}
PATCH/api/tickets/:ticket_id/replies/:reply_id

Edit a reply. Users can only edit their own replies within 15 minutes of creation. Staff and admins can edit any reply at any time.

{ "body": "Updated reply text." }
DELETE/api/tickets/:ticket_id/replies/:reply_id

Soft-delete a reply. The reply is removed from normal views but preserved in the audit log. Users can only delete their own replies within 15 minutes. Staff and admins can delete any reply.

GET/api/tickets/:id/links

List all linked tickets for the given ticket.

{
  "data": [
    {
      "id":          "01JQABC...",
      "link_type":   "related_to",
      "ticket": {
        "id":      "01JQDEF...",
        "subject": "VPN not connecting",
        "status":  "Resolved"
      }
    }
  ]
}
POST/api/tickets/:id/links

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

{
  "target_id": "01JQDEF...",
  "link_type": "related_to"
}

Valid link_type values: related_to, parent_of, child_of, caused_by, duplicate_of.

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

DELETE/api/tickets/:ticket_id/links/:link_id

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

Categories, types, items & statuses

GET/api/categories

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

{ "data": [{ "id": "01JQ...", "name": "Hardware", "sort_order": 1 }] }
GET/api/categories/:id/types

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

GET/api/categories/:category_id/types/:type_id/items

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

GET/api/statuses

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

{
  "data": [
    { "id": "01JQ...", "name": "New",         "color": "#64748b", "is_system": true  },
    { "id": "01JQ...", "name": "In Progress", "color": "#2563eb", "is_system": false },
    { "id": "01JQ...", "name": "Resolved",    "color": "#16a34a", "is_system": true  },
    { "id": "01JQ...", "name": "Closed",      "color": "#475569", "is_system": true  }
  ]
}

Auth endpoints

POST/api/auth/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/auth/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/auth/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/auth/logout

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

POST/api/auth/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/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/me

Get the currently authenticated user's profile.

{
  "id":           "01JQXYZ...",
  "name":         "Alex Chen",
  "email":        "alex@example.com",
  "role":         "staff",
  "mfa_enrolled": true,
  "created_at":   "2026-01-15T08:00:00Z"
}
PATCH/api/me/password

Change the authenticated user's password. Requires the current password for verification.

{ "current_password": "oldpassword", "new_password": "newpassword123" }
POST/api/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/me/mfa/confirm.

{
  "totp_uri": "otpauth://totp/Open%20Help%20Desk:alex%40example.com?secret=BASE32SECRET&issuer=...",
  "qr_code":  "data:image/png;base64,..."
}
POST/api/me/mfa/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"] }
DELETE/api/me/mfa

Disable MFA on the current account. Requires the current TOTP code or a recovery code for verification.

{ "code": "123456" }
GET/api/me/api-keys

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

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

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

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

// Response — HTTP 201
{ "id": "01JQ...", "name": "monitoring-script", "key": "ohd_sk_xxxxxxxxxx", "created_at": "..." }
DELETE/api/me/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/admin/* endpoints require the Admin role. Requests from Staff or User accounts receive 403 Forbidden.

Users

MethodPathDescription
GET/api/admin/usersList all users. Filter by role with ?role=staff.
POST/api/admin/usersCreate a user with a specified role. Body: { "name", "email", "password", "role" }.
PATCH/api/admin/users/:idUpdate 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/admin/users/:idDisable/enable: { "disabled": true }.
DELETE/api/admin/users/:idPermanently delete a user. Tickets they submitted or are assigned to are preserved.

Groups

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

Categories, types, items

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

Statuses, SLA, webhooks, plugins, settings

MethodPathDescription
POST/api/admin/statusesCreate a custom status. Body: { "name", "color", "sort_order" }.
PATCH/api/admin/statuses/:idUpdate name, color, or sort order. System statuses can be updated but not deleted.
DELETE/api/admin/statuses/:idDelete a custom status. Fails if any tickets currently have this status.
GET/api/admin/sla-policiesList SLA policies.
POST/api/admin/sla-policiesCreate an SLA policy. Body: { "name", "priority", "category_id", "response_hours", "resolution_hours" }.
PATCH/api/admin/sla-policies/:idUpdate an SLA policy.
DELETE/api/admin/sla-policies/:idDelete an SLA policy.
GET/api/admin/webhooksList configured webhooks.
POST/api/admin/webhooksCreate a webhook. Body: { "url", "secret", "events": [...], "enabled": true }.
PATCH/api/admin/webhooks/:idUpdate URL, secret, events, or enabled state.
DELETE/api/admin/webhooks/:idDelete a webhook and all its delivery history.
GET/api/admin/pluginsList installed plugins with status, version, and error count.
POST/api/admin/pluginsInstall a plugin. Multipart upload with file field, or JSON body { "url": "..." }.
PATCH/api/admin/plugins/:idUpdate plugin config or toggle enabled state. Body: { "enabled": true, "config": { ... } }.
DELETE/api/admin/plugins/:idUninstall a plugin.
GET/api/admin/settingsGet all settings as a flat key/value object.
PATCH/api/admin/settingsUpdate settings. Body is a partial key/value object — only included keys are updated.

Errors & pagination

Error format

All error responses use the same JSON shape:

{
  "error": "subject is required"
}

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

All list endpoints return a paginated envelope:

{
  "data":        [ ... ],
  "page":        1,
  "per_page":    25,
  "total":       142,
  "total_pages": 6
}

Use the page and per_page query parameters to navigate. The maximum per_page is 100. If page is beyond total_pages, the response contains an empty data array (not a 404).

Rate limiting

The API does not apply rate limiting by default. If you deploy behind a reverse proxy (nginx, Caddy, Cloudflare), configure rate limiting there.

← Working with Tickets MCP Server →