Webhooks

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.

Quickstart

  1. Step 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. Step 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. Step 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. Step 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)
{
  "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"
    }
  }
}

Fields

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:

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

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)
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)
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
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
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.