Webhooks
Recevez immédiatement un message sur votre serveur dès qu'un changement a lieu dans Klantly, et vérifiez qu'il provient bien de Klantly.
Avec les webhooks, plus besoin de demander sans cesse si quelque chose a changé. Klantly envoie lui-même un message à une adresse de votre serveur, par exemple dès qu'un prospect arrive ou qu'un deal est gagné.
Klantly suit le standard ouvert Standard Webhooks. Si vous utilisez déjà une bibliothèque compatible, vous pouvez l'employer telle quelle.
Créer un endpoint
Un endpoint est l'adresse à laquelle les messages sont envoyés. Vous le créez de l'une de ces deux façons :
- Dans Klantly : allez dans Intégrations → API, ouvrez l'onglet Webhooks et choisissez Nouvel endpoint.
- Via l'API : avec Créer un endpoint de webhook et une clé disposant du scope
webhooks.manage.
Choisissez les événements que l'endpoint reçoit, ou tous les événements. Vous voyez ensuite une seule fois le secret, qui commence par whsec_. Il sert à vérifier la signature. Conservez-le aussi soigneusement qu'une clé API.
Remarque
Un endpoint ne reçoit jamais plus que ce que son créateur a le droit de voir. Si vous le créez dans Klantly, vos propres droits s'appliquent. Via l'API, ce sont les scopes de lecture de la clé : customers.read pour les clients et deals.read pour les deals. Une note suit le client ou le deal auquel elle appartient.
Vous pouvez créer 10 endpoints au maximum par entreprise. L'adresse doit utiliser https, sur le port 443, 80 ou 8443, et ne doit pas pointer vers un réseau interne.
Ce que vous recevez
Chaque message est un POST avec un corps JSON de la même forme que Récupérer un événement. Sous data.object figure l'objet exactement tel que l'API REST le renvoie.
{
"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"
}
}
}Chaque message est accompagné de ces en-têtes :
| En-tête | Contenu |
|---|---|
webhook-id |
L'id de l'événement (evt_…). Identique à chaque nouvelle tentative. |
webhook-timestamp |
Le moment de l'envoi, en secondes depuis 1970 (heure Unix). |
webhook-signature |
La signature, par exemple v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=. |
Content-Type |
application/json |
User-Agent |
Klantly-Webhooks/1.0 |
Événements
| Événement | Description | Scope |
|---|---|---|
customer.created |
Un client ou un prospect a été créé : dans Klantly, via un formulaire ou via l'API. | customers.read |
customer.updated |
Les données d'un client ont changé. | customers.read |
customer.converted |
Un prospect est devenu client. | customers.read |
deal.created |
Un deal a été placé sur le tableau de pipeline. | deals.read |
deal.updated |
Les données d'un deal ont changé, comme le titre, la valeur ou le responsable. | deals.read |
deal.stage_changed |
Un deal a été déplacé vers une autre étape. | deals.read |
deal.won |
Un deal a été gagné. | deals.read |
deal.lost |
Un deal a été perdu. | deals.read |
note.created |
Une note a été ajoutée à un client ou à un deal. | customers.read, deals.read |
note.updated |
Une note a été modifiée. | customers.read, deals.read |
note.deleted |
Une note a été supprimée. | customers.read, deals.read |
Dans la version 1, des événements peuvent uniquement s'ajouter. Un endpoint avec tous les événements (*) reçoit automatiquement les nouveaux : traitez donc proprement un type inconnu.
Vérifier la signature
Vérifiez toujours la signature en premier, avant de faire quoi que ce soit avec un message. Vous êtes ainsi certain qu'il provient de Klantly et qu'il n'a pas été modifié en route.
- Prenez le corps exactement tel que vous l'avez reçu, avant de le lire comme JSON. Après conversion, la signature ne correspond plus.
- Construisez le texte
{webhook-id}.{webhook-timestamp}.{body}. - Calculez dessus un HMAC-SHA256. La clé est la partie du secret après
whsec_, décodée depuis le base64. - Encodez le résultat en base64 et comparez-le aux signatures de
webhook-signature. Cet en-tête contient une ou plusieurs signatures de la formev1,<signature>, séparées par une espace. Si l'une d'elles correspond, le message est authentique. - Refusez un message si
webhook-timestamps'écarte de plus de 5 minutes de votre propre horloge. Ainsi, personne ne peut rejouer un ancien message.
Comparez en temps constant, avec hash_equals, crypto.timingSafeEqual ou hmac.compare_digest. La signature ne peut alors pas être devinée à partir du temps de réponse.
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);
// Traitez ici $event['type'] et $event['data']['object'].
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 garde le corps tel qu'il est arrivé.
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'));
// Traitez ici event.type et event.data.object.
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()
# Traitez ici event["type"] et event["data"]["object"].
return "", 204Réponses et nouvelles tentatives
Répondez dans les 10 secondes avec un statut de la plage 200. Faites de préférence le vrai travail ensuite, par exemple via une file d'attente : une réponse trop tardive compte comme un échec. Klantly ne suit pas les redirections.
Si la livraison échoue, Klantly réessaie après 5 secondes, 5 minutes, 30 minutes, 2 heures, 5 heures, 10 heures et encore 10 heures. Cela fait huit tentatives en 27 heures environ.
Si un endpoint ne renvoie que des erreurs pendant 5 jours, Klantly le désactive et les administrateurs de l'entreprise reçoivent un e-mail. Réactivez-le dans Klantly, ou avec Modifier un endpoint de webhook et "status": "active". Dans Klantly, vous voyez pour chaque endpoint les tentatives de livraison des 30 derniers jours, et vous pouvez renvoyer un message.
Messages en double et ordre
- Un message peut arriver plus d'une fois, par exemple si votre réponse s'est perdue en route. Retenez le
webhook-iddes messages traités et ignorez un id que vous connaissez déjà. - L'ordre n'est pas garanti : une nouvelle tentative peut arriver après un événement plus récent. Comparez le
updated_atde l'objet, ou récupérez l'objet via l'API si vous voulez être sûr de son dernier état.
Rattraper les événements manqués
Votre serveur était-il indisponible ? Avec Lister les événements, vous récupérez ce que vous avez manqué. Les événements sont conservés 30 jours.
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"La clé a besoin pour cela du scope events.read et ne voit que les événements portant sur des données qu'elle peut lire. Le message de test ping n'y figure jamais.
Envoyer un message de test
Avec Tester dans Klantly, ou avec Envoyer un message de test via l'API, Klantly envoie immédiatement l'événement ping :
{
"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"
}
}
}Un message de test est aussi envoyé à un endpoint désactivé et n'est pas renvoyé en cas d'échec. Vous vérifiez ainsi que votre serveur fonctionne à nouveau avant de réactiver l'endpoint.
Renouveler le secret
Le secret a fuité, ou vous souhaitez simplement le remplacer régulièrement ? Renouvelez-le dans Klantly ou avec Renouveler le secret, et choisissez un chevauchement. Tant qu'il dure, webhook-signature contient deux signatures : l'une avec l'ancien secret, l'autre avec le nouveau. Placez le nouveau secret dans votre récepteur avant la fin du chevauchement. Si le secret a fuité, ne choisissez pas de chevauchement.
Dernière mise à jour le 15 septembre 2026