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 and deals.read for deals. A note follows the customer or deal it belongs to.
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 |
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.won |
A deal was won. | deals.read |
deal.lost |
A deal was lost. | 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 |
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 15, 2026