Webhooks
Receive a message on your server as soon as something changes in Klantly, and check that it really comes from Klantly.
With webhooks you do not have to keep asking whether something changed. Klantly sends a message to a URL on your server itself, for example as soon as a lead comes in or a deal is won.
Klantly follows the open Standard Webhooks specification. If you already use a library that supports it, you can use it as is.
Creating an endpoint
An endpoint is the URL the messages are sent to. You create one in either of two ways:
- In Klantly: go to Integrations → API, open the Webhooks tab and choose New endpoint.
- Through the API: with Create a webhook endpoint and a key with the
webhooks.managescope.
Choose which events the endpoint receives, or choose all events. You then see the secret once; it starts with whsec_. You use it to check the signature. Keep it as safe as an API key.
Note
An endpoint never receives more than its creator is allowed to see. If you create it in Klantly, your own permissions apply. Through the API, the read scopes of the key apply: customers.read for customers, deals.read for deals, appointments.read for appointments, tasks.read for tasks, work_orders.read for work orders, quotes.read for quotes, invoices.read for invoices and credit notes, orders.read for webshop orders, messages.read for conversations and messages, and reviews.read for reviews. For everything except customers, conversations and messages the company must also have the matching feature. A note follows the customer or deal it belongs to. The endpoint stays tied to that key: if you revoke it or take a read scope off it, the endpoint no longer receives those events. Changing, turning off, deleting or rotating the secret requires a key that can read everything the endpoint receives, and a webhook can never point to Klantly itself, not even through an IP address. Administrators get an email when an endpoint is added, gets a different address or a new secret.
You can create up to 10 endpoints per company. The URL must use https, on port 443, 80 or 8443, and must not point to an internal network.
What you receive
Each message is a POST with a JSON body in the same shape as Retrieve an event. Under data.object you find the object exactly as the REST API returns it.
{
"object": "event",
"id": "evt_01j7zs1a2b3c4d5e6f7g8h9j0k",
"type": "customer.created",
"created_at": "2026-09-14T10:15:00Z",
"data": {
"object": {
"object": "customer",
"id": "9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70",
"type": "business",
"status": "lead",
"name": "Jan de Vries",
"email": "jan@example.com",
"phone": "+31 6 12345678",
"company_name": "De Vries Bouw",
"vat_number": null,
"coc_number": null,
"address": null,
"postal_code": null,
"city": "Utrecht",
"country": "NL",
"email_unsubscribed": false,
"converted_at": null,
"last_activity_at": "2026-09-14T10:15:00Z",
"created_at": "2026-09-14T10:15:00Z",
"updated_at": "2026-09-14T10:15:00Z"
}
}
}Every message comes with these headers:
| Header | Content |
|---|---|
webhook-id |
The id of the event (evt_…). The same for every new attempt. |
webhook-timestamp |
When the message was sent, in seconds since 1970 (Unix time). |
webhook-signature |
The signature, for example v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=. |
Content-Type |
application/json |
User-Agent |
Klantly-Webhooks/1.0 |
Events
| Event | Description | Scope |
|---|---|---|
customer.created |
A customer or lead was created: in Klantly, through a form or through the API. | customers.read |
customer.updated |
Details of a customer changed. | customers.read |
customer.converted |
A lead became a customer. | customers.read |
customer.deleted |
A customer was deleted. Remove it from your own system too: a deletion never shows up through filter[updated_since]. | customers.read |
deal.created |
A deal was put on the pipeline board. | deals.read |
deal.updated |
Details of a deal changed, such as title, value or owner. | deals.read |
deal.stage_changed |
A deal moved to another stage. | deals.read |
deal.labels_changed |
The labels of a deal changed; the new list is in labels. | deals.read |
deal.won |
A deal was won. | deals.read |
deal.lost |
A deal was lost. | deals.read |
deal.deleted |
A deal was deleted. | deals.read |
note.created |
A note was added to a customer or deal. | customers.read, deals.read |
note.updated |
A note was changed. | customers.read, deals.read |
note.deleted |
A note was deleted. | customers.read, deals.read |
appointment.created |
An appointment was scheduled: in the calendar, through the booking page, the app or the API. | appointments.read |
appointment.updated |
Details of an appointment changed, for example its time (rescheduled). | appointments.read |
appointment.confirmed |
An appointment was confirmed. | appointments.read |
appointment.cancelled |
An appointment was cancelled. | appointments.read |
appointment.completed |
An appointment was completed. | appointments.read |
appointment.deleted |
An appointment was deleted. | appointments.read |
task.created |
A task was created. | tasks.read |
task.updated |
Details of a task changed, such as title, deadline, priority or links. | tasks.read |
task.moved |
A task moved to another column. | tasks.read |
task.labels_changed |
The task’s own labels changed; the new list is in labels. | tasks.read |
task.completed |
A task was completed. | tasks.read |
task.deleted |
A task was deleted. | tasks.read |
quote.created |
A quote was drafted: in Klantly, through the configurator or through the API. | quotes.read |
quote.updated |
Details or lines of a quote changed. | quotes.read |
quote.sent |
A quote was sent to the customer. | quotes.read |
quote.accepted |
A quote was accepted. | quotes.read |
quote.rejected |
A quote was rejected. | quotes.read |
quote.deleted |
A quote was deleted. | quotes.read |
invoice.created |
An invoice was created: in Klantly, from a quote or work order, or through the API. A new credit note also comes as this event, with type credit_note. | invoices.read |
invoice.updated |
Details or lines of an invoice changed. | invoices.read |
invoice.sent |
An invoice was sent to the customer. | invoices.read |
invoice.paid |
An invoice was paid in full — whether that was recorded online, by hand or through the API. | invoices.read |
invoice.cancelled |
An invoice was cancelled. | invoices.read |
invoice.credited |
A credit note was created on an invoice. The event is about the invoice (with credited_amount, credit_note_ids and the new status); the credit note itself arrives as invoice.created with type credit_note. | invoices.read |
invoice.deleted |
An invoice was deleted. | invoices.read |
order.created |
An order was placed in the webshop or configurator. | orders.read |
order.updated |
An order changed, for example its track and trace. | orders.read |
order.status_changed |
An order got a different status. | orders.read |
order.paid |
An order was paid. | orders.read |
work_order.created |
A work order was created. | work_orders.read |
work_order.updated |
Details of a work order changed, such as lines, checklist or planning. | work_orders.read |
work_order.status_changed |
The status of a work order changed. | work_orders.read |
work_order.completed |
A work order was completed. | work_orders.read |
work_order.signed |
A work order was signed by the customer. | work_orders.read |
work_order.deleted |
A work order was deleted. | work_orders.read |
service.created |
A service request was created. | services.read |
service.updated |
Details of a service request changed, such as its description, assignment or planning. | services.read |
service.status_changed |
The status of a service request changed. | services.read |
service.completed |
A service request was completed. | services.read |
service.signed |
A service request was signed by the customer. | services.read |
service.deleted |
A service request was deleted. | services.read |
conversation.created |
A conversation started: by a customer through chat, WhatsApp or email, or by the company. | messages.read |
conversation.closed |
A conversation was closed. | messages.read |
message.received |
A customer sent a message. | messages.read |
message.sent |
The company sent a message, from the inbox, the app, the API or the AI agent. | messages.read |
message.not_delivered |
WhatsApp did not deliver a sent message. Meta sometimes reports this minutes later; the message is marked as not delivered in the conversation. Usually the number cannot receive WhatsApp, or more than 24 hours have passed since the customer last wrote. | messages.read |
review.received |
A review came in: through a review request, from Google or entered by the company. | reviews.read |
review.replied |
The company replied to a review. | reviews.read |
review.updated |
A review changed: hidden, shown again, or its text, rating or consent was changed. A mirror on your website should pick this up. | reviews.read |
review.deleted |
A review was deleted. | reviews.read |
conversation.reopened |
A closed conversation was reopened, for example because the customer replied after all. | messages.read |
conversation.deleted |
A conversation was deleted. | messages.read |
meta_copilot.alert_created |
The Meta co-pilot has a new alert: lost reactions, a disapproval, a spend spike, stalled delivery, an expiring connection or a change outside Klantly. | meta_copilot.read |
meta_copilot.stopped |
The Meta co-pilot was halted with the emergency stop, in the app or via the API. | meta_copilot.read |
meta_copilot.opportunity_created |
The Meta co-pilot found a new opportunity: something that saves or earns money, with the figures and the step it proposes. | meta_copilot.read |
meta_copilot.approval_requested |
The Meta co-pilot has prepared a proposal that awaits the company's decision, with a deadline. | meta_copilot.read |
meta_copilot.approval_expiring |
A proposal from the Meta co-pilot is about to expire without a decision. | meta_copilot.read |
meta_copilot.action_applied |
A proposal from the Meta co-pilot was applied at Meta (by the company, via the API, the e-mail link or autonomously within the mandate). | meta_copilot.read |
meta_copilot.action_failed |
A proposal from the Meta co-pilot could not be applied; the reason is in error. | meta_copilot.read |
meta_copilot.briefing_created |
The Meta co-pilot has written yesterday's morning briefing: spend, leads, quotes and won deals against the weekly average, with the kind of day and that morning's milestones. | meta_copilot.read |
Within version 1, events are only ever added. An endpoint with all events (*) receives new events automatically, so handle an unknown type gracefully.
Checking the signature
Always check the signature first, before you do anything with a message. That way you know for sure it comes from Klantly and was not changed along the way.
- Take the body exactly as you received it, before you parse it as JSON. After parsing, the signature no longer matches.
- Build the text
{webhook-id}.{webhook-timestamp}.{body}. - Calculate an HMAC-SHA256 over it. The key is the part of the secret after
whsec_, decoded from base64. - Encode the result in base64 and compare it with the signatures in
webhook-signature. That header holds one or more signatures of the formv1,<signature>, separated by a space. If one of them matches, the message is genuine. - Reject a message if
webhook-timestampis more than 5 minutes off from your own clock. That way nobody can replay an old message.
Compare in constant time, with hash_equals, crypto.timingSafeEqual or hmac.compare_digest. Then the signature cannot be guessed from the response time.
function verifyKlantlyWebhook(string $body, array $headers, string $secret): bool
{
$id = $headers['webhook-id'] ?? '';
$timestamp = $headers['webhook-timestamp'] ?? '';
$signatures = $headers['webhook-signature'] ?? '';
if (! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
return false;
}
$key = base64_decode(substr($secret, strlen('whsec_')));
$expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$body}", $key, true));
foreach (explode(' ', $signatures) as $signature) {
[$version, $value] = array_pad(explode(',', $signature, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, $value)) {
return true;
}
}
return false;
}
$body = file_get_contents('php://input');
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
if (! verifyKlantlyWebhook($body, $headers, getenv('KLANTLY_WEBHOOK_SECRET'))) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
// Handle $event['type'] and $event['data']['object'] here.
http_response_code(204);import crypto from 'node:crypto';
import express from 'express';
const app = express();
const secret = process.env.KLANTLY_WEBHOOK_SECRET;
function verifyKlantlyWebhook(body, headers) {
const id = headers['webhook-id'] ?? '';
const timestamp = headers['webhook-timestamp'] ?? '';
const signatures = headers['webhook-signature'] ?? '';
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return false;
}
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const expected = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.`).update(body).digest();
return signatures.split(' ').some((signature) => {
const [version, value = ''] = signature.split(',');
const received = Buffer.from(value, 'base64');
return version === 'v1' && received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
// express.raw keeps the body exactly as it arrived.
app.post('/webhooks/klantly', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyKlantlyWebhook(req.body, req.headers)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// Handle event.type and event.data.object here.
res.sendStatus(204);
});import base64
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["KLANTLY_WEBHOOK_SECRET"]
def verify_klantly_webhook(body: bytes, headers) -> bool:
msg_id = headers.get("webhook-id", "")
timestamp = headers.get("webhook-timestamp", "")
signatures = headers.get("webhook-signature", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(SECRET.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for signature in signatures.split(" "):
version, _, value = signature.partition(",")
if version == "v1" and hmac.compare_digest(expected, value):
return True
return False
@app.post("/webhooks/klantly")
def klantly_webhook():
body = request.get_data()
if not verify_klantly_webhook(body, request.headers):
abort(401)
event = request.get_json()
# Handle event["type"] and event["data"]["object"] here.
return "", 204Responses and retries
Respond within 10 seconds with a status in the 200 range. Preferably do the real work afterwards, for example through a queue: a late response counts as a failure. Klantly does not follow redirects.
If a delivery fails, Klantly tries again after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and another 10 hours. That is eight attempts in about 27 hours.
If an endpoint returns nothing but errors for 5 days, Klantly disables it and the company's administrators get an email. Turn it back on in Klantly, or with Update a webhook endpoint and "status": "active". In Klantly you see the delivery attempts of the last 30 days per endpoint, and you can send a message again.
Duplicate messages and order
- A message can arrive more than once, for example when your response got lost on the way. Remember the
webhook-idof processed messages and skip an id you already know. - The order is not guaranteed: a retry can arrive after a later event. Compare the object's
updated_at, or fetch the object through the API if you want to be sure of its latest state.
Catching up on missed events
Was your server down? With List events you fetch what you missed. Events are kept for 30 days.
curl --globoff "https://app.klantly.com/api/v1/events?sort=created_at&filter[created_since]=2026-09-14T08:00:00Z" \
-H "Authorization: Bearer $KLANTLY_API_KEY"The key needs the events.read scope for this and only sees events about data it can read. The ping test message is never included.
Sending a test message
With Test in Klantly, or with Send a test message through the API, Klantly sends the ping event right away:
{
"object": "event",
"id": "evt_01j7zt4m6n8p0r2t4v6w8y0a2c",
"type": "ping",
"created_at": "2026-09-14T10:15:00Z",
"data": {
"object": {
"object": "ping",
"webhook_endpoint_id": "01j7zr8m2k4n6p8r0t2v4w6y8a",
"message": "Klantly webhook test"
}
}
}A test message is also sent to a disabled endpoint and is not retried. That way you can check that your server works again before you turn the endpoint back on.
Rotating the secret
Has the secret leaked, or do you simply want to replace it regularly? Rotate it in Klantly or with Rotate the secret, and choose an overlap. While it runs, webhook-signature holds two signatures: one with the old and one with the new secret. Put the new secret in your receiver before the overlap ends. If the secret leaked, choose no overlap.
Last updated on September 18, 2026