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
/v1/projects/{projectId}/tickets/labelsList 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.
Path parameters
| Name | Type | Description |
|---|---|---|
projectId | string | Project ID |
Request
curl "https://api.competlab.com/v1/projects/507f1f77bcf86cd799439011/tickets/labels" \
-H "CL-API-Key: YOUR_COMPETLAB_API_KEY"Response 200 OK
Example
{
"items": [
{
"id": "3f2a9c6e-1b77-4c1e-9a54-5c7b2f0a91d2",
"name": "Pricing page",
"color": "#3b82f6"
}
]
}Errors
| Status | Code | Meaning |
|---|---|---|
| 401 | api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scope | The CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope. |
| 402 | subscription_required | Request failed. |
| 429 | rate_limit_exceeded | Rate 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
/v1/projects/{projectId}/tickets/labelsAdd 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.
Path parameters
| Name | Type | Description |
|---|---|---|
projectId | string | Project ID |
Request body — application/json
| Field | Type | Description |
|---|---|---|
name | string | The label’s name. Unique within the project, compared without regard to case, at most 40 characters. A project holds at most 50 labels. |
color | string | The 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
Example
{
"items": [
{
"id": "3f2a9c6e-1b77-4c1e-9a54-5c7b2f0a91d2",
"name": "Pricing page",
"color": "#3b82f6"
}
]
}Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_parameters · invalid_run_id · invalid_check_id · bad_request | Bad request — the payload failed validation. |
| 401 | api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scope | The CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope. |
| 402 | subscription_required | Request failed. |
| 403 | insufficient_scope · forbidden | The key is not permitted to access this resource. |
| 429 | rate_limit_exceeded | Rate 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
/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.
Path parameters
| Name | Type | Description |
|---|---|---|
labelId | string | Label ID, as the label list returns it |
projectId | string | Project ID |
Request body — application/json
| Field | Type | Description |
|---|---|---|
name | string | The label’s name. Unique within the project, compared without regard to case, at most 40 characters. A project holds at most 50 labels. |
color | string | The 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
Example
{
"items": [
{
"id": "3f2a9c6e-1b77-4c1e-9a54-5c7b2f0a91d2",
"name": "Pricing page",
"color": "#3b82f6"
}
]
}Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_parameters · invalid_run_id · invalid_check_id · bad_request | Bad request — the payload failed validation. |
| 401 | api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scope | The CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope. |
| 402 | subscription_required | Request failed. |
| 403 | insufficient_scope · forbidden | The key is not permitted to access this resource. |
| 404 | project_not_found | No label matches the id in the path. |
| 429 | rate_limit_exceeded | Rate 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
/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.
Path parameters
| Name | Type | Description |
|---|---|---|
labelId | string | Label ID, as the label list returns it |
projectId | string | Project 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
Example
{
"items": [
{
"id": "3f2a9c6e-1b77-4c1e-9a54-5c7b2f0a91d2",
"name": "Pricing page",
"color": "#3b82f6"
}
]
}Errors
| Status | Code | Meaning |
|---|---|---|
| 401 | api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scope | The CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope. |
| 402 | subscription_required | Request failed. |
| 403 | insufficient_scope · forbidden | The key is not permitted to access this resource. |
| 404 | project_not_found | No label matches the id in the path. |
| 429 | rate_limit_exceeded | Rate 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
/v1/projects/{projectId}/tickets/assigneesList 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.
Path parameters
| Name | Type | Description |
|---|---|---|
projectId | string | Project ID |
Request
curl "https://api.competlab.com/v1/projects/507f1f77bcf86cd799439011/tickets/assignees" \
-H "CL-API-Key: YOUR_COMPETLAB_API_KEY"Response 200 OK
Example
{
"items": [
{
"userId": "507f1f77bcf86cd799439011",
"fullName": "Dana Ellis"
}
]
}Errors
| Status | Code | Meaning |
|---|---|---|
| 401 | api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scope | The CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope. |
| 402 | subscription_required | Request failed. |
| 429 | rate_limit_exceeded | Rate 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
/v1/projects/{projectId}/ticketsList the project’s Strategic Tickets in one flat list — the work the team has taken on, whether a person opened it or your own automation did. Order is the board’s: the columns in board order, and inside each column the order the team put them in. A ticket carries no position of its own on this API, so keep the order you receive. Pass status to read one column. done and dismissed grow without bound, so by default they return only the tickets that reached them in the last 30 days and hasMore is true when there are more; closed=all returns them whole. origin narrows the list to what one source opened, and dimension and briefingRunId to what a Strategic Briefing opened — origin=briefing&briefingRunId={runId} is one edition’s tickets, with {runId} taken from GET /v1/projects/{projectId}/strategic-briefing/history. One edition’s tickets are returned whole, whatever closed says, so the list matches the count the briefing states for it. total is the whole count of the columns this call covers, narrowed the same way — quote that, not the length of items.
Path parameters
| Name | Type | Description |
|---|---|---|
projectId | string | Project ID |
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
status | array<string> | Return only the tickets in these columns — one, or several separated by commas (status=todo,in_progress). Omit it for every column, in board order. | |
closed | string | How much of done and dismissed to return. recent (the default) returns the tickets that reached those columns within the last 30 days; all returns them whole. The other columns are always whole, whichever you pass. Values: recent, all. | |
origin | string | Return only the tickets one source opened: user — a person working in the app; api — an API key, which is what everything you write through this API carries; briefing or ai_sources — CompetLab itself. No part of CompetLab opens ai_sources tickets today, so that value matches none. Values: user, api, briefing, ai_sources. | |
dimension | string | Return only the tickets a Strategic Briefing opened for one part of its analysis. Nothing a person or an API key opens carries a dimension, so this narrows the list to briefing tickets whether or not you also pass origin. 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 14 values (e.g. ai-visibility, ai-sources, positioning, …). | |
briefingRunId | string | Return 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. | |
assignee | string | Return 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. | |
labelId | string | Return only the tickets carrying one label — its ID, from GET /v1/projects/{projectId}/tickets/labels. | |
number | number | Return 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. | |
q | string | Return only the tickets whose TITLE contains this text, compared without regard to case. It is matched literally — punctuation is text, not a pattern — and descriptions and comments are not searched. | |
include | array<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 returned as an empty string. Pass include=description for the bodies — one ticket’s description runs to thousands of characters, so a whole board 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?status=todo&closed=recent&origin=briefing&dimension=pricing&briefingRunId=6a3950c06bda450a1372048e&assignee=507f1f77bcf86cd799439031&labelId=3f2a9c6e-1b77-4c1e-9a54-5c7b2f0a91d2&number=14&q=pricing%20page&include=description" \
-H "CL-API-Key: YOUR_COMPETLAB_API_KEY"Response 200 OK
Example
{
"items": [
{
"id": "507f1f77bcf86cd799439011",
"number": 14,
"origin": "api",
"author": {
"userId": "507f1f77bcf86cd799439011",
"fullName": "Dana Ellis"
},
"briefing": {
"runId": "6a3950c06bda450a1372048e",
"dimension": "pricing",
"estimatedMinutes": 90
},
"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"
}
],
"total": 42,
"hasMore": false
}Errors
| Status | Code | Meaning |
|---|---|---|
| 401 | api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scope | The CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope. |
| 402 | subscription_required | Request failed. |
| 429 | rate_limit_exceeded | Rate 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
/v1/projects/{projectId}/ticketsOpen 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.
Path parameters
| Name | Type | Description |
|---|---|---|
projectId | string | Project ID |
Request body — application/json
| Field | Type | Description |
|---|---|---|
title | string | The ticket’s title. |
description | string | The ticket’s description, in Markdown. At most 20000 characters. |
status | string | The 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. |
labelIds | array<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. |
assigneeUserId | string | The 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. |
dueDate | string | The day the ticket is due, as YYYY-MM-DD — a calendar day, with no clock and no time zone. |
effort | string | How much work the ticket is. Values: low, medium, high. |
impact | number | How much the ticket matters, from 1 to 4: 1 Minor · 2 Moderate · 3 Significant · 4 Critical. |
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
Example
{
"item": {
"id": "507f1f77bcf86cd799439011",
"number": 14,
"origin": "api",
"author": {
"userId": "507f1f77bcf86cd799439011",
"fullName": "Dana Ellis"
},
"briefing": {
"runId": "6a3950c06bda450a1372048e",
"dimension": "pricing",
"estimatedMinutes": 90
},
"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
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_parameters · invalid_run_id · invalid_check_id · bad_request | Bad request — the payload failed validation. |
| 401 | api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scope | The CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope. |
| 402 | subscription_required | Request failed. |
| 403 | insufficient_scope · forbidden | The key is not permitted to access this resource. |
| 429 | rate_limit_exceeded | Rate 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
/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.
Path parameters
| Name | Type | Description |
|---|---|---|
ticketId | string | Ticket ID |
projectId | string | Project ID |
Request
curl "https://api.competlab.com/v1/projects/507f1f77bcf86cd799439011/tickets/507f1f77bcf86cd799439011" \
-H "CL-API-Key: YOUR_COMPETLAB_API_KEY"Response 200 OK
Example
{
"item": {
"id": "507f1f77bcf86cd799439011",
"number": 14,
"origin": "api",
"author": {
"userId": "507f1f77bcf86cd799439011",
"fullName": "Dana Ellis"
},
"briefing": {
"runId": "6a3950c06bda450a1372048e",
"dimension": "pricing",
"estimatedMinutes": 90
},
"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
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_parameters · invalid_run_id · invalid_check_id · bad_request | Bad request — the payload failed validation. |
| 401 | api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scope | The CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope. |
| 402 | subscription_required | Request failed. |
| 404 | project_not_found | No ticket matches the id in the path. |
| 429 | rate_limit_exceeded | Rate 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
/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.
Path parameters
| Name | Type | Description |
|---|---|---|
ticketId | string | Ticket ID |
projectId | string | Project ID |
Request body — application/json
| Field | Type | Description |
|---|---|---|
title | string | The ticket’s title. |
description | string | The ticket’s description, in Markdown. At most 20000 characters. Send "" to clear it. |
labelIds | array<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. |
assigneeUserId | string | null | The 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. |
dueDate | string | null | The 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. |
effort | string | null | How much work the ticket is. Send null to take it off. Values: low, medium, high. Nullable. |
impact | number | null | How much the ticket matters, from 1 to 4: 1 Minor · 2 Moderate · 3 Significant · 4 Critical. 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
Example
{
"item": {
"id": "507f1f77bcf86cd799439011",
"number": 14,
"origin": "api",
"author": {
"userId": "507f1f77bcf86cd799439011",
"fullName": "Dana Ellis"
},
"briefing": {
"runId": "6a3950c06bda450a1372048e",
"dimension": "pricing",
"estimatedMinutes": 90
},
"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
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_parameters · invalid_run_id · invalid_check_id · bad_request | Bad request — the payload failed validation. |
| 401 | api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scope | The CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope. |
| 402 | subscription_required | Request failed. |
| 403 | insufficient_scope · forbidden | The key is not permitted to access this resource. |
| 404 | project_not_found | No ticket matches the id in the path. |
| 429 | rate_limit_exceeded | Rate 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
/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.
Path parameters
| Name | Type | Description |
|---|---|---|
ticketId | string | Ticket ID |
projectId | string | Project 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
Example
{
"item": {
"success": true,
"id": "507f1f77bcf86cd799439011"
}
}Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_parameters · invalid_run_id · invalid_check_id · bad_request | Bad request — the payload failed validation. |
| 401 | api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scope | The CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope. |
| 402 | subscription_required | Request failed. |
| 403 | insufficient_scope · forbidden | The key is not permitted to access this resource. |
| 404 | project_not_found | No ticket matches the id in the path. |
| 429 | rate_limit_exceeded | Rate 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
/v1/projects/{projectId}/tickets/{ticketId}/moveMove a ticket to another column, or reorder it inside the one it is in. Name the destination column and the two tickets it will sit between: beforeId is the one directly above it, afterId the one directly below. Name either or both. To put a ticket at the top of a column, send the ticket currently first there as afterId and no beforeId; omitting both puts it at the bottom — which is the column’s first card when the column is empty. There is no position and 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 since moved or been deleted is ignored and the ticket lands at the nearest place that is still true: a move never fails because your view of the board was a moment out of date. Reordering inside a column leaves statusChangedAt alone. Requires a read_write key.
Path parameters
| Name | Type | Description |
|---|---|---|
ticketId | string | Ticket ID |
projectId | string | Project ID |
Request body — application/json
| Field | Type | Description |
|---|---|---|
status | string | The 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. |
beforeId | string | The 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. |
afterId | string | The 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. |
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"}'Response 200 OK
Example
{
"item": {
"id": "507f1f77bcf86cd799439011",
"number": 14,
"origin": "api",
"author": {
"userId": "507f1f77bcf86cd799439011",
"fullName": "Dana Ellis"
},
"briefing": {
"runId": "6a3950c06bda450a1372048e",
"dimension": "pricing",
"estimatedMinutes": 90
},
"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
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_parameters · invalid_run_id · invalid_check_id · bad_request | Bad request — the payload failed validation. |
| 401 | api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scope | The CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope. |
| 402 | subscription_required | Request failed. |
| 403 | insufficient_scope · forbidden | The key is not permitted to access this resource. |
| 404 | project_not_found | No ticket matches the id in the path. |
| 429 | rate_limit_exceeded | Rate 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
/v1/projects/{projectId}/tickets/{ticketId}/commentsList 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.
Path parameters
| Name | Type | Description |
|---|---|---|
ticketId | string | Ticket ID |
projectId | string | Project ID |
Request
curl "https://api.competlab.com/v1/projects/507f1f77bcf86cd799439011/tickets/507f1f77bcf86cd799439011/comments" \
-H "CL-API-Key: YOUR_COMPETLAB_API_KEY"Response 200 OK
Example
{
"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"
}
]
}Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_parameters · invalid_run_id · invalid_check_id · bad_request | Bad request — the payload failed validation. |
| 401 | api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scope | The CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope. |
| 402 | subscription_required | Request failed. |
| 404 | project_not_found | No ticket matches the id in the path. |
| 429 | rate_limit_exceeded | Rate 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
/v1/projects/{projectId}/tickets/{ticketId}/commentsAdd 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.
Path parameters
| Name | Type | Description |
|---|---|---|
ticketId | string | Ticket ID |
projectId | string | Project ID |
Request body — application/json
| Field | Type | Description |
|---|---|---|
body | string | The 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
Example
{
"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"
}
}Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_parameters · invalid_run_id · invalid_check_id · bad_request | Bad request — the payload failed validation. |
| 401 | api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scope | The CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope. |
| 402 | subscription_required | Request failed. |
| 403 | insufficient_scope · forbidden | The key is not permitted to access this resource. |
| 404 | project_not_found | No ticket matches the id in the path. |
| 429 | rate_limit_exceeded | Rate 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
/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.
Path parameters
| Name | Type | Description |
|---|---|---|
ticketId | string | Ticket ID |
commentId | string | Comment ID |
projectId | string | Project ID |
Request body — application/json
| Field | Type | Description |
|---|---|---|
body | string | The 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
Example
{
"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"
}
}Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_parameters · invalid_run_id · invalid_check_id · bad_request | Bad request — the payload failed validation. |
| 401 | api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scope | The CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope. |
| 402 | subscription_required | Request failed. |
| 403 | insufficient_scope · forbidden | The key is not permitted to access this resource. |
| 404 | project_not_found | No comment matches the id in the path. |
| 429 | rate_limit_exceeded | Rate 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
/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.
Path parameters
| Name | Type | Description |
|---|---|---|
ticketId | string | Ticket ID |
commentId | string | Comment ID |
projectId | string | Project 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
Example
{
"item": {
"success": true,
"id": "507f1f77bcf86cd799439011"
}
}Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_parameters · invalid_run_id · invalid_check_id · bad_request | Bad request — the payload failed validation. |
| 401 | api_key_missing · api_key_invalid · api_key_revoked · api_key_expired · insufficient_scope | The CL-API-Key is missing, malformed, revoked, expired, or lacks the required scope. |
| 402 | subscription_required | Request failed. |
| 403 | insufficient_scope · forbidden | The key is not permitted to access this resource. |
| 404 | project_not_found | No comment matches the id in the path. |
| 429 | rate_limit_exceeded | Rate 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.