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, deals.read for deals, appointments.read for appointments, tasks.read for tasks, work_orders.read for work orders, quotes.read for quotes, invoices.read for invoices and credit notes, orders.read for webshop orders, messages.read for conversations and messages, and reviews.read for reviews. For everything except customers, conversations and messages the company must also have the matching feature. A note follows the customer or deal it belongs to. The endpoint stays tied to that key: if you revoke it or take a read scope off it, the endpoint no longer receives those events. Changing, turning off, deleting or rotating the secret requires a key that can read everything the endpoint receives, and a webhook can never point to Klantly itself, not even through an IP address. Administrators get an email when an endpoint is added, gets a different address or a new secret.

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
customer.deleted A customer was deleted. Remove it from your own system too: a deletion never shows up through filter[updated_since]. 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.labels_changed The labels of a deal changed; the new list is in labels. deals.read
deal.won A deal was won. deals.read
deal.lost A deal was lost. deals.read
deal.deleted A deal was deleted. 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
appointment.created An appointment was scheduled: in the calendar, through the booking page, the app or the API. appointments.read
appointment.updated Details of an appointment changed, for example its time (rescheduled). appointments.read
appointment.confirmed An appointment was confirmed. appointments.read
appointment.cancelled An appointment was cancelled. appointments.read
appointment.completed An appointment was completed. appointments.read
appointment.deleted An appointment was deleted. appointments.read
task.created A task was created. tasks.read
task.updated Details of a task changed, such as title, deadline, priority or links. tasks.read
task.moved A task moved to another column. tasks.read
task.labels_changed The task’s own labels changed; the new list is in labels. tasks.read
task.completed A task was completed. tasks.read
task.deleted A task was deleted. tasks.read
quote.created A quote was drafted: in Klantly, through the configurator or through the API. quotes.read
quote.updated Details or lines of a quote changed. quotes.read
quote.sent A quote was sent to the customer. quotes.read
quote.accepted A quote was accepted. quotes.read
quote.rejected A quote was rejected. quotes.read
quote.deleted A quote was deleted. quotes.read
invoice.created An invoice was created: in Klantly, from a quote or work order, or through the API. A new credit note also comes as this event, with type credit_note. invoices.read
invoice.updated Details or lines of an invoice changed. invoices.read
invoice.sent An invoice was sent to the customer. invoices.read
invoice.paid An invoice was paid in full — whether that was recorded online, by hand or through the API. invoices.read
invoice.cancelled An invoice was cancelled. invoices.read
invoice.credited A credit note was created on an invoice. The event is about the invoice (with credited_amount, credit_note_ids and the new status); the credit note itself arrives as invoice.created with type credit_note. invoices.read
invoice.deleted An invoice was deleted. invoices.read
order.created An order was placed in the webshop or configurator. orders.read
order.updated An order changed, for example its track and trace. orders.read
order.status_changed An order got a different status. orders.read
order.paid An order was paid. orders.read
work_order.created A work order was created. work_orders.read
work_order.updated Details of a work order changed, such as lines, checklist or planning. work_orders.read
work_order.status_changed The status of a work order changed. work_orders.read
work_order.completed A work order was completed. work_orders.read
work_order.signed A work order was signed by the customer. work_orders.read
work_order.deleted A work order was deleted. work_orders.read
service.created A service request was created. services.read
service.updated Details of a service request changed, such as its description, assignment or planning. services.read
service.status_changed The status of a service request changed. services.read
service.completed A service request was completed. services.read
service.signed A service request was signed by the customer. services.read
service.deleted A service request was deleted. services.read
conversation.created A conversation started: by a customer through chat, WhatsApp or email, or by the company. messages.read
conversation.closed A conversation was closed. messages.read
message.received A customer sent a message. messages.read
message.sent The company sent a message, from the inbox, the app, the API or the AI agent. messages.read
message.not_delivered WhatsApp did not deliver a sent message. Meta sometimes reports this minutes later; the message is marked as not delivered in the conversation. Usually the number cannot receive WhatsApp, or more than 24 hours have passed since the customer last wrote. messages.read
review.received A review came in: through a review request, from Google or entered by the company. reviews.read
review.replied The company replied to a review. reviews.read
review.updated A review changed: hidden, shown again, or its text, rating or consent was changed. A mirror on your website should pick this up. reviews.read
review.deleted A review was deleted. reviews.read
conversation.reopened A closed conversation was reopened, for example because the customer replied after all. messages.read
conversation.deleted A conversation was deleted. messages.read
meta_copilot.alert_created The Meta co-pilot has a new alert: lost reactions, a disapproval, a spend spike, stalled delivery, an expiring connection or a change outside Klantly. meta_copilot.read
meta_copilot.stopped The Meta co-pilot was halted with the emergency stop, in the app or via the API. meta_copilot.read
meta_copilot.opportunity_created The Meta co-pilot found a new opportunity: something that saves or earns money, with the figures and the step it proposes. meta_copilot.read
meta_copilot.approval_requested The Meta co-pilot has prepared a proposal that awaits the company's decision, with a deadline. meta_copilot.read
meta_copilot.approval_expiring A proposal from the Meta co-pilot is about to expire without a decision. meta_copilot.read
meta_copilot.action_applied A proposal from the Meta co-pilot was applied at Meta (by the company, via the API, the e-mail link or autonomously within the mandate). meta_copilot.read
meta_copilot.action_failed A proposal from the Meta co-pilot could not be applied; the reason is in error. meta_copilot.read
meta_copilot.briefing_created The Meta co-pilot has written yesterday's morning briefing: spend, leads, quotes and won deals against the weekly average, with the kind of day and that morning's milestones. meta_copilot.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 18, 2026