{"openapi":"3.1.0","info":{"title":"Fesk API","version":"v1","description":"REST API for Fesk ticket management system."},"servers":[{"url":"https://api.fesk.io/v1"}],"security":[{"BearerAuth":[]}],"paths":{"/tickets":{"get":{"summary":"List tickets","operationId":"listTickets","description":"Retrieve a paginated list of tickets with optional filtering and sorting\n\n**Required Scopes:** tickets:read","tags":["Tickets"],"parameters":[{"name":"project","in":"query","required":false,"description":"Project slug (e.g. `infrastructure`), a human-readable alternative to `project_id`. Discover slugs via `GET /projects`. Ignored when the API key is scoped to a project.","schema":{"type":"string"}},{"name":"project_id","in":"query","required":false,"description":"Project ID. Required only when the API key is not scoped to a project; a project-scoped key supplies it automatically.","schema":{"type":"integer"}},{"name":"page","in":"query","required":false,"description":"Page number (1-indexed)","schema":{"type":"integer","default":1,"minimum":1}},{"name":"per_page","in":"query","required":false,"description":"Items per page (max 100)","schema":{"type":"integer","default":50,"minimum":1,"maximum":100}},{"name":"sort","in":"query","required":false,"description":"Sort field with +/- prefix (e.g., -created_at, +priority). Allowed: id, title, status, priority, created_at, updated_at, assigned_to","schema":{"type":"string","default":"-created_at"}},{"name":"status","in":"query","required":false,"description":"Filter by status slug. Defaults: open, work_in_progress, waiting, closed; projects may define custom statuses.","schema":{"type":"string"}},{"name":"priority","in":"query","required":false,"description":"Filter by priority","schema":{"type":"string","enum":["low","medium","high","critical"]}},{"name":"assignee","in":"query","required":false,"description":"Filter by assigned user ID","schema":{"type":"integer"}},{"name":"q","in":"query","required":false,"description":"Search query (searches title and description)","schema":{"type":"string"}}],"responses":{"200":{"description":"Paginated list of tickets with metadata","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"data":{"type":"object","properties":{"tickets":{"type":"array","items":{"$ref":"#/components/schemas/Ticket"}},"pagination":{"$ref":"#/components/schemas/PaginationMeta"}},"required":["tickets","pagination"]}},"required":["ok","data"]}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden, API key lacks the required scope for this operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}],"x-scopes":["tickets:read"]},"post":{"summary":"Create a ticket","operationId":"createTicket","description":"Create a new support ticket.\n\nThe ticket's project is resolved from (in order): the API key's project scope, a `project_id` query parameter, or a `project_id` field in the request body. A project-scoped API key is recommended, in which case `project_id` can be omitted entirely. If none of the three supply a project, the request fails with `400 \"Project ID is required.\"`\n\n**Required Scopes:** tickets:write","tags":["Tickets"],"parameters":[{"name":"project","in":"query","required":false,"description":"Project slug (e.g. `infrastructure`), a human-readable alternative to `project_id`. Discover slugs via `GET /projects`. Ignored when the API key is scoped to a project.","schema":{"type":"string"}},{"name":"project_id","in":"query","required":false,"description":"Project ID. Optional alternative to a project-scoped API key or a body `project_id`.","schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["ticket_type_id","title"],"properties":{"project_id":{"type":"integer","description":"Project ID. Optional, only used when the API key is not project-scoped and no `project_id` query parameter is given."},"ticket_type_id":{"type":"integer","description":"Ticket type ID"},"title":{"type":"string","maxLength":200,"description":"Ticket subject"},"description":{"type":"string","maxLength":5000,"description":"Detailed description (markdown supported)"},"priority":{"type":"string","enum":["low","medium","high","critical"],"default":"medium","description":"Priority level"},"assigned_to":{"type":["integer","null"],"description":"User ID to assign the ticket to"},"custom_fields":{"type":"object","description":"Additional custom field key-value pairs"}}}}}},"responses":{"201":{"description":"Ticket created successfully","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"data":{"type":"object","properties":{"ticket":{"$ref":"#/components/schemas/Ticket"}},"required":["ticket"]}},"required":["ok","data"]}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden, API key lacks the required scope for this operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}],"x-scopes":["tickets:write"]}},"/tickets/{id}":{"get":{"summary":"Get a ticket","operationId":"getTicket","description":"Retrieve a single ticket by ID\n\n**Required Scopes:** tickets:read","tags":["Tickets"],"parameters":[{"name":"project_id","in":"query","required":false,"description":"Project ID. Required when the API key is not scoped to a project (or supply the `project` slug instead); a project-scoped key supplies it automatically.","schema":{"type":"integer"}},{"name":"project","in":"query","required":false,"description":"Project slug (e.g. `infrastructure`), a human-readable alternative to `project_id`. Discover slugs via `GET /projects`. Ignored when the API key is scoped to a project.","schema":{"type":"string"}},{"name":"id","in":"path","required":true,"description":"","schema":{"type":"integer"}}],"responses":{"200":{"description":"Ticket details","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"data":{"type":"object","properties":{"ticket":{"$ref":"#/components/schemas/Ticket"}},"required":["ticket"]}},"required":["ok","data"]}}}},"400":{"description":"Invalid ticket ID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Ticket not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden, API key lacks the required scope for this operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}],"x-scopes":["tickets:read"]},"put":{"summary":"Update a ticket","operationId":"updateTicket","description":"Update an existing ticket. Only provided fields are changed; omitted fields remain unchanged.\n\n**Optimistic Concurrency:** Include the `version` field (from your last GET) in the request body. If the ticket has been modified since that version, you receive `409 Conflict`. When `version` is omitted, the server does not perform client-side conflict detection, but a `409` may still occur in rare cases of truly simultaneous writes (the internal storage layer always guards against corrupt partial writes). For multi-user integrations, always include `version`.\n\n**Required Scopes:** tickets:write","tags":["Tickets"],"parameters":[{"name":"project_id","in":"query","required":false,"description":"Project ID. Required when the API key is not scoped to a project (or supply the `project` slug instead); a project-scoped key supplies it automatically.","schema":{"type":"integer"}},{"name":"project","in":"query","required":false,"description":"Project slug (e.g. `infrastructure`), a human-readable alternative to `project_id`. Discover slugs via `GET /projects`. Ignored when the API key is scoped to a project.","schema":{"type":"string"}},{"name":"id","in":"path","required":true,"description":"","schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string","maxLength":200,"description":"New title (cannot be empty)"},"description":{"type":"string","maxLength":5000,"description":"New description"},"status":{"type":"string","description":"New status slug (defaults: open, work_in_progress, waiting, closed; projects may define custom statuses)"},"priority":{"type":"string","enum":["low","medium","high","critical"],"description":"New priority"},"assigned_to":{"type":["integer","null"],"description":"User ID to assign. Send `0` to unassign. Omit or send `null` to leave unchanged."},"custom_fields":{"type":"object","description":"Custom field key-value pairs (merged with existing)"},"version":{"type":"integer","description":"Expected version for optimistic concurrency. When provided, the server rejects the update with 409 if the ticket has been modified since this version. When omitted, no client-side conflict detection (a 409 may still occur on rare simultaneous writes)."}}}}}},"responses":{"200":{"description":"Ticket updated successfully","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"data":{"type":"object","properties":{"ticket":{"$ref":"#/components/schemas/Ticket"}},"required":["ticket"]}},"required":["ok","data"]}}}},"400":{"description":"Invalid ticket ID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Ticket not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Version conflict, ticket was modified by another client. Re-fetch and retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden, API key lacks the required scope for this operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}],"x-scopes":["tickets:write"]},"delete":{"summary":"Delete a ticket","operationId":"deleteTicket","description":"Soft delete a ticket\n\n**Required Scopes:** tickets:delete","tags":["Tickets"],"parameters":[{"name":"project_id","in":"query","required":false,"description":"Project ID. Required when the API key is not scoped to a project (or supply the `project` slug instead); a project-scoped key supplies it automatically.","schema":{"type":"integer"}},{"name":"project","in":"query","required":false,"description":"Project slug (e.g. `infrastructure`), a human-readable alternative to `project_id`. Discover slugs via `GET /projects`. Ignored when the API key is scoped to a project.","schema":{"type":"string"}},{"name":"id","in":"path","required":true,"description":"","schema":{"type":"integer"}}],"responses":{"200":{"description":"Ticket deleted successfully","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"data":{"type":"object","properties":{"message":{"type":"string","example":"Ticket deleted"}}}},"required":["ok","data"]}}}},"400":{"description":"Invalid ticket ID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Ticket not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden, API key lacks the required scope for this operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}],"x-scopes":["tickets:delete"]}},"/tickets/{id}/status":{"patch":{"summary":"Update ticket status","operationId":"updateTicketStatus","description":"Update the status of a ticket. Optional neighbour IDs position the ticket within the target lane (drag-and-drop); `resolution_type` may be supplied when moving to a terminal/closed status.\n\n**Required Scopes:** tickets:write","tags":["Tickets"],"parameters":[{"name":"project_id","in":"query","required":false,"description":"Project ID. Required when the API key is not scoped to a project (or supply the `project` slug instead); a project-scoped key supplies it automatically.","schema":{"type":"integer"}},{"name":"project","in":"query","required":false,"description":"Project slug (e.g. `infrastructure`), a human-readable alternative to `project_id`. Discover slugs via `GET /projects`. Ignored when the API key is scoped to a project.","schema":{"type":"string"}},{"name":"id","in":"path","required":true,"description":"","schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["status"],"properties":{"status":{"type":"string","description":"New status slug (defaults: open, work_in_progress, waiting, closed; projects may define custom statuses)"},"after_id":{"type":["integer","null"],"description":"Place the ticket immediately after this ticket within the target lane"},"before_id":{"type":["integer","null"],"description":"Place the ticket immediately before this ticket within the target lane"},"resolution_type":{"type":["string","null"],"enum":["resolved","wont_fix","duplicate",null],"description":"Resolution reason, only valid when moving to a terminal/closed status"}}}}}},"responses":{"200":{"description":"Status updated successfully","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"data":{"type":"object","properties":{"ticket":{"$ref":"#/components/schemas/Ticket"}},"required":["ticket"]}},"required":["ok","data"]}}}},"400":{"description":"Invalid ticket ID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Ticket not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden, API key lacks the required scope for this operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}],"x-scopes":["tickets:write"]}},"/tickets/bulk-update":{"post":{"summary":"Bulk update tickets","operationId":"bulkUpdateTickets","description":"Update multiple tickets at once (max 100 tickets per request)\n\n**Required Scopes:** tickets:write","tags":["Tickets"],"parameters":[{"name":"project_id","in":"query","required":false,"description":"Project ID. Required when the API key is not scoped to a project (or supply the `project` slug instead); a project-scoped key supplies it automatically.","schema":{"type":"integer"}},{"name":"project","in":"query","required":false,"description":"Project slug (e.g. `infrastructure`), a human-readable alternative to `project_id`. Discover slugs via `GET /projects`. Ignored when the API key is scoped to a project.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["ticket_ids"],"properties":{"ticket_ids":{"type":"array","items":{"type":"integer"},"minItems":1,"maxItems":100,"description":"Array of ticket IDs to update (max 100)"},"status":{"type":"string","enum":["open","work_in_progress","waiting","closed"],"description":"New status for all tickets"},"priority":{"type":"string","enum":["low","medium","high","critical"],"description":"New priority for all tickets"},"assigned_to":{"type":["integer","null"],"description":"User ID to assign tickets to"},"tags":{"type":["array","null"],"items":{"type":"string"},"description":"Tags to apply to all tickets"}}}}}},"responses":{"200":{"description":"Bulk update completed with summary","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"data":{"type":"object","properties":{"success_count":{"type":"integer","description":"Number of successfully updated tickets"},"failed_count":{"type":"integer","description":"Number of failed updates"},"failed_ids":{"type":"array","items":{"type":"integer"},"description":"IDs of tickets that failed to update"},"tickets":{"type":"array","items":{"$ref":"#/components/schemas/Ticket"},"description":"Array of successfully updated ticket objects"}},"required":["success_count","failed_count","failed_ids","tickets"]}},"required":["ok","data"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden, API key lacks the required scope for this operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}],"x-scopes":["tickets:write"]}},"/board":{"get":{"summary":"Get board data","operationId":"getBoard","description":"Retrieve tickets grouped by status for swimlane display. Returns tickets organized into lanes matching the project's workflow statuses, defaults are open, work_in_progress, waiting, closed, but projects with custom workflows may have additional or different lanes. Clients should iterate the returned `lanes` object dynamically.\n\n**Required Scopes:** tickets:read","tags":["Board"],"parameters":[{"name":"project_id","in":"query","required":false,"description":"Project ID. Required when the API key is not scoped to a project (or supply the `project` slug instead); a project-scoped key supplies it automatically.","schema":{"type":"integer"}},{"name":"project","in":"query","required":false,"description":"Project slug (e.g. `infrastructure`), a human-readable alternative to `project_id`. Discover slugs via `GET /projects`. Ignored when the API key is scoped to a project.","schema":{"type":"string"}},{"name":"priority","in":"query","required":false,"description":"Filter by priority","schema":{"type":"string","enum":["low","medium","high","critical"]}},{"name":"assignee","in":"query","required":false,"description":"Filter by assigned user ID","schema":{"type":"integer"}}],"responses":{"200":{"description":"Board data with tickets grouped by status","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"data":{"type":"object","properties":{"lanes":{"type":"object","description":"Tickets grouped by status slug. Keys are the project's workflow status slugs (defaults: open, work_in_progress, waiting, closed; custom statuses produce additional keys).","additionalProperties":{"type":"array","items":{"$ref":"#/components/schemas/Ticket"}}},"counts":{"type":"object","description":"Ticket counts per lane plus a total.","properties":{"total":{"type":"integer"}},"additionalProperties":{"type":"integer"}}},"required":["lanes","counts"]}},"required":["ok","data"]}}}},"400":{"description":"Bad request (missing project context)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden, API key lacks the required scope for this operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}],"x-scopes":["tickets:read"]}},"/tickets/{ticketId}/comments":{"get":{"summary":"List comments","operationId":"listComments","description":"Retrieve comments for a ticket in chronological order (oldest first). Returns up to 1,000 comments. If a ticket exceeds this limit, comments beyond the oldest 1,000 cannot be retrieved via this endpoint. Internal notes are included by default for authenticated team members.\n\n**Required Scopes:** comments:read","tags":["Comments"],"parameters":[{"name":"project_id","in":"query","required":false,"description":"Project ID. Required when the API key is not scoped to a project (or supply the `project` slug instead); a project-scoped key supplies it automatically.","schema":{"type":"integer"}},{"name":"project","in":"query","required":false,"description":"Project slug (e.g. `infrastructure`), a human-readable alternative to `project_id`. Discover slugs via `GET /projects`. Ignored when the API key is scoped to a project.","schema":{"type":"string"}},{"name":"ticketId","in":"path","required":true,"description":"Ticket ID","schema":{"type":"integer"}},{"name":"include_internal","in":"query","required":false,"description":"Include internal notes (team-only)","schema":{"type":"boolean","default":true}}],"responses":{"200":{"description":"List of comments","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"data":{"type":"object","properties":{"comments":{"type":"array","items":{"$ref":"#/components/schemas/Comment"}},"count":{"type":"integer"},"ticket_id":{"type":"integer"}},"required":["comments","count"]}},"required":["ok","data"]}}}},"400":{"description":"Invalid ticket ID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Ticket not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden, API key lacks the required scope for this operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}],"x-scopes":["comments:read"]},"post":{"summary":"Create a comment","operationId":"createComment","description":"Add a comment to a ticket. Comments default to internal notes (team-only). Set `is_internal` to false for customer-visible comments.\n\n**Required Scopes:** comments:write","tags":["Comments"],"parameters":[{"name":"project_id","in":"query","required":false,"description":"Project ID. Required when the API key is not scoped to a project (or supply the `project` slug instead); a project-scoped key supplies it automatically.","schema":{"type":"integer"}},{"name":"project","in":"query","required":false,"description":"Project slug (e.g. `infrastructure`), a human-readable alternative to `project_id`. Discover slugs via `GET /projects`. Ignored when the API key is scoped to a project.","schema":{"type":"string"}},{"name":"ticketId","in":"path","required":true,"description":"Ticket ID","schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["content"],"properties":{"content":{"type":"string","maxLength":50000,"description":"Comment text content"},"content_html":{"type":["string","null"],"maxLength":50000,"description":"Rich HTML content (sanitized server-side)"},"is_internal":{"type":"boolean","default":true,"description":"Whether this is an internal note (true) or public comment (false)"}}}}}},"responses":{"201":{"description":"Comment created successfully","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"data":{"type":"object","properties":{"comment":{"$ref":"#/components/schemas/Comment"}},"required":["comment"]}},"required":["ok","data"]}}}},"400":{"description":"Invalid ticket ID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Ticket not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden, API key lacks the required scope for this operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}],"x-scopes":["comments:write"]}},"/comments/{id}":{"get":{"summary":"Get a comment","operationId":"getComment","description":"Retrieve a single comment by ID. The comment must belong to a ticket within the API key's project scope.\n\n**Required Scopes:** comments:read","tags":["Comments"],"parameters":[{"name":"project_id","in":"query","required":false,"description":"Project ID. Required when the API key is not scoped to a project (or supply the `project` slug instead); a project-scoped key supplies it automatically.","schema":{"type":"integer"}},{"name":"project","in":"query","required":false,"description":"Project slug (e.g. `infrastructure`), a human-readable alternative to `project_id`. Discover slugs via `GET /projects`. Ignored when the API key is scoped to a project.","schema":{"type":"string"}},{"name":"id","in":"path","required":true,"description":"Comment ID","schema":{"type":"integer"}}],"responses":{"200":{"description":"Comment details","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"data":{"type":"object","properties":{"comment":{"$ref":"#/components/schemas/Comment"}},"required":["comment"]}},"required":["ok","data"]}}}},"400":{"description":"Invalid ticket or comment ID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Comment not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden, API key lacks the required scope for this operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}],"x-scopes":["comments:read"]},"put":{"summary":"Update a comment","operationId":"updateComment","description":"Update an existing comment. Only the author or team leads+ can edit comments. Only provided fields are updated. The comment must belong to a ticket within the API key's project scope.\n\n**Required Scopes:** comments:write","tags":["Comments"],"parameters":[{"name":"project_id","in":"query","required":false,"description":"Project ID. Required when the API key is not scoped to a project (or supply the `project` slug instead); a project-scoped key supplies it automatically.","schema":{"type":"integer"}},{"name":"project","in":"query","required":false,"description":"Project slug (e.g. `infrastructure`), a human-readable alternative to `project_id`. Discover slugs via `GET /projects`. Ignored when the API key is scoped to a project.","schema":{"type":"string"}},{"name":"id","in":"path","required":true,"description":"Comment ID","schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"content":{"type":"string","maxLength":50000,"description":"Updated text content"},"content_html":{"type":["string","null"],"maxLength":50000,"description":"Updated rich HTML content"},"is_internal":{"type":"boolean","description":"Change visibility (cannot convert public to internal)"}}}}}},"responses":{"200":{"description":"Comment updated successfully","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"data":{"type":"object","properties":{"comment":{"$ref":"#/components/schemas/Comment"}},"required":["comment"]}},"required":["ok","data"]}}}},"400":{"description":"Invalid ticket or comment ID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Cannot edit another user's comment","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Comment not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}],"x-scopes":["comments:write"]},"delete":{"summary":"Delete a comment","operationId":"deleteComment","description":"Delete a comment. Only the author or admins can delete comments. The comment must belong to a ticket within the API key's project scope.\n\n**Required Scopes:** comments:write","tags":["Comments"],"parameters":[{"name":"project_id","in":"query","required":false,"description":"Project ID. Required when the API key is not scoped to a project (or supply the `project` slug instead); a project-scoped key supplies it automatically.","schema":{"type":"integer"}},{"name":"project","in":"query","required":false,"description":"Project slug (e.g. `infrastructure`), a human-readable alternative to `project_id`. Discover slugs via `GET /projects`. Ignored when the API key is scoped to a project.","schema":{"type":"string"}},{"name":"id","in":"path","required":true,"description":"Comment ID","schema":{"type":"integer"}}],"responses":{"200":{"description":"Comment deleted successfully","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"data":{"type":"object","properties":{"message":{"type":"string","example":"Comment deleted"}}}},"required":["ok","data"]}}}},"400":{"description":"Invalid ticket or comment ID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Cannot delete another user's comment","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Comment not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}],"x-scopes":["comments:write"]}},"/webhooks":{"get":{"summary":"List webhooks","operationId":"listWebhooks","description":"Retrieve all webhook subscriptions for the authenticated user within the project scope\n\n**Required Scopes:** tickets:read","tags":["Webhooks"],"parameters":[{"name":"project_id","in":"query","required":false,"description":"Project ID. Required when the API key is not scoped to a project (or supply the `project` slug instead); a project-scoped key supplies it automatically.","schema":{"type":"integer"}},{"name":"project","in":"query","required":false,"description":"Project slug (e.g. `infrastructure`), a human-readable alternative to `project_id`. Discover slugs via `GET /projects`. Ignored when the API key is scoped to a project.","schema":{"type":"string"}}],"responses":{"200":{"description":"List of webhook subscriptions","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"data":{"type":"object","properties":{"webhooks":{"type":"array","items":{"$ref":"#/components/schemas/Webhook"}},"count":{"type":"integer"}},"required":["webhooks","count"]}},"required":["ok","data"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden, API key lacks the required scope for this operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}],"x-scopes":["tickets:read"]},"post":{"summary":"Create a webhook","operationId":"createWebhook","description":"Create a new webhook subscription. Fesk will send POST requests to the specified URL when subscribed events occur.\n\n**Available events:** `ticket.created`, `ticket.updated`, `ticket.status_changed`, `ticket.assigned`, `comment.created`\n\n**Delivery guarantees:** Best-effort delivery with retries when the async delivery queue is active (production default), or a single attempt without retries when no queue is configured. Delivery is not guaranteed, and your endpoint should be idempotent because duplicate deliveries are possible.\n\n**Delivery payload format:**\n```json\n{\"event\": \"ticket.created\", \"timestamp\": 1717600000, \"data\": {...}}\n```\nFor ticket events, `data` contains the full ticket object. For comment events, `data` contains the comment object.\n\n**Delivery headers:**\n- `Content-Type: application/json`\n- `X-Webhook-Signature: <hex-encoded HMAC-SHA256 of body>`\n- `X-Webhook-Event: <event type>`\n\n**Signature verification:** Compute `HMAC-SHA256(request_body, secret)` and compare (constant-time) to the `X-Webhook-Signature` header value. The secret is returned at creation time (auto-generated if not supplied).\n\n**Retry policy:** Failed deliveries (non-2xx or timeout after 10s) are retried up to 2 more times with exponential backoff starting at 100ms (\u00d72, jittered: ~50\u2013100ms, ~100\u2013200ms). After all attempts are exhausted, the delivery is logged as failed but the webhook subscription remains active. Events may be permanently lost if the queue is unavailable or all retries fail.\n\n**Event ordering:** Not guaranteed across concurrent deliveries. Use `timestamp` and resource `version` to determine recency.\n\nSee the `WebhookDelivery` schema for the full payload structure.\n\n**Required Scopes:** tickets:write","tags":["Webhooks"],"parameters":[{"name":"project_id","in":"query","required":false,"description":"Project ID. Required when the API key is not scoped to a project (or supply the `project` slug instead); a project-scoped key supplies it automatically.","schema":{"type":"integer"}},{"name":"project","in":"query","required":false,"description":"Project slug (e.g. `infrastructure`), a human-readable alternative to `project_id`. Discover slugs via `GET /projects`. Ignored when the API key is scoped to a project.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url","events","project_id"],"properties":{"url":{"type":"string","format":"uri","description":"Delivery URL (must be HTTPS in production)"},"events":{"type":"array","items":{"type":"string","enum":["ticket.created","ticket.updated","ticket.status_changed","ticket.assigned","comment.created"]},"description":"Event types to subscribe to"},"project_id":{"type":"integer","description":"Project ID to scope the webhook to"},"secret":{"type":["string","null"],"description":"HMAC secret for signature verification (auto-generated if omitted)"}}}}}},"responses":{"201":{"description":"Webhook created successfully. The response includes the signing secret (shown only once).","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"data":{"type":"object","properties":{"webhook":{"$ref":"#/components/schemas/WebhookCreated"}},"required":["webhook"]}},"required":["ok","data"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden, API key lacks the required scope for this operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}],"x-scopes":["tickets:write"]}},"/webhooks/{id}":{"delete":{"summary":"Delete a webhook","operationId":"deleteWebhook","description":"Delete a webhook subscription. No further events will be delivered.\n\n**Required Scopes:** tickets:write","tags":["Webhooks"],"parameters":[{"name":"project_id","in":"query","required":false,"description":"Project ID. Required when the API key is not scoped to a project (or supply the `project` slug instead); a project-scoped key supplies it automatically.","schema":{"type":"integer"}},{"name":"project","in":"query","required":false,"description":"Project slug (e.g. `infrastructure`), a human-readable alternative to `project_id`. Discover slugs via `GET /projects`. Ignored when the API key is scoped to a project.","schema":{"type":"string"}},{"name":"id","in":"path","required":true,"description":"Webhook ID","schema":{"type":"integer"}}],"responses":{"200":{"description":"Webhook deleted successfully","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"data":{"type":"object","properties":{"message":{"type":"string","example":"Webhook deleted"}},"required":["message"]}},"required":["ok","data"]}}}},"400":{"description":"Invalid webhook ID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Webhook not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden, API key lacks the required scope for this operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}],"x-scopes":["tickets:write"]}},"/projects":{"get":{"summary":"List projects","operationId":"listProjects","description":"List the projects (workspaces) the API key owner can access.\n\nUse this to discover each project's `slug` and numeric `id`, so requests can reference a project by its human-readable slug (`?project=infrastructure`) instead of an opaque numeric ID. A project-scoped key returns only its own project.\n\nRequires only a valid API key, no specific scope.","tags":["Projects"],"responses":{"200":{"description":"List of accessible projects","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"data":{"type":"object","properties":{"projects":{"type":"array","items":{"$ref":"#/components/schemas/Project"}},"count":{"type":"integer"}},"required":["projects","count"]}},"required":["ok","data"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden, API key lacks the required scope for this operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}]}},"/ticket-types":{"get":{"summary":"List ticket types","operationId":"listTicketTypes","description":"Retrieve available ticket types for the API key's project. Each ticket type includes a `field_schema` array describing the custom fields available for that type.\n\nThe project may be supplied via the API key's scope, a numeric `project_id`, or a `project` slug (e.g. `?project=infrastructure`, discover slugs via `GET /projects`).\n\nUse these to populate dynamic forms and validate `custom_fields` when creating tickets.\n\n**Required Scopes:** tickets:read","tags":["Ticket Types"],"parameters":[{"name":"project_id","in":"query","required":false,"description":"Project ID. Required when the API key is not scoped to a project (or supply the `project` slug instead); a project-scoped key supplies it automatically.","schema":{"type":"integer"}},{"name":"project","in":"query","required":false,"description":"Project slug (e.g. `infrastructure`), a human-readable alternative to `project_id`. Discover slugs via `GET /projects`. Ignored when the API key is scoped to a project.","schema":{"type":"string"}}],"responses":{"200":{"description":"List of ticket types with their field schemas","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"data":{"type":"object","properties":{"ticket_types":{"type":"array","items":{"$ref":"#/components/schemas/TicketType"}},"count":{"type":"integer"}},"required":["ticket_types","count"]}},"required":["ok","data"]}}}},"400":{"description":"Project ID is required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden, API key lacks the required scope for this operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}],"x-scopes":["tickets:read"]}},"/users":{"get":{"summary":"List users","operationId":"listUsers","description":"List users in the account. Useful for populating the `assigned_to` field when creating or updating tickets.\n\n**Required Scopes:** tickets:read","tags":["Users"],"responses":{"200":{"description":"List of users","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"data":{"type":"object","properties":{"users":{"type":"array","items":{"$ref":"#/components/schemas/User"}},"count":{"type":"integer"}},"required":["users","count"]}},"required":["ok","data"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden, API key lacks the required scope for this operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}],"x-scopes":["tickets:read"]}},"/tickets/{id}/attachments":{"get":{"summary":"List attachments","operationId":"listAttachments","description":"List all attachments for a ticket.\n\n**Required Scopes:** tickets:read","tags":["Attachments"],"parameters":[{"name":"project_id","in":"query","required":false,"description":"Project ID. Required when the API key is not scoped to a project (or supply the `project` slug instead); a project-scoped key supplies it automatically.","schema":{"type":"integer"}},{"name":"project","in":"query","required":false,"description":"Project slug (e.g. `infrastructure`), a human-readable alternative to `project_id`. Discover slugs via `GET /projects`. Ignored when the API key is scoped to a project.","schema":{"type":"string"}},{"name":"id","in":"path","required":true,"description":"Ticket ID","schema":{"type":"integer"}}],"responses":{"200":{"description":"List of attachments","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"data":{"type":"object","properties":{"attachments":{"type":"array","items":{"$ref":"#/components/schemas/Attachment"}}},"required":["attachments"]}},"required":["ok","data"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Ticket not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden, API key lacks the required scope for this operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}],"x-scopes":["tickets:read"]},"post":{"summary":"Upload attachment","operationId":"uploadAttachment","description":"Upload a file attachment to a ticket. Uses multipart/form-data with the file in a field named `file`.\n\n**Required Scopes:** tickets:write","tags":["Attachments"],"parameters":[{"name":"project_id","in":"query","required":false,"description":"Project ID. Required when the API key is not scoped to a project (or supply the `project` slug instead); a project-scoped key supplies it automatically.","schema":{"type":"integer"}},{"name":"project","in":"query","required":false,"description":"Project slug (e.g. `infrastructure`), a human-readable alternative to `project_id`. Discover slugs via `GET /projects`. Ignored when the API key is scoped to a project.","schema":{"type":"string"}},{"name":"id","in":"path","required":true,"description":"Ticket ID","schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["file"],"properties":{"file":{"type":"string","format":"binary","description":"The file to upload"}}}}}},"responses":{"201":{"description":"Attachment uploaded successfully","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"data":{"type":"object","properties":{"attachment":{"$ref":"#/components/schemas/Attachment"}},"required":["attachment"]}},"required":["ok","data"]}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Ticket not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation failed (file too large, unsupported type)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden, API key lacks the required scope for this operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}],"x-scopes":["tickets:write"]}},"/attachments/{id}/download":{"get":{"summary":"Download attachment","operationId":"downloadAttachment","description":"Download an attachment file. Returns the raw file binary with appropriate Content-Type and Content-Disposition headers.\n\n**Response headers:**\n- `Content-Type`: The file's MIME type (e.g., `image/png`, `application/pdf`)\n- `Content-Disposition`: `attachment; filename=\"original.png\"; filename*=UTF-8''original.png`\n- `Content-Length`: File size in bytes\n- `Cache-Control`: `private, no-cache`\n\nThe response body is the raw file bytes (not JSON). Save the response directly to disk.\n\n**Required Scopes:** tickets:read","tags":["Attachments"],"parameters":[{"name":"project_id","in":"query","required":false,"description":"Project ID. Required when the API key is not scoped to a project (or supply the `project` slug instead); a project-scoped key supplies it automatically.","schema":{"type":"integer"}},{"name":"project","in":"query","required":false,"description":"Project slug (e.g. `infrastructure`), a human-readable alternative to `project_id`. Discover slugs via `GET /projects`. Ignored when the API key is scoped to a project.","schema":{"type":"string"}},{"name":"id","in":"path","required":true,"description":"Attachment ID","schema":{"type":"integer"}}],"responses":{"200":{"description":"File binary stream with Content-Disposition header","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"400":{"description":"Invalid attachment ID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Attachment not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden, API key lacks the required scope for this operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}],"x-scopes":["tickets:read"]}},"/attachments/{id}":{"delete":{"summary":"Delete attachment","operationId":"deleteAttachment","description":"Delete an attachment from a ticket.\n\n**Required Scopes:** tickets:write","tags":["Attachments"],"parameters":[{"name":"project_id","in":"query","required":false,"description":"Project ID. Required when the API key is not scoped to a project (or supply the `project` slug instead); a project-scoped key supplies it automatically.","schema":{"type":"integer"}},{"name":"project","in":"query","required":false,"description":"Project slug (e.g. `infrastructure`), a human-readable alternative to `project_id`. Discover slugs via `GET /projects`. Ignored when the API key is scoped to a project.","schema":{"type":"string"}},{"name":"id","in":"path","required":true,"description":"Attachment ID","schema":{"type":"integer"}}],"responses":{"200":{"description":"Attachment deleted successfully","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"data":{"type":"object","properties":{"message":{"type":"string","example":"Attachment deleted"}},"required":["message"]}},"required":["ok","data"]}}}},"400":{"description":"Invalid attachment ID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Attachment not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden, API key lacks the required scope for this operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}],"x-scopes":["tickets:write"]}},"/api-keys":{"get":{"summary":"List API keys","operationId":"listApiKeys","description":"Retrieve all API keys for the authenticated user. Keys are returned with masked values.\n\n**Required Scopes:** api-keys:manage","tags":["API Keys"],"parameters":[{"name":"active_only","in":"query","required":false,"description":"Filter to only active keys","schema":{"type":"boolean","default":false}}],"responses":{"200":{"description":"List of API keys"},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden, API key lacks the required scope for this operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}],"x-scopes":["api-keys:manage"]},"post":{"summary":"Create an API key","operationId":"createApiKey","description":"Create a new API key. The plain text key is returned only once and cannot be retrieved later.\n\n**Required Scopes:** api-keys:manage","tags":["API Keys"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","maxLength":100,"description":"Human-readable name for the key"},"project_id":{"type":["integer","null"],"description":"Project ID for project-scoped access"},"scopes":{"type":"array","items":{"type":"string"},"description":"Permission scopes"},"expires_at":{"type":"string","format":"date-time","description":"ISO 8601 expiration timestamp"}}}}}},"responses":{"201":{"description":"API key created successfully. Store the key securely."},"400":{"description":"Bad request (e.g., maximum keys reached)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden, API key lacks the required scope for this operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}],"x-scopes":["api-keys:manage"]}},"/api-keys/{id}":{"get":{"summary":"Get an API key","operationId":"getApiKey","description":"Retrieve a single API key by ID. Returns masked key value.","tags":["API Keys"],"parameters":[{"name":"id","in":"path","required":true,"description":"","schema":{"type":"integer"}}],"responses":{"200":{"description":"API key details"},"400":{"description":"Invalid API key ID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Access denied (not your key)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"API key not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}]},"delete":{"summary":"Revoke an API key","operationId":"revokeApiKey","description":"Permanently revoke an API key. Revoked keys cannot be reactivated.","tags":["API Keys"],"parameters":[{"name":"id","in":"path","required":true,"description":"","schema":{"type":"integer"}}],"responses":{"200":{"description":"API key revoked successfully"},"400":{"description":"API key is already revoked","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Access denied (not your key)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"API key not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}]}},"/api-keys/{id}/regenerate":{"post":{"summary":"Regenerate an API key","operationId":"regenerateApiKey","description":"Regenerate an API key, issuing a new secret. The old key is immediately invalidated. The new key is returned only once.\n\n**Required Scopes:** api-keys:manage","tags":["API Keys"],"parameters":[{"name":"id","in":"path","required":true,"description":"","schema":{"type":"integer"}}],"responses":{"200":{"description":"API key regenerated. New key returned (shown only once)."},"400":{"description":"Invalid API key ID or key is revoked","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Access denied (not your key)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"API key not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}],"x-scopes":["api-keys:manage"]}},"/api-keys/{id}/permanent":{"delete":{"summary":"Permanently delete an API key","operationId":"deleteApiKey","description":"Permanently delete an API key record. Works regardless of the key's revoked state.","tags":["API Keys"],"parameters":[{"name":"id","in":"path","required":true,"description":"","schema":{"type":"integer"}}],"responses":{"200":{"description":"API key deleted successfully"},"400":{"description":"Invalid API key ID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Access denied (not your key)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"API key not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many requests, rate limit exceeded. See the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"402":{"description":"Payment required, free API allowance exhausted or monthly call limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"BearerAuth":[]}]}}},"components":{"securitySchemes":{"BearerAuth":{"type":"http","scheme":"bearer","description":"API key passed as a Bearer token in the Authorization header. Generate a key from Settings \u2192 API Keys."}},"schemas":{"Ticket":{"type":"object","properties":{"id":{"type":"integer","example":1},"project_id":{"type":"integer","example":1},"ticket_type_id":{"type":"integer","example":1},"project_ref_id":{"type":"integer","example":1,"description":"Sequential ID within the project"},"ticket_number":{"type":"string","example":"PRJ-1","description":"Human-readable ticket number"},"title":{"type":"string","example":"Login page not loading"},"description":{"type":["string","null"],"example":"Detailed description"},"status":{"type":"string","description":"Status slug. Default workflow: open, work_in_progress, waiting, closed. Projects may define custom statuses, so treat this as an open string, not a fixed enum.","example":"open"},"status_label":{"type":"string","example":"Open"},"priority":{"type":"string","enum":["low","medium","high","critical"],"example":"medium"},"priority_label":{"type":"string","example":"Medium"},"assigned_to":{"type":["integer","null"],"example":5},"created_by":{"type":"integer","example":1},"updated_by":{"type":["integer","null"]},"custom_fields":{"type":"object"},"source_type":{"type":"string","enum":["manual","email","api","webhook"],"description":"How the ticket was created: manual (web UI), email (inbound email), api (external API), webhook (source connector)","example":"web"},"merged_into_id":{"type":["integer","null"]},"version":{"type":"integer","example":1,"description":"Optimistic concurrency version"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}},"required":["id","title","status","priority","project_id","ticket_type_id","created_by","version"]},"Comment":{"type":"object","properties":{"id":{"type":"integer","example":1},"ticket_id":{"type":"integer","example":42},"user_id":{"type":"integer","example":5},"user_name":{"type":"string","example":"Jane Smith"},"content":{"type":"string","example":"This is a comment"},"content_html":{"type":["string","null"],"example":"<p>This is a comment</p>"},"is_internal":{"type":"boolean","example":true,"description":"Whether this is an internal note (team-only) or public comment"},"email_message_id":{"type":["string","null"],"description":"Email message ID if comment originated from email"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}},"required":["id","ticket_id","user_id","content","is_internal"]},"Attachment":{"type":"object","properties":{"id":{"type":"integer","example":7},"ticket_id":{"type":"integer","example":42},"original_filename":{"type":"string","example":"screenshot.png"},"mime_type":{"type":"string","example":"image/png"},"size_bytes":{"type":"integer","example":84521},"created_at":{"type":"string","format":"date-time"}},"required":["id","ticket_id","original_filename","mime_type","size_bytes","created_at"]},"User":{"type":"object","properties":{"id":{"type":"integer","example":5},"name":{"type":"string","example":"Jane Smith"},"email":{"type":"string","format":"email","example":"jane@example.com"},"role":{"type":"string","enum":["admin","team_lead","agent"],"example":"agent"},"is_active":{"type":"boolean","example":true}},"required":["id","name","email","role","is_active"]},"ApiKey":{"type":"object","properties":{"id":{"type":"integer","example":1},"name":{"type":"string","example":"My API Key"},"key_masked":{"type":"string","example":"ld_****abcd"},"last_used_at":{"type":["string","null"],"format":"date-time"},"expires_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"}},"required":["id","name","key_masked"]},"Webhook":{"type":"object","description":"Webhook subscription (list representation, secret is never included)","properties":{"id":{"type":"integer","example":1},"url":{"type":"string","format":"uri","example":"https://example.com/webhook"},"events":{"type":"array","items":{"type":"string"},"example":["ticket.created","ticket.updated"]},"is_active":{"type":"boolean","example":true},"created_at":{"type":"string","format":"date-time"}},"required":["id","url","events","is_active"]},"WebhookCreated":{"type":"object","description":"Webhook subscription (create response, includes one-time secret)","properties":{"id":{"type":"integer","example":1},"url":{"type":"string","format":"uri","example":"https://example.com/webhook"},"events":{"type":"array","items":{"type":"string"},"example":["ticket.created","ticket.updated"]},"is_active":{"type":"boolean","example":true},"secret":{"type":"string","description":"HMAC signing secret, returned ONLY in this create response. Store it securely.","example":"a1b2c3d4e5f6..."},"created_at":{"type":"string","format":"date-time"}},"required":["id","url","events","is_active","secret"]},"PaginationMeta":{"type":"object","properties":{"total":{"type":"integer","example":150},"page":{"type":"integer","example":1},"per_page":{"type":"integer","example":50},"total_pages":{"type":"integer","example":3}},"required":["total","page","per_page","total_pages"]},"ErrorResponse":{"type":"object","properties":{"ok":{"type":"boolean","example":false},"error":{"type":"object","properties":{"code":{"type":"integer","example":422},"message":{"type":"string","example":"Validation failed"},"validation_errors":{"type":["object","null"],"description":"Field-level validation errors (keys are field names, values are arrays of error messages) or structured details for non-validation errors (e.g. billing: error_code, used, limit).","additionalProperties":true}},"required":["code","message"]}},"required":["ok","error"]},"SuccessResponse":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"data":{"type":"object"}},"required":["ok","data"]},"WebhookDelivery":{"type":"object","description":"Payload delivered to webhook subscriber URLs via HTTP POST","properties":{"event":{"type":"string","enum":["ticket.created","ticket.updated","ticket.status_changed","ticket.assigned","comment.created"],"description":"The event type that triggered this delivery","example":"ticket.created"},"timestamp":{"type":"integer","description":"Unix timestamp when the event occurred","example":1717600000},"data":{"type":"object","description":"Event-specific payload. For ticket events: a Ticket object. For comment events: a Comment object with ticket_id.","oneOf":[{"$ref":"#/components/schemas/WebhookDeliveryTicketPayload"},{"$ref":"#/components/schemas/WebhookDeliveryCommentPayload"}]}},"required":["event","timestamp","data"]},"WebhookDeliveryTicketPayload":{"type":"object","description":"Payload for ticket.created, ticket.updated, ticket.status_changed, ticket.assigned events","properties":{"id":{"type":"integer","example":42},"project_id":{"type":"integer","example":1},"ticket_type_id":{"type":"integer","example":1},"ticket_number":{"type":"string","example":"PRJ-42"},"title":{"type":"string","example":"Login page returns 500 error"},"status":{"type":"string","example":"open"},"priority":{"type":"string","example":"high"},"assigned_to":{"type":["integer","null"],"example":5},"created_by":{"type":"integer","example":1},"updated_by":{"type":["integer","null"],"example":5},"version":{"type":"integer","example":2},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}},"required":["id","project_id","title","status","priority"]},"WebhookDeliveryCommentPayload":{"type":"object","description":"Payload for comment.created events","properties":{"id":{"type":"integer","example":15},"ticket_id":{"type":"integer","example":42},"user_id":{"type":"integer","example":5},"content":{"type":"string","example":"We have identified the root cause."},"is_internal":{"type":"boolean","example":false},"created_at":{"type":"string","format":"date-time"}},"required":["id","ticket_id","user_id","content","is_internal"]},"FieldDefinition":{"type":"object","description":"A custom field definition within a ticket type schema","properties":{"name":{"type":"string","description":"Field identifier (unique within ticket type, used as key in custom_fields)","example":"customer_email"},"label":{"type":"string","description":"Human-readable display label","example":"Customer Email"},"type":{"type":"string","enum":["text","textarea","number","date","datetime","select","multi_select","boolean","email","url"],"description":"Data type determining validation and rendering","example":"email"},"required":{"type":"boolean","description":"Whether the field is required when creating/updating tickets","example":false},"constraints":{"type":["object","null"],"description":"Validation constraints (varies by field type)","properties":{"min_length":{"type":"integer","description":"Minimum string length (text/textarea)","example":1},"max_length":{"type":"integer","description":"Maximum string length (text/textarea)","example":500},"min_value":{"type":"number","description":"Minimum numeric value (number)","example":0},"max_value":{"type":"number","description":"Maximum numeric value (number)","example":100},"pattern":{"type":"string","description":"Regex pattern for validation (text)","example":"^[A-Z]{2,5}$"},"allowed_values":{"type":"array","items":{"type":"string"},"description":"Allowed values (required for select/multi_select)","example":["Option A","Option B","Option C"]}}},"default_value":{"description":"Default value for the field (type varies by field type)","example":null},"order":{"type":"integer","description":"Display order for form rendering (lower = first)","example":0}},"required":["name","label","type"]},"TicketType":{"type":"object","properties":{"id":{"type":"integer","example":1},"name":{"type":"string","example":"Bug Report"},"slug":{"type":"string","description":"Slugified name for reference","example":"bug-report"},"description":{"type":["string","null"],"example":"For reporting software bugs"},"is_default":{"type":"boolean","example":false},"is_active":{"type":"boolean","example":true},"field_schema":{"type":"array","items":{"$ref":"#/components/schemas/FieldDefinition"},"description":"Array of custom field definitions for this ticket type"},"display_fields":{"type":"array","items":{"type":"string"},"description":"Field names to display in list/card views","example":["title","priority","customer_email"]},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}},"required":["id","name","is_default","field_schema"]},"Project":{"type":"object","description":"A project (workspace) the API key owner can access.","properties":{"id":{"type":"integer","description":"Numeric project ID","example":3},"name":{"type":"string","description":"Human-readable project name","example":"Infrastructure"},"slug":{"type":"string","description":"Stable, human-readable identifier. Pass it as the `project` parameter on project-scoped endpoints instead of the numeric `project_id`.","example":"infrastructure"},"description":{"type":["string","null"],"example":"Cloud infrastructure and platform tickets"}},"required":["id","name","slug"]},"RateLimitInfo":{"type":"object","description":"Rate limit information returned in response headers (X-RateLimit-*)","properties":{"limit":{"type":"integer","description":"Maximum requests allowed in the current window","example":100},"remaining":{"type":"integer","description":"Remaining requests in the current window","example":87},"reset":{"type":"integer","description":"Unix timestamp when the rate limit window resets","example":1717600060}},"required":["limit","remaining","reset"]}}},"tags":[{"name":"Tickets"},{"name":"Board"},{"name":"Comments"},{"name":"Webhooks"},{"name":"Projects"},{"name":"Ticket Types"},{"name":"Users"},{"name":"Attachments"},{"name":"API Keys"}],"x-reference-sections":[{"title":"API Versioning & Stability","content":"The Fesk API uses URL-based versioning (`/v1/`). Current version: **v1** (stable).\n\n**Non-breaking changes** (no version bump): new optional response fields, new optional parameters, new endpoints, new enum values.\n\n**Breaking changes** require a new version and 90-day deprecation notice.\n\n**Client recommendation:** Ignore unknown JSON properties for forward compatibility."},{"title":"Rate Limiting","content":"All endpoints enforce sliding-window rate limits.\n\n| Type | Limit | Window |\n|------|-------|--------|\n| API key (authenticated) | 100 requests | 60 seconds |\n| Session (authenticated) | 100 requests | 60 seconds |\n| Anonymous | 30 requests | 60 seconds |\n| Auth endpoints (login/register) | 10 requests | 60 seconds |\n\nPer-route limits:\n- Search: 10 requests / 60s\n- AI suggest-reply: 30 requests / hour\n- Password reset: 5 requests / hour\n- Data export: 3 requests / hour\n\nRate limit headers are included in every response:\n- `X-RateLimit-Limit`, Maximum requests in current window\n- `X-RateLimit-Remaining`, Remaining requests in current window\n- `X-RateLimit-Reset`, Unix timestamp when window resets\n\nWhen rate limited, you receive HTTP `429`. Use exponential backoff with jitter to retry."},{"title":"Optimistic Concurrency","content":"Tickets include a `version` field (integer, starting at 1) that increments on each update.\n\n**To enable conflict detection:** include `\"version\": <n>` in your PUT request body. The server checks this against the current DB version and returns `409 Conflict` if they differ.\n\n**When omitted:** the server still uses a version-based `WHERE` clause internally to prevent corrupt partial writes. In rare cases of truly simultaneous requests, you may still receive `409`. However, the common lost-update scenario (read \u2192 pause \u2192 write over someone else's changes) is NOT detected without an explicit `version` field. For multi-user integrations, always include `version`.\n\nOn 409: re-fetch the ticket, re-apply your changes, retry with the new version.\n\nNote: Only ticket PUT updates support optimistic concurrency. Comments, webhooks, and attachments do not."},{"title":"Webhook Delivery Guarantees","content":"Webhooks use **best-effort delivery with retries** when an async delivery queue is active (production default), or **single-attempt, no-retry** when no queue is configured.\n\n- Delivery timeout: 10 seconds\n- Max retries: 2 (3 total attempts including the initial delivery)\n- Retry delays: exponential backoff starting at 100ms (\u00d72 with jitter: ~50\u2013100ms, ~100\u2013200ms)\n- After all retries exhausted: delivery logged as failed, subscription stays active\n- Events may be permanently lost if the queue is unavailable at enqueue time or all attempts fail\n- No automatic disabling after repeated failures\n- No replay/redelivery API, poll endpoints to reconcile missed events\n\nEvent ordering is NOT guaranteed across concurrent deliveries. Use `timestamp` and `version` for recency."},{"title":"Idempotency","content":"POST requests support the `Idempotency-Key` header.\n\nWhen the same authenticated user or API key retries the same POST path with the same key and payload, the API replays the cached successful response instead of creating a duplicate. Replayed responses include `X-Idempotency-Replay: true`.\n\nIf the same key is reused with a different payload, the API returns `409 Conflict`.\n\nPUT and DELETE operations are naturally idempotent."},{"title":"CORS","content":"The API supports Cross-Origin Resource Sharing for browser-based integrations.\n\n| Setting | Value |\n|---------|-------|\n| Allowed Methods | GET, POST, PUT, PATCH, DELETE, OPTIONS |\n| Allowed Headers | Content-Type, X-CSRF-Token, X-API-Key, Authorization, Idempotency-Key |\n| Exposed Headers | X-Correlation-ID, X-CSRF-Token, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Idempotency-Key, X-Idempotency-Replay |\n| Preflight Cache | 86400 seconds (24 hours) |\n| Credentials | Supported when specific origins are configured |\n\nAllowed origins are configured per deployment. Contact your administrator to whitelist your domain."}]}