# Receiving Kairon webhooks

Kairon POSTs a signed JSON event to your URL every time one of your leads moves to a new pipeline stage. This page is everything you need to accept those requests correctly: the event, the headers, how to check the signature, and what to answer.

Web version: https://heykairon.com/webhooks

## Quickstart

1. **Register your URL.** In Kairon, open Settings → Integrations → Webhooks and add an endpoint. The URL must be https and reachable from the public internet.
2. **Store the signing secret.** Kairon shows the secret (it starts with `whsec_`) exactly once. Put it in an environment variable, for example `KAIRON_WEBHOOK_SECRET`. If you lose it, roll a new one on the same page.
3. **Write the handler.** Check the signature, skip events you already have, store the event, and answer 200. The code below does all four.
4. **Send a test event.** Press "Send test event" on the endpoint. It delivers a sample lead through the real pipeline, and the delivery log shows the body we sent and what your server answered.

## Setting it up from an agent

An agent connected to the Kairon MCP server (Claude, Codex, ChatGPT) can do the whole setup inside your codebase. Ask it to "connect this app to Kairon webhooks". It runs these tools:

1. `doc_get { slug: "webhooks" }`: reads this guide before it writes any code.
2. `webhook_set { url }`: registers the endpoint and returns the signing secret, once.
3. It writes the handler and stores the secret in your environment, never in the code.
4. `webhook_test { id }`: sends a test event and returns the status code your server answered.
5. `webhook_deliveries { endpointId }`: reads the log when a delivery did not land.

## The event

One event type today: `lead.stage.changed`. It fires whoever moved the lead: a person on the board, a bulk change, an agent, or a Kairon campaign. It fires on EVERY stage move, so filter on `data.object.stage` for the stages you care about. The lead object carries everything Kairon holds about the person.

A lead reaching "meeting_booked" (the body "Send test event" delivers):

```json
{
  "id": "evt_01k5x2q3r4s5t6v7w8x9y0z1a2",
  "type": "lead.stage.changed",
  "created": 1790000000,
  "data": {
    "object": {
      "id": "lead_00000000000000000000000000",
      "object": "lead",
      "created_at": "2026-08-14T09:22:41.310Z",
      "stage": "meeting_booked",
      "full_name": "Sample Person",
      "first_name": "Sample",
      "last_name": "Person",
      "headline": "Head of Revenue Operations at Example Co",
      "summary": "This is a test event from Kairon. No such person exists.",
      "location": "Madrid, Community of Madrid, Spain",
      "avatar_url": null,
      "linkedin_url": "https://www.linkedin.com/in/sample-person",
      "linkedin_public_identifier": "sample-person",
      "email": "sample.person@example.com",
      "email_source": "manual",
      "email_found_at": "2026-08-20T11:04:02.881Z",
      "phone": "+1 415 555 0100",
      "phone_source": "apollo",
      "phone_found_at": "2026-08-21T09:12:40.105Z",
      "current_company": {
        "id": "comp_00000000000000000000000000",
        "name": "Example Co",
        "domain": "example.com",
        "linkedin_url": "https://www.linkedin.com/company/example",
        "industry": "Software Development",
        "employee_count": 240,
        "location": "Madrid, Spain"
      },
      "positions": [
        {
          "title": "Head of Revenue Operations",
          "company_name": "Example Co",
          "company_linkedin_id": "1234567",
          "start": "2024-03",
          "end": null,
          "is_current": true,
          "description": "Owns the go-to-market stack and pipeline reporting."
        },
        {
          "title": "Revenue Operations Manager",
          "company_name": "Former Co",
          "company_linkedin_id": null,
          "start": "2021-01",
          "end": "2024-02",
          "is_current": false,
          "description": null
        }
      ],
      "education": [
        {
          "school": "Example University",
          "degree": "BSc",
          "field_of_study": "Business Administration",
          "start": "2008",
          "end": "2012"
        }
      ],
      "owner": {
        "id": "user_00000000000000000000000000",
        "name": "Sample Owner",
        "email": "owner@example.com"
      },
      "tags": [
        {
          "id": "tag_00000000000000000000000000",
          "name": "Sample tag"
        }
      ],
      "qualification": {
        "status": "qualified",
        "decided_by": "kairon",
        "decided_at": "2026-08-15T07:31:19.002Z",
        "icp_version_id": "icpv_00000000000000000000000000",
        "rationale": "A sample rationale. Kairon explains every verdict it reaches."
      }
    },
    "previous_attributes": {
      "stage": "interested"
    }
  }
}
```

| Field | Type | Meaning |
| --- | --- | --- |
| `id` | string | The event id (`evt_…`). The same on every retry and every "Send again". Dedupe on it. |
| `type` | string | Always `lead.stage.changed` today. Ignore types you do not know: we will add more. |
| `created` | integer | When the stage changed, in Unix SECONDS. |
| `data.previous_attributes.stage` | string | The stage the lead left. |
| `data.object.id` | string | The lead id (`lead_…`). Stable: use it to match the lead in your system. |
| `data.object.stage` | string | The stage the lead moved to. One of `new`, `invite_sent`, `connected`, `contacted`, `replied`, `interested`, `meeting_booked`, `won`, `on_hold`, `lost`, `discarded`. |
| `data.object.created_at` | ISO 8601 | When the lead was created in Kairon. |
| `data.object.full_name, first_name, last_name` | string \| null | The name as LinkedIn gives it. |
| `data.object.headline, summary, location` | string \| null | From the LinkedIn profile. |
| `data.object.avatar_url` | string \| null | The profile photo, when there is one. |
| `data.object.linkedin_url` | string \| null | The profile URL. |
| `data.object.linkedin_public_identifier` | string \| null | The handle at the end of the profile URL. |
| `data.object.email` | string \| null | The work email, when Kairon found one or a person typed it. Often null. |
| `data.object.email_source` | string \| null | `manual` when a person typed it, else the name of the finder. |
| `data.object.email_found_at` | ISO 8601 \| null | When the email was found. |
| `data.object.phone` | string \| null | The phone number, as the finder or the person gave it. Often null. |
| `data.object.phone_source` | string \| null | `manual` when a person typed it, else the name of the finder. |
| `data.object.phone_found_at` | ISO 8601 \| null | When the phone was found. |
| `data.object.current_company` | object \| null | `id`, `name`, `domain`, `linkedin_url`, `industry`, `employee_count`, `location`. Every field but `id` can be null. |
| `data.object.positions[]` | array | The full job history: `title`, `company_name`, `company_linkedin_id`, `description`, `start`, `end` (`YYYY-MM` when parseable), `is_current`. |
| `data.object.education[]` | array | `school`, `degree`, `field_of_study`, `start`, `end`. |
| `data.object.owner` | object \| null | The teammate responsible for the lead: `id`, `name`, `email`. Null when nobody owns it. |
| `data.object.tags[]` | array | `id` and `name` of each tag on the lead. |
| `data.object.qualification` | object \| null | What was decided about the lead against your ICP: `status`, `decided_by` (`kairon` or `human`), `decided_at`, `icp_version_id`, `rationale`. Null until the lead is judged. |

## Headers

Every request is a POST with these headers:

| Header | Value | Meaning |
| --- | --- | --- |
| `Content-Type` | application/json | The body is UTF-8 JSON. |
| `Kairon-Signature` | t=…,v1=… | The signature. Check it on every request (below). |
| `Kairon-Event-Id` | evt_… | The same as `id` in the body. |
| `Kairon-Event-Type` | string | The same as `type` in the body. |
| `Kairon-Delivery-Attempt` | 1–6 | Which try this is. Anything above 1 is a retry of an event you may already have. |
| `User-Agent` | Kairon-Webhooks/1 | For your logs and your firewall. |

## Check the signature

The `Kairon-Signature` header looks like `t=1790000000,v1=5257a8…`. `t` is the Unix time we signed at. `v1` is the hex HMAC-SHA256 of `"{t}.{raw body}"`, keyed with your whole secret, `whsec_` prefix included. Compute the same value, compare it in constant time, and refuse a `t` more than 5 minutes from now. This is the same scheme Stripe uses.

**Use the RAW body. The most common mistake is to parse the JSON first and sign the re-serialized object: key order and spacing change, and every signature fails. In Express use `express.raw`, in Flask `request.get_data()`, in Next.js `await req.text()`.**

Node.js (no dependencies):

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

/**
 * True when a request really came from Kairon.
 * rawBody: the body exactly as it arrived (a Buffer or string), never re-serialized JSON.
 */
export function verifyKaironSignature(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = {};
  for (const piece of String(header ?? '').split(',')) {
    const at = piece.indexOf('=');
    if (at > 0) parts[piece.slice(0, at).trim()] ??= piece.slice(at + 1).trim();
  }
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;

  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const got = Buffer.from(parts.v1);
  const want = Buffer.from(expected);
  return got.length === want.length && timingSafeEqual(got, want);
}
```

Python (standard library only):

```python
import hashlib
import hmac
import time


def verify_kairon_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    """True when a request really came from Kairon. raw_body: the bytes exactly as they arrived."""
    parts = {}
    for piece in (header or "").split(","):
        key, sep, value = piece.partition("=")
        if sep and key.strip() and key.strip() not in parts:
            parts[key.strip()] = value.strip()
    try:
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if "v1" not in parts or abs(time.time() - t) > tolerance:
        return False
    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), parts["v1"].encode())
```

## A complete handler

Verify, skip what you do not handle, dedupe, store, answer. Keep the slow work (CRM calls, emails) out of the request. Each handler imports the verifier above, saved as `verify-kairon-signature.js` (Node) or `verify_kairon_signature.py` (Python).

Node.js + Express:

```js
import express from 'express';
import { verifyKaironSignature } from './verify-kairon-signature.js';

const app = express();

// express.raw, NOT express.json: the signature is over the exact bytes we sent.
app.post('/kairon/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  const ok = verifyKaironSignature(req.body, req.get('Kairon-Signature'), process.env.KAIRON_WEBHOOK_SECRET);
  if (!ok) return res.status(400).send('bad signature');

  const event = JSON.parse(req.body.toString('utf8'));

  // More event types will come: answer 200 to one you do not handle, so it is not retried.
  if (event.type !== 'lead.stage.changed') return res.sendStatus(200);

  // A test event ("Send test event", webhook_test) carries the invented sample lead: accept it, store nothing.
  if (event.data.object.id === 'lead_00000000000000000000000000') return res.sendStatus(200);

  // Same id on every retry and every "Send again": skip what you already have.
  if (await alreadyStored(event.id)) return res.sendStatus(200);

  // Store it, answer, and do the slow work afterwards (a queue, a job, a background task).
  await storeEvent(event);
  res.sendStatus(200);
});
```

Python + Flask:

```python
import json
import os

from flask import Flask, request

from verify_kairon_signature import verify_kairon_signature

app = Flask(__name__)


@app.post("/kairon/webhook")
def kairon_webhook():
    # get_data(), NOT get_json(): the signature is over the exact bytes we sent.
    raw = request.get_data()
    if not verify_kairon_signature(raw, request.headers.get("Kairon-Signature"), os.environ["KAIRON_WEBHOOK_SECRET"]):
        return "bad signature", 400

    event = json.loads(raw)

    # More event types will come: answer 200 to one you do not handle, so it is not retried.
    if event["type"] != "lead.stage.changed":
        return "", 200

    # A test event ("Send test event", webhook_test) carries the invented sample lead: accept it, store nothing.
    if event["data"]["object"]["id"] == "lead_00000000000000000000000000":
        return "", 200

    # Same id on every retry and every "Send again": skip what you already have.
    if already_stored(event["id"]):
        return "", 200

    # Store it, answer, and do the slow work afterwards.
    store_event(event)
    return "", 200
```

## What to answer, and how we retry

- **Answer 2xx within 10 seconds.** Any 2xx means "received". If your server sends nothing for 10 seconds, the try counts as failed. Store the event and answer; do the work afterwards.
- **Answer 200 to events you ignore.** An event for a stage you do not care about is still "received". If you answer 4xx to it, we treat it as a failure and retry it.
- **Every other answer is retried, 4xx included.** Any status outside 2xx, a timeout, or a refused connection is retried: 6 tries in total, after 1 minute, 10 minutes, 1 hour, 6 hours and 16 hours (about 23 hours). We do not follow redirects, so a 3xx is a failure too.
- **Expect duplicates, and do not rely on order.** A retry can arrive after a newer event for the same lead. Dedupe on `id`. To know the latest stage, compare `created`, which is in whole seconds: two moves in the same second tie, so on a tie keep the one you received last.
- **An endpoint that fails for 5 days is switched off.** If every delivery to an endpoint fails for 5 days, Kairon switches it off and emails the person who registered it (the workspace owner, when an agent registered it). Turn it back on in Settings once your server is fixed.

## Testing and troubleshooting

- **localhost is refused.** We only deliver to public https addresses. To test on your machine, expose it with a tunnel (for example `ngrok http 3000` or `cloudflared tunnel`) and register the tunnel URL.
- **Test events and "Send again" get one try.** They are for checking your server, so they are not retried and never count toward the 5-day switch-off. Fix your server and press the button again.
- **Recognise a test event, and do not store it.** A test event carries an invented person ("Sample Person") whose lead id is always `lead_00000000000000000000000000`. Each test has a new event id, so dedupe does not catch it: check the lead id, answer 200, and store nothing. The handlers above already do.
- **The delivery log is the first place to look.** Each endpoint in Settings shows its recent deliveries: the status your server answered and every try, and "View body" shows the body we sent. From an agent, `webhook_deliveries` returns the statuses and tries (not the body; the body of a test event is the example above).
- **Every signature fails.** Almost always the body was parsed before it was checked (see the warning above), or the secret is from an older endpoint or was rolled. The secret includes its `whsec_` prefix.