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
- 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.
- Step 2
Store the signing secret
Kairon shows the secret (it starts with
whsec_) exactly once. Put it in an environment variable, for exampleKAIRON_WEBHOOK_SECRET. If you lose it, roll a new one on the same page. - 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.
- 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:
doc_get { slug: "webhooks" }: reads this guide before it writes any code.webhook_set { url }: registers the endpoint and returns the signing secret, once.- It writes the handler and stores the secret in your environment, never in the code.
webhook_test { id }: sends a test event and returns the status code your server answered.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.
{
"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
idstring
The event id (evt_…). The same on every retry and every "Send again". Dedupe on it.typestring
Alwayslead.stage.changedtoday. Ignore types you do not know: we will add more.createdinteger
When the stage changed, in Unix SECONDS.data.previous_attributes.stagestring
The stage the lead left.data.object.idstring
The lead id (lead_…). Stable: use it to match the lead in your system.data.object.stagestring
The stage the lead moved to. One ofnew,invite_sent,connected,contacted,replied,interested,meeting_booked,won,on_hold,lost,discarded.data.object.created_atISO 8601
When the lead was created in Kairon.data.object.full_name, first_name, last_namestring | null
The name as LinkedIn gives it.data.object.headline, summary, locationstring | null
From the LinkedIn profile.data.object.avatar_urlstring | null
The profile photo, when there is one.data.object.linkedin_urlstring | null
The profile URL.data.object.linkedin_public_identifierstring | null
The handle at the end of the profile URL.data.object.emailstring | null
The work email, when Kairon found one or a person typed it. Often null.data.object.email_sourcestring | nullmanualwhen a person typed it, else the name of the finder.data.object.email_found_atISO 8601 | null
When the email was found.data.object.phonestring | null
The phone number, as the finder or the person gave it. Often null.data.object.phone_sourcestring | nullmanualwhen a person typed it, else the name of the finder.data.object.phone_found_atISO 8601 | null
When the phone was found.data.object.current_companyobject | nullid,name,domain,linkedin_url,industry,employee_count,location. Every field butidcan be null.data.object.positions[]array
The full job history:title,company_name,company_linkedin_id,description,start,end(YYYY-MMwhen parseable),is_current.data.object.education[]arrayschool,degree,field_of_study,start,end.data.object.ownerobject | null
The teammate responsible for the lead:id,name,email. Null when nobody owns it.data.object.tags[]arrayidandnameof each tag on the lead.data.object.qualificationobject | null
What was decided about the lead against your ICP:status,decided_by(kaironorhuman),decided_at,icp_version_id,rationale. Null until the lead is judged.
Headers
Every request is a POST with these headers:
Content-Typeapplication/json
The body is UTF-8 JSON.Kairon-Signaturet=…,v1=…
The signature. Check it on every request (below).Kairon-Event-Idevt_…
The same asidin the body.Kairon-Event-Typestring
The same astypein the body.Kairon-Delivery-Attempt1–6
Which try this is. Anything above 1 is a retry of an event you may already have.User-AgentKairon-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().
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);
}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).
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);
});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 "", 200What 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.