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 theX-Webhook-Eventheader.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 calledversion, 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:
amountis an integer at scale 10⁴ (250139400=25013.94),decimalis the exact string,formattedis for display only. Never parseformatted.
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, notquote.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.lostand nothing else — never alsodeal.stage_changed, so you do not have to de-duplicate. Where the deal came from is infrom_stage. sale.recordedis the whole sale in one body — deal, payment and term together, plusmatched_byandclaimable. You will also receivedeal.wonandpayment.recordedfor the same event: subscribe to whichever suits you, not to all three.subscription.expiringfires once per threshold (30, 7 and 1 days by default), never once a day, anddays_lefttravels in the body so you do not have to recompute it in your own timezone.subscription.expiredfires the day after the term is over.quote.withdrawnmeans 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-Timestamporders 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.