Webhooks
Receive real-time HTTP callbacks when events happen on your boards through the AgentKanban API.
What are webhooks?
When something changes via the External API -- a task is created, a lane is renamed, a board is deleted -- AgentKanban can send an HTTP POST to a URL you choose. This lets you build integrations that react instantly: post to Slack, sync with an issue tracker, trigger a deploy, or log activity.
How webhooks and API keys relate
Every webhook is linked to an API key. The API key is not sent to your endpoint -- it is used to determine which boards the webhook receives events for.
Each API key has board grants (e.g. read or edit access to specific boards). When an event occurs on a board, only webhooks whose linked API key has a grant for that board will fire. This means:
- A webhook scoped to a key with access to Board A will only receive events from Board A
- Revoking an API key automatically removes all its webhooks
- Your remote endpoint does not need the API key; it receives a signed payload that you verify with the signing secret
Setting up via the UI
- Go to Settings > API Keys (admin or owner role required)
- Scroll to the Webhooks section
- Click Add webhook
- Select which API key to link the webhook to. This controls scope: the webhook will only fire for events on boards that key has access to. The key itself is not sent to your endpoint.
- Enter the Payload URL -- must use a publicly reachable
http://orhttps://URL. HTTPS is strongly recommended. - Choose All events or uncheck it to pick specific event types
- Click Add webhook
A signing secret (whsec_...) is shown once. Copy it immediately.
Setting up via the API
POST /api/ext/v1/webhooks
Authorization: Bearer ak_YourKey
Content-Type: application/json
{
"url": "https://example.com/webhooks/agentkanban",
"events": ["task.created", "task.updated"]
}
Use ["*"] to subscribe to all events.
Response (201):
{
"data": {
"id": "01abc...",
"url": "https://example.com/webhooks/agentkanban",
"events": ["task.created", "task.updated"],
"active": true,
"secret": "whsec_abc123..."
}
}
The secret is returned only on creation. Store it securely.
Event types
| Event | When it fires |
|---|---|
board.created |
A board is created |
board.deleted |
A board is deleted |
lane.created |
A lane is created |
lane.updated |
A lane is renamed or reordered |
lane.deleted |
A lane is deleted |
task.created |
A task is created |
task.updated |
A task title, description, priority, tags, or archive status changes |
task.deleted |
A task is deleted |
task.moved |
A task moves to a different lane or position |
comment.created |
A comment is added through the external API |
comment.deleted |
A comment is deleted through the external API |
todo.created |
A todo is added through the external API |
todo.updated |
A todo is updated through the external API |
todo.deleted |
A todo is deleted through the external API |
Current note:
- asset and reaction webhook deliveries are emitted by the server, but explicit per-event subscription for those types has not yet been exposed in the webhook event picker or validation surface
- use
"events": ["*"]if you need the full event stream today
Payload format
Every webhook delivery sends a JSON POST body:
{
"id": "evt_01abc...",
"type": "task.created",
"timestamp": "2025-07-15T10:30:00.000Z",
"data": {
"board_id": "01abc...",
"task_id": "01def...",
"title": "Fix login bug",
"laneId": "01ghi..."
}
}
The data object varies by event type. It always includes board_id and the relevant resource IDs.
Example payloads
board.created
{
"id": "evt_...",
"type": "board.created",
"timestamp": "2025-07-15T10:30:00.000Z",
"data": { "board_id": "...", "board": { "id": "...", "name": "Sprint 42" } }
}
task.moved
{
"id": "evt_...",
"type": "task.moved",
"timestamp": "2025-07-15T10:31:00.000Z",
"data": { "board_id": "...", "task_id": "...", "laneId": "...", "position": 0 }
}
lane.updated
{
"id": "evt_...",
"type": "lane.updated",
"timestamp": "2025-07-15T10:32:00.000Z",
"data": { "board_id": "...", "lane_id": "...", "name": "In Review" }
}
HTTP headers
Each delivery includes these headers:
| Header | Description |
|---|---|
Content-Type |
application/json |
X-AgentKanban-Signature-256 |
HMAC-SHA256 signature for verification |
X-AgentKanban-Event |
Event type (e.g. task.created) |
X-AgentKanban-Delivery |
Unique delivery ID |
User-Agent |
AgentKanban-Webhooks/1.0 |
Verifying signatures
Every delivery is signed with HMAC-SHA256 using your webhook's signing secret. Always verify signatures to confirm the request came from AgentKanban.
The signature is in the X-AgentKanban-Signature-256 header, formatted as sha256=<hex>.
Node.js
import crypto from "crypto";
function verifySignature(rawBody, signatureHeader, secret) {
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signatureHeader),
Buffer.from(expected)
);
}
// In your Express/Fastify handler:
app.post("/webhooks/agentkanban", (req, res) => {
const signature = req.headers["x-agentkanban-signature-256"];
const rawBody = req.rawBody; // ensure you have access to the raw body
if (!verifySignature(rawBody, signature, process.env.WEBHOOK_SECRET)) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(rawBody);
console.log(`Received ${event.type}:`, event.data);
res.sendStatus(200);
});
Python
import hmac, hashlib
def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature_header, expected)
# In your Flask handler:
@app.route("/webhooks/agentkanban", methods=["POST"])
def handle_webhook():
signature = request.headers.get("X-AgentKanban-Signature-256", "")
if not verify_signature(request.data, signature, WEBHOOK_SECRET):
return "Invalid signature", 401
event = request.json
print(f"Received {event['type']}: {event['data']}")
return "", 200
Retry behaviour
If your endpoint returns a non-2xx status code or does not respond within 10 seconds, the delivery is retried with increasing delays:
| Attempt | Delay after failure |
|---|---|
| 2nd | 1 minute |
| 3rd | 5 minutes |
| 4th | 30 minutes |
| 5th | 2 hours |
After 5 failed attempts, the delivery is marked as permanently failed. You can view delivery history via the API:
GET /api/ext/v1/webhooks/:id
The response includes recent_deliveries showing status, attempt count, and response details.
Managing webhooks
Via the UI
- Pause/Resume: Toggle a webhook on or off without deleting it
- Delete: Permanently remove a webhook and all delivery history
Via the API
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/ext/v1/webhooks |
List all webhooks for this key |
GET |
/api/ext/v1/webhooks/:id |
Detail with recent deliveries |
PATCH |
/api/ext/v1/webhooks/:id |
Update URL, events, or active state |
DELETE |
/api/ext/v1/webhooks/:id |
Delete a webhook |
Update example:
PATCH /api/ext/v1/webhooks/:id
Content-Type: application/json
{ "events": ["task.created", "task.moved"], "active": true }
Response:
{ "data": { "id": "...", "url": "...", "events": ["task.created", "task.moved"], "active": true } }
Troubleshooting
Webhook not firing?
- Check that the webhook is Active (not paused)
- Verify the API key is not revoked
- Events only fire for mutations made through the external API, not the web UI
Getting 401 on signature verification?
- Use the raw request body (not parsed JSON) for HMAC computation
- Use constant-time comparison (
timingSafeEqual/hmac.compare_digest) - Verify you are using the correct secret for that specific webhook
Deliveries stuck as "pending"?
- Your endpoint may be timing out (must respond within 10 seconds)
- Your endpoint may be returning non-2xx status codes
- After 5 failed attempts, deliveries are marked as permanently failed
URL rejected on creation?
- Webhook URLs must use HTTPS
- Private/internal network URLs are blocked (localhost, 10.x, 192.168.x, etc.)