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
| Method | How to authenticate | Best 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
List tickets. Scope is applied automatically based on the authenticated user's role and group membership. Admins see all tickets.
Query parameters
| Param | Type | Description |
|---|---|---|
status | string | Filter by status name. Repeatable: ?status=New&status=In+Progress |
priority | string | Filter by priority: Critical, High, Medium, Low |
assignee_id | ULID | Filter by assigned staff user ID. Use none for unassigned tickets. |
group_id | ULID | Filter by assigned group. |
category_id | ULID | Filter by category. |
type_id | ULID | Filter by type. |
q | string | Full-text search on subject and description. |
sort | string | updated_at (default), created_at, priority, sla_deadline |
order | string | desc (default) or asc |
page | int | Page number, 1-indexed. Default: 1. |
per_page | int | Results 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
}
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 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": [ ... ]
}
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.
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
}
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." }
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
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
}
]
}
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
}
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." }
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.
Ticket 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"
}
}
]
}
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.
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.
{ "data": [{ "id": "01JQ...", "name": "Hardware", "sort_order": 1 }] }
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.
{
"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
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 }
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": "01JQXYZ...",
"name": "Alex Chen",
"email": "alex@example.com",
"role": "staff",
"mfa_enrolled": true,
"created_at": "2026-01-15T08:00:00Z"
}
Change the authenticated user's password. Requires the current password for verification.
{ "current_password": "oldpassword", "new_password": "newpassword123" }
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,..."
}
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"] }
Disable MFA on the current account. Requires the current TOTP code or a recovery code for verification.
{ "code": "123456" }
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" }
]
}
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": "..." }
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
| Method | Path | Description |
|---|---|---|
| GET | /api/admin/users | List all users. Filter by role with ?role=staff. |
| POST | /api/admin/users | Create a user with a specified role. Body: { "name", "email", "password", "role" }. |
| PATCH | /api/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/admin/users/:id | Disable/enable: { "disabled": true }. |
| DELETE | /api/admin/users/:id | Permanently delete a user. Tickets they submitted or are assigned to are preserved. |
Groups
| Method | Path | Description |
|---|---|---|
| GET | /api/admin/groups | List all groups with member counts and scope entries. |
| POST | /api/admin/groups | Create a group. Body: { "name": "Networking", "scope": [{ "category_id": "...", "type_id": null }], "member_ids": ["..."] }. |
| PATCH | /api/admin/groups/:id | Update name, scope entries, or member list. Scope and members are replaced entirely — include the full desired state. |
| DELETE | /api/admin/groups/:id | Delete a group. Members are not deleted. |
Categories, types, items
| Method | Path | Description |
|---|---|---|
| POST | /api/admin/categories | Create a category. Body: { "name": "Hardware" }. |
| PATCH | /api/admin/categories/:id | Update name or sort order. Set "archived": true to hide from new tickets. |
| DELETE | /api/admin/categories/:id | Delete. Fails with 409 if any tickets reference this category. |
| POST | /api/admin/categories/:id/types | Create a type under a category. |
| PATCH | /api/admin/types/:id | Update a type. |
| DELETE | /api/admin/types/:id | Delete a type. |
| POST | /api/admin/types/:id/items | Create an item under a type. |
| PATCH | /api/admin/items/:id | Update an item. |
| DELETE | /api/admin/items/:id | Delete an item. |
Statuses, SLA, webhooks, plugins, settings
| Method | Path | Description |
|---|---|---|
| POST | /api/admin/statuses | Create a custom status. Body: { "name", "color", "sort_order" }. |
| PATCH | /api/admin/statuses/:id | Update name, color, or sort order. System statuses can be updated but not deleted. |
| DELETE | /api/admin/statuses/:id | Delete a custom status. Fails if any tickets currently have this status. |
| GET | /api/admin/sla-policies | List SLA policies. |
| POST | /api/admin/sla-policies | Create an SLA policy. Body: { "name", "priority", "category_id", "response_hours", "resolution_hours" }. |
| PATCH | /api/admin/sla-policies/:id | Update an SLA policy. |
| DELETE | /api/admin/sla-policies/:id | Delete an SLA policy. |
| GET | /api/admin/webhooks | List configured webhooks. |
| POST | /api/admin/webhooks | Create a webhook. Body: { "url", "secret", "events": [...], "enabled": true }. |
| PATCH | /api/admin/webhooks/:id | Update URL, secret, events, or enabled state. |
| DELETE | /api/admin/webhooks/:id | Delete a webhook and all its delivery history. |
| GET | /api/admin/plugins | List installed plugins with status, version, and error count. |
| POST | /api/admin/plugins | Install a plugin. Multipart upload with file field, or JSON body { "url": "..." }. |
| PATCH | /api/admin/plugins/:id | Update plugin config or toggle enabled state. Body: { "enabled": true, "config": { ... } }. |
| DELETE | /api/admin/plugins/:id | Uninstall a plugin. |
| GET | /api/admin/settings | Get all settings as a flat key/value object. |
| PATCH | /api/admin/settings | Update 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
| 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
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.