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 en deals.read voor deals. Een notitie volgt de klant of deal waar hij bij hoort.

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
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.won Een deal is gewonnen. deals.read
deal.lost Een deal is verloren. 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

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 15 september 2026