Klantly Developers

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.manage scope.

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.

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"
    }
  }
}

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.

  1. Take the body exactly as you received it, before you parse it as JSON. After parsing, the signature no longer matches.
  2. Build the text {webhook-id}.{webhook-timestamp}.{body}.
  3. Calculate an HMAC-SHA256 over it. The key is the part of the secret after whsec_, decoded from base64.
  4. Encode the result in base64 and compare it with the signatures in webhook-signature. That header holds one or more signatures of the form v1,<signature>, separated by a space. If one of them matches, the message is genuine.
  5. Reject a message if webhook-timestamp is 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.

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);
// Handle $event['type'] and $event['data']['object'] here.
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 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);
});
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()
    # Handle event["type"] and event["data"]["object"] here.
    return "", 204

Responses 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-id of 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
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:

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"
    }
  }
}

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