Webhooks
Ontvang direct een bericht op je server zodra er in Klantly iets verandert, en controleer dat het echt van Klantly komt.
Met webhooks hoef je niet steeds te vragen of er iets is veranderd. Klantly stuurt zelf een bericht naar een adres op jouw server, bijvoorbeeld zodra er een lead binnenkomt of een deal wordt gewonnen.
Klantly volgt de open standaard Standard Webhooks. Gebruik je al een bibliotheek die daarmee werkt, dan kun je die gewoon inzetten.
Een endpoint aanmaken
Een endpoint is het adres waar de berichten naartoe gaan. Je maakt het op een van twee manieren aan:
- In Klantly: ga naar Integraties → API, open het tabblad Webhooks en kies Nieuw endpoint.
- Via de API: met Webhook-endpoint aanmaken en een sleutel met de scope
webhooks.manage.
Kies welke events het endpoint ontvangt, of kies alle events. Daarna zie je eenmalig het secret, dat begint met whsec_. Daarmee controleer je de handtekening. Bewaar het net zo zorgvuldig als een API-sleutel.
Let op
Een endpoint ontvangt nooit meer dan degene die hem aanmaakte mag zien. Maak je hem in Klantly aan, dan gelden jouw rechten. Via de API gelden de leesscopes van de sleutel: customers.read voor klanten, deals.read voor deals, appointments.read voor afspraken, tasks.read voor taken, work_orders.read voor werkbonnen, quotes.read voor offertes, invoices.read voor facturen en creditnota's, orders.read voor webshopbestellingen, messages.read voor gesprekken en berichten en reviews.read voor reviews. Op alles behalve klanten, gesprekken en berichten moet het bedrijf ook de bijbehorende functie hebben. Een notitie volgt de klant of deal waar hij bij hoort. Het endpoint blijft aan die sleutel hangen: trek je hem in of haal je er een leesscope af, dan krijgt het endpoint die events niet meer. Wijzigen, uitzetten, verwijderen of een nieuw secret kan alleen met een sleutel die alles kan lezen wat het endpoint ontvangt, en een webhook kan nooit naar Klantly zelf wijzen, ook niet via een IP-adres. Een beheerder krijgt een mail als er een endpoint bijkomt, een ander adres krijgt of een nieuw secret.
Per bedrijf kun je maximaal 10 endpoints aanmaken. Het adres moet https gebruiken, op poort 443, 80 of 8443, en mag niet naar een intern netwerk wijzen.
Wat je ontvangt
Elk bericht is een POST met een JSON-body in dezelfde vorm als Event ophalen. Onder data.object staat het object precies zoals de REST-API het teruggeeft.
{
"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"
}
}
}Bij elk bericht horen deze headers:
| Header | Inhoud |
|---|---|
webhook-id |
De id van het event (evt_…). Bij elke nieuwe poging hetzelfde. |
webhook-timestamp |
Het moment van versturen, in seconden sinds 1970 (Unix-tijd). |
webhook-signature |
De handtekening, bijvoorbeeld v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=. |
Content-Type |
application/json |
User-Agent |
Klantly-Webhooks/1.0 |
Events
| Event | Omschrijving | Scope |
|---|---|---|
customer.created |
Een klant of lead is aangemaakt: in Klantly, via een formulier of via de API. | customers.read |
customer.updated |
Gegevens van een klant zijn gewijzigd. | customers.read |
customer.converted |
Een lead is klant geworden. | customers.read |
customer.deleted |
Een klant is verwijderd. Ruim hem ook in je eigen systeem op: via filter[updated_since] zie je een verwijdering nooit. | customers.read |
deal.created |
Een deal is op het pipelinebord gezet. | deals.read |
deal.updated |
Gegevens van een deal zijn gewijzigd, zoals titel, waarde of eigenaar. | deals.read |
deal.stage_changed |
Een deal is naar een andere fase verplaatst. | deals.read |
deal.labels_changed |
De labels van een deal zijn gewijzigd; de nieuwe lijst staat in labels. | deals.read |
deal.won |
Een deal is gewonnen. | deals.read |
deal.lost |
Een deal is verloren. | deals.read |
deal.deleted |
Een deal is verwijderd. | deals.read |
note.created |
Een notitie is geplaatst bij een klant of deal. | customers.read, deals.read |
note.updated |
Een notitie is gewijzigd. | customers.read, deals.read |
note.deleted |
Een notitie is verwijderd. | customers.read, deals.read |
appointment.created |
Een afspraak is ingepland: in de agenda, via de boekingspagina, de app of de API. | appointments.read |
appointment.updated |
Gegevens van een afspraak zijn gewijzigd, bijvoorbeeld de tijd (verzet). | appointments.read |
appointment.confirmed |
Een afspraak is bevestigd. | appointments.read |
appointment.cancelled |
Een afspraak is geannuleerd. | appointments.read |
appointment.completed |
Een afspraak is afgerond. | appointments.read |
appointment.deleted |
Een afspraak is verwijderd. | appointments.read |
task.created |
Een taak is aangemaakt. | tasks.read |
task.updated |
Gegevens van een taak zijn gewijzigd, zoals titel, deadline, prioriteit of koppelingen. | tasks.read |
task.moved |
Een taak is naar een andere kolom verplaatst. | tasks.read |
task.labels_changed |
De eigen labels van een taak zijn gewijzigd; de nieuwe lijst staat in labels. | tasks.read |
task.completed |
Een taak is afgerond. | tasks.read |
task.deleted |
Een taak is verwijderd. | tasks.read |
quote.created |
Een offerte is opgesteld: in Klantly, via de configurator of via de API. | quotes.read |
quote.updated |
Gegevens of regels van een offerte zijn gewijzigd. | quotes.read |
quote.sent |
Een offerte is naar de klant gestuurd. | quotes.read |
quote.accepted |
Een offerte is geaccepteerd. | quotes.read |
quote.rejected |
Een offerte is afgewezen. | quotes.read |
quote.deleted |
Een offerte is verwijderd. | quotes.read |
invoice.created |
Een factuur is aangemaakt: in Klantly, uit een offerte of werkbon, of via de API. Een nieuwe creditnota komt ook als dit event, met type credit_note. | invoices.read |
invoice.updated |
Gegevens of regels van een factuur zijn gewijzigd. | invoices.read |
invoice.sent |
Een factuur is naar de klant gestuurd. | invoices.read |
invoice.paid |
Een factuur is volledig betaald — ongeacht of dat online, met de hand of via de API is vastgelegd. | invoices.read |
invoice.cancelled |
Een factuur is geannuleerd. | invoices.read |
invoice.credited |
Er is een creditnota op een factuur gemaakt. Het event gaat over de factuur (met credited_amount, credit_note_ids en de nieuwe status); de creditnota zelf komt als invoice.created met type credit_note. | invoices.read |
invoice.deleted |
Een factuur is verwijderd. | invoices.read |
order.created |
Een bestelling is geplaatst in de webshop of configurator. | orders.read |
order.updated |
Een bestelling is gewijzigd, bijvoorbeeld de track and trace. | orders.read |
order.status_changed |
Een bestelling heeft een andere status gekregen. | orders.read |
order.paid |
Een bestelling is betaald. | orders.read |
work_order.created |
Een werkbon is aangemaakt. | work_orders.read |
work_order.updated |
Gegevens van een werkbon zijn gewijzigd, zoals regels, checklist of planning. | work_orders.read |
work_order.status_changed |
De status van een werkbon is gewijzigd. | work_orders.read |
work_order.completed |
Een werkbon is afgerond. | work_orders.read |
work_order.signed |
Een werkbon is door de klant ondertekend. | work_orders.read |
work_order.deleted |
Een werkbon is verwijderd. | work_orders.read |
service.created |
Een serviceverzoek is aangemaakt. | services.read |
service.updated |
Gegevens van een serviceverzoek zijn gewijzigd, zoals de omschrijving, de toewijzing of de planning. | services.read |
service.status_changed |
De status van een serviceverzoek is gewijzigd. | services.read |
service.completed |
Een serviceverzoek is afgerond. | services.read |
service.signed |
Een serviceverzoek is door de klant ondertekend. | services.read |
service.deleted |
Een serviceverzoek is verwijderd. | services.read |
conversation.created |
Een gesprek is begonnen: door een klant via chat, WhatsApp of e-mail, of door het bedrijf. | messages.read |
conversation.closed |
Een gesprek is gesloten. | messages.read |
message.received |
Een klant heeft een bericht gestuurd. | messages.read |
message.sent |
Het bedrijf heeft een bericht gestuurd, uit de inbox, de app, de API of door de AI-agent. | messages.read |
message.not_delivered |
WhatsApp heeft een verstuurd bericht niet bezorgd. Meta meldt dit soms pas minuten later; het bericht staat in het gesprek als niet bezorgd. Vaak kan het nummer geen WhatsApp ontvangen of is het meer dan 24 uur geleden dat de klant schreef. | messages.read |
review.received |
Een review is binnengekomen: via een reviewverzoek, uit Google of zelf ingevoerd. | reviews.read |
review.replied |
Het bedrijf heeft op een review geantwoord. | reviews.read |
review.updated |
Een review is gewijzigd: verborgen, weer getoond, of de tekst, score of toestemming is aangepast. Een spiegel op je website neemt dit over. | reviews.read |
review.deleted |
Een review is verwijderd. | reviews.read |
conversation.reopened |
Een gesloten gesprek is heropend, bijvoorbeeld doordat de klant alsnog antwoordde. | messages.read |
conversation.deleted |
Een gesprek is verwijderd. | messages.read |
meta_copilot.alert_created |
De Meta co-pilot heeft een nieuwe melding: verloren reacties, een afkeuring, een bestedingsschok, een gestopte levering, een verlopen koppeling of een wijziging buiten Klantly. | meta_copilot.read |
meta_copilot.stopped |
De Meta co-pilot is met de noodstop stilgezet, in het scherm of via de API. | meta_copilot.read |
meta_copilot.opportunity_created |
De Meta co-pilot heeft een nieuwe kans gevonden: iets wat geld bespaart of oplevert, met de cijfers erbij en de stap die hij voorstelt. | meta_copilot.read |
meta_copilot.approval_requested |
De Meta co-pilot heeft een voorstel klaargezet dat op een besluit van het bedrijf wacht, met deadline. | meta_copilot.read |
meta_copilot.approval_expiring |
Een voorstel van de Meta co-pilot verloopt binnenkort zonder besluit. | meta_copilot.read |
meta_copilot.action_applied |
Een voorstel van de Meta co-pilot is doorgevoerd bij Meta (door het bedrijf, via de API, de mail-link of autonoom binnen het mandaat). | meta_copilot.read |
meta_copilot.action_failed |
Een voorstel van de Meta co-pilot kon niet worden doorgevoerd; de reden staat in error. | meta_copilot.read |
meta_copilot.briefing_created |
De Meta co-pilot heeft de ochtendbriefing van gisteren opgesteld: besteding, leads, offertes en gewonnen opdrachten tegen het weekgemiddelde, met de soort dag en de mijlpalen van die ochtend. | meta_copilot.read |
Binnen versie 1 komen er alleen events bij. Een endpoint met alle events (*) ontvangt nieuwe events vanzelf, dus vang een onbekend type netjes op.
De handtekening controleren
Controleer altijd eerst de handtekening, voordat je iets met een bericht doet. Zo weet je zeker dat het van Klantly komt en onderweg niet is veranderd.
- Neem de body precies zoals je hem ontving, voordat je hem als JSON leest. Na het omzetten klopt de handtekening niet meer.
- Maak de tekst
{webhook-id}.{webhook-timestamp}.{body}. - Bereken daarover een HMAC-SHA256. De sleutel is het deel van het secret na
whsec_, gedecodeerd uit base64. - Codeer de uitkomst in base64 en vergelijk hem met de handtekeningen in
webhook-signature. Daar staan er een of meer in, van de vormv1,<handtekening>en gescheiden door een spatie. Klopt er één, dan is het bericht echt. - Weiger een bericht als
webhook-timestampmeer dan 5 minuten van je eigen klok afwijkt. Zo kan niemand een oud bericht opnieuw afspelen.
Vergelijk in constante tijd, met hash_equals, crypto.timingSafeEqual of hmac.compare_digest. Dan is de handtekening niet te raden aan de hand van de reactietijd.
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);
// Verwerk hier $event['type'] en $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: de body blijft zoals hij binnenkwam.
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'));
// Verwerk hier event.type en 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()
# Verwerk hier event["type"] en event["data"]["object"].
return "", 204Antwoorden en nieuwe pogingen
Antwoord binnen 10 seconden met een status in de 200-reeks. Doe het echte werk liever daarna, bijvoorbeeld via een wachtrij: een te laat antwoord telt als mislukt. Redirects volgt Klantly niet.
Mislukt de aflevering, dan probeert Klantly het opnieuw na 5 seconden, 5 minuten, 30 minuten, 2 uur, 5 uur, 10 uur en nog eens 10 uur. Dat zijn acht pogingen in ongeveer 27 uur.
Geeft een endpoint 5 dagen lang alleen fouten, dan zet Klantly hem uit en krijgen de beheerders van het bedrijf een e-mail. Zet hem weer aan in Klantly, of met Webhook-endpoint bijwerken en "status": "active". In Klantly zie je per endpoint de afleverpogingen van de afgelopen 30 dagen, en kun je een bericht opnieuw versturen.
Dubbele berichten en volgorde
- Een bericht kan meer dan eens aankomen, bijvoorbeeld als je antwoord onderweg verloren ging. Onthoud de
webhook-idvan verwerkte berichten en sla een id over die je al kent. - De volgorde is niet gegarandeerd: een nieuwe poging kan na een later event aankomen. Vergelijk
updated_atvan het object, of haal het object op via de API als je zeker wilt zijn van de laatste stand.
Gemiste events inhalen
Lag je server eruit? Met Events opvragen haal je op wat je hebt gemist. Events blijven 30 dagen bewaard.
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"De sleutel heeft daarvoor de scope events.read nodig en ziet alleen events over gegevens waar hij leesrecht op heeft. Het testbericht ping staat er nooit tussen.
Een testbericht versturen
Met Testen in Klantly, of met Testbericht versturen via de API, stuurt Klantly direct het event 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"
}
}
}Een testbericht gaat ook naar een uitgeschakeld endpoint en wordt niet herhaald. Zo controleer je of je server weer werkt, voordat je het endpoint aanzet.
Het secret vernieuwen
Is het secret uitgelekt, of wil je het gewoon regelmatig vervangen? Vernieuw het in Klantly of met Secret vernieuwen, en kies een overlap. Zolang die loopt, staan er in webhook-signature twee handtekeningen: een met het oude en een met het nieuwe secret. Zet het nieuwe secret in je ontvanger voordat de overlap voorbij is. Bij een uitgelekt secret kies je geen overlap.
Laatst bijgewerkt op 18 september 2026