API

With the API, other software can work with the tickets in your LeanTrack: read projects and tickets, create and change tickets, add comments and files. It speaks JSON over HTTPS, under /api/v1/ of your LeanTrack's address.

Every LeanTrack has the same reference built in: My account → API keys → How the API works, with your own address filled in.

A key

Make a key under My account → API keys. Copy it straight away: it is shown only once. A key works as you: it sees the same projects and may do the same as you, and everything it changes shows up under your name in the history.

For software, make a separate user (for example "Merxo") that sees only the projects it needs, log in as that user once and make the key there. Admins see each user's keys under Users and can revoke them; deactivating a user stops their keys. The API can be switched off altogether under Settings → Features.

When you make a key, you can limit it to some of your projects: it then only reaches those, and only as long as you can still see them. Without a limit it reaches every project you can see, also ones you get later. GET me tells which projects a key reaches.

Sending the key

Every request sends the key in the Authorization header:

curl -H "Authorization: Bearer ltk_…" https://tickets.example.com/api/v1/me
{
    "id": 7,
    "username": "Merxo",
    "role": "user",
    "language": "en",
    "leantrack_version": "0.20.45",
    "key": { "name": "Merxo", "projects": [3, 5] },
    "features": { "subtickets": true, "priority": true, "assignee": true, "api": true }
}

Endpoints

All under /api/v1/.

RequestWhat it does
GET meThe key's user: name, role, language, which features are on; and which projects the key reaches ("all", or their IDs).
GET projectsThe projects the key's user can see.
GET projects/{id}One project, with the prefixes, statuses (in order), groups, users who can be assigned and tags in use.
GET ticketsTickets, 50 per page, newest change first. Filters: project, status, assignee, priority, q, tag, group, page.
POST ticketsCreate a ticket (or a sub-ticket, with parent_id).
GET tickets/{id}One ticket with everything: description, comments, attachments, sub-tickets, watchers and history.
PATCH tickets/{id}Change a ticket: only the fields you send.
DELETE tickets/{id}Delete a ticket (it can be revived on the Deleted page).
POST tickets/{id}/commentsAdd a comment.
PATCH comments/{id}Change your own comment.
DELETE comments/{id}Delete your own comment.
POST tickets/{id}/attachmentsAttach files (multipart form, field file or files[]).
GET attachments/{id}Download an attachment.
DELETE attachments/{id}Remove an attachment (yours, or any as admin).
POST tickets/{id}/watchWatch a ticket.
DELETE tickets/{id}/watchStop watching a ticket.

Examples

List tickets

Without status you get the tickets that aren't closed; status=all gives all of them. assignee takes a user ID, none or me; q searches like the search field at the top.

curl -H "Authorization: Bearer ltk_…" "https://tickets.example.com/api/v1/tickets?project=3&assignee=me"
{
    "tickets": [
        {
            "id": 42,
            "key": "WEB-42",
            "label": "Invoice PDF shows the wrong VAT",
            "project": { "id": 3, "name": "Webshop" },
            "prefix": "WEB",
            "status": { "code": "in_progress", "label": "In Progress" },
            "priority": "high",
            "assignee": { "id": 7, "username": "Merxo" },
            "reporter": { "id": 2, "username": "anna" },
            "parent_id": null,
            "tags": ["invoice"],
            "created_at": "2026-10-19T09:12:00Z",
            "updated_at": "2026-10-19T14:03:27Z",
            "closed_at": null,
            "deleted": false,
            "url": "https://tickets.example.com/ticket/42"
        }
    ],
    "page": 1,
    "pages": 1,
    "per_page": 50,
    "total": 1
}

GET tickets/{id} gives the same plus description, resolution, groups, comments, attachments (with a download url), subtickets, watchers and history.

Create a ticket

curl -X POST -H "Authorization: Bearer ltk_…" -H "Content-Type: application/json" \
     -d '{"project_id": 3, "prefix": "WEB", "label": "Customer cannot log in",
          "description": "<p>Since this morning.</p>", "priority": "high", "tags": ["login"]}' \
     https://tickets.example.com/api/v1/tickets

The answer is 201 with the whole new ticket, as with GET tickets/{id}.

Change a ticket

Send only what changes. The same checks as the edit form apply: for example, a ticket can't be closed while one of its blocking sub-tickets is still open.

curl -X PATCH -H "Authorization: Bearer ltk_…" -H "Content-Type: application/json" \
     -d '{"status": "review", "assignee_id": null}' https://tickets.example.com/api/v1/tickets/42

Comment, attach, watch

curl -X POST -H "Authorization: Bearer ltk_…" -H "Content-Type: application/json" \
     -d '{"body": "<p>Fixed in release 2.4.</p>"}' https://tickets.example.com/api/v1/tickets/42/comments

curl -X POST -H "Authorization: Bearer ltk_…" -F "file=@screenshot.png" \
     https://tickets.example.com/api/v1/tickets/42/attachments

curl -X POST -H "Authorization: Bearer ltk_…" https://tickets.example.com/api/v1/tickets/42/watch

Ticket fields

FieldValue
labeltext, required
project_idnumber, required when creating (not for a sub-ticket)
prefixthe prefix code, e.g. WEB (or prefix_id), required when creating
descriptionHTML
resolutionHTML
statusa status code from GET projects/{id}; new tickets start in the first one
prioritylow, mid, high
assignee_ida user ID from GET projects/{id}, or null for nobody
tagsa list of names, e.g. ["bug", "invoice"]
groupsa list of group IDs of the ticket's project
parent_idonly when creating: makes it a sub-ticket
blocks_parenttrue/false, sub-tickets only: the parent can't be closed while this one is open

Fields the API doesn't know are refused (422), so a typo doesn't go unnoticed. A ticket can't be moved to another parent — on the pages neither.

Errors

Every error is JSON with a message, and for checks the fields that are wrong:

{
    "message": "Give the ticket a label. Choose a prefix.",
    "errors": {
        "label": "Give the ticket a label.",
        "prefix_id": "Choose a prefix."
    }
}
StatusMeaning
400The body isn't a JSON object.
401No key, an unknown or revoked key, or the key's user is deactivated.
403Not allowed, e.g. changing someone else's comment.
404Doesn't exist, or the key's user can't see it (the same answer, on purpose). Also when the API is switched off.
409The ticket is deleted: revive it first.
422A check failed; errors says which field and why.
429More than 120 requests in a minute with this key. Retry-After says how many seconds to wait.

Good to know