Klantly Developers

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.

customer.created
{
  "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.

  1. Neem de body precies zoals je hem ontving, voordat je hem als JSON leest. Na het omzetten klopt de handtekening niet meer.
  2. Maak de tekst {webhook-id}.{webhook-timestamp}.{body}.
  3. Bereken daarover een HMAC-SHA256. De sleutel is het deel van het secret na whsec_, gedecodeerd uit base64.
  4. Codeer de uitkomst in base64 en vergelijk hem met de handtekeningen in webhook-signature. Daar staan er een of meer in, van de vorm v1,<handtekening> en gescheiden door een spatie. Klopt er één, dan is het bericht echt.
  5. Weiger een bericht als webhook-timestamp meer 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.

PHP
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);
Node.js
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);
});
Python
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 "", 204

Antwoorden 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-id van 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_at van 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
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:

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