Current account
get/me
Responses
- 200The authenticated account
curl "https://www.taskneuron.ai/api/v1/me" \
-H "Authorization: Bearer $TASKNEURON_API_KEY"Developers
Automate your projects and tasks with the REST API, give AI assistants governed access over MCP, and react to changes with webhooks.
REST API
The TaskNeuron REST API gives you programmatic access to your projects and tasks: list and search work, create and update tasks, read a project's structure, and run AI plan generation. Requests and responses are JSON over HTTPS.
tn_; treat them like passwords and keep them out of source control. You can revoke a key from the same screen at any time.Every endpoint lives under https://www.taskneuron.ai/api/v1. Paths in the API reference are relative to that base URL.
export TASKNEURON_API_KEY="tn_..."
curl "https://www.taskneuron.ai/api/v1/me" \
-H "Authorization: Bearer $TASKNEURON_API_KEY"const res = await fetch("https://www.taskneuron.ai/api/v1/tasks", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TASKNEURON_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ projectId: "<project id>", title: "Write the launch post", priority: "high" }),
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${res.status} ${error.code ?? ""} ${error.message}`);
}
const created = await res.json();The machine-readable description of the API is served at /api/v1/openapi.json (OpenAPI 3.1, no authentication needed). Import it into Postman, Insomnia or a client generator.
Send your API key as a Bearer token in the Authorization header on every request. A key acts on behalf of the account that created it.
Authorization: Bearer tn_your_api_key401.403.Errors use standard HTTP status codes and a consistent JSON envelope. message is written for humans; code is a stable, machine-readable identifier and is present when the error has one, so check for it before branching on it.
{
"error": {
"message": "Rate limit exceeded. Try again shortly.",
"code": "rate_limited"
}
}Status codes you should handle: 400 invalid input, 401 missing or invalid key, 403 not allowed, 404 not found, 409 conflict, 429 rate limited, and 5xx server errors (retry reads with backoff; check before retrying a create so you don't make it twice).
Common codes include invalid_input, not_found, invalid_cursor and rate_limited.
List endpoints return a page of results plus a pagination object. limit sets the page size: it defaults to 50 and is capped at 200.
Page with the cursor. Pass the previous response's pagination.nextCursor as ?cursor= and keep going until nextCursor is null. A cursor is anchored to the last row you received, so items edited while you page can't shift past the page boundary.
?offset= still works for existing integrations but is deprecated: lists are ordered by last update, so a row edited between requests can move and be skipped by every page.
"pagination": {
"limit": 50,
"offset": 0,
"returned": 50,
"hasMore": true,
"nextCursor": "dGFzazp0c2tfMDFI..."
}async function listAllTasks() {
const tasks = [];
let cursor = null;
do {
const url = new URL("https://www.taskneuron.ai/api/v1/tasks");
url.searchParams.set("limit", "200");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.TASKNEURON_API_KEY}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const page = await res.json();
tasks.push(...page.tasks);
cursor = page.pagination.nextCursor;
} while (cursor);
return tasks;
}Each API key can make up to 120 requests per 60 seconds. Over the limit, requests get 429 with error code rate_limited and a Retry-After header giving the number of seconds to wait. Wait that long before retrying; for bulk jobs, spread requests out rather than sending them in bursts.
Some endpoints have their own limits on top of this, such as the number of AI plan jobs you can run at once (POST /plans returns 429 when too many are active).
AI assistants
TaskNeuron runs a Model Context Protocol server, so Claude, ChatGPT, Cursor, Claude Code and other MCP clients can read and update your projects and tasks. The assistant signs in with OAuth: your browser opens, you approve access for your workspace, and the client gets a scoped token. You never paste an API key or secret into the assistant.
Before you start
MCP access is part of the Pro plan. Settings → MCP shows the server URL for your workspace and copy-ready config for each client.MCP server URL
https://mcp.taskneuron.ai/mcp
Streamable HTTP transport, OAuth 2.1 with PKCE.
Claude Desktop: add TaskNeuron under mcpServers in claude_desktop_config.json (~/Library/Application Support/Claude/ on macOS, %APPDATA%\Claude\ on Windows), then restart Claude Desktop. It registers itself with TaskNeuron, opens the sign-in and consent page in your browser, and the TaskNeuron tools appear once you approve.
{
"mcpServers": {
"taskneuron": {
"type": "streamableHttp",
"url": "https://mcp.taskneuron.ai/mcp"
}
}
}Claude Code: add the server from your terminal, then run /mcp inside Claude Code to sign in.
claude mcp add --transport http taskneuron https://mcp.taskneuron.ai/mcpWhere custom connectors live in ChatGPT's settings, and which ChatGPT plans can add them, is controlled by OpenAI. If you don't see the option, check OpenAI's help center for connectors.
In Cursor, open Settings → MCP → Add new MCP server, choose HTTP, and paste the MCP server URL. Any MCP client that supports Streamable HTTP with OAuth works the same way: TaskNeuron publishes its OAuth metadata and supports dynamic client registration, so the client discovers everything from the server URL.
tasks:read or projects:write). Each tool below shows the scope it needs.Tools are grouped by what they work on. List tools return up to 50 rows with a nextCursor; keep calling with it until it's null.
| Tool | What it does |
|---|---|
get_mereadcontext:read | The account, workspace, role and granted scopes behind the current token. |
| Tool | What it does |
|---|---|
list_projectsreadprojects:read | Your projects, newest first. Page with nextCursor. |
get_project_structurereadprojects:read | A project expanded into phases and tasks nested to any depth, each with its roll-up, plus per-status totals. |
get_project_tnlreadprojects:read | A project as a Taskneol (.tnl) file with ids, details, briefs and dependencies, plus anything the file couldn't hold. |
get_tnl_specreadprojects:read | The Taskneol (.tnl) language spec, as markdown. |
create_projectwriteprojects:write | Create a project. |
update_projectwriteprojects:write | Change a project's name, description, goal, summary, status, type, location or target date. |
list_workstreamsreadprojects:read | A project's workstreams (board swimlanes), in board order. |
create_workstreamwriteprojects:write | Add a workstream to a project. |
update_workstreamwriteprojects:write | Rename or re-describe a workstream. |
delete_workstreamwriteprojects:write | Delete a workstream. Its tasks are kept. |
approve_planwriteprojects:write | Human only. Approve every task waiting for review in a project at once. |
| Tool | What it does |
|---|---|
list_checkpointsreadprojects:read | A project's phase-boundary approval gates and their progress. |
create_checkpointwriteprojects:write | Add a gate that holds a phase (or the whole project) until enough people approve it. |
approve_checkpointwriteprojects:write | Human only. Cast one approval toward a gate. |
delete_checkpointwriteprojects:write | Human only. Remove a gate and its approvals. |
| Tool | What it does |
|---|---|
list_tasksreadtasks:read | Your tasks, filtered by project, status and work item type, each with its roll-up. view tree nests a project to any depth. Page with nextCursor. |
search_tasksreadtasks:read | Substring search over task titles and descriptions, with filters. |
get_taskreadtasks:read | Everything needed to work on a task: brief, notes, dependencies, ancestors, subtasks, roll-up, item key, code links and project goal. |
get_task_historyreadtasks:read | The task's event history, newest first, with who made each change. |
create_taskwritetasks:write | Create a task or subtask, optionally with a type, agile fields, custom field values, a structured brief and blockers. |
update_taskwritetasks:write | Change a task's title, description, due date, phase, workstream, parent, type, agile fields, custom field values, brief or blockers. |
move_taskwritetasks:write | Move a task and everything below it under a new parent or to the top level, optionally setting phase and workstream on the whole branch. |
list_custom_fieldsreadtasks:read | Custom field definitions (keys, kinds, picklist options, types, required) to use in create_task and update_task. |
update_task_statuswritetasks:write | Move a task between todo, in_progress, review and completed. |
set_task_priority_effortwritetasks:write | Set a task's priority and/or estimated hours. |
approve_taskwritetasks:write | Human only. Approve one task out of review so it can be picked up. |
assign_taskwritetasks:write | Assign a task to a teammate or an AI agent identity. |
unassign_taskwritetasks:write | Clear a task's assignee. |
remove_task_dependencywritetasks:write | Remove one blocker from a task. |
delete_taskwritetasks:write | Delete a task and its subtasks (recoverable from Trash). |
| Tool | What it does |
|---|---|
generate_project_planwriteplans:write | Start AI plan generation from a goal. Returns a job id right away. |
get_plan_jobreadplans:read | Poll a plan job's status and progress. |
create_project_from_planwriteprojects:write | Turn a finished plan job into a project with its full task tree. |
| Tool | What it does |
|---|---|
request_next_taskreadtasks:read | The single highest-priority task this agent may pick up next. |
get_current_taskreadcontext:read | The task the agent is working on now. |
get_project_contextreadcontext:read | A compact project summary sized for a model's context window. |
submit_work_artifactwriteartifacts:write | Link a pull request, commit, file or note to a task. |
update_work_artifactwriteartifacts:write | Update a work artifact's type, title, URL or summary. |
delete_work_artifactwriteartifacts:write | Remove a work artifact from a task. |
| Tool | What it does |
|---|---|
list_attachmentsreadtasks:read | The files attached to a project or task. |
get_attachmentreadtasks:read | One attachment's metadata and contents. |
upload_attachmentwritetasks:write | Attach a file (up to 5 MB) to a project or task. |
delete_attachmentwritetasks:write | Remove an attachment from a project or task. |
| Tool | What it does |
|---|---|
sync_conversationwriteprojects:write | Save the chat's transcript with the projects and tasks it touched. |
get_conversationsreadcontext:read | Recent messages from earlier chats about a project or task. |
v1.0.0
Generated from the OpenAPI document. Paths are relative to https://www.taskneuron.ai/api/v1, and every endpoint needs a Bearer API key. Besides the responses listed, any request can return 401, 403 or 429 (see Errors).
get/me
curl "https://www.taskneuron.ai/api/v1/me" \
-H "Authorization: Bearer $TASKNEURON_API_KEY"get/projects
| Field | Description |
|---|---|
viewstring · querydefault "list" | tree (needs projectId) returns { tree, taskCount }: the project's active tasks nested to any depth, each node with level, rollup and children. Not paginated; status, workItemType and cursor are rejected in this mode.One of:listtree |
rootTaskIdstring · query | With view=tree: return only this task's branch. |
limitinteger · querymax 200 | |
offsetinteger · querydeprecated | Legacy paging. Lossy: the list is ordered by a column concurrent writes can move, so a row updated between requests shifts past the offset and is returned by no page. Prefer cursor. |
cursorstring · query | Opaque page token from the previous response's pagination.nextCursor. Anchors the next page to a row rather than a count, so concurrent writes cannot shift the boundary. Supersedes offset. Keep paging while nextCursor is non-null. |
curl "https://www.taskneuron.ai/api/v1/projects" \
-H "Authorization: Bearer $TASKNEURON_API_KEY"post/projects
application/json · required| Field | Description |
|---|---|
namestringrequired | |
descriptionstring | |
projectTypestring |
curl -X POST "https://www.taskneuron.ai/api/v1/projects" \
-H "Authorization: Bearer $TASKNEURON_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"<name>"}'patch/projects/{id}
| Field | Description |
|---|---|
idstring · pathrequired |
application/json · requiredAt least one field is required; an empty patch is rejected.
| Field | Description |
|---|---|
namestring | |
descriptionstringnullable | |
goalstringnullable | |
summarystringnullable | |
statusstring | One of:activearchivedcompleted |
projectTypestring | |
primaryLocationstringnullable | |
targetDateintegernullable | Epoch milliseconds; null clears the target date. |
itemKeystring | Work item key prefix (lowercase is uppercased). Keys in older commits stop resolving after a change. People only (agents get 403 human_required); audited. |
codeMergeStatusstringnullable | Move linked work items to this status when their pull request merges; none = never; null = default (the PR-status capability, when enabled, completes them). People only; audited.One of:nonein_progressreviewcompleted |
curl -X PATCH "https://www.taskneuron.ai/api/v1/projects/<id>" \
-H "Authorization: Bearer $TASKNEURON_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"<name>","description":"<description>"}'get/projects/{id}/structure
| Field | Description |
|---|---|
idstring · pathrequired |
curl "https://www.taskneuron.ai/api/v1/projects/<id>/structure" \
-H "Authorization: Bearer $TASKNEURON_API_KEY"get/projects/{id}/map
| Field | Description |
|---|---|
idstring · pathrequired |
curl "https://www.taskneuron.ai/api/v1/projects/<id>/map" \
-H "Authorization: Bearer $TASKNEURON_API_KEY"get/projects/{id}/tnl
| Field | Description |
|---|---|
idstring · pathrequired | |
formatstring · query | json returns { projectId, fileName, tnl, losses } instead of the fileOne of:json |
curl "https://www.taskneuron.ai/api/v1/projects/<id>/tnl" \
-H "Authorization: Bearer $TASKNEURON_API_KEY"get/tasks
| Field | Description |
|---|---|
projectIdstring · query | |
statusstring · query | |
workItemTypestring · query | One of:epicfeaturestorybugtaskissue |
limitinteger · querymax 200 | |
offsetinteger · querydeprecated | Legacy paging. Lossy: the list is ordered by a column concurrent writes can move, so a row updated between requests shifts past the offset and is returned by no page. Prefer cursor. |
cursorstring · query | Opaque page token from the previous response's pagination.nextCursor. Anchors the next page to a row rather than a count, so concurrent writes cannot shift the boundary. Supersedes offset. Keep paging while nextCursor is non-null. |
rollup) + pagination, or the tree with view=treecurl "https://www.taskneuron.ai/api/v1/tasks" \
-H "Authorization: Bearer $TASKNEURON_API_KEY"post/tasks
application/json · requiredPlus the optional WorkItemFields (type, agile fields, customFields).
| Field | Description |
|---|---|
projectIdstringrequired | |
titlestringrequired | |
descriptionstring | |
prioritystring | One of:lowmediumhighurgent |
estimatedHoursinteger | |
dueDateinteger | Epoch milliseconds |
phasestring | |
workItemTypestring | One of:epicfeaturestorybugtaskissue |
storyPointsnumbermin 0 | |
severitystring | One of:criticalhighmediumlow |
customFieldsobject | Key → value; validated against /custom-fields |
curl -X POST "https://www.taskneuron.ai/api/v1/tasks" \
-H "Authorization: Bearer $TASKNEURON_API_KEY" \
-H "Content-Type: application/json" \
-d '{"projectId":"<projectId>","title":"<title>"}'patch/tasks/{id}
| Field | Description |
|---|---|
idstring · pathrequired |
curl -X PATCH "https://www.taskneuron.ai/api/v1/tasks/<id>" \
-H "Authorization: Bearer $TASKNEURON_API_KEY"delete/tasks/{id}
| Field | Description |
|---|---|
idstring · pathrequired |
curl -X DELETE "https://www.taskneuron.ai/api/v1/tasks/<id>" \
-H "Authorization: Bearer $TASKNEURON_API_KEY"post/tasks/{id}/move
Re-parents the task under any allowed parent in the same project, or to the top level with null. Its children, grandchildren and so on move with it. Hierarchy rules apply (no cycle, the parent's type must accept the task's type, the branch must fit the maximum depth); a violation returns 400 or 409 invalid_parent with the rule in the message. Optional phase and workstreamId are applied to the whole branch.
| Field | Description |
|---|---|
idstring · pathrequired |
application/json · required| Field | Description |
|---|---|
parentTaskIdstringrequirednullable | |
phasestringnullable | |
workstreamIdstringnullable |
curl -X POST "https://www.taskneuron.ai/api/v1/tasks/<id>/move" \
-H "Authorization: Bearer $TASKNEURON_API_KEY" \
-H "Content-Type: application/json" \
-d '{"parentTaskId":"<parentTaskId>"}'get/tasks/{id}/context
| Field | Description |
|---|---|
idstring · pathrequired |
curl "https://www.taskneuron.ai/api/v1/tasks/<id>/context" \
-H "Authorization: Bearer $TASKNEURON_API_KEY"get/tasks/search
| Field | Description |
|---|---|
qstring · queryrequired | |
projectIdstring · query | |
statusstring · query | One of:todoin_progressreviewcompleted |
prioritystring · query | One of:lowmediumhighurgent |
limitinteger · querymax 200 | |
offsetinteger · querydeprecated | Legacy paging. Lossy: the list is ordered by a column concurrent writes can move, so a row updated between requests shifts past the offset and is returned by no page. Prefer cursor. |
cursorstring · query | Opaque page token from the previous response's pagination.nextCursor. Anchors the next page to a row rather than a count, so concurrent writes cannot shift the boundary. Supersedes offset. Keep paging while nextCursor is non-null. |
curl "https://www.taskneuron.ai/api/v1/tasks/search?q=<q>" \
-H "Authorization: Bearer $TASKNEURON_API_KEY"get/next-task
| Field | Description |
|---|---|
projectIdstring · query |
curl "https://www.taskneuron.ai/api/v1/next-task" \
-H "Authorization: Bearer $TASKNEURON_API_KEY"get/custom-fields
The caller's workspace, or a team's with ?teamId. Agents may read.
| Field | Description |
|---|---|
teamIdstring · query | |
includeArchivedboolean · query |
curl "https://www.taskneuron.ai/api/v1/custom-fields" \
-H "Authorization: Bearer $TASKNEURON_API_KEY"post/custom-fields
People only (agents get 403 human_required); a team's fields need team owner or admin. Audited.
application/json · required| Field | Description |
|---|---|
teamIdstring | |
keystringrequired | |
labelstringrequired | |
kindstringrequired | One of:textnumberdatepicklistpersonboolean |
appliesToarray of string | |
optionsarray of object | |
requiredboolean | |
positioninteger |
curl -X POST "https://www.taskneuron.ai/api/v1/custom-fields" \
-H "Authorization: Bearer $TASKNEURON_API_KEY" \
-H "Content-Type: application/json" \
-d '{"key":"<key>","label":"<label>","kind":"text"}'patch/custom-fields/{id}
label, appliesTo, options, required, position, archived. key and kind cannot change. People only; audited.
| Field | Description |
|---|---|
idstring · pathrequired |
curl -X PATCH "https://www.taskneuron.ai/api/v1/custom-fields/<id>" \
-H "Authorization: Bearer $TASKNEURON_API_KEY"delete/custom-fields/{id}
| Field | Description |
|---|---|
idstring · pathrequired |
curl -X DELETE "https://www.taskneuron.ai/api/v1/custom-fields/<id>" \
-H "Authorization: Bearer $TASKNEURON_API_KEY"get/taskneol/spec
curl "https://www.taskneuron.ai/api/v1/taskneol/spec" \
-H "Authorization: Bearer $TASKNEURON_API_KEY"post/plans
application/json · required| Field | Description |
|---|---|
goalstringrequired3–8000 chars | |
projectTypestring | |
locationstring |
curl -X POST "https://www.taskneuron.ai/api/v1/plans" \
-H "Authorization: Bearer $TASKNEURON_API_KEY" \
-H "Content-Type: application/json" \
-d '{"goal":"<goal>"}'get/plans/{id}
| Field | Description |
|---|---|
idstring · pathrequired |
curl "https://www.taskneuron.ai/api/v1/plans/<id>" \
-H "Authorization: Bearer $TASKNEURON_API_KEY"post/projects/from-plan
application/json · required| Field | Description |
|---|---|
jobIdstringrequired |
curl -X POST "https://www.taskneuron.ai/api/v1/projects/from-plan" \
-H "Authorization: Bearer $TASKNEURON_API_KEY" \
-H "Content-Type: application/json" \
-d '{"jobId":"<jobId>"}'get/conversations
| Field | Description |
|---|---|
projectIdstring · query | |
taskIdstring · query | |
limitinteger · query1–200default 40 |
curl "https://www.taskneuron.ai/api/v1/conversations" \
-H "Authorization: Bearer $TASKNEURON_API_KEY"post/conversations
Omit conversationId to start a conversation; pass the returned id on later calls so a continued chat keeps appending. Messages already stored are skipped. Messages over 20,000 characters are cut, and a conversation holds up to 1,000,000 characters.
application/json · required| Field | Description |
|---|---|
conversationIdstring | |
clientstring | Assistant name, e.g. claude or chatgpt |
titlestring | |
projectIdstring | Project this chat is about |
taskIdsarray of stringmax 50 items | |
messagesarray of objectrequiredmax 200 items | |
messages[].rolestringrequired | One of:userassistant |
messages[].contentstringrequired |
curl -X POST "https://www.taskneuron.ai/api/v1/conversations" \
-H "Authorization: Bearer $TASKNEURON_API_KEY" \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"<content>"}]}'Shapes shared across endpoints. List responses wrap their items with a pagination object; errors use the Error envelope.
| Field | Description |
|---|---|
errorobject | |
error.messagestringrequired | |
error.codestring |
| Field | Description |
|---|---|
limitinteger | |
offsetinteger | |
returnedinteger | |
hasMoreboolean | |
nextCursorstringnullable | Pass as ?cursor= to fetch the next page. Null on the last page. |
| Field | Description |
|---|---|
idstring | |
namestring | |
descriptionstringnullable | |
statusstring | |
projectTypestring | |
itemKeystring | Work item key prefix: "TN" makes task 123 "TN-123" |
codeMergeStatusstringnullable | Status linked work items move to when their pull request merges; null = defaultOne of:nonein_progressreviewcompleted |
createdAtstring (date-time) | |
updatedAtstring (date-time) |
| Field | Description |
|---|---|
idstring | |
projectIdstring | |
parentTaskIdstringnullable | |
itemNumberinteger | Number within the project; with the project's itemKey, e.g. TN-123 |
titlestring | |
descriptionstringnullable | |
statusstring | One of:todoin_progressreviewcompleted |
prioritystring | One of:lowmediumhighurgent |
estimatedHoursintegernullable | |
dueDatestring (date-time)nullable | |
phasestringnullable | |
workItemTypestring | One of:epicfeaturestorybugtaskissue |
storyPointsnumbernullable | |
originalEstimatenumbernullable | Hours |
remainingWorknumbernullable | Hours |
completedWorknumbernullable | Hours |
severitystringnullable | One of:criticalhighmediumlow |
reproStepsstringnullable | |
acceptanceCriteriaTextstringnullable | |
customFieldsobject | Values (string, number or boolean) keyed by custom field key; see /custom-fields |
createdAtstring (date-time) | |
updatedAtstring (date-time) |
| Field | Description |
|---|---|
workItemTypestringdefault "task" | One of:epicfeaturestorybugtaskissue |
storyPointsnumbernullable0–100000 | |
originalEstimatenumbernullable0–100000 | Hours |
remainingWorknumbernullable0–100000 | Hours |
completedWorknumbernullable0–100000 | Hours |
severitystringnullable | One of:criticalhighmediumlow |
reproStepsstringnullablemax 20000 chars | |
acceptanceCriteriaTextstringnullablemax 20000 chars | |
customFieldsobject | Key → string, number, boolean, or null to clear. Merges by key on update. |
| Field | Description |
|---|---|
descendantCountinteger | |
completedCountinteger | |
percentCompletenumbernullable | |
storyPointsnumbernullable | |
remainingWorknumbernullable | |
completedWorknumbernullable | |
estimatedHoursnumbernullable |
| Field | Description |
|---|---|
idstring | |
workspaceIdstring | |
keystring | |
labelstring | |
kindstring | One of:textnumberdatepicklistpersonboolean |
appliesToarray of string | Empty = every type |
optionsarray of object | |
options[].valuestring | |
options[].labelstring | |
requiredboolean | |
positioninteger | |
archivedAtstring (date-time)nullable |
Events
Webhooks push events to your server as they happen, so you don't have to poll. Each endpoint chooses which event types it receives.
Add an HTTPS URL, pick the event types to send, and keep the endpoint's signing secret somewhere safe: you'll need it to verify deliveries. Use Send test to send a ping event and check your endpoint.
Each delivery is an HTTP POST with a JSON body:
{
"id": "<delivery id>",
"type": "task.status_changed",
"createdAt": "2026-10-07T14:03:12.000Z",
"data": {
"eventId": "<event id>",
"projectId": "<project id>",
"taskId": "<task id>",
"actor": { "type": "agent", "id": "<actor id>" },
"payload": { }
}
}| Field | Description |
|---|---|
idstring | The delivery id. Same value as the TaskNeuron-Delivery header. |
typestring | The event type, e.g. task.status_changed. Same as the TaskNeuron-Event header. |
createdAtstring (ISO 8601) | When the event happened. |
data.eventIdstring | The id of the underlying event. |
data.projectIdstring | The project the event belongs to. |
data.taskIdstring | null | The task the event is about; null for project-level events. |
data.actorobject | Who made the change: type is human, agent or system, plus their id. |
data.payloadobject | Event-specific details. |
Every delivery also carries these headers:
TaskNeuron-Event: the event type.TaskNeuron-Delivery: the delivery id.TaskNeuron-Signature: t=<unix seconds>,v1=<signature>, where the signature is the hex HMAC-SHA256 of <t>.<raw body> using the endpoint's signing secret.POST /your/webhook/endpoint HTTP/1.1
Content-Type: application/json
TaskNeuron-Event: task.status_changed
TaskNeuron-Delivery: <delivery id>
TaskNeuron-Signature: t=1791381792,v1=5f1c0a…e93btask.createdtask.updatedtask.status_changedtask.approvedtask.assignedtask.unassignedtask.dependency_addedtask.dependency_removedtask.artifact_linkedtask.deletedtask.restoredproject.createdproject.updatedproject.deletedproject.restoredcheckpoint.createdcheckpoint.approvedping is sent by the Send test button.
2xx status within 10 seconds. Acknowledge first and do slow work afterwards.Check the signature on every delivery before trusting it. Compute the HMAC over the raw request body (not re-serialized JSON), compare in constant time, and reject signatures whose timestamp is more than 5 minutes old to prevent replay attacks.
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
/**
* rawBody: the request body exactly as received (Buffer or string), before any
* JSON parsing. header: the TaskNeuron-Signature header. secret: the endpoint's
* signing secret.
*/
export function verifyTaskNeuronSignature(rawBody, header, secret) {
const parts = {};
for (const item of (header ?? "").split(",")) {
const i = item.indexOf("=");
if (i > 0) parts[item.slice(0, i).trim()] = item.slice(i + 1).trim();
}
const { t, v1 } = parts;
if (!t || !v1 || !/^\d+$/.test(t)) return false;
// Reject old signatures so a captured request can't be replayed.
if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.`)
.update(rawBody)
.digest();
const received = Buffer.from(v1, "hex");
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
// Express: keep the raw body for verification.
app.post("/webhooks/taskneuron", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyTaskNeuronSignature(
req.body,
req.get("TaskNeuron-Signature"),
process.env.TASKNEURON_WEBHOOK_SECRET,
);
if (!ok) return res.status(400).send("Invalid signature");
const delivery = JSON.parse(req.body.toString("utf8"));
res.sendStatus(204); // acknowledge fast, then do the work
handleDelivery(delivery);
});import hashlib
import hmac
import time
TOLERANCE_SECONDS = 5 * 60
def verify_taskneuron_signature(raw_body: bytes, header: str, secret: str) -> bool:
"""raw_body: the request body bytes exactly as received, before JSON parsing."""
parts = {}
for item in (header or "").split(","):
key, sep, value = item.partition("=")
if sep:
parts[key.strip()] = value.strip()
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or not v1:
return False
# Reject old signatures so a captured request can't be replayed.
if abs(time.time() - int(t)) > TOLERANCE_SECONDS:
return False
signed = t.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
# Flask
@app.post("/webhooks/taskneuron")
def taskneuron_webhook():
if not verify_taskneuron_signature(
request.get_data(),
request.headers.get("TaskNeuron-Signature", ""),
os.environ["TASKNEURON_WEBHOOK_SECRET"],
):
abort(400)
delivery = request.get_json()
enqueue(delivery) # do the work after responding
return "", 204