Skip to Content

Strategic Tickets

REST endpoints for Strategic Tickets. Base URL https://api.competlab.com; every request needs a CL-API-Key header (see Authentication). Responses wrap in { item } or { items }; errors in { error }.

List the project’s ticket labels

GET/v1/projects/{projectId}/tickets/labels

List the project’s label list — the only labels a ticket may carry, in the order the project picks them. A label is a name and a colour; a ticket names labels by ID. The list is empty until the project defines a label, in the app or through POST /v1/projects/{projectId}/tickets/labels.

Returns · { items }

Path parameters

NameTypeDescription
projectIdstringProject ID

Request

curl "https://api.competlab.com/v1/projects/507f1f77bcf86cd799439011/tickets/labels" \ -H "CL-API-Key: YOUR_COMPETLAB_API_KEY"

Response 200 OK

{ "items": [ { "id": "3f2a9c6e-1b77-4c1e-9a54-5c7b2f0a91d2", "name": "Pricing page", "color": "#3b82f6" } ] }

Errors

StatusCodeMeaning
401api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scopeThe CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope.
429rate_limit_exceededRate limit exceeded — retry with backoff.

Every error uses the shared { error: { code, message, status } } envelope; code is one of the values listed above.

{ "error": { "code": "api_key_invalid", "message": "Invalid API key", "status": 401 } }

Transient failures (429, 5xx) are safe to retry with exponential backoff.

Create a ticket label

POST/v1/projects/{projectId}/tickets/labels

Add a label to the project’s list. Names are unique within the project and compared without regard to case, so Pricing and pricing cannot both exist. Answers with the whole list. Requires a read_write key.

Returns · { items }

Path parameters

NameTypeDescription
projectIdstringProject ID

Request body — application/json

FieldTypeDescription
namestringThe label’s name. Unique within the project, compared without regard to case, at most 40 characters. A project holds at most 50 labels.
colorstringThe label’s colour, as hex — #rrggbb. It is stored lower-case, which is the form every tracker a ticket may be copied into speaks.

Request

curl -X POST "https://api.competlab.com/v1/projects/507f1f77bcf86cd799439011/tickets/labels" \ -H "CL-API-Key: YOUR_COMPETLAB_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"Pricing page","color":"#3b82f6"}'

Response 200 OK

{ "items": [ { "id": "3f2a9c6e-1b77-4c1e-9a54-5c7b2f0a91d2", "name": "Pricing page", "color": "#3b82f6" } ] }

Errors

StatusCodeMeaning
400invalid_parameters · invalid_run_id · invalid_check_id · bad_request · paging_requires_compact_view · paging_requires_summary · nothing_to_returnBad request — the payload failed validation.
401api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scopeThe CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope.
403insufficient_scope · forbiddenThe key is not permitted to access this resource.
429rate_limit_exceededRate limit exceeded — retry with backoff.

Every error uses the shared { error: { code, message, status } } envelope; code is one of the values listed above.

{ "error": { "code": "invalid_parameters", "message": "Domain is required", "status": 400 } }

Transient failures (429, 5xx) are safe to retry with exponential backoff.

Rename or recolour a ticket label

PATCH/v1/projects/{projectId}/tickets/labels/{labelId}

Rename a label, recolour it, or both. It is one write on the list and touches no ticket, because a ticket holds the label’s ID. Answers with the whole list. Requires a read_write key.

Returns · { items }

Path parameters

NameTypeDescription
labelIdstringLabel ID, as the label list returns it
projectIdstringProject ID

Request body — application/json

FieldTypeDescription
namestringThe label’s name. Unique within the project, compared without regard to case, at most 40 characters. A project holds at most 50 labels.
colorstringThe label’s colour, as hex — #rrggbb. It is stored lower-case, which is the form every tracker a ticket may be copied into speaks.

Request

curl -X PATCH "https://api.competlab.com/v1/projects/507f1f77bcf86cd799439011/tickets/labels/3f2a9c6e-1b77-4c1e-9a54-5c7b2f0a91d2" \ -H "CL-API-Key: YOUR_COMPETLAB_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"Pricing page","color":"#3b82f6"}'

Response 200 OK

{ "items": [ { "id": "3f2a9c6e-1b77-4c1e-9a54-5c7b2f0a91d2", "name": "Pricing page", "color": "#3b82f6" } ] }

Errors

StatusCodeMeaning
400invalid_parameters · invalid_run_id · invalid_check_id · bad_request · paging_requires_compact_view · paging_requires_summary · nothing_to_returnBad request — the payload failed validation.
401api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scopeThe CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope.
403insufficient_scope · forbiddenThe key is not permitted to access this resource.
404not_foundNo label matches the id in the path.
429rate_limit_exceededRate limit exceeded — retry with backoff.

Every error uses the shared { error: { code, message, status } } envelope; code is one of the values listed above.

{ "error": { "code": "invalid_parameters", "message": "Domain is required", "status": 400 } }

Transient failures (429, 5xx) are safe to retry with exponential backoff.

Delete a ticket label

DELETE/v1/projects/{projectId}/tickets/labels/{labelId}

Remove a label from the project’s list and from every ticket carrying it. Answers with the whole list. Requires a read_write key.

Returns · { items }

Path parameters

NameTypeDescription
labelIdstringLabel ID, as the label list returns it
projectIdstringProject ID

Request

curl -X DELETE "https://api.competlab.com/v1/projects/507f1f77bcf86cd799439011/tickets/labels/3f2a9c6e-1b77-4c1e-9a54-5c7b2f0a91d2" \ -H "CL-API-Key: YOUR_COMPETLAB_API_KEY" \ -H "Content-Type: application/json" \ -d '{}'

Response 200 OK

{ "items": [ { "id": "3f2a9c6e-1b77-4c1e-9a54-5c7b2f0a91d2", "name": "Pricing page", "color": "#3b82f6" } ] }

Errors

StatusCodeMeaning
401api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scopeThe CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope.
403insufficient_scope · forbiddenThe key is not permitted to access this resource.
404not_foundNo label matches the id in the path.
429rate_limit_exceededRate limit exceeded — retry with backoff.

Every error uses the shared { error: { code, message, status } } envelope; code is one of the values listed above.

{ "error": { "code": "api_key_invalid", "message": "Invalid API key", "status": 401 } }

Transient failures (429, 5xx) are safe to retry with exponential backoff.

List the people a ticket can be assigned to

GET/v1/projects/{projectId}/tickets/assignees

List the people a ticket in this project can be assigned to — everyone who is currently a member of the organization, each as the same userId and fullName a ticket already carries for its author and its assignee. This is where assigneeUserId comes from: a create or an update that names anyone else is refused. Somebody invited but not yet joined is not here, because a ticket cannot be assigned to them. Nothing about a person beyond their name and id is returned, on this endpoint or any other.

Returns · { items }

Path parameters

NameTypeDescription
projectIdstringProject ID

Request

curl "https://api.competlab.com/v1/projects/507f1f77bcf86cd799439011/tickets/assignees" \ -H "CL-API-Key: YOUR_COMPETLAB_API_KEY"

Response 200 OK

{ "items": [ { "userId": "507f1f77bcf86cd799439011", "fullName": "Dana Ellis" } ] }

Errors

StatusCodeMeaning
401api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scopeThe CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope.
429rate_limit_exceededRate limit exceeded — retry with backoff.

Every error uses the shared { error: { code, message, status } } envelope; code is one of the values listed above.

{ "error": { "code": "api_key_invalid", "message": "Invalid API key", "status": 401 } }

Transient failures (429, 5xx) are safe to retry with exponential backoff.

List project tickets

GET/v1/projects/{projectId}/tickets

List the project’s Strategic Tickets, a page at a time — what the team is deciding on and working on, whether a person opened a ticket, your own automation did, or a Strategic Briefing did. By default the list is the board flattened: the columns in board order, and inside each column the order the team keeps — in triage that starts as the order an edition delivered its recommendations, most important first, newest edition on top, until somebody moves them. A ticket carries no order number of its own on this API; its place is where it sits in the list. Each row is a summary: every field except the Markdown description, which include=description adds. pagination.total counts every ticket matching every filter across all pages; pagination.hasMore says a next page exists — ask for page+1. byStatus counts the matches per column whatever status you passed, narrowed by every other filter, so limit=1 with no other filter is the board’s census. One call answers each of these. What to start: status=triage,todo&sort=priority. Quick wins among a briefing’s recommendations: add impactMin=3&maxMinutes=120 — that leaves out every ticket without an edition’s estimate; for those, impactMin=3&effort=low. Overdue: status=triage,todo,in_progress&dueBefore={today}&sort=due. Due this week: status=triage,todo,in_progress&dueFrom={today}&dueBefore={the day after the week ends}. Changed since yesterday: activeSince={the start of the customer's yesterday, as ISO 8601 with its offset — a bare day is midnight UTC}&sort=activity; on a changed ticket, a statusChangedAt later than that means it changed column, and a lastActivityAt later than its updatedAt means a thread entry was written or edited after the ticket’s own last change. Due dates are calendar days with no time zone, so pass the customer’s own today. origin=briefing&briefingRunId={runId} is one edition’s tickets, with {runId} taken from GET /v1/projects/{projectId}/strategic-briefing/history; they are returned whole whatever closed says, so the list matches the count the briefing states. To read neighbours for a move, use sort=board (the default) and the destination column alone: any other read can hide tickets that sit between two rows.

Returns · { items, pagination, byStatus }

Path parameters

NameTypeDescription
projectIdstringProject ID

Query parameters

NameTypeRequiredDescription
pagenumberPage number (1-indexed). Default 1. Min 1.
limitnumberHow many tickets per page, 1 to 100; 50 when omitted. Pass limit=1 when you only need pagination.total and byStatus. Default 50. Range 1–100.
statusarray<string>Return only the tickets in these columns — one, or several separated by commas (status=todo,in_progress). Omit it for every column. byStatus ignores this filter, so its counts still cover every column, narrowed by every other filter you passed.
sortstringThe order of the list. board (the default) is the board flattened: the columns in board order, each in the order the team keeps — and the only order to read neighbours for a move from. priority puts the highest impact first; among the same impact, the least effort first, and among the same effort the fewest minutes of the edition’s estimate; then the earliest due date, then board order. A missing value sorts last at each step. due puts the earliest due date first, undated last. activity puts the most recently touched first (lastActivityAt). Any order but board mixes the columns; each ticket carries its status. Values: board, priority, due, activity. Default board.
closedstringHow much of done and dismissed to return. all (the default) returns them whole; recent returns only the tickets that reached those columns within the last 30 days. The other columns are always whole, whichever you pass, and a read narrowed to one edition is whole whatever you pass. Values: recent, all. Default all.
originstringWho opened the ticket, never which dimension it is about (that is dimension): user — a person in the app; api — an API key, which is what everything you write here carries; briefing — a Strategic Briefing; ai_sources — reserved for a future AI Sources writer, which opens none today, so that value matches none. Values: user, api, briefing, ai_sources.
dimensionstringReturn only the tickets a Strategic Briefing opened for one part of its analysis, or none for the tickets no part claims — everything a person or an API key opened, and a briefing’s ticket whose edition named none. agent-readiness is the key for Agent Adoption — how well a site is set up for AI agents to discover, access and read it. The key predates the name and does not change. One of 15 values (e.g. ai-visibility, ai-sources, positioning, …).
briefingRunIdstringReturn only the tickets one Strategic Briefing edition opened. Take the id from GET /v1/projects/{projectId}/strategic-briefing/history, or from meta.runId on a briefing read. Nothing a person or an API key opens carries an edition, so this narrows the list to that edition’s tickets whether or not you also pass origin. An edition’s tickets are returned whole, the finished and dismissed ones included however long ago they were closed — closed has no effect on this read — so the list matches the count the briefing states for that edition.
assigneestringReturn only the tickets one person owns — their user ID, from GET /v1/projects/{projectId}/tickets/assignees. Pass none for the tickets nobody owns. A ticket whose owner has left the organization reads as unassigned everywhere, but is still found here by their ID.
labelIdstringReturn only the tickets carrying one label — its ID, from GET /v1/projects/{projectId}/tickets/labels. A project’s list is empty until the project defines a label, in the app or through POST /v1/projects/{projectId}/tickets/labels.
numbernumberReturn the one ticket carrying this number on the board — what a person means by #14. It is a name and not an address: this narrows the list, and every endpoint that acts on a ticket still takes its id. Min 1.
qstringReturn only the tickets whose title or description contains this text, compared without regard to case. It is matched literally — punctuation is text, not a pattern — and threads are not searched. A match in a description is weaker evidence than one in the title: a briefing’s description quotes its grounding, so read the ticket before calling two tickets the same work.
impactMinnumberReturn only the tickets whose impact is at least this, 1 to 4 (1 Minor · 2 Moderate · 3 Significant · 4 Critical; 4 matters most). Tickets nobody sized are left out. A Strategic Briefing sets impact and effort on the tickets it opens, so a value there is the edition’s estimate until a person changes it. Range 1–4.
effortarray<string>Return only the tickets sized as one of these — one, or several separated by commas (effort=low,medium). Tickets nobody sized are left out. This is the ticket’s size word, not its minutes: a briefing’s briefing.estimatedMinutes is the edition’s own time estimate, neither is derived from the other, and a low ticket can be an afternoon — when a person asks for something quick, use maxMinutes.
maxMinutesnumberReturn only the tickets a Strategic Briefing opened whose edition estimated at most this many minutes of work. A ticket without an edition’s estimate — everything a person or an API key opened, and a briefing’s ticket the edition did not size — is left out; use effort for those. Min 1.
dueFromstringReturn only the tickets due on or after this calendar day, YYYY-MM-DD. Tickets with no due date are left out. A due date has no time zone, so pass the customer’s own day.
dueBeforestringReturn only the tickets due strictly before this calendar day, YYYY-MM-DD. dueBefore=<today> with the open columns (status=triage,todo,in_progress) is everything overdue; with dueFrom it is a range. Tickets with no due date are left out. A due date has no time zone, so pass the customer’s own day.
activeSincestringReturn only the tickets something happened to at or after this moment — an edit, a move, or a new thread entry (lastActivityAt). ISO 8601 with its offset; a time without one is read as UTC, and YYYY-MM-DD is the start of that day in UTC. A deleted ticket is gone and does not appear.
includearray<string>What each ticket in the list carries. By default a list is an INDEX: every field except the ticket’s own Markdown description, which is then not on the row at all. Pass include=description for the bodies — one ticket’s description runs to thousands of characters, so a page fetched with them is large enough to be worth asking for on purpose. GET …/tickets/{ticketId} always returns the description whatever you pass here.

Request

curl "https://api.competlab.com/v1/projects/507f1f77bcf86cd799439011/tickets?page=1&limit=50&status=todo&sort=priority&closed=all&origin=briefing&dimension=pricing&briefingRunId=6a3950c06bda450a1372048e&assignee=507f1f77bcf86cd799439031&labelId=3f2a9c6e-1b77-4c1e-9a54-5c7b2f0a91d2&number=14&q=pricing%20page&impactMin=3&effort=low&maxMinutes=120&dueFrom=2026-10-01&dueBefore=2026-10-08&activeSince=2026-09-21&include=description" \ -H "CL-API-Key: YOUR_COMPETLAB_API_KEY"

Response 200 OK

{ "items": [ { "id": "507f1f77bcf86cd799439011", "number": 14, "origin": "api", "author": { "userId": "507f1f77bcf86cd799439011", "fullName": "Dana Ellis" }, "briefing": { "runId": "6a3950c06bda450a1372048e", "dimension": "pricing", "estimatedMinutes": 90, "extendsTicketId": "507f1f77bcf86cd799439012" }, "title": "Get onto the 2026 buyer guide", "labels": [ { "id": "3f2a9c6e-1b77-4c1e-9a54-5c7b2f0a91d2", "name": "Pricing page", "color": "#3b82f6" } ], "status": "todo", "statusChangedAt": "2026-09-15T10:00:00.000Z", "assignee": { "userId": "507f1f77bcf86cd799439011", "fullName": "Dana Ellis" }, "dueDate": "2026-10-01", "effort": "medium", "impact": 3, "commentCount": 2, "deletable": true, "createdAt": "2026-09-15T10:00:00.000Z", "updatedAt": "2026-09-16T08:30:00.000Z", "lastActivityAt": "2026-09-17T11:02:00.000Z", "description": "The guide names three of our competitors and not us." } ], "pagination": { "page": 1, "limit": 20, "total": 47, "totalPages": 3, "hasMore": true }, "byStatus": { "triage": 0, "todo": 1, "in_progress": 2, "done": 3, "dismissed": 4 } }

Paginated — pass page and limit query parameters and follow pagination.hasMore to page through the full set.

Errors

StatusCodeMeaning
400invalid_parameters · invalid_run_id · invalid_check_id · bad_request · paging_requires_compact_view · paging_requires_summary · nothing_to_returnBad request — the payload failed validation.
401api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scopeThe CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope.
429rate_limit_exceededRate limit exceeded — retry with backoff.

Every error uses the shared { error: { code, message, status } } envelope; code is one of the values listed above.

{ "error": { "code": "invalid_parameters", "message": "Domain is required", "status": 400 } }

Transient failures (429, 5xx) are safe to retry with exponential backoff.

Create a ticket

POST/v1/projects/{projectId}/tickets

Open a ticket on the project’s board. title and status are required — status names the column it lands in and has no default, because where a ticket belongs depends on who opened it. A new ticket lands at the top of its column. Labels are named by ID from the project’s label list, so create the label there first. A board holds at most 5,000 tickets; past that a create is refused with 400 until some are deleted. The ticket is recorded as opened by an API key — origin is api and author is null — which is how the board tells your automation’s tickets from a person’s. Requires a read_write key.

Returns · { item }

Path parameters

NameTypeDescription
projectIdstringProject ID

Request body — application/json

FieldTypeDescription
titlestringThe ticket’s title.
descriptionstringThe ticket’s description, in Markdown. At most 20000 characters.
statusstringThe column the ticket opens in. Required and with no default: where a ticket belongs depends on who opened it, so a request that does not say is refused rather than landed somewhere plausible. Values: triage, todo, in_progress, done, dismissed.
labelIdsarray<string>IDs from the project’s own label list — read it with the labels endpoint, and create a label there before naming it. At most 10 per ticket, each named once.
assigneeUserIdstringThe user ID of a current member of your organization — list them with GET /v1/projects/{projectId}/tickets/assignees. A user ID from anywhere else is refused.
dueDatestringThe day the ticket is due, as YYYY-MM-DD — a calendar day, with no clock and no time zone.
effortstringHow much work the ticket is. Values: low, medium, high.
impactnumberHow much the ticket matters, from 1 to 4 (1 Minor · 2 Moderate · 3 Significant · 4 Critical; 4 matters most).

Request

curl -X POST "https://api.competlab.com/v1/projects/507f1f77bcf86cd799439011/tickets" \ -H "CL-API-Key: YOUR_COMPETLAB_API_KEY" \ -H "Content-Type: application/json" \ -d '{"title":"Get onto the 2026 buyer guide","description":"The guide names three of our competitors and not us.","status":"triage","labelIds":["3f2a9c6e-1b77-4c1e-9a54-5c7b2f0a91d2"],"assigneeUserId":"507f1f77bcf86cd799439031","dueDate":"2026-10-01","effort":"medium","impact":3}'

Response 200 OK

{ "item": { "id": "507f1f77bcf86cd799439011", "number": 14, "origin": "api", "author": { "userId": "507f1f77bcf86cd799439011", "fullName": "Dana Ellis" }, "briefing": { "runId": "6a3950c06bda450a1372048e", "dimension": "pricing", "estimatedMinutes": 90, "extendsTicketId": "507f1f77bcf86cd799439012" }, "title": "Get onto the 2026 buyer guide", "description": "The guide names three of our competitors and not us.", "labels": [ { "id": "3f2a9c6e-1b77-4c1e-9a54-5c7b2f0a91d2", "name": "Pricing page", "color": "#3b82f6" } ], "status": "todo", "statusChangedAt": "2026-09-15T10:00:00.000Z", "assignee": { "userId": "507f1f77bcf86cd799439011", "fullName": "Dana Ellis" }, "dueDate": "2026-10-01", "effort": "medium", "impact": 3, "commentCount": 2, "deletable": true, "createdAt": "2026-09-15T10:00:00.000Z", "updatedAt": "2026-09-16T08:30:00.000Z", "lastActivityAt": "2026-09-17T11:02:00.000Z" } }

Errors

StatusCodeMeaning
400invalid_parameters · invalid_run_id · invalid_check_id · bad_request · paging_requires_compact_view · paging_requires_summary · nothing_to_returnBad request — the payload failed validation.
401api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scopeThe CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope.
403insufficient_scope · forbiddenThe key is not permitted to access this resource.
429rate_limit_exceededRate limit exceeded — retry with backoff.

Every error uses the shared { error: { code, message, status } } envelope; code is one of the values listed above.

{ "error": { "code": "invalid_parameters", "message": "Domain is required", "status": 400 } }

Transient failures (429, 5xx) are safe to retry with exponential backoff.

Get a ticket

GET/v1/projects/{projectId}/tickets/{ticketId}

Get one ticket: its description, its labels resolved to name and colour, its assignee, its dates, and how many entries its thread holds. On a ticket a Strategic Briefing opened, briefing names the edition it came from; it is null on every other ticket. An ID that belongs to another project answers 404 exactly as an ID that exists nowhere does.

Returns · { item }

Path parameters

NameTypeDescription
ticketIdstringTicket ID
projectIdstringProject ID

Request

curl "https://api.competlab.com/v1/projects/507f1f77bcf86cd799439011/tickets/507f1f77bcf86cd799439011" \ -H "CL-API-Key: YOUR_COMPETLAB_API_KEY"

Response 200 OK

{ "item": { "id": "507f1f77bcf86cd799439011", "number": 14, "origin": "api", "author": { "userId": "507f1f77bcf86cd799439011", "fullName": "Dana Ellis" }, "briefing": { "runId": "6a3950c06bda450a1372048e", "dimension": "pricing", "estimatedMinutes": 90, "extendsTicketId": "507f1f77bcf86cd799439012" }, "title": "Get onto the 2026 buyer guide", "description": "The guide names three of our competitors and not us.", "labels": [ { "id": "3f2a9c6e-1b77-4c1e-9a54-5c7b2f0a91d2", "name": "Pricing page", "color": "#3b82f6" } ], "status": "todo", "statusChangedAt": "2026-09-15T10:00:00.000Z", "assignee": { "userId": "507f1f77bcf86cd799439011", "fullName": "Dana Ellis" }, "dueDate": "2026-10-01", "effort": "medium", "impact": 3, "commentCount": 2, "deletable": true, "createdAt": "2026-09-15T10:00:00.000Z", "updatedAt": "2026-09-16T08:30:00.000Z", "lastActivityAt": "2026-09-17T11:02:00.000Z" } }

Errors

StatusCodeMeaning
400invalid_parameters · invalid_run_id · invalid_check_id · bad_request · paging_requires_compact_view · paging_requires_summary · nothing_to_returnBad request — the payload failed validation.
401api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scopeThe CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope.
404not_foundNo ticket matches the id in the path.
429rate_limit_exceededRate limit exceeded — retry with backoff.

Every error uses the shared { error: { code, message, status } } envelope; code is one of the values listed above.

{ "error": { "code": "invalid_parameters", "message": "Domain is required", "status": 400 } }

Transient failures (429, 5xx) are safe to retry with exponential backoff.

Update a ticket

PATCH/v1/projects/{projectId}/tickets/{ticketId}

Change a ticket’s content and planning fields. Omit a field to leave it as it is, send a value to replace it, send null to clear it. The description and the labels are cleared by their own empty value instead — "" and [] — because for those an empty value is a real one. A column, and a place inside one, are a move and are not accepted here, so a ticket can never change column as a side effect of an edit. Requires a read_write key.

Returns · { item }

Path parameters

NameTypeDescription
ticketIdstringTicket ID
projectIdstringProject ID

Request body — application/json

FieldTypeDescription
titlestringThe ticket’s title.
descriptionstringThe ticket’s description, in Markdown. At most 20000 characters. Send "" to clear it.
labelIdsarray<string>IDs from the project’s own label list — read it with the labels endpoint, and create a label there before naming it. At most 10 per ticket, each named once. The list replaces what the ticket holds; send [] to clear it.
assigneeUserIdstring | nullThe user ID of a current member of your organization — list them with GET /v1/projects/{projectId}/tickets/assignees. A user ID from anywhere else is refused. Send null to leave the ticket unassigned. Nullable.
dueDatestring | nullThe day the ticket is due, as YYYY-MM-DD — a calendar day, with no clock and no time zone. Send null to take the due date off. Nullable.
effortstring | nullHow much work the ticket is. Send null to take it off. Values: low, medium, high. Nullable.
impactnumber | nullHow much the ticket matters, from 1 to 4 (1 Minor · 2 Moderate · 3 Significant · 4 Critical; 4 matters most). Send null to take it off. Nullable.

Request

curl -X PATCH "https://api.competlab.com/v1/projects/507f1f77bcf86cd799439011/tickets/507f1f77bcf86cd799439011" \ -H "CL-API-Key: YOUR_COMPETLAB_API_KEY" \ -H "Content-Type: application/json" \ -d '{"title":"Get onto the 2026 buyer guide","description":"The guide names three of our competitors and not us.","labelIds":["3f2a9c6e-1b77-4c1e-9a54-5c7b2f0a91d2"],"assigneeUserId":"507f1f77bcf86cd799439031","dueDate":"2026-10-01","effort":"medium","impact":3}'

Response 200 OK

{ "item": { "id": "507f1f77bcf86cd799439011", "number": 14, "origin": "api", "author": { "userId": "507f1f77bcf86cd799439011", "fullName": "Dana Ellis" }, "briefing": { "runId": "6a3950c06bda450a1372048e", "dimension": "pricing", "estimatedMinutes": 90, "extendsTicketId": "507f1f77bcf86cd799439012" }, "title": "Get onto the 2026 buyer guide", "description": "The guide names three of our competitors and not us.", "labels": [ { "id": "3f2a9c6e-1b77-4c1e-9a54-5c7b2f0a91d2", "name": "Pricing page", "color": "#3b82f6" } ], "status": "todo", "statusChangedAt": "2026-09-15T10:00:00.000Z", "assignee": { "userId": "507f1f77bcf86cd799439011", "fullName": "Dana Ellis" }, "dueDate": "2026-10-01", "effort": "medium", "impact": 3, "commentCount": 2, "deletable": true, "createdAt": "2026-09-15T10:00:00.000Z", "updatedAt": "2026-09-16T08:30:00.000Z", "lastActivityAt": "2026-09-17T11:02:00.000Z" } }

Errors

StatusCodeMeaning
400invalid_parameters · invalid_run_id · invalid_check_id · bad_request · paging_requires_compact_view · paging_requires_summary · nothing_to_returnBad request — the payload failed validation.
401api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scopeThe CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope.
403insufficient_scope · forbiddenThe key is not permitted to access this resource.
404not_foundNo ticket matches the id in the path.
429rate_limit_exceededRate limit exceeded — retry with backoff.

Every error uses the shared { error: { code, message, status } } envelope; code is one of the values listed above.

{ "error": { "code": "invalid_parameters", "message": "Domain is required", "status": 400 } }

Transient failures (429, 5xx) are safe to retry with exponential backoff.

Delete a ticket

DELETE/v1/projects/{projectId}/tickets/{ticketId}

Delete a ticket and its thread. A ticket a Strategic Briefing opened cannot be deleted — move it to dismissed instead, so that what opened it does not open it again. Read deletable on the ticket first; deleting one that is not is refused with 400. Requires a read_write key.

Returns · { item }

Path parameters

NameTypeDescription
ticketIdstringTicket ID
projectIdstringProject ID

Request

curl -X DELETE "https://api.competlab.com/v1/projects/507f1f77bcf86cd799439011/tickets/507f1f77bcf86cd799439011" \ -H "CL-API-Key: YOUR_COMPETLAB_API_KEY" \ -H "Content-Type: application/json" \ -d '{}'

Response 200 OK

{ "item": { "success": true, "id": "507f1f77bcf86cd799439011" } }

Errors

StatusCodeMeaning
400invalid_parameters · invalid_run_id · invalid_check_id · bad_request · paging_requires_compact_view · paging_requires_summary · nothing_to_returnBad request — the payload failed validation.
401api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scopeThe CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope.
403insufficient_scope · forbiddenThe key is not permitted to access this resource.
404not_foundNo ticket matches the id in the path.
429rate_limit_exceededRate limit exceeded — retry with backoff.

Every error uses the shared { error: { code, message, status } } envelope; code is one of the values listed above.

{ "error": { "code": "invalid_parameters", "message": "Domain is required", "status": 400 } }

Transient failures (429, 5xx) are safe to retry with exponential backoff.

Move a ticket

PATCH/v1/projects/{projectId}/tickets/{ticketId}/move

Move a ticket to another column, or reorder it inside the one it is in. Name the destination column, then where it goes: position=top or position=bottom, or the two tickets it will sit between — beforeId is the one directly ABOVE it, afterId the one directly BELOW; name either or both. A request naming a position and a neighbour is refused. Omitting everything puts it at the bottom — the opposite of a new ticket, which lands at the top. There is no index to compute — you name neighbours you can see and the server works out the order, so two clients moving tickets at the same time converge on one order instead of overwriting each other. A neighbour that has been deleted, or now sits in another column, is not used; if you named both and the one below now sits above the one above, the one above is kept; if neither can be used, the ticket goes to the bottom. A move never fails because your view of the board was a moment old. The answer says where it landed: placement.above and placement.below are its neighbours now, and placement.ignored names each neighbour you named that was not used and why — other_column most often means you read it off another column’s list. An empty ignored means every neighbour you named was used, not that nothing sits between them: compare above and below with what you named, and if they differ and the exact place matters, re-read that column in board order and move again. Reordering inside a column leaves statusChangedAt alone. Requires a read_write key.

Returns · { item, placement }

Path parameters

NameTypeDescription
ticketIdstringTicket ID
projectIdstringProject ID

Request body — application/json

FieldTypeDescription
statusstringThe column the ticket ends up in. Always the destination, never a change flag — reordering a ticket inside the column it already sits in names that column. Values: triage, todo, in_progress, done, dismissed.
beforeIdstringThe ticket that will sit directly ABOVE this one. Name either neighbour or both. Omitting both puts the ticket at the BOTTOM of the destination column — which is its first card when that column is empty.
afterIdstringThe ticket that will sit directly BELOW this one. To put a ticket at the TOP of a column, send the ticket currently first there as afterId, and no beforeId.
positionstringThe top or the bottom of the destination column, without reading the column first. Name a position OR neighbours: a request carrying both is refused. Omitting everything puts the ticket at the bottom. Values: top, bottom.

Request

curl -X PATCH "https://api.competlab.com/v1/projects/507f1f77bcf86cd799439011/tickets/507f1f77bcf86cd799439011/move" \ -H "CL-API-Key: YOUR_COMPETLAB_API_KEY" \ -H "Content-Type: application/json" \ -d '{"status":"in_progress","beforeId":"507f1f77bcf86cd799439012","afterId":"507f1f77bcf86cd799439013","position":"top"}'

Response 200 OK

{ "item": { "id": "507f1f77bcf86cd799439011", "number": 14, "origin": "api", "author": { "userId": "507f1f77bcf86cd799439011", "fullName": "Dana Ellis" }, "briefing": { "runId": "6a3950c06bda450a1372048e", "dimension": "pricing", "estimatedMinutes": 90, "extendsTicketId": "507f1f77bcf86cd799439012" }, "title": "Get onto the 2026 buyer guide", "description": "The guide names three of our competitors and not us.", "labels": [ { "id": "3f2a9c6e-1b77-4c1e-9a54-5c7b2f0a91d2", "name": "Pricing page", "color": "#3b82f6" } ], "status": "todo", "statusChangedAt": "2026-09-15T10:00:00.000Z", "assignee": { "userId": "507f1f77bcf86cd799439011", "fullName": "Dana Ellis" }, "dueDate": "2026-10-01", "effort": "medium", "impact": 3, "commentCount": 2, "deletable": true, "createdAt": "2026-09-15T10:00:00.000Z", "updatedAt": "2026-09-16T08:30:00.000Z", "lastActivityAt": "2026-09-17T11:02:00.000Z" }, "placement": { "status": "triage", "above": { "id": "507f1f77bcf86cd799439012", "number": 12, "title": "Publish the integrations page" }, "below": { "id": "507f1f77bcf86cd799439012", "number": 12, "title": "Publish the integrations page" }, "ignored": [ { "id": "507f1f77bcf86cd799439014", "as": "beforeId", "reason": "not_found", "status": "triage" } ] } }

Errors

StatusCodeMeaning
400invalid_parameters · invalid_run_id · invalid_check_id · bad_request · paging_requires_compact_view · paging_requires_summary · nothing_to_returnBad request — the payload failed validation.
401api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scopeThe CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope.
403insufficient_scope · forbiddenThe key is not permitted to access this resource.
404not_foundNo ticket matches the id in the path.
429rate_limit_exceededRate limit exceeded — retry with backoff.

Every error uses the shared { error: { code, message, status } } envelope; code is one of the values listed above.

{ "error": { "code": "invalid_parameters", "message": "Domain is required", "status": 400 } }

Transient failures (429, 5xx) are safe to retry with exponential backoff.

List a ticket’s comments

GET/v1/projects/{projectId}/tickets/{ticketId}/comments

List a ticket’s thread, oldest first and whole — nothing pages it, so nothing is counted twice. Each entry says what wrote it: a person working in the app, or an API key.

Returns · { items }

Path parameters

NameTypeDescription
ticketIdstringTicket ID
projectIdstringProject ID

Request

curl "https://api.competlab.com/v1/projects/507f1f77bcf86cd799439011/tickets/507f1f77bcf86cd799439011/comments" \ -H "CL-API-Key: YOUR_COMPETLAB_API_KEY"

Response 200 OK

{ "items": [ { "id": "507f1f77bcf86cd799439021", "ticketId": "507f1f77bcf86cd799439011", "origin": "api", "author": { "userId": "507f1f77bcf86cd799439011", "fullName": "Dana Ellis" }, "body": "Reached the editor on 14 Sep; they update the guide quarterly.", "edited": false, "createdAt": "2026-09-15T10:05:00.000Z", "updatedAt": "2026-09-15T10:05:00.000Z", "briefing": { "runId": "6a3950c06bda450a1372048e", "kind": "result", "editionNumber": 5, "completedAt": "2026-10-05T06:12:00.000Z" } } ] }

Errors

StatusCodeMeaning
400invalid_parameters · invalid_run_id · invalid_check_id · bad_request · paging_requires_compact_view · paging_requires_summary · nothing_to_returnBad request — the payload failed validation.
401api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scopeThe CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope.
404not_foundNo ticket matches the id in the path.
429rate_limit_exceededRate limit exceeded — retry with backoff.

Every error uses the shared { error: { code, message, status } } envelope; code is one of the values listed above.

{ "error": { "code": "invalid_parameters", "message": "Domain is required", "status": 400 } }

Transient failures (429, 5xx) are safe to retry with exponential backoff.

Add a ticket comment

POST/v1/projects/{projectId}/tickets/{ticketId}/comments

Add an entry to a ticket’s thread, written in Markdown. The entry is recorded as written by an API key. A thread holds at most 500 entries. Requires a read_write key.

Returns · { item }

Path parameters

NameTypeDescription
ticketIdstringTicket ID
projectIdstringProject ID

Request body — application/json

FieldTypeDescription
bodystringThe entry’s text, in Markdown. At most 20000 characters. It is stored exactly as it is sent — leading whitespace is a code block in the dialect that draws it, so nothing is trimmed off the ends.

Request

curl -X POST "https://api.competlab.com/v1/projects/507f1f77bcf86cd799439011/tickets/507f1f77bcf86cd799439011/comments" \ -H "CL-API-Key: YOUR_COMPETLAB_API_KEY" \ -H "Content-Type: application/json" \ -d '{"body":"Reached the editor on 14 Sep; they update the guide quarterly."}'

Response 200 OK

{ "item": { "id": "507f1f77bcf86cd799439021", "ticketId": "507f1f77bcf86cd799439011", "origin": "api", "author": { "userId": "507f1f77bcf86cd799439011", "fullName": "Dana Ellis" }, "body": "Reached the editor on 14 Sep; they update the guide quarterly.", "edited": false, "createdAt": "2026-09-15T10:05:00.000Z", "updatedAt": "2026-09-15T10:05:00.000Z", "briefing": { "runId": "6a3950c06bda450a1372048e", "kind": "result", "editionNumber": 5, "completedAt": "2026-10-05T06:12:00.000Z" } } }

Errors

StatusCodeMeaning
400invalid_parameters · invalid_run_id · invalid_check_id · bad_request · paging_requires_compact_view · paging_requires_summary · nothing_to_returnBad request — the payload failed validation.
401api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scopeThe CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope.
403insufficient_scope · forbiddenThe key is not permitted to access this resource.
404not_foundNo ticket matches the id in the path.
429rate_limit_exceededRate limit exceeded — retry with backoff.

Every error uses the shared { error: { code, message, status } } envelope; code is one of the values listed above.

{ "error": { "code": "invalid_parameters", "message": "Domain is required", "status": 400 } }

Transient failures (429, 5xx) are safe to retry with exponential backoff.

Edit a ticket comment

PATCH/v1/projects/{projectId}/tickets/{ticketId}/comments/{commentId}

Rewrite an entry this API wrote. An entry a person wrote in the app is theirs to edit there, and rewriting it here answers 403 forbidden. The text is the only part of an entry anyone may change. Requires a read_write key.

Returns · { item }

Path parameters

NameTypeDescription
ticketIdstringTicket ID
commentIdstringComment ID
projectIdstringProject ID

Request body — application/json

FieldTypeDescription
bodystringThe entry’s text, in Markdown. At most 20000 characters. It is stored exactly as it is sent — leading whitespace is a code block in the dialect that draws it, so nothing is trimmed off the ends.

Request

curl -X PATCH "https://api.competlab.com/v1/projects/507f1f77bcf86cd799439011/tickets/507f1f77bcf86cd799439011/comments/507f1f77bcf86cd799439021" \ -H "CL-API-Key: YOUR_COMPETLAB_API_KEY" \ -H "Content-Type: application/json" \ -d '{"body":"Reached the editor on 14 Sep; they update the guide quarterly."}'

Response 200 OK

{ "item": { "id": "507f1f77bcf86cd799439021", "ticketId": "507f1f77bcf86cd799439011", "origin": "api", "author": { "userId": "507f1f77bcf86cd799439011", "fullName": "Dana Ellis" }, "body": "Reached the editor on 14 Sep; they update the guide quarterly.", "edited": false, "createdAt": "2026-09-15T10:05:00.000Z", "updatedAt": "2026-09-15T10:05:00.000Z", "briefing": { "runId": "6a3950c06bda450a1372048e", "kind": "result", "editionNumber": 5, "completedAt": "2026-10-05T06:12:00.000Z" } } }

Errors

StatusCodeMeaning
400invalid_parameters · invalid_run_id · invalid_check_id · bad_request · paging_requires_compact_view · paging_requires_summary · nothing_to_returnBad request — the payload failed validation.
401api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scopeThe CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope.
403insufficient_scope · forbiddenThe key is not permitted to access this resource.
404not_foundNo comment matches the id in the path.
429rate_limit_exceededRate limit exceeded — retry with backoff.

Every error uses the shared { error: { code, message, status } } envelope; code is one of the values listed above.

{ "error": { "code": "invalid_parameters", "message": "Domain is required", "status": 400 } }

Transient failures (429, 5xx) are safe to retry with exponential backoff.

Delete a ticket comment

DELETE/v1/projects/{projectId}/tickets/{ticketId}/comments/{commentId}

Delete an entry from a ticket’s thread. A key reaches any entry on the ticket, whoever wrote it: a key is issued by an owner or an admin and acts with that authority. Requires a read_write key.

Returns · { item }

Path parameters

NameTypeDescription
ticketIdstringTicket ID
commentIdstringComment ID
projectIdstringProject ID

Request

curl -X DELETE "https://api.competlab.com/v1/projects/507f1f77bcf86cd799439011/tickets/507f1f77bcf86cd799439011/comments/507f1f77bcf86cd799439021" \ -H "CL-API-Key: YOUR_COMPETLAB_API_KEY" \ -H "Content-Type: application/json" \ -d '{}'

Response 200 OK

{ "item": { "success": true, "id": "507f1f77bcf86cd799439011" } }

Errors

StatusCodeMeaning
400invalid_parameters · invalid_run_id · invalid_check_id · bad_request · paging_requires_compact_view · paging_requires_summary · nothing_to_returnBad request — the payload failed validation.
401api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scopeThe CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope.
403insufficient_scope · forbiddenThe key is not permitted to access this resource.
404not_foundNo comment matches the id in the path.
429rate_limit_exceededRate limit exceeded — retry with backoff.

Every error uses the shared { error: { code, message, status } } envelope; code is one of the values listed above.

{ "error": { "code": "invalid_parameters", "message": "Domain is required", "status": 400 } }

Transient failures (429, 5xx) are safe to retry with exponential backoff.

Last updated on