Receive webhooks
Get board events pushed to your own endpoint, signed and retried.
- Open Settings → Connections → API & Webhooks and add a subscription with your endpoint URL. The address has to resolve to a public server — see below.
- A signing secret is generated for you (or use your own) and shown once. Every delivery is signed with it; verify the signature before you trust the body.
- Pick the events you want. Send test event queues a
pingdelivery, and Deliveries lists the recent ones with their outcome and, when one failed, the reason.
Change a subscription
- Edit changes the URL and the events. The signing secret stays as it is, and a new URL is checked the same way as when the subscription was created.
- Rotate secret issues a new signing secret and shows it once. The old one stops verifying at once: deliveries are signed when they are sent, so even retries that are already waiting go out under the new secret. Update your receiver right away.
- Both are written to the organization’s audit log — the URL and the events, never a secret.
Verify the signature
- Every delivery is a
POSTwith a JSON body and three headers:X-KyroTask-Event(the event name),X-KyroTask-Timestamp(Unix seconds) andX-KyroTask-Signature(sha256=followed by a hex digest). - The signature is HMAC-SHA256, keyed with your signing secret, over the timestamp, a dot and the raw body:
{timestamp}.{body}.
- Read the body as raw bytes. Do not parse the JSON and serialize it again to check it: the spacing changes and the signature no longer matches.
- Compute the HMAC over
X-KyroTask-Timestamp, a.and the raw body, and write it as lowercase hex. - Compare it with the part of
X-KyroTask-Signatureaftersha256=, in constant time. - Refuse old timestamps — five minutes is a sensible window. Every attempt, retries included, is signed again with a fresh timestamp, so a late retry still passes.
A complete receiver in Node, with no dependencies. It was checked against a real ping from Send test event: the correct secret answered 200, a wrong one 401.
// verify-webhook.mjs — Node 18+. Run: KYROTASK_WEBHOOK_SECRET=... node verify-webhook.mjs
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.KYROTASK_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const body = Buffer.concat(chunks); // the raw bytes, exactly as sent
const timestamp = String(req.headers['x-kyrotask-timestamp'] ?? '');
const received = String(req.headers['x-kyrotask-signature'] ?? '').replace(/^sha256=/, '');
const expected = createHmac('sha256', SECRET).update(`${timestamp}.`).update(body).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= TOLERANCE_SECONDS;
const valid = fresh && received.length === expected.length
&& timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!valid) {
res.writeHead(401).end();
return;
}
const payload = JSON.parse(body.toString('utf8'));
console.log(req.headers['x-kyrotask-event'], payload);
res.writeHead(200).end();
});
}).listen(Number(process.env.PORT ?? 3000));Answer with a 2xx within 10 seconds. Any other answer, a redirect included, counts as a failed delivery and is retried later.
What each event sends
Every body has the same envelope — event, occurredAt (UTC), organizationId, projectId — and a data object that depends on the event. Task events carry the task itself; intake.submitted and client.approval.pending carry it under data.task, next to the form it came through or the moment it started waiting; a budget alert carries the numbers it fired on.
The samples below are the sender’s own output for example values. On the wire the same keys can arrive in another order and with other spacing, so check the signature on the raw bytes first, then parse the JSON — never compare the text.
task.created — a task was created.
{
"event": "task.created",
"occurredAt": "2026-09-27T09:30:00Z",
"organizationId": "4f6c2a0e-8d1b-4c3a-9e27-5b0d6f1a2c34",
"projectId": "9a1e7c52-3f4b-4d86-b0a9-2c7e5d8f1b60",
"data": {
"id": "c3d8b1f4-6a2e-4f97-8b15-0e9d7a4c2f81",
"projectId": "9a1e7c52-3f4b-4d86-b0a9-2c7e5d8f1b60",
"boardColumnId": 412,
"assigneeId": "5d0c3b82-7e1f-4a69-9c58-1b4a6e2d8f03",
"reporterId": "2e5a8d71-9b3c-4f06-8a4d-6c1e0f7b3a92",
"parentTaskId": null,
"taskNumber": "WEB-42",
"title": "Homepage hero copy",
"description": "Two headline options and a supporting line.",
"priority": "High",
"dueDate": "2026-10-02T00:00:00Z",
"estimatedHours": 3,
"actualHours": null,
"position": 1024,
"createdAt": "2026-09-24T14:05:00Z",
"updatedAt": "2026-09-27T09:30:00Z",
"subtaskCount": 0,
"commentCount": 2,
"labels": [
{
"id": "8f4e1a63-2d7b-4c90-b5e3-9a0c6d1f7e24",
"projectId": "9a1e7c52-3f4b-4d86-b0a9-2c7e5d8f1b60",
"name": "Copy",
"color": "#7c3aed",
"createdAt": "2026-09-24T14:05:00Z"
}
],
"startDate": null,
"issueType": "Task",
"storyPoints": 3,
"sprintId": null,
"sprintName": null,
"isBlocked": false,
"assigneeName": "Deniz Aksoy",
"isClientFacing": true,
"requiresClientApproval": false,
"isClientApproved": false,
"clientApprovedAt": null,
"version": "1834",
"recurrenceParentId": null
}
}task.updated — a task’s fields changed — title, assignee, dates, priority and so on.
{
"event": "task.updated",
"occurredAt": "2026-09-27T09:30:00Z",
"organizationId": "4f6c2a0e-8d1b-4c3a-9e27-5b0d6f1a2c34",
"projectId": "9a1e7c52-3f4b-4d86-b0a9-2c7e5d8f1b60",
"data": {
"id": "c3d8b1f4-6a2e-4f97-8b15-0e9d7a4c2f81",
"projectId": "9a1e7c52-3f4b-4d86-b0a9-2c7e5d8f1b60",
"boardColumnId": 412,
"assigneeId": "5d0c3b82-7e1f-4a69-9c58-1b4a6e2d8f03",
"reporterId": "2e5a8d71-9b3c-4f06-8a4d-6c1e0f7b3a92",
"parentTaskId": null,
"taskNumber": "WEB-42",
"title": "Homepage hero copy",
"description": "Two headline options and a supporting line.",
"priority": "High",
"dueDate": "2026-10-02T00:00:00Z",
"estimatedHours": 3,
"actualHours": null,
"position": 1024,
"createdAt": "2026-09-24T14:05:00Z",
"updatedAt": "2026-09-27T09:30:00Z",
"subtaskCount": 0,
"commentCount": 2,
"labels": [
{
"id": "8f4e1a63-2d7b-4c90-b5e3-9a0c6d1f7e24",
"projectId": "9a1e7c52-3f4b-4d86-b0a9-2c7e5d8f1b60",
"name": "Copy",
"color": "#7c3aed",
"createdAt": "2026-09-24T14:05:00Z"
}
],
"startDate": null,
"issueType": "Task",
"storyPoints": 3,
"sprintId": null,
"sprintName": null,
"isBlocked": false,
"assigneeName": "Deniz Aksoy",
"isClientFacing": true,
"requiresClientApproval": false,
"isClientApproved": false,
"clientApprovedAt": null,
"version": "1834",
"recurrenceParentId": null
}
}task.moved — a task moved to another column.
{
"event": "task.moved",
"occurredAt": "2026-09-27T09:30:00Z",
"organizationId": "4f6c2a0e-8d1b-4c3a-9e27-5b0d6f1a2c34",
"projectId": "9a1e7c52-3f4b-4d86-b0a9-2c7e5d8f1b60",
"data": {
"id": "c3d8b1f4-6a2e-4f97-8b15-0e9d7a4c2f81",
"projectId": "9a1e7c52-3f4b-4d86-b0a9-2c7e5d8f1b60",
"boardColumnId": 412,
"assigneeId": "5d0c3b82-7e1f-4a69-9c58-1b4a6e2d8f03",
"reporterId": "2e5a8d71-9b3c-4f06-8a4d-6c1e0f7b3a92",
"parentTaskId": null,
"taskNumber": "WEB-42",
"title": "Homepage hero copy",
"description": "Two headline options and a supporting line.",
"priority": "High",
"dueDate": "2026-10-02T00:00:00Z",
"estimatedHours": 3,
"actualHours": null,
"position": 1024,
"createdAt": "2026-09-24T14:05:00Z",
"updatedAt": "2026-09-27T09:30:00Z",
"subtaskCount": 0,
"commentCount": 2,
"labels": [
{
"id": "8f4e1a63-2d7b-4c90-b5e3-9a0c6d1f7e24",
"projectId": "9a1e7c52-3f4b-4d86-b0a9-2c7e5d8f1b60",
"name": "Copy",
"color": "#7c3aed",
"createdAt": "2026-09-24T14:05:00Z"
}
],
"startDate": null,
"issueType": "Task",
"storyPoints": 3,
"sprintId": null,
"sprintName": null,
"isBlocked": false,
"assigneeName": "Deniz Aksoy",
"isClientFacing": true,
"requiresClientApproval": false,
"isClientApproved": false,
"clientApprovedAt": null,
"version": "1834",
"recurrenceParentId": null
}
}task.deleted — a task was deleted; only its id is left to send.
{
"event": "task.deleted",
"occurredAt": "2026-09-27T09:30:00Z",
"organizationId": "4f6c2a0e-8d1b-4c3a-9e27-5b0d6f1a2c34",
"projectId": "9a1e7c52-3f4b-4d86-b0a9-2c7e5d8f1b60",
"data": {
"taskId": "c3d8b1f4-6a2e-4f97-8b15-0e9d7a4c2f81"
}
}comment.added — a comment was added to a task.
{
"event": "comment.added",
"occurredAt": "2026-09-27T09:30:00Z",
"organizationId": "4f6c2a0e-8d1b-4c3a-9e27-5b0d6f1a2c34",
"projectId": "9a1e7c52-3f4b-4d86-b0a9-2c7e5d8f1b60",
"data": {
"taskId": "c3d8b1f4-6a2e-4f97-8b15-0e9d7a4c2f81",
"comment": {
"id": "e1b7c4a9-0f3d-4e82-96a5-7d2c8b0e4f16",
"taskId": "c3d8b1f4-6a2e-4f97-8b15-0e9d7a4c2f81",
"authorId": "2e5a8d71-9b3c-4f06-8a4d-6c1e0f7b3a92",
"content": "First draft is in the brief, @deniz — can you check the tone?",
"createdAt": "2026-09-27T09:30:00Z",
"updatedAt": null,
"isInternal": true,
"agentJobId": null,
"agentRunnerName": null,
"agentAdapter": null
}
}
}intake.submitted — a client sent a request through a portal intake form and it became a task. A task.created is sent for the same task as well.
{
"event": "intake.submitted",
"occurredAt": "2026-09-27T09:30:00Z",
"organizationId": "4f6c2a0e-8d1b-4c3a-9e27-5b0d6f1a2c34",
"projectId": "9a1e7c52-3f4b-4d86-b0a9-2c7e5d8f1b60",
"data": {
"task": {
"id": "c3d8b1f4-6a2e-4f97-8b15-0e9d7a4c2f81",
"projectId": "9a1e7c52-3f4b-4d86-b0a9-2c7e5d8f1b60",
"boardColumnId": 412,
"assigneeId": "5d0c3b82-7e1f-4a69-9c58-1b4a6e2d8f03",
"reporterId": "2e5a8d71-9b3c-4f06-8a4d-6c1e0f7b3a92",
"parentTaskId": null,
"taskNumber": "WEB-42",
"title": "New landing page for the autumn sale",
"description": "Two headline options and a supporting line.",
"priority": "High",
"dueDate": "2026-10-02T00:00:00Z",
"estimatedHours": 3,
"actualHours": null,
"position": 1024,
"createdAt": "2026-09-24T14:05:00Z",
"updatedAt": "2026-09-27T09:30:00Z",
"subtaskCount": 0,
"commentCount": 2,
"labels": [
{
"id": "8f4e1a63-2d7b-4c90-b5e3-9a0c6d1f7e24",
"projectId": "9a1e7c52-3f4b-4d86-b0a9-2c7e5d8f1b60",
"name": "Copy",
"color": "#7c3aed",
"createdAt": "2026-09-24T14:05:00Z"
}
],
"startDate": null,
"issueType": "Task",
"storyPoints": 3,
"sprintId": null,
"sprintName": null,
"isBlocked": false,
"assigneeName": "Deniz Aksoy",
"isClientFacing": true,
"requiresClientApproval": false,
"isClientApproved": false,
"clientApprovedAt": null,
"version": "1834",
"recurrenceParentId": null
},
"formId": "7b2f9e14-5c8a-4d31-a6e0-3f1b8c9d2e57",
"formName": "Website request"
}
}budget.threshold.reached — a project crossed its budget’s alert threshold. usagePct goes above 100 once the budget is overspent.
{
"event": "budget.threshold.reached",
"occurredAt": "2026-09-27T09:30:00Z",
"organizationId": "4f6c2a0e-8d1b-4c3a-9e27-5b0d6f1a2c34",
"projectId": "9a1e7c52-3f4b-4d86-b0a9-2c7e5d8f1b60",
"data": {
"projectName": "Northwind website",
"budgetType": "Hours",
"budgetAmount": 120,
"usedAmount": 98.5,
"usagePct": 82,
"thresholdPct": 80
}
}client.approval.pending — a task started waiting for the client’s approval; pendingSince is the moment it did.
{
"event": "client.approval.pending",
"occurredAt": "2026-09-27T09:30:00Z",
"organizationId": "4f6c2a0e-8d1b-4c3a-9e27-5b0d6f1a2c34",
"projectId": "9a1e7c52-3f4b-4d86-b0a9-2c7e5d8f1b60",
"data": {
"task": {
"id": "c3d8b1f4-6a2e-4f97-8b15-0e9d7a4c2f81",
"projectId": "9a1e7c52-3f4b-4d86-b0a9-2c7e5d8f1b60",
"boardColumnId": 412,
"assigneeId": "5d0c3b82-7e1f-4a69-9c58-1b4a6e2d8f03",
"reporterId": "2e5a8d71-9b3c-4f06-8a4d-6c1e0f7b3a92",
"parentTaskId": null,
"taskNumber": "WEB-42",
"title": "Homepage hero copy",
"description": "Two headline options and a supporting line.",
"priority": "High",
"dueDate": "2026-10-02T00:00:00Z",
"estimatedHours": 3,
"actualHours": null,
"position": 1024,
"createdAt": "2026-09-24T14:05:00Z",
"updatedAt": "2026-09-27T09:30:00Z",
"subtaskCount": 0,
"commentCount": 2,
"labels": [
{
"id": "8f4e1a63-2d7b-4c90-b5e3-9a0c6d1f7e24",
"projectId": "9a1e7c52-3f4b-4d86-b0a9-2c7e5d8f1b60",
"name": "Copy",
"color": "#7c3aed",
"createdAt": "2026-09-24T14:05:00Z"
}
],
"startDate": null,
"issueType": "Task",
"storyPoints": 3,
"sprintId": null,
"sprintName": null,
"isBlocked": false,
"assigneeName": "Deniz Aksoy",
"isClientFacing": true,
"requiresClientApproval": true,
"isClientApproved": false,
"clientApprovedAt": null,
"version": "1834",
"recurrenceParentId": null
},
"pendingSince": "2026-09-27T09:30:00Z"
}
}ping — the test event Send test event queues. It carries no envelope fields.
{
"event": "ping",
"data": {
"message": "KyroTask webhook test"
}
}What we refuse to send to
The address is checked twice. When you save it, it has to resolve to a public server: private network, loopback and link-local addresses are refused, and so is a host that does not resolve. When a delivery goes out, the destination is checked again against the real IP resolved at the moment of connection — a name can point somewhere else by then — and redirects are not followed. This is a protection for everyone on the platform, not a limitation of your endpoint.