API
Last updated:
On this page
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 areYYYY-MM-DD. - IDs are opaque strings. Issue
numbervalues 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 acode. - 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
GETrequests, including the event stream. - Read & write also allows
POST,PATCH,PUTandDELETE. A read token gets403for these.
The token is shown once. Send it in the Authorization header on every request:
Authorization: Bearer $TIKKETTY_TOKENThe 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:
read -rs TIKKETTY_TOKEN
export TIKKETTY_TOKENA 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
curl https://tikketty.com/api/projects \
-H "Authorization: Bearer $TIKKETTY_TOKEN"{
"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
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"
}'{
"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
curl "https://tikketty.com/api/issues?projectId=PROJECT_ID&status=inbox&priority=important&limit=25" \
-H "Authorization: Bearer $TIKKETTY_TOKEN"{
"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
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"] }'{
"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
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." }'{
"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:
{
"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.
curl "https://tikketty.com/api/issues?limit=50&cursor=NEXT_CURSOR" \
-H "Authorization: Bearer $TIKKETTY_TOKEN"{
"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:
| Field | Type | Description |
|---|---|---|
projectId | string | Only issues in this project. |
status | string | Workflow column ID, such as inbox, todo, in_progress or done. |
priority | string | critical, important, normal or minor. |
assignee | string | User ID of an assignee. |
type | string | bug, task, story, epic or subtask. |
parentId | string | Direct children of this issue, or none for top-level issues. |
epicId | string | Every issue under this epic, including subtasks. |
sprintId | string | current for active sprints, backlog for issues without a sprint, or a sprint ID. Team roles only. |
order | string | number (newest first, the default) or rank (backlog order, team roles only). With rank, cursor is the last issue ID. |
q | string | Text search over title, description, issue number and tags. Up to 200 characters. |
includeArchived | boolean | true to include archived issues. Defaults to false. |
limit | integer | 1 to 100. Defaults to 50. |
cursor | string | nextCursor from the previous page. |
Errors and status codes
{
"error": "title: Too small: expected string to have >=1 characters"
}| Status | Meaning |
|---|---|
200 | Success. |
201 | Created. |
400 | Invalid JSON, unknown fields, invalid values or an invalid cursor. |
401 | Missing, invalid or revoked token. |
402 | The workspace needs an active subscription to make changes. code is billing_required. |
403 | Your role or token scope doesn't allow the request. |
404 | The resource doesn't exist or you don't have access to it. |
409 | Conflict, such as removing a workflow column that still has issues. |
413 | Request body over 1 MB or file over 25 MB. |
429 | Rate limit reached. Wait for Retry-After seconds. |
500 | Server error. Safe to retry reads. |
503 | Billing 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
| Endpoint | Scope | Description |
|---|---|---|
GET /projects | read | List projects you can access. Paginated. Add includeArchived=true for archived projects. |
GET /projects | read | Get a project. |
GET /projects | read | List the people added to a project. |
POST /projects | write | Create a project with the default workflow. Owners and managers. |
PATCH /projects | write | Update 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
| Field | Type | Description |
|---|---|---|
name | string | Required on create. 1 to 100 characters. |
description | string | Up to 5,000 characters. |
color | string | Hex color such as #6366f1. |
sites | string[] | Website origins where the widget runs. Up to 30. |
boardType | string | Create only: kanban (default) or scrum. On update, send settings.boardType. Scrum enables sprints. |
columns | object[] | Update only. Workflow columns as { id, name, color }. Up to 20; must keep inbox. |
archived | boolean | Update only. Archive or restore the project. |
Issues
| Endpoint | Scope | Description |
|---|---|---|
GET /issues | read | List issues, newest first. Paginated and filterable. |
GET /issues | read | Get an issue with its comments, activity and attachments. |
POST /issues | write | Create an issue. |
PATCH /issues | write | Update an issue. Only fields you send change. |
POST /issues | write | Apply the same changes to up to 100 issues. All or nothing. Owners, managers and members. |
POST /issues | write | Merge issues from the same project into this one. Owners, managers and members. |
DELETE /issues | write | Permanently 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
| Field | Type | Description |
|---|---|---|
projectId | string | Required on create. The project the issue belongs to. |
title | string | Required on create. 1 to 240 characters. |
description | string | Markdown, up to 30,000 characters. |
type | string | bug, task, story, epic or subtask. Defaults to task. |
parentId | string | null | Parent in the same project. Bugs, tasks and stories take an epic; subtasks take a bug, task or story; epics have none. |
sprintId | string | null | Sprint in the same scrum project, or null for the backlog. Epics can't join sprints; subtasks follow their parent. |
startDate | string | null | Calendar date as YYYY-MM-DD, on or before dueDate. |
rank | number | Backlog order, lower first. New issues go to the end. |
status | string | A workflow column ID from the project. Defaults to inbox. |
priority | string | critical, important, normal or minor. Defaults to normal. |
assigneeIds | string[] | User IDs. Each assignee needs access to the issue. |
tags | string[] | Up to 30 tags, each 1 to 50 characters. |
dueDate | string | null | Calendar date as YYYY-MM-DD. |
storyPoints | number | null | 0, 1, 2, 3, 5, 8, 13, 21, 34, 55 or 89. |
visibility | string | shared (visible to clients on the project) or team. Defaults to shared. |
url | string | Page the feedback is about. http or https, up to 4,096 characters. |
selector | string | CSS selector of the element, up to 4,096 characters. |
position | object | null | { "x": number, "y": number } pin position on the page. |
environment | object | Browser and device details. Up to 16,000 characters as JSON. |
archived | boolean | Update 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
| Endpoint | Scope | Description |
|---|---|---|
GET /projects | read | List a project's sprints in order. Paginated. Filter with state. Owners, managers and members. |
POST /projects | write | Plan a sprint in a scrum project. Owners, managers and members. |
GET /sprints | read | Get a sprint with issue and story point totals. Owners, managers and members. |
PATCH /sprints | write | Change the name, goal or dates. Owners, managers and members. |
DELETE /sprints | write | Delete a planned sprint. Its issues return to the backlog. Owners, managers and members. |
POST /sprints | write | Start a planned sprint. Needs start and end dates. One active sprint per project. Owners, managers and members. |
POST /sprints | write | Complete the active sprint and move open issues to moveOpenIssuesTo: backlog or a planned sprint ID. Owners, managers and members. |
POST /issues | write | Move 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.
| Field | Type | Description |
|---|---|---|
name | string | 1 to 100 characters. Defaults to Sprint N. |
goal | string | Up to 2,000 characters. |
startDate | string | null | YYYY-MM-DD. Required to start the sprint. |
endDate | string | null | YYYY-MM-DD, on or after startDate. Required to start the 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" }'{
"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
curl https://tikketty.com/api/issues/move \
-H "Authorization: Bearer $TIKKETTY_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "ids": ["ISSUE_ID"], "sprintId": "SPRINT_ID" }'{
"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.
Activity
| Endpoint | Scope | Description |
|---|---|---|
GET /issues | read | List an issue's history, oldest first. Paginated. |
action is created, updated, commented or merged. For updates, detail lists the changed fields.
{
"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
| Endpoint | Scope | Description |
|---|---|---|
GET /members | read | List workspace members you can see. Paginated. |
role is owner, manager, member or client. Clients see themselves and people who share a project with them.
{
"id": "49282567-1d17-4c0c-8cb0-b5af0bd50e9e",
"name": "Sam Rivera",
"email": "sam@example.com",
"role": "member"
}Notifications
| Endpoint | Scope | Description |
|---|---|---|
GET /notifications | read | List your notifications, newest first. Paginated. |
PATCH /notifications | write | Mark one notification read or unread. |
POST /notifications | write | Mark all your notifications read. |
kind is mention, comment, assignment or update. To mark one read, send { "read": true }.
{
"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
| Endpoint | Scope | Description |
|---|---|---|
POST /issues | write | Upload a file to an issue as multipart form data. |
GET /attachments | read | List files. Filter with projectId or issueId. Paginated when you pass limit or cursor. |
GET /attachments | read | Download a file. May redirect to a short-lived storage link. |
DELETE /attachments | write | Delete 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.
curl https://tikketty.com/api/issues/ISSUE_ID/attachments \
-H "Authorization: Bearer $TIKKETTY_TOKEN" \
-F "file=@screenshot.png"{
"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"
}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.
| Endpoint | Scope | Description |
|---|---|---|
GET /webhooks | read | List a project's webhooks. Owners and managers. |
POST /webhooks | write | Create a webhook. The response includes the signing secret once. Owners and managers. |
DELETE /webhooks | write | Delete a webhook and cancel queued deliveries. Owners and managers. |
GET /webhooks | read | List the latest 100 deliveries with their attempts. Owners and managers. |
POST /webhook-deliveries | write | Send a delivery again. Owners and managers. |
| Field | Type | Description |
|---|---|---|
projectId | string | Required. Project to watch. |
url | string | Required. Public HTTPS URL, up to 2,048 characters. |
events | string[] | Required. One or more event names. |
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"]
}'{
"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
| Event | Sent when |
|---|---|
issue.created | An issue was created. |
issue.updated | Issue fields changed, including archive and restore. |
issue.commented | A shared comment was added. Internal comments don't send events. |
issue.merged | Other issues were merged into this one. |
issue.deleted | The issue was permanently deleted. actorId is null. |
sprint.created | A sprint was planned. The payload has sprintId instead of issueId. |
sprint.started | A sprint started. |
sprint.completed | A sprint was completed. |
Payload
Payloads contain IDs only. Fetch the issue with your API token if you need its content.
{
"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 oftimestamp + "." + 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:
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
| Endpoint | Scope | Description |
|---|---|---|
GET /events | read | Server-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.
curl -N https://tikketty.com/api/events \
-H "Authorization: Bearer $TIKKETTY_TOKEN"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
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.'#!/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
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.'#!/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)
Comments
/issues /:id /comments/issues /:id /comments/issues /:id /comments /:commentId/comments /:idbodyinternalOnly the author can edit a comment. Edited comments return
editedAt; it isnulluntil the first edit.Mention someone with
[@Sam Rivera](/people?member=USER_ID)in a comment or description to notify them.