Skip to content
Klozzo API
OpenAPI Postman

Outbound webhooks

Use the Webhook Endpoints (outbound) group of the reference to register one or more HTTPS URLs that should receive event notifications. Each delivery is a POST with JSON payload, retried on non-2xx with exponential backoff.

Envelope

{
  "event": "deal.won",
  "data": {
    "schema_version": 1,
    "id": 7,
    "ulid": "01JZ8Z0ZP00000000000000000",
    "location_id": 3,
    "title": "ADPA — Paquete 1,100 firmas",
    "status": "won",
    "amount": { "amount": 250139400, "currency": "MXN", "decimal": "25013.9400", "formatted": "$25,013.94" },
    "external_ref": "sub_88213"
  },
  "delivery_id": 5512
}
  • event — dotted event name; the same value travels in the X-Webhook-Event header.
  • data.schema_version — the payload contract version. Fields are added without bumping it; it changes only when a field changes meaning or leaves. It is deliberately not called version, because a quote already has one of its own.
  • delivery_id — stable per delivery attempt-set; use it for idempotency on your side. Retries of the same delivery carry the same value.
  • Money is always an object: amount is an integer at scale 10⁴ (250139400 = 25013.94), decimal is the exact string, formatted is for display only. Never parse formatted.

Events

Group Events
Contacts contact.created, contact.updated, contact.deleted
Messages message.received, message.sent
Operations bulk_operation.completed
Deals deal.created, deal.stage_changed, deal.won, deal.lost
Quotes quote.shared, quote.viewed, quote.accepted, quote.rejected, quote.withdrawn, quote.expired, quote.superseded
Payments payment.recorded, sale.recorded
Vigencias subscription.expiring, subscription.expired

Notes that save a support ticket:

  • quote.shared, not quote.published. Sharing is the act that freezes the document and assigns its folio; there is no separate "publish".
  • A closing stage sends deal.won / deal.lost and nothing else — never also deal.stage_changed, so you do not have to de-duplicate. Where the deal came from is in from_stage.
  • sale.recorded is the whole sale in one body — deal, payment and term together, plus matched_by and claimable. You will also receive deal.won and payment.recorded for the same event: subscribe to whichever suits you, not to all three.
  • subscription.expiring fires once per threshold (30, 7 and 1 days by default), never once a day, and days_left travels in the body so you do not have to recompute it in your own timezone. subscription.expired fires the day after the term is over.
  • quote.withdrawn means the customer's link is dead as of now. Any price you are still quoting from it is no longer valid.
  • Nothing is announced before it is committed: if the CRM rolls a transaction back, no delivery goes out.

Signature verification

Every delivery includes the headers:

Header Description
X-Webhook-Event The dotted event name.
X-Webhook-Delivery Delivery id, same as the body's delivery_id. Use for log correlation.
X-Webhook-Timestamp Unix epoch seconds when the delivery was signed. Reject requests older than 5 minutes.
X-Webhook-Signature sha256= followed by the hex HMAC-SHA256 described below.

What is signed is timestamp + "." + rawBody, not the body alone. The timestamp is inside the signature on purpose: without it, a captured delivery can be replayed against you forever.

Your endpoint secret is shown in the CRM under Configuración → Webhooks, next to the endpoint.

Four receivers, the same four steps: check the clock, rebuild the string, hash it with your secret, compare in constant time.

const crypto = require('crypto');

function verify(rawBody, headers, secret) {
  const timestamp = headers['x-webhook-timestamp'];
  const received = headers['x-webhook-signature'];              // "sha256=abc123…"

  // Replay window first: an old delivery is refused before anything is hashed.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  // Both buffers must be the same length or this throws.
  if (received.length !== expected.length) return false;

  return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}
function verify(string $rawBody, array $headers, string $secret): bool
{
    $timestamp = (string) ($headers['x-webhook-timestamp'] ?? '');
    $received = (string) ($headers['x-webhook-signature'] ?? '');

    if (abs(time() - (int) $timestamp) > 300) {
        return false;
    }

    $expected = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);

    return hash_equals($expected, $received);
}
import hashlib
import hmac
import time


def verify(raw_body: bytes, headers, secret: str) -> bool:
    timestamp = headers.get("X-Webhook-Timestamp", "")
    received = headers.get("X-Webhook-Signature", "")

    if abs(time.time() - int(timestamp or 0)) > 300:
        return False

    signed = f"{timestamp}.".encode() + raw_body
    expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()

    return hmac.compare_digest(expected, received)
require "openssl"

def verify(raw_body, headers, secret)
  timestamp = headers["X-Webhook-Timestamp"].to_s
  received = headers["X-Webhook-Signature"].to_s

  return false if (Time.now.to_i - timestamp.to_i).abs > 300

  expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")

  OpenSSL.secure_compare(expected, received)
end

Use the raw body — the bytes as they arrived. Re-encoding the parsed JSON changes key order and whitespace, and the signature stops matching. In Express that means express.raw(); in Rails, request.raw_post; in Laravel, $request->getContent().

Compare in constant time. == on a signature leaks how much of it you got right, one byte at a time, to anybody who can send you deliveries.

Retry policy

Attempts and delays come from config/webhooks.php (max_attempts, backoff). The defaults:

Attempt Delay before it
1 immediate
2 1 min
3 5 min
4 15 min
5 1 h

Respond with 2xx within 15 seconds to acknowledge. Any other status, or a timeout, triggers a retry; after the last attempt the delivery is left on record with its final response for inspection in the CRM.

Delivery order

Do not rely on it. Each delivery is an independent queued job, and a retry of an earlier one can land after a later one that succeeded first. Two events about the same record can arrive out of order, and after an outage they will.

What to do instead:

  • Read the state, do not accumulate it. The payload carries the record as it was when the event fired; if what you keep depends on order, fetch the record from the API instead of adding up the events.
  • X-Webhook-Timestamp orders them. It is the moment the delivery was signed. If you already applied something newer, drop the older one.

Idempotency on your side

The same delivery can reach you more than once — a retry after your 2xx was lost in transit is the common case, and it is not a bug on either side.

delivery_id is stable across every attempt of the same delivery, and it also travels in the X-Webhook-Delivery header. Store it, and make the second arrival a no-op:

if seen(delivery_id):
    return 200

with transaction():
    apply(payload)
    remember(delivery_id)

Do the two things in one transaction. Recording the id first means losing an event if applying it fails; applying it first means doing the work twice.

data.id is not a substitute: two different events about the same record share it.