Skip to content
tikketty.
Sign inCreate workspace
  1. Overview
  2. Authentication
  3. Quick start
  4. Pagination
  5. Errors and status codes
  6. Rate limits
  7. Projects
  8. Issues
  9. Sprints
  10. Comments
  11. Activity
  12. Members
  13. Notifications
  14. Files
  15. Webhooks
  16. Event stream
  17. Example clients

API

Last updated: 2026-11-02

On this page
  1. Overview
  2. Authentication
  3. Quick start
  4. Pagination
  5. Errors and status codes
  6. Rate limits
  7. Projects
  8. Issues
  9. Sprints
  10. Comments
  11. Activity
  12. Members
  13. Notifications
  14. Files
  15. Webhooks
  16. Event stream
  17. Example clients

Overview

The Tikketty API is a REST API for the projects, issues, comments and files in your workspace. It uses the same permissions as the app.

  • Base URL: https://tikketty.com/api. Every path below is relative to it.
  • Send JSON request bodies with Content-Type: application/json. Responses are JSON.
  • Timestamps are UTC ISO 8601 strings, such as 2026-11-02T15:04:05.120Z. Due dates are YYYY-MM-DD.
  • IDs are opaque strings. Issue number values count up across the workspace and are never reused.
  • Unknown fields and query parameters are rejected with 400.
  • Errors return an HTTP error status and a JSON body: { "error": "Issue not found" }. Some errors also include a code.
  • Call the API from a server or script, not from browser code on another website.

Authentication

Create a personal API token in Integrations → API tokens. Choose a scope:

  • Read allows GET requests, including the event stream.
  • Read & write also allows POST, PATCH, PUT and DELETE. A read token gets 403 for these.

The token is shown once. Send it in the Authorization header on every request:

Header
Authorization: Bearer $TIKKETTY_TOKEN

The examples on this page read the token from the TIKKETTY_TOKEN environment variable. To set it without saving it in your shell history, run this and paste the token:

Shell
read -rs TIKKETTY_TOKEN
export TIKKETTY_TOKEN

A token acts as you in the workspace where you created it, with your current role and project access. If your role changes, the token changes with it. Clients see only the projects they’re added to and shared issues; internal comments and files stay hidden.

Keep tokens out of URLs, client-side code and source control. Store them in a secret manager or environment variable. Revoke a token at any time in Integrations → API tokens; it stops working on the next request. Removing someone from the workspace also revokes their tokens.

Sign-in, account settings, workspace switching, token management and billing are browser-only and aren’t part of the API.

OAuth for MCP clients

AI tools such as Claude, ChatGPT, Cursor and VS Code can connect to Tikketty’s MCP server at https://tikketty.com/mcp with OAuth instead of a personal token. You sign in, approve read or read & write access for one workspace, and can revoke the connection in Integrations → Connected apps. OAuth access tokens work with the same API routes as personal tokens. See the MCP install guides for each client.

Quick start

Replace PROJECT_ID, ISSUE_ID and USER_ID with IDs from earlier responses. User IDs come from GET /members.

List projects

List projects
curl https://tikketty.com/api/projects \
  -H "Authorization: Bearer $TIKKETTY_TOKEN"
Response 200
{
  "items": [
    {
      "id": "7fe8130d-f5e0-45d1-9852-c26986e665eb",
      "name": "Website refresh",
      "description": "New storefront",
      "color": "#6366f1",
      "sites": [
        "https://www.example.com"
      ],
      "columns": [
        {
          "id": "inbox",
          "name": "Inbox",
          "color": "#8b5cf6"
        },
        {
          "id": "backlog",
          "name": "Backlog",
          "color": "#64748b"
        },
        {
          "id": "todo",
          "name": "To do",
          "color": "#3b82f6"
        },
        {
          "id": "in_progress",
          "name": "In progress",
          "color": "#f59e0b"
        },
        {
          "id": "in_review",
          "name": "In review",
          "color": "#ec4899"
        },
        {
          "id": "done",
          "name": "Done",
          "color": "#22c55e"
        }
      ],
      "boardType": "kanban",
      "feedback": {
        "open": true,
        "paused": false,
        "deadline": null,
        "clientCanResolve": false
      },
      "widgetKey": "5c1e8f0a9b2d4c7e6f3a1b8d0c9e2f4a7b6d5c3e1f0a9b8d",
      "settings": {},
      "archived": false,
      "issueCount": 12,
      "createdAt": "2026-10-01T09:30:00.000Z"
    }
  ],
  "nextCursor": null
}

Create an issue

Create an issue
curl https://tikketty.com/api/issues \
  -H "Authorization: Bearer $TIKKETTY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "projectId": "PROJECT_ID",
    "title": "Checkout button overlaps the footer on mobile",
    "description": "On iPhone 15 the Pay now button sits on top of the footer links.",
    "priority": "important",
    "tags": ["checkout", "mobile"],
    "url": "https://www.example.com/checkout"
  }'
Response 201
{
  "id": "c3a1f0e2-5b7d-4e9a-8f21-6d4b9c0e7a15",
  "number": 42,
  "projectId": "7fe8130d-f5e0-45d1-9852-c26986e665eb",
  "type": "task",
  "parentId": null,
  "sprintId": null,
  "rank": 43008,
  "progress": null,
  "title": "Checkout button overlaps the footer on mobile",
  "description": "On iPhone 15 the Pay now button sits on top of the footer links.",
  "status": "inbox",
  "priority": "important",
  "assigneeIds": [],
  "tags": [
    "checkout",
    "mobile"
  ],
  "startDate": null,
  "dueDate": null,
  "storyPoints": null,
  "visibility": "shared",
  "reporterId": "b804b2c7-1593-4940-9ee9-7511a76f6b17",
  "url": "https://www.example.com/checkout",
  "selector": "",
  "position": null,
  "environment": {},
  "archived": false,
  "mergedInto": null,
  "assetId": null,
  "assetVersionId": null,
  "assetPage": null,
  "createdAt": "2026-11-02T15:04:05.120Z",
  "updatedAt": "2026-11-02T15:04:05.120Z"
}

List issues with filters

List issues with filters
curl "https://tikketty.com/api/issues?projectId=PROJECT_ID&status=inbox&priority=important&limit=25" \
  -H "Authorization: Bearer $TIKKETTY_TOKEN"
Response 200
{
  "items": [
    {
      "id": "c3a1f0e2-5b7d-4e9a-8f21-6d4b9c0e7a15",
      "number": 42,
      "projectId": "7fe8130d-f5e0-45d1-9852-c26986e665eb",
      "type": "task",
      "parentId": null,
      "sprintId": null,
      "rank": 43008,
      "progress": null,
      "title": "Checkout button overlaps the footer on mobile",
      "description": "On iPhone 15 the Pay now button sits on top of the footer links.",
      "status": "inbox",
      "priority": "important",
      "assigneeIds": [],
      "tags": [
        "checkout",
        "mobile"
      ],
      "startDate": null,
      "dueDate": null,
      "storyPoints": null,
      "visibility": "shared",
      "reporterId": "b804b2c7-1593-4940-9ee9-7511a76f6b17",
      "url": "https://www.example.com/checkout",
      "selector": "",
      "position": null,
      "environment": {},
      "archived": false,
      "mergedInto": null,
      "assetId": null,
      "assetVersionId": null,
      "assetPage": null,
      "createdAt": "2026-11-02T15:04:05.120Z",
      "updatedAt": "2026-11-02T15:04:05.120Z"
    }
  ],
  "nextCursor": null
}

Change status and assignee

Change status and assignee
curl -X PATCH https://tikketty.com/api/issues/ISSUE_ID \
  -H "Authorization: Bearer $TIKKETTY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "status": "in_progress", "assigneeIds": ["USER_ID"] }'
Response 200
{
  "id": "c3a1f0e2-5b7d-4e9a-8f21-6d4b9c0e7a15",
  "number": 42,
  "projectId": "7fe8130d-f5e0-45d1-9852-c26986e665eb",
  "type": "task",
  "parentId": null,
  "sprintId": null,
  "rank": 43008,
  "progress": null,
  "title": "Checkout button overlaps the footer on mobile",
  "description": "On iPhone 15 the Pay now button sits on top of the footer links.",
  "status": "in_progress",
  "priority": "important",
  "assigneeIds": [
    "49282567-1d17-4c0c-8cb0-b5af0bd50e9e"
  ],
  "tags": [
    "checkout",
    "mobile"
  ],
  "startDate": null,
  "dueDate": null,
  "storyPoints": null,
  "visibility": "shared",
  "reporterId": "b804b2c7-1593-4940-9ee9-7511a76f6b17",
  "url": "https://www.example.com/checkout",
  "selector": "",
  "position": null,
  "environment": {},
  "archived": false,
  "mergedInto": null,
  "assetId": null,
  "assetVersionId": null,
  "assetPage": null,
  "createdAt": "2026-11-02T15:04:05.120Z",
  "updatedAt": "2026-11-02T15:12:09.554Z"
}

Add a comment

Add a comment
curl https://tikketty.com/api/issues/ISSUE_ID/comments \
  -H "Authorization: Bearer $TIKKETTY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "body": "Fixed in the next deploy." }'
Response 201
{
  "id": "5e2d8c41-7a9b-4f03-b6e1-2c9d0f4a8b73",
  "issueId": "c3a1f0e2-5b7d-4e9a-8f21-6d4b9c0e7a15",
  "userId": "b804b2c7-1593-4940-9ee9-7511a76f6b17",
  "body": "Fixed in the next deploy.",
  "internal": false,
  "createdAt": "2026-11-02T15:20:41.008Z",
  "editedAt": null,
  "author": {
    "id": "b804b2c7-1593-4940-9ee9-7511a76f6b17",
    "name": "Alex Morgan",
    "email": "alex@example.com"
  }
}

Pagination

List endpoints return a page of results:

Page
{
  "items": [
    "…"
  ],
  "nextCursor": "41"
}

limit is 1 to 100 and defaults to 50. When nextCursor is a string, pass it as cursor with the same filters to get the next page. It’s null on the last page. Treat cursors as opaque.

Request the next page
curl "https://tikketty.com/api/issues?limit=50&cursor=NEXT_CURSOR" \
  -H "Authorization: Bearer $TIKKETTY_TOKEN"
Response 200
{
  "items": [
    {
      "id": "7a0c5e3b-1f8d-4b26-9e4a-0d2c6b8f1e53",
      "number": 41,
      "projectId": "7fe8130d-f5e0-45d1-9852-c26986e665eb",
      "type": "task",
      "parentId": null,
      "sprintId": null,
      "rank": 43008,
      "progress": null,
      "title": "Checkout button overlaps the footer on mobile",
      "description": "On iPhone 15 the Pay now button sits on top of the footer links.",
      "status": "inbox",
      "priority": "important",
      "assigneeIds": [],
      "tags": [
        "checkout",
        "mobile"
      ],
      "startDate": null,
      "dueDate": null,
      "storyPoints": null,
      "visibility": "shared",
      "reporterId": "b804b2c7-1593-4940-9ee9-7511a76f6b17",
      "url": "https://www.example.com/checkout",
      "selector": "",
      "position": null,
      "environment": {},
      "archived": false,
      "mergedInto": null,
      "assetId": null,
      "assetVersionId": null,
      "assetPage": null,
      "createdAt": "2026-11-02T15:04:05.120Z",
      "updatedAt": "2026-11-02T15:04:05.120Z"
    }
  ],
  "nextCursor": null
}

Paginated endpoints: GET /projects, GET /issues, GET /members, GET /notifications, GET /issues/:id/comments, GET /issues/:id/activity, GET /attachments, GET /projects/:id/sprints.

Issue filters

GET /issues accepts these query parameters:

FieldTypeDescription
projectIdstringOnly issues in this project.
statusstringWorkflow column ID, such as inbox, todo, in_progress or done.
prioritystringcritical, important, normal or minor.
assigneestringUser ID of an assignee.
typestringbug, task, story, epic or subtask.
parentIdstringDirect children of this issue, or none for top-level issues.
epicIdstringEvery issue under this epic, including subtasks.
sprintIdstringcurrent for active sprints, backlog for issues without a sprint, or a sprint ID. Team roles only.
orderstringnumber (newest first, the default) or rank (backlog order, team roles only). With rank, cursor is the last issue ID.
qstringText search over title, description, issue number and tags. Up to 200 characters.
includeArchivedbooleantrue to include archived issues. Defaults to false.
limitinteger1 to 100. Defaults to 50.
cursorstringnextCursor from the previous page.

Errors and status codes

Error
{
  "error": "title: Too small: expected string to have >=1 characters"
}
StatusMeaning
200Success.
201Created.
400Invalid JSON, unknown fields, invalid values or an invalid cursor.
401Missing, invalid or revoked token.
402The workspace needs an active subscription to make changes. code is billing_required.
403Your role or token scope doesn't allow the request.
404The resource doesn't exist or you don't have access to it.
409Conflict, such as removing a workflow column that still has issues.
413Request body over 1 MB or file over 25 MB.
429Rate limit reached. Wait for Retry-After seconds.
500Server error. Safe to retry reads.
503Billing can't be checked right now, so the workspace is read only. code is billing_unavailable.

The error message is a short reason you can log or show to people, such as Invalid notification cursor or Project is archived. A body that isn’t valid JSON returns Request body must be valid JSON.

Retry reads after a timeout. Before retrying a create or comment, check whether the first request already succeeded.

Rate limits

Each IP address can make 600 API requests per minute. Over the limit, the API returns 429 with a Retry-After header in seconds. Wait that long before sending more requests.

Each user can keep up to 10 event streams open at once.

Projects

EndpointScopeDescription
GET /projectsreadList projects you can access. Paginated. Add includeArchived=true for archived projects.
GET /projects/:idreadGet a project.
GET /projects/:id/membersreadList the people added to a project.
POST /projectswriteCreate a project with the default workflow. Owners and managers.
PATCH /projects/:idwriteUpdate a project. Only fields you send change. Owners and managers.

widgetKey and settings are included for owners and managers. columns are the project’s workflow; use their IDs as issue status values.

Create and update fields

FieldTypeDescription
namestringRequired on create. 1 to 100 characters.
descriptionstringUp to 5,000 characters.
colorstringHex color such as #6366f1.
sitesstring[]Website origins where the widget runs. Up to 30.
boardTypestringCreate only: kanban (default) or scrum. On update, send settings.boardType. Scrum enables sprints.
columnsobject[]Update only. Workflow columns as { id, name, color }. Up to 20; must keep inbox.
archivedbooleanUpdate only. Archive or restore the project.

Issues

EndpointScopeDescription
GET /issuesreadList issues, newest first. Paginated and filterable.
GET /issues/:idreadGet an issue with its comments, activity and attachments.
POST /issueswriteCreate an issue.
PATCH /issues/:idwriteUpdate an issue. Only fields you send change.
POST /issues/bulkwriteApply the same changes to up to 100 issues. All or nothing. Owners, managers and members.
POST /issues/:id/mergewriteMerge issues from the same project into this one. Owners, managers and members.
DELETE /issues/:idwritePermanently delete an issue. Owners, managers and members.

GET /issues/:id returns { issue, comments, activity, attachments }. Team roles may also get integration details.

Create and update fields

FieldTypeDescription
projectIdstringRequired on create. The project the issue belongs to.
titlestringRequired on create. 1 to 240 characters.
descriptionstringMarkdown, up to 30,000 characters.
typestringbug, task, story, epic or subtask. Defaults to task.
parentIdstring | nullParent in the same project. Bugs, tasks and stories take an epic; subtasks take a bug, task or story; epics have none.
sprintIdstring | nullSprint in the same scrum project, or null for the backlog. Epics can't join sprints; subtasks follow their parent.
startDatestring | nullCalendar date as YYYY-MM-DD, on or before dueDate.
ranknumberBacklog order, lower first. New issues go to the end.
statusstringA workflow column ID from the project. Defaults to inbox.
prioritystringcritical, important, normal or minor. Defaults to normal.
assigneeIdsstring[]User IDs. Each assignee needs access to the issue.
tagsstring[]Up to 30 tags, each 1 to 50 characters.
dueDatestring | nullCalendar date as YYYY-MM-DD.
storyPointsnumber | null0, 1, 2, 3, 5, 8, 13, 21, 34, 55 or 89.
visibilitystringshared (visible to clients on the project) or team. Defaults to shared.
urlstringPage the feedback is about. http or https, up to 4,096 characters.
selectorstringCSS selector of the element, up to 4,096 characters.
positionobject | null{ "x": number, "y": number } pin position on the page.
environmentobjectBrowser and device details. Up to 16,000 characters as JSON.
archivedbooleanUpdate only. Archive or restore the issue.

On update, send only the fields you want to change. POST /issues/bulk takes { "ids": [...], "changes": { ... } } with the same update fields. POST /issues/:id/merge takes { "sourceIds": [...] }; merged issues are archived and their comments and files move to the target.

Clients can create shared issues in Inbox and edit the title and description of their own issues. They can’t assign, estimate, bulk edit, merge or delete.

Sprints

EndpointScopeDescription
GET /projects/:id/sprintsreadList a project's sprints in order. Paginated. Filter with state. Owners, managers and members.
POST /projects/:id/sprintswritePlan a sprint in a scrum project. Owners, managers and members.
GET /sprints/:idreadGet a sprint with issue and story point totals. Owners, managers and members.
PATCH /sprints/:idwriteChange the name, goal or dates. Owners, managers and members.
DELETE /sprints/:idwriteDelete a planned sprint. Its issues return to the backlog. Owners, managers and members.
POST /sprints/:id/startwriteStart a planned sprint. Needs start and end dates. One active sprint per project. Owners, managers and members.
POST /sprints/:id/completewriteComplete the active sprint and move open issues to moveOpenIssuesTo: backlog or a planned sprint ID. Owners, managers and members.
POST /issues/movewriteMove up to 100 issues into a sprint or the backlog, or reorder them with beforeId or afterId. Owners, managers and members.

Sprints need a scrum project: create it with "boardType": "scrum" or send { "settings": { "boardType": "scrum" } }. Kanban projects return 409 for sprint changes. Clients can’t see sprints, sprintId or rank.

FieldTypeDescription
namestring1 to 100 characters. Defaults to Sprint N.
goalstringUp to 2,000 characters.
startDatestring | nullYYYY-MM-DD. Required to start the sprint.
endDatestring | nullYYYY-MM-DD, on or after startDate. Required to start the sprint.

Plan a sprint

Plan a sprint
curl https://tikketty.com/api/projects/PROJECT_ID/sprints \
  -H "Authorization: Bearer $TIKKETTY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "goal": "Fix checkout on mobile", "startDate": "2026-11-02", "endDate": "2026-11-13" }'
Response 201
{
  "id": "d2f6a8c0-1b3e-4f57-9a2c-6e8b0d4f1a39",
  "projectId": "7fe8130d-f5e0-45d1-9852-c26986e665eb",
  "sequence": 1,
  "name": "Sprint 1",
  "goal": "Fix checkout on mobile",
  "startDate": "2026-11-02",
  "endDate": "2026-11-13",
  "state": "planned",
  "createdAt": "2026-11-02T15:04:05.120Z",
  "updatedAt": "2026-11-02T15:04:05.120Z",
  "startedAt": null,
  "completedAt": null,
  "issueCount": 0,
  "doneCount": 0,
  "points": 0,
  "donePoints": 0
}

Move issues into the sprint

Move issues into the sprint
curl https://tikketty.com/api/issues/move \
  -H "Authorization: Bearer $TIKKETTY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "ids": ["ISSUE_ID"], "sprintId": "SPRINT_ID" }'
Response 200
{
  "items": [
    {
      "id": "c3a1f0e2-5b7d-4e9a-8f21-6d4b9c0e7a15",
      "number": 42,
      "projectId": "7fe8130d-f5e0-45d1-9852-c26986e665eb",
      "type": "task",
      "parentId": null,
      "sprintId": "d2f6a8c0-1b3e-4f57-9a2c-6e8b0d4f1a39",
      "rank": 43008,
      "progress": null,
      "title": "Checkout button overlaps the footer on mobile",
      "description": "On iPhone 15 the Pay now button sits on top of the footer links.",
      "status": "inbox",
      "priority": "important",
      "assigneeIds": [],
      "tags": [
        "checkout",
        "mobile"
      ],
      "startDate": null,
      "dueDate": null,
      "storyPoints": null,
      "visibility": "shared",
      "reporterId": "b804b2c7-1593-4940-9ee9-7511a76f6b17",
      "url": "https://www.example.com/checkout",
      "selector": "",
      "position": null,
      "environment": {},
      "archived": false,
      "mergedInto": null,
      "assetId": null,
      "assetVersionId": null,
      "assetPage": null,
      "createdAt": "2026-11-02T15:04:05.120Z",
      "updatedAt": "2026-11-02T15:04:05.120Z"
    }
  ]
}

Start with POST /sprints/:id/start. Complete with { "moveOpenIssuesTo": "backlog" } or a planned sprint ID; finished issues stay in the completed sprint. List the current sprint with GET /issues?projectId=...&sprintId=current&order=rank and the backlog with sprintId=backlog. Subtasks follow their parent’s sprint and epics stay out of sprints.

Comments

EndpointScopeDescription
GET /issues/:id/commentsreadList comments, oldest first. Paginated.
POST /issues/:id/commentswriteAdd a comment.
PATCH /issues/:id/comments/:commentIdwriteReplace the body of your comment. Internal stays as it was. Author.
DELETE /comments/:idwriteDelete a comment. Author, owners and managers.
FieldTypeDescription
bodystringRequired. Markdown, 1 to 30,000 characters.
internalbooleantrue for a team-only comment. Clients can't post internal comments. Defaults to false.

Only the author can edit a comment. Edited comments return editedAt; it is null until the first edit.

Edit your comment
curl -X PATCH https://tikketty.com/api/issues/ISSUE_ID/comments/COMMENT_ID \
  -H "Authorization: Bearer $TIKKETTY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "body": "Fixed in the next deploy, Friday." }'
Response 200
{
  "id": "5e2d8c41-7a9b-4f03-b6e1-2c9d0f4a8b73",
  "issueId": "c3a1f0e2-5b7d-4e9a-8f21-6d4b9c0e7a15",
  "userId": "b804b2c7-1593-4940-9ee9-7511a76f6b17",
  "body": "Fixed in the next deploy, Friday.",
  "internal": false,
  "createdAt": "2026-11-02T15:20:41.008Z",
  "editedAt": "2026-11-02T15:31:07.219Z",
  "author": {
    "id": "b804b2c7-1593-4940-9ee9-7511a76f6b17",
    "name": "Alex Morgan",
    "email": "alex@example.com"
  }
}

Mention someone with [@Sam Rivera](/people?member=USER_ID) in a comment or description to notify them.

Activity

EndpointScopeDescription
GET /issues/:id/activityreadList an issue's history, oldest first. Paginated.

action is created, updated, commented or merged. For updates, detail lists the changed fields.

Activity item
{
  "id": "0b7e4f2a-91c3-4d58-a6e0-3f8b2d1c9e47",
  "issueId": "c3a1f0e2-5b7d-4e9a-8f21-6d4b9c0e7a15",
  "userId": "b804b2c7-1593-4940-9ee9-7511a76f6b17",
  "action": "updated",
  "detail": "status, assigneeIds",
  "createdAt": "2026-11-02T15:12:09.554Z",
  "author": {
    "id": "b804b2c7-1593-4940-9ee9-7511a76f6b17",
    "name": "Alex Morgan",
    "email": "alex@example.com"
  }
}

Members

EndpointScopeDescription
GET /membersreadList workspace members you can see. Paginated.

role is owner, manager, member or client. Clients see themselves and people who share a project with them.

Member
{
  "id": "49282567-1d17-4c0c-8cb0-b5af0bd50e9e",
  "name": "Sam Rivera",
  "email": "sam@example.com",
  "role": "member"
}

Notifications

EndpointScopeDescription
GET /notificationsreadList your notifications, newest first. Paginated.
PATCH /notifications/:idwriteMark one notification read or unread.
POST /notifications/read-allwriteMark all your notifications read.

kind is mention, comment, assignment or update. To mark one read, send { "read": true }.

Notification
{
  "id": "9d1f3b6e-2a4c-4e87-b0d5-7c6a8e2f1b39",
  "issueId": "c3a1f0e2-5b7d-4e9a-8f21-6d4b9c0e7a15",
  "title": "Comment: Checkout button overlaps the footer on mobile",
  "kind": "comment",
  "read": false,
  "createdAt": "2026-11-02T15:20:41.008Z"
}

Files

EndpointScopeDescription
POST /issues/:id/attachmentswriteUpload a file to an issue as multipart form data.
GET /attachmentsreadList files. Filter with projectId or issueId. Paginated when you pass limit or cursor.
GET /attachments/:id/downloadreadDownload a file. May redirect to a short-lived storage link.
DELETE /attachments/:idwriteDelete a file. Uploader, owners and managers.

Upload with a multipart file field. Add -F internal=true for a team-only file. Files can be up to 25 MB: PNG, JPEG, WebP, GIF, PDF, MP4, WebM, plain text, CSV or ZIP.

Upload a file
curl https://tikketty.com/api/issues/ISSUE_ID/attachments \
  -H "Authorization: Bearer $TIKKETTY_TOKEN" \
  -F "file=@screenshot.png"
Response 201
{
  "id": "e41b7c9a-3d62-4f8e-a5b0-1c7d9e2f6a84",
  "issueId": "c3a1f0e2-5b7d-4e9a-8f21-6d4b9c0e7a15",
  "name": "screenshot.png",
  "size": 48213,
  "type": "image/png",
  "internal": false,
  "createdAt": "2026-11-02T15:25:13.771Z"
}
Download a file
curl -L -o screenshot.png https://tikketty.com/api/attachments/ATTACHMENT_ID/download \
  -H "Authorization: Bearer $TIKKETTY_TOKEN"

Webhooks

Webhooks send a signed POST to your HTTPS endpoint when issues in a project change. A project can have up to 20 webhooks.

EndpointScopeDescription
GET /webhooks?projectId=:idreadList a project's webhooks. Owners and managers.
POST /webhookswriteCreate a webhook. The response includes the signing secret once. Owners and managers.
DELETE /webhooks/:idwriteDelete a webhook and cancel queued deliveries. Owners and managers.
GET /webhooks/:id/deliveriesreadList the latest 100 deliveries with their attempts. Owners and managers.
POST /webhook-deliveries/:id/retrywriteSend a delivery again. Owners and managers.
FieldTypeDescription
projectIdstringRequired. Project to watch.
urlstringRequired. Public HTTPS URL, up to 2,048 characters.
eventsstring[]Required. One or more event names.
Create a webhook
curl https://tikketty.com/api/webhooks \
  -H "Authorization: Bearer $TIKKETTY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "projectId": "PROJECT_ID",
    "url": "https://example.com/webhooks/tikketty",
    "events": ["issue.created", "issue.updated", "issue.commented"]
  }'
Response 201
{
  "id": "a8c2e6f0-4b1d-4e93-9f7a-5d3c1b8e0f26",
  "projectId": "7fe8130d-f5e0-45d1-9852-c26986e665eb",
  "url": "https://example.com/webhooks/tikketty",
  "events": [
    "issue.created",
    "issue.updated",
    "issue.commented"
  ],
  "createdAt": "2026-11-02T15:30:00.412Z",
  "secret": "4f7d1c9e2b8a6f3d0e5c7b9a1d4f6e8c2b0a9d7f5e3c1b8a6d4f2e0c9b7a5d3f"
}

Save secret when you create the webhook. It isn’t shown again; to replace it, delete the webhook and create a new one.

Events

EventSent when
issue.createdAn issue was created.
issue.updatedIssue fields changed, including archive and restore.
issue.commentedA shared comment was added. Internal comments don't send events.
issue.mergedOther issues were merged into this one.
issue.deletedThe issue was permanently deleted. actorId is null.
sprint.createdA sprint was planned. The payload has sprintId instead of issueId.
sprint.startedA sprint started.
sprint.completedA sprint was completed.

Payload

Payloads contain IDs only. Fetch the issue with your API token if you need its content.

Payload
{
  "eventId": "0b7e4f2a-91c3-4d58-a6e0-3f8b2d1c9e47",
  "event": "issue.updated",
  "projectId": "7fe8130d-f5e0-45d1-9852-c26986e665eb",
  "issueId": "c3a1f0e2-5b7d-4e9a-8f21-6d4b9c0e7a15",
  "actorId": "b804b2c7-1593-4940-9ee9-7511a76f6b17",
  "createdAt": "2026-11-02T15:12:09.554Z",
  "deliveryId": "3f9c2a7d0b6e4c1a9e8f7d6c5b4a3921"
}

Each request has these headers:

  • Tikketty-Delivery: the delivery ID. It stays the same across retries.
  • Tikketty-Event: the event name.
  • Tikketty-Timestamp: Unix time in seconds when this attempt was sent.
  • Tikketty-Signature: sha256= followed by the hex HMAC-SHA256 of timestamp + "." + raw body, keyed with the webhook secret.

Verify signatures

Compute the signature from the raw request body before parsing it, compare in constant time and reject timestamps more than five minutes old. This Node.js receiver needs no dependencies:

Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

const secret = process.env.TIKKETTY_WEBHOOK_SECRET;

function verify(rawBody, headers) {
  const timestamp = String(headers["tikketty-timestamp"] ?? "");
  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = Buffer.from("sha256=" + createHmac("sha256", secret).update(timestamp + "." + rawBody).digest("hex"));
  const received = Buffer.from(String(headers["tikketty-signature"] ?? ""));
  return received.length === expected.length && timingSafeEqual(received, expected);
}

createServer((req, res) => {
  const chunks = [];
  req.on("data", chunk => chunks.push(chunk));
  req.on("end", () => {
    const rawBody = Buffer.concat(chunks).toString("utf8");
    if (req.method !== "POST" || !verify(rawBody, req.headers)) return res.writeHead(401).end();
    const event = JSON.parse(rawBody);
    if (event.deliveryId !== req.headers["tikketty-delivery"]) return res.writeHead(400).end();
    console.log(event.event, event.issueId);
    res.writeHead(204).end();
  });
}).listen(process.env.PORT || 3000);

Return a 2xx status within 10 seconds. Tikketty doesn’t follow redirects. Failed deliveries are retried up to 5 times, after about 30 seconds, 2 minutes, 8 minutes and 32 minutes. A delivery can arrive more than once, so store deliveryId and skip ones you’ve already handled.

Event stream

EndpointScopeDescription
GET /eventsreadServer-sent events for changes in your workspace.

GET /events is a server-sent event stream. A ready event arrives first, then a change event whenever something in the workspace changes. Events carry only a revision number, so fetch the data you need after each one.

Listen for changes
curl -N https://tikketty.com/api/events \
  -H "Authorization: Bearer $TIKKETTY_TOKEN"
Output
retry: 2000

id: 118
event: ready
data: {"revision":118}

id: 119
event: change
data: {"revision":119}

The server closes each stream after 5 minutes. Reconnect and send the last id as Last-Event-ID; you’ll get a change event right away if anything changed in between. Streams also close when the token is revoked.

Example clients

These scripts have no dependencies. They read the token from TIKKETTY_TOKEN, follow pagination and never retry writes. Node.js 24 or later, or Python 3.

Node.js

Usage
TIKKETTY_URL=https://tikketty.com node api-client.mjs issues 25
TIKKETTY_URL=https://tikketty.com node api-client.mjs create PROJECT_ID '{"title":"Review the header"}'
TIKKETTY_URL=https://tikketty.com node api-client.mjs update ISSUE_ID '{"status":"todo","priority":"important"}'
TIKKETTY_URL=https://tikketty.com node api-client.mjs comment ISSUE_ID 'Reproduced on staging.'
api-client.mjs
#!/usr/bin/env node
// Dependency-free CLI example; mutation requests are never retried.
const usage = 'Usage: api-client.mjs [issues [limit] | create PROJECT_ID JSON | update ISSUE_ID JSON | comment ISSUE_ID BODY]';
const token = process.env.TIKKETTY_TOKEN || '';
const print = (stream, value) => stream.write((token ? value.replaceAll(token, '[redacted]') : value) + '\n');

try {
  const origin = process.env.TIKKETTY_URL || 'http://127.0.0.1:3001';
  if (!/^https?:\/\//.test(origin) || /[\\\s?#]/.test(origin)) throw new Error('TIKKETTY_URL must be an HTTPS origin or loopback HTTP origin.');
  let base;
  try { base = new URL(origin); } catch { throw new Error('Invalid TIKKETTY_URL.'); }
  if (base.username || base.password || base.pathname !== '/' || !base.hostname || (base.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(base.hostname))) {
    throw new Error('TIKKETTY_URL must be an HTTPS origin or loopback HTTP origin.');
  }
  if (!token || token.length > 8192 || /[^\x21-\x7e]/.test(token)) throw new Error('Set TIKKETTY_TOKEN privately to a valid API token.');
  const [command = 'issues', ...args] = process.argv.slice(2);
  const fields = new Set(['title', 'description', 'status', 'priority', 'dueDate']);
  const object = text => {
    let value;
    try { value = JSON.parse(text); } catch { throw new Error('JSON must be an object with supported issue fields.'); }
    if (!value || Array.isArray(value) || typeof value !== 'object' || !Object.keys(value).length || Object.keys(value).some(key => !fields.has(key))) throw new Error('JSON must be an object with supported issue fields.');
    return value;
  };
  const identifier = value => {
    if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value || '')) throw new Error('Use the project or issue UUID from the API.');
    return value;
  };
  let path, method = 'GET', body;
  if (command === 'issues' && args.length <= 1) {
    const limit = args[0] || '50';
    if (!/^[1-9]\d?$|^100$/.test(limit)) throw new Error('Page limit must be 1 through 100.');
    path = `/api/issues?limit=${limit}`;
  } else if (command === 'create' && args.length === 2) {
    path = '/api/issues'; method = 'POST'; body = { ...object(args[1]), projectId: identifier(args[0]) };
  } else if (command === 'update' && args.length === 2) {
    path = `/api/issues/${identifier(args[0])}`; method = 'PATCH'; body = object(args[1]);
  } else if (command === 'comment' && args.length === 2 && args[1].trim()) {
    path = `/api/issues/${identifier(args[0])}/comments`; method = 'POST'; body = { body: args[1], internal: false };
  } else throw new Error(usage);

  async function request(route) {
    let response;
    try {
      response = await fetch(new URL(route, base), { method, redirect: 'manual', signal: AbortSignal.timeout(10_000),
        headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
        ...(body ? { body: JSON.stringify(body) } : {}) });
      if (!response.ok) {
        await response.body?.cancel();
        const retry = response.headers.get('retry-after');
        const seconds = retry && /^\d{1,10}$/.test(retry) ? retry : null;
        throw new Error(`HTTP ${response.status}${seconds ? `; Retry-After: ${seconds} seconds` : ''}. No retry was sent.`);
      }
      return await response.json();
    } catch (error) {
      if (error.message.startsWith('HTTP ')) throw error;
      throw new Error(method === 'GET' ? 'Request failed, timed out or returned invalid JSON.' : 'Write outcome unknown. Check the ticket before repeating this command.');
    }
  }

  if (method !== 'GET') print(process.stdout, JSON.stringify(await request(path)));
  else {
    let cursor = null;
    const seen = new Set();
    do {
      const page = await request(path + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''));
      if (!page || !Array.isArray(page.items) || !(page.nextCursor === null || typeof page.nextCursor === 'string' && page.nextCursor.length > 0)) throw new Error('Invalid paginated API response.');
      for (const issue of page.items) print(process.stdout, JSON.stringify(issue));
      cursor = page.nextCursor;
      if (cursor && seen.has(cursor)) throw new Error('API repeated a cursor; pagination stopped.');
      seen.add(cursor);
    } while (cursor);
  }
} catch (error) {
  print(process.stderr, error.message);
  process.exitCode = 1;
}

Python

Usage
TIKKETTY_URL=https://tikketty.com python3 api_client.py issues 25
TIKKETTY_URL=https://tikketty.com python3 api_client.py create PROJECT_ID '{"title":"Review the header"}'
TIKKETTY_URL=https://tikketty.com python3 api_client.py update ISSUE_ID '{"status":"todo","priority":"important"}'
TIKKETTY_URL=https://tikketty.com python3 api_client.py comment ISSUE_ID 'Reproduced on staging.'
api_client.py
#!/usr/bin/env python3
"""Dependency-free CLI example; mutation requests are never retried."""
import http.client
import json
import os
import re
import sys
from urllib.parse import urlencode, urlsplit

TOKEN = os.environ.get("TIKKETTY_TOKEN", "")
USAGE = "Usage: api_client.py [issues [limit] | create PROJECT_ID JSON | update ISSUE_ID JSON | comment ISSUE_ID BODY]"


def output(value, stream=sys.stdout):
    print(value.replace(TOKEN, "[redacted]") if TOKEN else value, file=stream)


def identifier(value):
    if not re.fullmatch(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", value):
        raise ValueError("Use the project or issue UUID from the API.")
    return value


def fields(text):
    try:
        value = json.loads(text)
    except ValueError:
        raise ValueError("JSON must be an object with supported issue fields.") from None
    if not isinstance(value, dict) or not value or set(value) - {"title", "description", "status", "priority", "dueDate"}:
        raise ValueError("JSON must be an object with supported issue fields.")
    return value


def main():
    origin = os.environ.get("TIKKETTY_URL", "http://127.0.0.1:3001")
    if not re.match(r"^https?://", origin) or re.search(r"[\\\s?#]", origin):
        raise ValueError("TIKKETTY_URL must be an HTTPS origin or loopback HTTP origin.")
    try:
        base = urlsplit(origin)
        port = base.port
    except ValueError:
        raise ValueError("Invalid TIKKETTY_URL.") from None
    if base.username or base.password or not base.hostname or base.path not in ("", "/") or (base.scheme == "http" and base.hostname not in ("localhost", "127.0.0.1", "::1")):
        raise ValueError("TIKKETTY_URL must be an HTTPS origin or loopback HTTP origin.")
    if not TOKEN or len(TOKEN) > 8192 or re.search(r"[^\x21-\x7e]", TOKEN):
        raise ValueError("Set TIKKETTY_TOKEN privately to a valid API token.")
    command, *args = sys.argv[1:] or ["issues"]
    method, body = "GET", None
    if command == "issues" and len(args) <= 1:
        limit = args[0] if args else "50"
        if not re.fullmatch(r"[1-9]\d?|100", limit):
            raise ValueError("Page limit must be 1 through 100.")
        path = "/api/issues?" + urlencode({"limit": limit})
    elif command == "create" and len(args) == 2:
        path, method, body = "/api/issues", "POST", {**fields(args[1]), "projectId": identifier(args[0])}
    elif command == "update" and len(args) == 2:
        path, method, body = "/api/issues/" + identifier(args[0]), "PATCH", fields(args[1])
    elif command == "comment" and len(args) == 2 and args[1].strip():
        path, method, body = "/api/issues/" + identifier(args[0]) + "/comments", "POST", {"body": args[1], "internal": False}
    else:
        raise ValueError(USAGE)

    def request(route):
        connection = (http.client.HTTPSConnection if base.scheme == "https" else http.client.HTTPConnection)(base.hostname, port, timeout=10)
        try:
            headers = {"Authorization": "Bearer " + TOKEN, "Accept": "application/json"}
            if body is not None:
                headers["Content-Type"] = "application/json"
            connection.request(method, route, body=json.dumps(body).encode() if body is not None else None, headers=headers)
            response = connection.getresponse()
            if not 200 <= response.status < 300:
                retry = response.getheader("Retry-After", "")
                suffix = "; Retry-After: " + retry + " seconds" if re.fullmatch(r"\d{1,10}", retry) else ""
                raise ValueError("HTTP " + str(response.status) + suffix + ". No retry was sent.")
            return json.loads(response.read())
        except ValueError as error:
            if str(error).startswith("HTTP "):
                raise
            raise ValueError("Request failed, timed out or returned invalid JSON." if method == "GET" else "Write outcome unknown. Check the ticket before repeating this command.") from None
        except (OSError, http.client.HTTPException):
            raise ValueError("Request failed, timed out or returned invalid JSON." if method == "GET" else "Write outcome unknown. Check the ticket before repeating this command.") from None
        finally:
            connection.close()

    if method != "GET":
        output(json.dumps(request(path)))
        return
    cursor, seen = None, set()
    while True:
        page = request(path + ("&" + urlencode({"cursor": cursor}) if cursor else ""))
        if not isinstance(page, dict) or not isinstance(page.get("items"), list) or "nextCursor" not in page or not (page["nextCursor"] is None or isinstance(page["nextCursor"], str) and page["nextCursor"]):
            raise ValueError("Invalid paginated API response.")
        for issue in page["items"]:
            output(json.dumps(issue))
        cursor = page["nextCursor"]
        if not cursor:
            return
        if cursor in seen:
            raise ValueError("API repeated a cursor; pagination stopped.")
        seen.add(cursor)


if __name__ == "__main__":
    try:
        main()
    except ValueError as error:
        output(str(error), sys.stderr)
        sys.exit(1)
tikketty.
PrivacyTermsSecurityAPI
© Tikketty