The REST API lets other tools read and change tasks in the projects you work on. Outgoing webhooks tell your systems when something happens.
- Base URL:
https://{your Taskshire host}/api/v1 - Format: JSON in, JSON out. Send
Accept: application/json. - Times: ISO 8601. Responses are always in UTC (
2026-10-05T14:30:00Z).
Authentication
Create a personal access token on your Profile page, under API tokens:
- Give it a name and choose Read only or Read and write.
- Choose when it expires: 30 days, 90 days, 6 months or a year. Every token expires, and a year is the longest you can choose. Create a new token before the old one runs out; an expired token gets
401 unauthenticated, just like a revoked one. - Confirm your password. Creating and revoking tokens is recorded in your account's activity.
- The token is shown once. Store it somewhere safe.
- You can hold up to 20 tokens at once.
Send the token with every request:
Authorization: Bearer 12|tsk_…
A token acts as you. It has your roles on every project you belong to, and every change goes through the same permission checks as the app. For example, if your role can't reassign tasks, the API can't either.
| Ability | Allows |
|---|---|
read |
Every GET endpoint |
write |
Every other method (POST, PATCH, DELETE) |
Some rules to know:
- A browser session never authenticates the API. Only tokens do.
- Tokens prefixed
tsk_can be found by secret scanners. - Revoking a token on the profile page stops it straight away.
- Last used shows when a token last made a request.
Rate limit
Each person can make 120 requests a minute. The limit is per person, not per token: two tokens owned by the same person share the same 120 requests, and replays of idempotent writes count too. Above that you get 429 with a Retry-After header.
Conventions
Resources
Each resource has this shape:
{
"type": "task",
"id": "9b2f…",
"attributes": { "…": "…" },
"links": { "self": "…", "web": "…" }
}
- A single resource comes back as
{"data": {…}}. - A list comes back as
{"data": […], "links": {…}, "meta": {…}}.
Errors
Every error has the same shape:
{
"error": {
"code": "validation_failed",
"message": "Give the ticket a title of up to 255 characters.",
"details": { "fields": { "title": ["Give the ticket a title of up to 255 characters."] } }
}
}
| Status | code |
When |
|---|---|---|
| 401 | unauthenticated |
Missing, wrong or expired token |
| 403 | forbidden |
The token lacks the ability, or your role can't do this |
| 404 | not_found |
The item doesn't exist, or you can't see it (the API never confirms either) |
| 409 | stale_caption |
Someone saved the caption since you read it |
| 409 | idempotency_in_progress |
A request with the same Idempotency-Key is still running |
| 412 | precondition_failed |
If-Match didn't match the task's current ETag |
| 422 | validation_failed, unknown_fields, transition_refused, caption_too_long, invalid_sort |
The request can't be applied |
| 422 | idempotency_key_reused, invalid_idempotency_key |
The Idempotency-Key was used with a different body, or is longer than 255 characters |
| 423 | content_locked |
The copy is locked in this stage, or the channel has published |
| 429 | rate_limited |
Too many requests |
Pagination
Task and time-entry lists use cursors:
- Set the page size with
per_page(1 to 100, default 25). - Follow
links.nextuntil it'snull. - Cursors keep working while tasks are added, so you won't skip or repeat a task.
The pages list (GET /projects/{project}/docs) uses plain page numbers instead: per_page (1 to 100, default 50) and page, with meta.total and meta.has_more telling you whether to ask for the next page.
Retrying writes safely
Networks fail. To retry a POST, PATCH, PUT or DELETE without doing it twice, send an Idempotency-Key header: any string of 1 to 255 characters that is unique to that one operation (a UUID is ideal).
- The first request runs as normal. Its response (any status below 500) is kept for 24 hours.
- A later request with the same token, method, path and key gets that stored response back, with the header
Idempotent-Replayed: true. Nothing runs again. - The same key with a different body is refused with
422 idempotency_key_reused. Use a fresh key for each new operation. - If the first request is still running, a duplicate gets
409 idempotency_in_progresswithRetry-After: 2. - Keys are scoped to the token that sent them.
GETrequests ignore the header. - Responses of 500 and above aren't kept, so a retry after a server error runs the request again.
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1d2c8e-5b1a-4f0e-9c3d-2a7b8e9f0a11" \
https://app.example.com/api/v1/projects/dark-queen/tasks -d '{"title": "Launch trailer"}'
Tasks by id or reference
Wherever a path has {task}, you can use the task's id, its reference (DQS-142) or just its number (142). Wherever a path has {project}, you can use the project's id or its slug.
Endpoints
You and your projects
GET /me
GET /projects?include_archived=1
GET /projects/{project}
GET /projects/{project}/stages
GET /projects/{project}/members
GET /projects/{project}/labels
/projectslists projects where you hold an internal role. Client reviewers use their approval links instead./membersreturns each accepted member with their roles.
Tasks
GET /projects/{project}/tasks
POST /projects/{project}/tasks
GET /projects/{project}/tasks/{task}
PATCH /projects/{project}/tasks/{task}
DELETE /projects/{project}/tasks/{task}
POST /projects/{project}/tasks/{task}/archive
POST /projects/{project}/tasks/{task}/restore
POST /projects/{project}/tasks/{task}/move
List filters
All filters are optional and can be combined.
| Parameter | Example | Meaning |
|---|---|---|
stage |
drafting,internal_review |
Stage ids or slugs |
assignee |
me, none, 42 |
Who it's assigned to |
label |
Launch or a label id |
Labels, by id or name (has any of them) |
priority |
high,urgent |
none, low, medium, high or urgent |
due_after, due_before |
2026-11-01 |
A date or ISO time. Dates are in your timezone; due_before includes the whole day. |
updated_since |
2026-10-05T09:00:00Z |
Changed since this time (useful for syncing) |
q |
trailer or DQS-142 |
Title contains the text, or matches the reference |
archived |
exclude (default), include, only |
Whether to include archived tasks |
sort |
-created_at (default), created_at, -updated_at, updated_at |
Order. A leading - means newest first. |
curl -H "Authorization: Bearer $TOKEN" -H "Accept: application/json" \
"https://app.example.com/api/v1/projects/dark-queen/tasks?stage=internal_review&assignee=me&per_page=50"
Show
The full task includes:
- the brief, as HTML and as plain text;
- watchers;
- channels and their captions, with caption versions;
- the checklist and its progress;
- custom fields.
Responses carry an ETag. Send it back as If-None-Match to get 304 Not Modified when nothing has changed.
Create
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
https://app.example.com/api/v1/projects/dark-queen/tasks -d '{
"title": "Launch trailer",
"brief": "<p>Cut a 30-second trailer.</p>",
"assignee_id": 42,
"priority": "high",
"due_at": "2026-11-01",
"label_ids": ["…"],
"channel_ids": ["…"],
"checklist": ["Storyboard", "Edit"],
"custom_values": {"<field id>": 1200}
}'
- New tasks start in the first stage.
- The response is
201 Created, with aLocationheader. - Accepted fields:
title(required),brief,assignee_id,campaign_id,scheduled_for,due_at,start_at,priority,channel_ids,label_ids,watcher_ids,checklist,custom_values. - Unknown fields are refused with
unknown_fields.
Update
Send only the fields you want to change:
title,brief,priority,due_at,start_at,campaign_id,label_ids(replaces the labels) need the draft permission;assignee_idneeds the assign or reassign permission;scheduled_forneeds the reschedule permission.
To make an update conditional, send the ETag you read as If-Match.
curl -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H 'If-Match: "3f1c…"' \
https://app.example.com/api/v1/projects/dark-queen/tasks/DQS-142 -d '{"priority": "urgent"}'
Move
POST /projects/{project}/tasks/{task}/move
{ "stage": "internal_review", "comment": "Ready for a look", "publish_now": false }
stageis a stage id or slug.- The workflow decides whether the move is allowed, just as on the board.
- Some moves need a comment, such as requesting revisions or rejecting.
- A refusal returns
422 transition_refusedwith the reason and the two stages:
{"error": {"code": "transition_refused", "message": "Cannot move from Internal review to Scheduled.",
"details": {"from_stage": {"slug": "internal_review", …}, "to_stage": {"slug": "scheduled", …}}}}
Archive, restore and delete
archiveandrestoreneed the cancel or configure permission.DELETEmoves the task to the bin. A project admin can restore it in the app.
Comments
GET /projects/{project}/tasks/{task}/comments
POST /projects/{project}/tasks/{task}/comments
- The list shows internal comments only to roles that can read them. Other roles see only comments shared with the client.
- Post a comment with
{"body": "…", "visibility": "internal" | "shared", "parent_id": "…"}. - A reply takes its thread's visibility.
- To mention someone, write
@[Name](user:42).
Captions
GET /projects/{project}/tasks/{task}/captions
PATCH /projects/{project}/tasks/{task}/captions/{caption}
{ "caption": "New copy", "expected_version": 3 }
Captions use optimistic locking. Send the version you read as expected_version.
- If someone saved since, you get
409 stale_caption.detailsholdscurrent_caption,current_versionandedited_by. - Captions over the platform's limit get
422 caption_too_long. - Locked copy gets
423 content_locked.
Checklist
GET /projects/{project}/tasks/{task}/checklist
POST /projects/{project}/tasks/{task}/checklist {"body": "…", "assignee_id": 42, "due_at": "2026-11-01"}
PATCH /projects/{project}/tasks/{task}/checklist/{item} {"body": "…", "done": true}
DELETE /projects/{project}/tasks/{task}/checklist/{item}
Subtasks and dependencies
Task objects include parent_id, subtask_counts ({done, total}), blocked and estimate_minutes.
GET /projects/{project}/tasks/{task}/subtasks
POST /projects/{project}/tasks/{task}/subtasks same body as creating a task, without board_id
GET /projects/{project}/tasks/{task}/dependencies
POST /projects/{project}/tasks/{task}/dependencies {"blocker_id": "<task id or reference such as DQS-142>"}
DELETE /projects/{project}/tasks/{task}/dependencies/{blocker}
A subtask lives on its parent's board and starts in that board's first stage. Subtasks are one level deep. Loops, self-links and more than 20 blockers return 422 invalid_dependency; a parent that is itself a subtask returns 422 structure_refused.
Time tracking
People see and change their own time; project admins see everyone's, plus rate and amount (minor units).
| Method | Path | Ability | Notes |
|---|---|---|---|
| GET | /projects/{project}/time-entries | read | Filters: from, to (YYYY-MM-DD), user_id (admins), task (id or reference), label_id, campaign_id, billable (yes/no), per_page (max 100). Cursor-paginated, newest first. |
| POST | /projects/{project}/time-entries | write | started_at (ISO 8601), minutes or duration ("1h 30m"), optional task, note, billable, confirm_overlap. 201. |
| PATCH | /projects/{project}/time-entries/{entry} | write | Any of task, started_at, ended_at, minutes/duration, note, billable, confirm_overlap. |
| DELETE | /projects/{project}/time-entries/{entry} | write | 204. |
| GET | /timer | read | Your running timer, or data: null. |
| POST | /projects/{project}/timer/start | write | Optional task, note. Stops any other timer you have; returns data and stopped. 201. |
| POST | /timer/stop | write | 409 no_timer_running if none. |
| GET | /projects/{project}/time-summary | read | Same filters, plus group_by (person, task, label, campaign, week). Returns totals, groups, budget (admins). |
Errors: 409 overlapping_time (resend with "confirm_overlap": true), 403 forbidden for locked entries you cannot change.
Pages and project overview
| Method | Path | Ability | Notes |
|---|---|---|---|
| GET | /projects/{project}/docs | read | q search; per_page (1–100, default 50), page; returns data[], meta{total,per_page,page,has_more} |
| GET | /projects/{project}/docs/{doc} | read | body_html, body_text, lock_version; ?format=md returns text/markdown |
| POST | /projects/{project}/docs | write | title, icon, parent_id, template, body (HTML, sanitised). 201. |
| PATCH | /projects/{project}/docs/{doc} | write | title, icon, body, lock_version; a stale lock_version returns 409 conflict |
| GET | /projects/{project}/overview | read | overview, goals, client_contacts, links |
| PATCH | /projects/{project}/overview | write | Project admins. Max 10 contacts, 12 links, https links only. |
Webhooks
Project admins add webhooks in Project tools → Webhooks (also linked from Project settings → General and from Integrations).
Events
| Event | Sent when |
|---|---|
task.created |
A task is created: by hand, by a request form, by the API or by an automation |
task.updated |
Title, brief, assignee, dates, labels, priority or campaign change. data.changes holds {field: {from, to}}. |
task.moved |
A task changes stage. Includes data.from_stage and data.to_stage. |
task.commented |
A comment is posted. Includes data.comment, with its visibility. |
task.published |
A task reaches a published stage |
task.deleted |
A task is deleted |
task.archived |
A task is archived; data.actor_id says who. |
task.restored |
A task comes back from the archive or the bin; data.from is archive or bin. |
webhook.test |
You press Send test |
Request
Each delivery is a POST with a JSON body:
{
"id": "0b6c…",
"event": "task.moved",
"created_at": "2026-10-05T14:30:00Z",
"project": {"id": "…", "slug": "dark-queen", "name": "Dark Queen"},
"data": {
"task": {"type": "task", "id": "…", "attributes": {"reference": "DQS-142", "title": "…", "stage": {…}, …}, "links": {…}},
"from_stage": {"id": "…", "slug": "drafting", "name": "Work In Progress"},
"to_stage": {"id": "…", "slug": "internal_review", "name": "Internal review"},
"actor_id": 42
}
}
It comes with these headers:
Content-Type: application/json
User-Agent: Taskshire-Webhooks/1.0
X-Taskshire-Event: task.moved
X-Taskshire-Delivery: 0b6c… (same as body.id; use it to drop duplicates)
X-Taskshire-Signature: t=1760000000,v1=5d0a…
Verifying the signature
v1 is the hex HMAC-SHA256 of "{t}.{raw request body}", keyed with the endpoint's signing secret (whsec_…). To check it:
- Recompute
v1over the raw body. - Compare the two values in constant time.
- Reject any request where
tis more than five minutes from now.
[$t, $v1] = sscanf($_SERVER['HTTP_X_TASKSHIRE_SIGNATURE'], 't=%d,v1=%s');
$expected = hash_hmac('sha256', $t.'.'.file_get_contents('php://input'), $secret);
$valid = hash_equals($expected, $v1) && abs(time() - $t) <= 300;
const [, t, v1] = req.headers['x-taskshire-signature'].match(/t=(\d+),v1=([a-f0-9]+)/);
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const valid = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1)) && Math.abs(Date.now() / 1000 - t) <= 300;
t, v1 = (p.split("=", 1)[1] for p in header.split(","))
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
valid = hmac.compare_digest(expected, v1) and abs(time.time() - int(t)) <= 300
You can create a new secret at any time with New secret. The old one stops signing straight away.
Delivery, retries and switching off
- Any
2xxresponse counts as delivered. Redirects aren't followed. - Each request waits up to 10 seconds for a response.
- Failed deliveries are retried after 30 seconds, then 1, 2, 4, 8, 16 and 32 minutes, up to 8 attempts in total.
- After 20 failed attempts in a row, the webhook is switched off. Project admins get a notice in their inbox, and by email if they get emails.
- Turn the webhook back on in settings once the endpoint is fixed.
- The delivery log keeps 30 days of deliveries. For each one it shows the payload, the response code and an excerpt of the response. Redeliver sends a delivery again as a new delivery, with
redelivery_ofset.
Allowed addresses
Webhook addresses must be public https:// URLs on port 443 or a port above 1023. They can't contain a username or password.
Addresses that resolve to loopback, private, link-local (including cloud metadata), carrier-grade NAT or reserved ranges are refused. This is checked:
- when you save the webhook;
- again before every delivery, with the connection pinned to the address that passed the check.
Automations and request forms
These have no API endpoints yet. Tasks they create or change still show up through the task endpoints and webhooks:
- Automations act as the project admin who last saved them.
- Request forms create tasks as the form's owner.