Tasks
Create, read, and drive a task through its whole lifecycle.
A task is the unit of work in Cadence. It is addressed by a KEY-N id — the
space key, a hyphen, and a number, such as OPS-42 — which never changes once
the task is created.
This page is the complete reference for the task lifecycle. Each capability is
reachable on one or more surfaces: the HTTP API (/api/v1), MCP tools, and the
web interface. Where a capability is not on /api/v1, that is stated. See
Authentication for the role each level needs;
creating and changing tasks needs member, and forcing a transition needs
admin.
Replace https://cadence.example.com with your instance's address.
Create a task
POST /api/v1/spaces/:space_key/tasks; MCP tasks_create. Needs member.
A new task lands on a workflow's default stage. Over MCP you must name the
workflow, with workflow_slug. Over the API you may name it with workflow;
without workflow, the task lands on the default stage of the space's first active
workflow.
Fields:
| Field | Notes |
|---|---|
title | Required. |
body | Free text. |
priority | One of P0, P1, P2, P3. Defaults to P2. |
sequence_label | One or two capital letters and a number, such as A1, B2, C10. |
due_at | A date-time. |
workflow | API only: the slug of a workflow in the space. The task lands on that workflow's default stage. Over MCP, the required workflow_slug does the same. Over the API, without workflow, the task lands on the default stage of the space's first active workflow. |
task_type | A task-type slug in the space. Left unset, it defaults to the named workflow's default type, then the space's default. With no workflow named, it defaults to the space's default type. |
tags | A list of tag names in the space. |
parent_task_key | The KEY-N id of a parent task in the same space. |
color | One of gray, red, orange, amber, yellow, green, teal, blue, indigo, purple, pink. |
afk | Marks the task as autonomous (unattended). Boolean; defaults to false. Rules can be scoped to this flag — see Stage rules. |
Over the API, a create call must carry an Idempotency-Key header, so a
retried request cannot create a second task:
curl -X POST https://cadence.example.com/api/v1/spaces/OPS/tasks \
-H "Authorization: Bearer smc_your_token_here" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1e-first-task" \
-d '{ "title": "Ship the release notes", "priority": "P1" }'The response is the created task:
{
"task": {
"key": "OPS-42",
"space": "OPS",
"title": "Ship the release notes",
"priority": "P1",
"stage": "Backlog",
"workflow": { "slug": "delivery", "name": "Delivery" },
"gate_status": "open"
}
}The gate_status field is open or blocked. A task reads blocked when it is
on hold, when a required check at its current stage has not passed, or when a
dependency that blocks at its current stage is unresolved; otherwise it reads
open. Cadence derives this field when the task is read — you cannot set it — and
you can filter tasks_list by it.
Naming a workflow over the API that the task cannot land on is refused with 422
and invalid_request, with one of these messages:
unknown workflow— no workflow in the space has that slug.workflow is archivedworkflow has no stages— every stage of the workflow is archived, or it has none.
{ "error": { "code": "invalid_request", "message": "unknown workflow" } }A create with no Idempotency-Key header is refused with 422 and
missing_idempotency_key. Reusing a key with a different request body is refused
with 422 and idempotency_fingerprint_mismatch; the same key with the same
body replays the first result rather than creating again.
Read a task
GET /api/v1/tasks/:key; MCP tasks_get; the task's page
in the browser. Needs read.
curl https://cadence.example.com/api/v1/tasks/OPS-42 \
-H "Authorization: Bearer smc_your_token_here"The task carries its key, title, body, priority, stage, stage_group,
workflow, task_type, assignee, participants, tags, timestamps, parent,
gate_status, and the hold fields hold_reason and hold_kind. Both hold
fields are always present: when the task is not held they are null, and when
it is held hold_reason carries the reason and hold_kind is the effective kind
(blocking or advisory, defaulting to blocking if none was recorded). The
tasks_get tool additionally returns attachments_count. A missing task returns
404 and task_not_found.
List tasks
GET /api/v1/spaces/:space_key/tasks; MCP tasks_list. Needs read.
Filter with any of archived (true, false, or all; defaults to false),
resolved, priority, gate_status, stage, stage_group, assignee
(none or user:<id>), tags, updated_since, blocked, parent,
has_children, and afk. Results are newest-first, in pages of up to 50 (200
maximum); follow next_cursor with the cursor filter to read the next page.
{ "tasks": [ … ], "next_cursor": "…" }An invalid filter value is refused with 422 and code invalid_filter, naming
the filter at fault. Over MCP, passing limit: 0 returns a summary of the
matching tasks — a total count, and breakdowns by_stage and by_assignee —
instead of the rows.
The blocked filter
blocked=true returns the tasks that are held up in some way; blocked=false
returns the rest. Reachable with read. A task counts as blocked when any
of these holds:
- It is on hold — it carries a hold reason, of either kind.
- A required check at its current stage has not passed.
- A lease for it is held
pendingat a capacity ceiling — the pool, space, or instance is full — and none of its leases is running. A lease that is merely booting or waiting on the runtime does not count here; only a lease held at a genuine capacity ceiling does. This is the same condition the board card shows as its Awaiting capacity badge, and the samependingwait a lease's card chip names (see how a queued lease reads on its board card). - It has an uncleared dependency edge — a blocker that has not reached the state the edge waits for. This counts whether the edge blocks the task now or only from a later stage; a cleared edge does not count.
- It is waiting on a WIP slot — a workflow WIP constraint is holding it out of the capped stages.
On the board card, the same states read as badges, and two are worth calling out:
- Awaiting capacity now means only a genuine capacity ceiling — a lease held
pendingbecause the pool, space, or instance is full. A lease that is only booting, waiting on the runtime, or awaiting its agent no longer reads as awaiting capacity; its own lease chip names that wait instead. - Waiting · Blocks from <stage> flags an uncleared blocker whose edge does not block the task at its current stage yet — it will start blocking at the named stage. It stops nothing today, so it is shown only so you can tell a task with an outstanding blocker ahead from one with none.
Search tasks
MCP tasks_search. Needs read. There is no /api/v1 route for task search;
for a combined tasks-and-docs search over HTTP, see Search.
Pass space_key, the query as q, and optionally limit (1 to 100, default
25). The result is { "tasks": [ … ] }. Each hit is the task, with its ref, a
snippet and a rank.
- If the whole query is a task's
KEY-Nid, such asOPS-42, that task comes first. Itssnippetisnull, it is not repeated further down, and it takes one of thelimitplaces. The key must belong to a task in the space you are searching. - Every other hit is a task whose title or body matches. It carries a plain-text
snippetwith the matched words wrapped in<mark>, and the hits are ordered byrank, highest first. A task that matched only on its title has no<mark>in its snippet.
Archived tasks are never returned. When a worker's lease calls
tasks_search, it sees only the tasks its scope covers. See
Leases § What a lease can reach.
The last word of the query matches as a prefix, and a query shorter than 2 characters returns nothing. For the full matching rules, see Search.
Update a task
PATCH /api/v1/tasks/:key; MCP tasks_update; web per-field edit. Needs
member.
The editable fields include title, body, priority, sequence_label,
due_at, tags, task_type, task_group_id, parent_task_key (or
parent_task_id, the same parent given by its numeric id), color, position,
and afk. A field the endpoint does not recognise is refused with 422 and
unpermitted_fields, listing the fields that cannot be set.
Move a task between stages
POST /api/v1/tasks/:key/transition; MCP tasks_transition; web. Needs
member.
Pass the target stage by name. The task moves to that stage on its current
workflow, subject to the stage's rules — a required check that has not passed, or a
required property that is not set, will refuse the move. See
Stage rules and
What moves a task without a person.
{ "stage": "In review" }A stage name that does not exist on the workflow is refused with unknown_stage
and the message no stage named "In review" on this workflow. An empty stage is
refused with stage required.
Passing force: true bypasses the target stage's check and property gating, its
task-type restriction and any WIP constraint. Forcing a transition needs
admin in the task's space, so a worker's lease can never force one, and the
bypass is recorded. The browser offers a forced move only when required checks
refused it. See
Web interface § Moving a task from its page.
Move a task to another workflow
MCP tasks_transfer; web. Needs member. There is no /api/v1 route.
Pass the required workflow_slug, and optionally a stage on that workflow;
without a stage the task lands on the destination workflow's default stage. An
unknown workflow is refused with unknown workflow_slug, a workflow with no stages that are not
archived with destination workflow has no stages, and an unknown stage with
no stage named "…" on this workflow. A destination that is archived is refused
with destination workflow is archived, and naming the workflow the task is
already on with destination workflow is the same as the source — to move a task
within its workflow, transition it instead.
A transfer is also refused while the task has an active lease on it — with one exception. When the caller is a worker moving the very task it is dispatched against, that worker's own lease does not block the move: it need not cancel itself first. Every other active lease still blocks. When you move a task yourself — in the browser, or over MCP as a user rather than as a lease — no lease is exempt: any active lease on the task blocks the move.
When an active lease blocks, the refusal names each blocking lease by its pool and state, for example:
cannot transfer: 1 active lease (Delivery workers — running). Cancel the lease and wait for it to reach a terminal state, then retry.Cancel the leases it names (see Leases) and wait for each to reach a terminal state, then transfer again.
A successful transfer adds a transferred entry to the task's
activity feed. Its payload carries
from_workflow and to_workflow (the workflow names), from_workflow_id and
to_workflow_id, from_stage and to_stage (the stage names), from_stage_id
and to_stage_id, and by. Transfers recorded before workflows took their current
name carry from_board, from_board_id, to_board, and to_board_id in place of
the four workflow fields, and those entries are not rewritten. A script that reads
transferred entries from activities_list must accept both sets of names.
Assign a task
POST /api/v1/tasks/:key/assign; MCP tasks_assign; web. Needs member.
Set assignee to user:<id>, pool:<id>, or null to unassign:
{ "assignee": "user:4088" }An unknown user is refused with unknown user user:<id>, an unknown pool with
unknown agent pool pool:<id>, and a malformed value with
assignee must match user:<id> or pool:<id>.
Assigning to pool:<id> creates a lease of that pool on the task straight away,
whatever stage the task is at, and the task's timeline shows an assigned entry.
It needs no assignment rule, and it does not check a hold, a dependency or a
required check. If a lease of that pool on the task is still active, that lease
is reused and no second one is created. While the task has a blocking hold, the
worker is refused when it tries to start; otherwise it runs. A worker's lease
can call tasks_assign on any task its scope reaches. See
What moves a task without a person § A person or a worker assigns a task to a pool.
Resolve and unresolve
POST /api/v1/tasks/:key/resolve and .../unresolve; MCP tasks_resolve and
tasks_unresolve. Needs member.
Resolving marks a task done without moving or archiving it; unresolving reopens it.
Archive and unarchive
POST /api/v1/tasks/:key/archive and .../unarchive; MCP tasks_archive and
tasks_unarchive. Needs member.
Archiving is a soft removal: the task drops out of default listings but is preserved and can be unarchived.
Holds
A hold stops a task from being worked. There are two kinds.
tasks_set_hold(MCP only) sets a hold. Pass areason, and optionally akind—blocking(the default) oradvisory. A blocking hold suppresses dispatch and lease claims and discards any queued work; an advisory hold records a note without suppressing anything.tasks_clear_hold(MCP and web; no/api/v1route) clears a hold, with an optionalinstruction.
Setting and clearing a hold needs member. tasks_clear_hold clears a
blocking hold only for a human operator: a worker or other non-human
principal that tries is refused with clear_hold_requires_human. An advisory
hold may be cleared by any principal.
A worker can still get past a blocking hold without a person. tasks_set_hold
accepts a lease on any task its scope reaches, and setting kind: "advisory"
replaces the blocking hold with an advisory one, which the worker may then
clear. Each change is recorded on the task's timeline as a hold_set entry
naming who made it. So a blocking hold alone does not guarantee a task waits for
a person. See
Web interface § Making a task wait for a person
and What moves a task without a person.
Nudge
MCP tasks_nudge (MCP only). Needs member.
Pass a message. A nudge re-dispatches a stalled task at its current stage,
carrying the message to the agent that picks it up. The response reports whether
work was re-dispatched. A non-human principal cannot nudge a task on a blocking
hold; it is refused with nudge_requires_human.
A nudge also clears the task's hold, if it has one, and resets the count of lost leases on every pool. Before it starts the stage again, it cancels the task's leases that have not started running; a worker already running is left alone.
A nudge turns off the lost-lease hold
Cadence starts a pool again on its own after one of its leases on the task is lost, and holds the task on the third lost lease, only when all of these are true:
- the lease was lost less than 24 hours ago
- the lease was created after the task entered its current stage
- an assignment rule at the task's current stage names the pool and applies to the task
- the pool is not archived and has no lease active on the task
- no later lease of that pool on the task ended in a state other than
lost
Starting the pool again, but not the hold, also needs the task to have no blocking hold, no unresolved dependency that blocks at its current stage, and no unpassed required check. While one of those stops it, Cadence tries again about once a minute, and starts the pool once all of them are true, as long as the conditions above still hold. Once the lease was lost more than 24 hours ago, nothing starts the pool again on its own. Cadence counts the pool's lost leases on the task at every stage, not only the current one, until the count is reset, so the hold can come sooner than three losses at this stage.
So a pool that no assignment rule at the stage names, such as one started only
by an assignment, a scheduled rule or an inbound rule, is never started again
this way and never gets this hold. When the hold is placed, its reason reads
dispatch blocked — manual intervention needed: <pool> (3 attempts). See
What moves a task without a person § A blocking hold waits for a person to clear it.
Once that hold has been placed for a pool, Cadence treats the pool as blocked
until one of these resets it: a clear of the task's hold with
tasks_clear_hold or Clear hold, a call to tasks_dispatch_pool or a click
on Dispatch while the task has a hold, or tasks_dispatch_unblock for that
pool. A nudge does not reset it. If you nudge the task while the pool is still
blocked, Cadence does not place that hold again for that pool. The next time
three leases of that pool are lost, it stops starting the pool and places
no hold, so the task sits idle at its stage.
What matters is whether the pool has been reset since its (3 attempts) hold
was placed, not which hold the task carries when you nudge it. A hold that
replaced the (3 attempts) hold, such as a stage-loop hold, does not reset the
pool. A move to another stage on the workflow resets every pool only when the
task has an advisory hold at the time, because Cadence clears that hold on the
move and records a hold_cleared entry. A move of a task with a blocking hold
or no hold, and a move to another workflow with tasks_transfer, leave the pool
blocked. A worker may nudge a task that has an advisory hold or no hold, so a
worker can turn this safeguard off with no person involved.
To tell whether a pool is still blocked, read the task's timeline with
activities_list task_key=OPS-42. The pool is blocked when its latest
dispatch_blocked entry, whose pool_id names the pool, is newer than every
hold_cleared entry and every dispatch_unblocked entry for that pool. If you
are not sure, do not rely on reading it: call tasks_dispatch_unblock key=OPS-42 with no pool_id straight after any nudge. It resets every pool
that is still blocked and lists them in cleared. An empty cleared means no
pool was blocked, and the call starts nothing.
Because a worker can nudge with no person involved, decide how you will catch
it. tasks_dispatch_unblock needs the same role as tasks_nudge, so if your
agent skills tell workers to nudge, tell them to call tasks_dispatch_unblock
straight after. Otherwise check for idle tasks from time to time with the steps
in Find and restart a task left idle after a nudge.
On a workflow with auto-advance on, the
abandoned-work hold
may catch an idle task, but only when every condition listed there holds:
among them, the stage has abandoned_work_detection on, the task has no
unresolved dependency that blocks at its current stage, every lease of the stage's assigned pools has ended and
the newest more than 3 minutes ago, and a check that the stage's create check
rules expect is still missing or pending. Otherwise nothing points you to it.
To keep the safeguard, reset the pool rather than nudge: clear the hold with
tasks_clear_hold or Clear hold. If you have already nudged, call
tasks_dispatch_unblock on the task straight after (see
Dispatch to a pool).
Find and restart a task left idle after a nudge
A task left idle this way has no hold and no active lease on the pool. Its
timeline (activities_list task_key=OPS-42) shows a nudged entry, then
lease_ended entries with "outcome": "lost" for the pool, and after the last
of them no new lease of that pool and no hold_set entry. To find candidates,
leases_list space_key=OPS state=lost lists the space's lost leases, newest
first, each with its task and pool.
To restart it:
- Call
tasks_dispatch_unblock key=OPS-42 pool_id=19. This puts the safeguard back for that pool and resets its count of lost leases. In the response,clearedlists the pool's id:[19]. It then dispatches the task at its current stage, but only while the task has no blocking hold, no unresolved dependency that blocks at its current stage and no unpassed required check; it does not touch a hold, so while the task has a blocking hold it starts nothing until a person clears that hold. When it does dispatch, it starts every pool, not only the one you named, whose assignment rule at the stage applies to the task, that is not archived, and that has no lease active on the task the rule leases. That can include a pool whose work at the stage already finished. A rule withtarget: parentleases the task's parent, not this task. - If that did not start the pool, call
tasks_dispatch_pool key=OPS-42 pool_id=19. This dispatches only the pool you name, through its assignment rule at the stage, and no other pool. It clears a hold the task has, and is refused for a worker if that hold is blocking. It starts the pool only while the task has no unresolved dependency that blocks at its current stage, no unpassed required check, and no lease of that pool is active on the task the rule leases.
Dispatch to a pool
MCP tasks_dispatch_pool and tasks_dispatch_unblock; web. Needs member.
Neither has an /api/v1 route.
-
tasks_dispatch_poolre-dispatches one pool at the task's current stage, clearing the task's hold if it carries one. Passkeyandpool_id. The pool is eligible once any of its leases on the task has reached a terminal state since the task entered its current stage, even if a newer lease of that pool is still running. An unknown pool is refused withunknown pool id. A pool with no such lease is refused withpool_not_redispatchable("pool has no terminal-state lease since the task entered its current stage"), and the hold is left in place. Clearing a hold this way carries the same human-only rule as clearing it directly: if the task is under a blocking hold, only a human operator may dispatch a pool on it — a non-human principal is refused withdispatch_pool_requires_human("only a human operator may dispatch a pool on a held task"), so a stuck agent cannot clear its own blocking hold and retry. If the pool is eligible, the hold is always cleared, but a new lease is created only through an assignment rule at the task's current stage that names the pool and applies to the task (see Stage rules § How a rule's scope is chosen), only while the task has no unresolved dependency that blocks at its current stage and no unpassed required check, and only while no lease of that pool is active on the task the rule leases. A rule withtarget: parentleases the task's parent, not this task, and starts nothing if the task has no parent or the parent is not of the type the rule names. Otherwise it starts nothing. To run a pool that no assignment rule at the stage names, assign the task to it again (see Assign a task). A scheduled rule's pool runs again at the rule's next due time (see Scheduled rules). An inbound rule's pool runs again when a new matching event arrives, or when an event that was already waiting in the dispatch queue reaches its turn. A waiting event starts the pool with no new event and no person, but only if, when its turn comes, the task has no blocking hold, no unresolved dependency that blocks at its current stage and no unpassed required check. If one of those stops it, the event is closed without starting anything and is not tried again; an event whose turn came while the hold was still on has already been closed this way and does not run after the clear. Cadence takes the waiting events for a task and pool one at a time: on each pass it closes or starts only the oldest. So while the hold is on, it closes them one by one, and if you clear the hold before it has closed them all, the next one starts the pool after the clear. To stop the waiting events from running, remove each of them withinbound_queue_removebefore you clear the hold. Listing and removing waiting events both need admin; a member asks a space admin to do it. Find each event'sidwithinbound_queue_list space_key=OPS: they are thewaitingrows whosesubjectis the task and whosepool_idis the pool. If no event for the task and pool is still waiting, Cadence will not start that pool on the task again until a new matching event arrives. To run it sooner, assign the task to the pool yourself (see Assign a task). Assigning does not use up an event that is still waiting: the event waits while the lease you started is active, and once that lease ends it starts the pool again, with no person, if the task passes the same conditions. If you do not want that second run, remove the waiting event first. The lease you start waits,pending, while the pool is paused, the space is archived, or the pool, the space or the instance has no room, and starts once all of them allow it. To check whether an event was closed, liststate=donerows; the event's delivery ininbound_deliveries_list, which also needs admin, is markedfailedwith the gate's reason (see Inbound § The dispatch queue and Inbound § When a dispatch mints nothing). -
tasks_dispatch_unblockclears the dispatch-blocked state that three lost leases of a pool leave on the task (see Nudge § A nudge turns off the lost-lease hold). A failed lease does not leave this state; it puts the task on a hold instead. Passkey, and optionally apool_idto unblock one pool rather than every blocked pool. It does not touch a hold. For each pool it unblocks, it resets the pool's count of lost leases, so the pool gets three more tries, and it puts back the lost-lease hold: after it, the third lost lease on that pool holds the task again. That is why you call it after a nudge. The response lists the unblocked pools' ids incleared. A pool you name that is not blocked is listed inalready_clearinstead, and nothing is reset or started for it.When it unblocks a pool, it dispatches the task at its current stage. That starts nothing while the task has a blocking hold, an unresolved dependency that blocks at its current stage, or an unpassed required check. Because this call does not clear a hold, a task still under its
(3 attempts)hold stays idle until a person clears the hold. Otherwise it starts every pool, not only the ones it unblocked, whose assignment rule at the stage applies to the task, that is not archived, and that has no lease active on the task the rule leases. A rule withtarget: parentleases the task's parent, not this task.
Participants
MCP tasks_add_participant, tasks_remove_participant (both MCP and web), and
tasks_list_participants (MCP only). None have an /api/v1 route. Adding and
removing need member; listing needs read.
A participant is a user in one of four roles: owner, reporter, observer, or
assignee. A task has at most one assignee. Pass user as user:<id> and a
role.
Comments and the activity feed
MCP activities_create and activities_list; POST /api/v1/tasks/:key/activities and GET /api/v1/tasks/:key/activities; web
comment and timeline. Commenting needs member; reading needs read.
activities_create appends a comment: pass task_key and a body.
activities_list reads the timeline: pass task_key, and optionally limit
(up to 200), order (asc or desc, newest-first by default), and since_id
to read only entries after a given one. Each entry carries its id, kind,
author, author_name, payload, and created_at.
Over the API, GET /api/v1/tasks/:key/activities reads the same entries, and can
also filter them by kind. Archived and resolved tasks are served like any other.
It takes these query parameters:
| Parameter | Notes |
|---|---|
order | asc or desc. Defaults to desc, newest first. |
since_id | A positive integer. Returns only entries whose id is larger. |
limit | An integer from 1 to 200. Defaults to 50. |
kind | A comma-separated list of activity kinds, such as comment,hold_set. Returns only entries of those kinds. API only: MCP activities_list takes no kind. |
curl "https://cadence.example.com/api/v1/tasks/OPS-42/activities?order=asc&kind=comment" \
-H "Authorization: Bearer smc_your_token_here"The response wraps the entries in activities:
{
"activities": [
{
"id": 9182,
"kind": "comment",
"author": "user:7",
"author_name": "Sam Rivera",
"payload": { "author": "user:7", "body": "Release notes drafted." },
"created_at": "2026-10-10T09:14:02Z"
}
]
}A bad parameter is refused with 422 and invalid_request, with one of these
messages:
order must be "asc" or "desc"since_id must be a positive integerlimit must be an integer between 1 and 200kind must name at least one activity kindunknown activity kind: "<kind>"
A task key that does not exist is refused with 404 and task_not_found.
Post a comment over the API
POST /api/v1/tasks/:key/activities appends a comment, the same as
activities_create. It needs a user API token with member on the space,
and it requires an Idempotency-Key header. Send the comment as body:
curl -X POST https://cadence.example.com/api/v1/tasks/OPS-42/activities \
-H "Authorization: Bearer smc_your_token_here" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 3c9a-release-notes-comment" \
-d '{ "body": "Release notes drafted." }'Cadence answers 201 with the new entry under activity, in the same shape
activities_list returns:
{
"activity": {
"id": 9182,
"kind": "comment",
"author": "user:7",
"author_name": "Sam Rivera",
"payload": { "author": "user:7", "body": "Release notes drafted." },
"created_at": "2026-10-10T09:14:02Z"
}
}| Field | Notes |
|---|---|
body | Required. The comment text. It must not be blank. |
kind | Optional. If you send it, it must be "comment". |
If you send the same key with the same body again within 24 hours, Cadence replays the first response and does not post a second comment, so a post is safe to retry after a timeout. See HTTP API § Idempotency keys.
A post is refused with:
404andtask_not_found— the task key does not exist.403andlease_forbidden— the call used a lease's token on a task the lease can reach (Posting a comment requires a user API token.). A worker comments with MCPactivities_createinstead. A lease's token on any other task gets the usual403refusal, such asscope_violation; see HTTP API § A 403 refusal.422andmissing_idempotency_key— noIdempotency-Keyheader (Idempotency-Key header required).422andidempotency_fingerprint_mismatch— the key was already used with a different body (Idempotency-Key reused with a different request body).422andinvalid_request, with one of these messages:kind must be "comment"comment body cannot be blankcomment body cannot contain a null byte (U+0000)
A 422 with invalid_request is also replayed for its key, so after you fix the
body, send it with a new key.