Webhook events

Create webhooks, the events kanman sends for stories, runs, gates, decisions and teams, how to verify signatures, and the delivery and retry rules.

Webhooks send a signed HTTP request to your server when something happens in kanman: a story becomes ready, a run finishes or fails, a gate fails, a decision waits for an answer, a team report is published. Use them to post into your own chat tools, update a dashboard or start your own automation.

Webhooks belong to the workspace. Owners and admins create and manage them; members do not see them.

Webhooks in the workspace settings (Desktop) Webhooks in the workspace settings (Mobile)

Create a webhook

  1. Open Settings, Webhooks (/<workspace>/settings/webhooks).
  2. Click Add webhook.
  3. Enter a Name and the Endpoint URL of your server.
  4. Under Events, select the events the webhook should receive. They are grouped into stories, runs and pull requests, outcome gate, decisions, and teams and reports. A new webhook starts with Run completed, Run failed or stopped and Decision waiting selected.
  5. Click Add webhook.
Form for creating a webhook (Desktop) Form for creating a webhook (Mobile)

kanman generates a Signing secret for every webhook (it starts with whsec_). Click Show to see it and Regenerate to replace it; your receiver needs it to verify signatures. The switch on each webhook turns delivery on or off. Click a webhook’s name to change its name, endpoint or events.

Events

Event Sent when
story.created A story appears in a team: an issue was picked up from your tracker, or an approved draft story was written to it.
story.ready A story enters the team’s Ready column.
run.started kanman starts working on a story (also for every new attempt).
run.stage_changed A run moves to another stage: planning, implementing, verifying, review, done.
run.completed An attempt of a run finishes its work.
run.failed An attempt fails, times out or is stopped.
pr.opened kanman opens the pull request of a run.
gate.passed A gate of the outcome gate passes, for example the clean-room check or the reviewer.
gate.failed A gate fails or cannot run.
decision.created kanman needs a decision from a person.
decision.resolved Someone answered a decision, in the app, in Slack, through the MCP server or the REST API.
report.published A team’s weekly report is ready.
team.paused A team is paused.
team.resumed A paused team is resumed.
test You click Send test. Cannot be subscribed to.

Request format

Every delivery is an HTTP POST with a JSON body:

{
  "id": "6f1d2c80-4c1e-4f3a-9b7e-2a1d8c5e9f10",
  "event": "run.failed",
  "timestamp": "2026-10-01T09:16:56.512Z",
  "workspaceId": "a1b2c3d4-0000-4000-8000-000000000001",
  "attempt": 1,
  "data": {
    "team": { "id": "...", "slug": "sandbox-team", "name": "Sandbox Team", "keyPrefix": "SBX" },
    "story": {
      "id": "...", "key": "SBX-12", "title": "Export invoices as CSV", "readinessScore": 100,
      "tracker": { "platform": "github", "key": "#12", "url": "https://github.com/acme/app/issues/12" }
    },
    "run": {
      "key": "run-7f3k", "attempt": 2, "status": "failed", "stage": "verifying", "executor": "claude-code",
      "costCents": 140, "budgetCents": 400, "failureClass": "gate", "prUrl": null, "prNumber": null,
      "startedAt": "2026-10-01T09:02:10.000Z", "completedAt": "2026-10-01T09:16:56.000Z"
    },
    "error": "Verification failed"
  }
}

id is the delivery id; it stays the same when a delivery is retried, and attempt counts the tries. timestamp is when the event happened, not when it was sent.

What data contains depends on the event:

Events data fields
story.* team, story
run.* team, story, run; run.stage_changed adds previousStage, run.failed adds error
pr.opened team, story, run, pullRequest (url, number)
gate.* team, story, gate (name, verdict, reason, attempt, runKey, finishedAt)
decision.* team, story, decision (key, kind, authority, title, status, recommendation, options, runKey, resolution, resolvedVia, resolvedBy, resolvedAt, expiresAt); decision.resolved adds result
report.published team, report (periodKey, periodStart, periodEnd, summary, metrics)
team.* team, pausedAt, by

story is null for events without a story, for example a decision about the whole team. Treat unknown fields as optional: kanman may add fields, but does not remove or rename them.

Header Description
Content-Type application/json
User-Agent kanman-webhooks/1
X-Kanman-Event Event type, for example run.failed.
X-Kanman-Delivery Delivery id (UUID), the same as id in the body. Use it to ignore duplicates.
X-Kanman-Timestamp Unix time in seconds when this attempt was signed.
X-Kanman-Signature sha256= followed by the hex HMAC-SHA256 signature.

Verify the signature

The signature is HMAC-SHA256 with your webhook secret over the string <X-Kanman-Timestamp>.<raw request body>. Compute it over the raw bytes you received, before any JSON parsing. Parsing and serializing the body again changes whitespace and key order and breaks the signature. Also reject deliveries whose timestamp is more than five minutes old, so a captured request cannot be replayed.

Node.js with Express:

const crypto = require('crypto')
const express = require('express')

const app = express()
const SECRET = process.env.KANMAN_WEBHOOK_SECRET

// Keep the raw body: the signature covers the exact bytes kanman sent
app.post('/kanman', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = req.get('X-Kanman-Timestamp')
  const signature = req.get('X-Kanman-Signature') || ''
  const expected = 'sha256=' + crypto
    .createHmac('sha256', SECRET)
    .update(`${timestamp}.${req.body.toString('utf8')}`)
    .digest('hex')

  const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300
  const valid = signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
  if (!fresh || !valid) return res.status(401).send('invalid signature')

  const { event, data } = JSON.parse(req.body)
  res.status(200).send('ok')
  handle(event, data) // do the real work after answering
})

Python with Flask:

import hashlib
import hmac
import json
import os
import time

from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["KANMAN_WEBHOOK_SECRET"].encode()

@app.post("/kanman")
def kanman_webhook():
    timestamp = request.headers.get("X-Kanman-Timestamp", "0")
    signature = request.headers.get("X-Kanman-Signature", "")
    raw = request.get_data()  # raw bytes, not request.json
    expected = "sha256=" + hmac.new(SECRET, timestamp.encode() + b"." + raw, hashlib.sha256).hexdigest()
    if abs(time.time() - int(timestamp)) > 300 or not hmac.compare_digest(expected, signature):
        return "invalid signature", 401
    payload = json.loads(raw)
    print(payload["event"], payload["data"])
    return "ok", 200

Delivery rules

  • kanman sends an event within seconds after it happens.
  • Answer with a 2xx status within 10 seconds. Do slow work after you answered. Redirects are not followed.
  • A failed delivery is retried up to four times: after 1 minute, 5 minutes, 30 minutes and 2 hours. Network errors, timeouts, 408, 429 and 5xx answers are retried; other 4xx answers count as a final failure right away.
  • After 5 deliveries in a row failed for good, the webhook is paused and shows Paused with a short explanation; deliveries still waiting for it are dropped. Fix the endpoint, then turn the webhook on again with its switch under Settings, Webhooks. Turning it on resets the failure count.
  • Each event is delivered at least once. Use X-Kanman-Delivery (or id) to ignore a delivery you already handled.
  • Deliveries of different events can arrive out of order. Use timestamp to order them.

Testing

Click Send test on a webhook to send a test event to your endpoint. The app tells you whether it was delivered. The test body looks like this:

{
  "id": "0b9d4a6e-8c2f-4d1a-9e3b-7f6a5c4d3e2b",
  "event": "test",
  "timestamp": "2026-10-01T09:00:00.000Z",
  "workspaceId": "a1b2c3d4-0000-4000-8000-000000000001",
  "attempt": 1,
  "data": {
    "message": "This is a test delivery from kanman",
    "webhookId": "3f0c2b9e-6a51-4c1e-9f0a-2d7b8e41c5aa",
    "webhookName": "Ops alerts"
  }
}

A test is sent once and not retried. To test locally, expose your machine with a tunnel and use the public HTTPS URL as the endpoint.

To delete a webhook, click its delete button and confirm with Delete webhook.

Confirmation dialog for deleting a webhook (Desktop) Confirmation dialog for deleting a webhook (Mobile)

Last updated: January 1, 0001

Open kanman