# Outbound webhooks Use the [Webhook Endpoints (outbound)](/docs/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 ```json { "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. ```javascript 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)); } ``` ```php 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); } ``` ```python 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) ``` ```ruby 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: ```python 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.