> ## Documentation Index
> Fetch the complete documentation index at: https://docs.play2sell.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks (Outbound)

> Receive real-time events from SalesOS at your own HTTPS endpoint — envelope, signature verification, event catalog, retries and idempotency.

# Webhooks (Outbound)

Webhooks let SalesOS **call your system** when something happens (a lead is offered, a ranking is
published, a message is dispatched). You give us an HTTPS URL and a secret; we `POST` a signed JSON
payload to it whenever a subscribed event fires.

<Note>
  This page covers **outbound** webhooks (SalesOS → your endpoint). For sending data **into** SalesOS,
  see [Activities](/api/integrations/activities) and [API Keys](/api/integrations/api-keys).
</Note>

## Configure a webhook in the Dashboard

Go to **Integrations → Webhooks → New Webhook** and fill in the form.

<Steps>
  <Step title="Basic">
    * **Name** (required) — e.g. `Notify CRM`.
    * **Key** — a stable identifier (e.g. `notify_crm`).
    * **Description** — optional.
  </Step>

  <Step title="Events (triggers)">
    Pick a **Category**, then select the **events** that fire this webhook — this is your
    subscription (see the [catalog](#event-catalog)). Leave it empty for a webhook you only
    trigger manually (the *Test* button) or from a workflow.
  </Step>

  <Step title="Destination">
    * **Method** — `POST` (default), `PUT` or `PATCH`.
    * **Timeout (seconds)** — how long we wait for your `2xx` (default 30).
    * **URL** (required) — your endpoint. **Must be `https://`** and public (internal/loopback hosts are blocked).
    * **Authentication** — choose **HMAC** to sign every delivery, then in **Auth Config (JSON)** set your secret:
      ```json theme={null}
      { "secret": "whsec_your_secret" }
      ```
      (Other schemes are available: Bearer, API Key, Basic, OAuth2.)
  </Step>

  <Step title="Advanced">
    * **Custom Headers (JSON)** — extra headers sent with every delivery.
    * **Payload Template (JSON with `{{ }}` variables)** — shapes the envelope `data` from the event
      context, e.g. `{ "id": "{{event.id}}", "type": "{{event.type}}", "to": "{{event.to}}" }`.

    <Warning>
      Template values are interpolated into JSON. Use **flat, scalar** fields. Injecting a nested object
      via `"{{event.data}}"` may not render cleanly today — always check the **Preview** / send a **Test** first.
    </Warning>
  </Step>

  <Step title="Preview, test & save">
    The form shows a live **Preview** (headers + body). Click **Save webhook**, then use **Test Webhook**
    to send a sample delivery and confirm your endpoint receives — and verifies — it.
  </Step>
</Steps>

## The envelope

On the event path, every delivery is a single JSON object:

```json theme={null}
{
  "id": "evt_2KWPBgLlAfxdpx2AI54pPJ85f4W",
  "type": "ranking.weekly.published",
  "version": "1",
  "ts": "2026-06-03T12:00:00.000Z",
  "tenant": "9b1f0c2e-1a2b-4c3d-8e4f-5a6b7c8d9e0f",
  "data": { /* event-specific — see catalog */ }
}
```

| Field     | Description                                                        |
| --------- | ------------------------------------------------------------------ |
| `id`      | Unique event id, **stable across retries** → your idempotency key. |
| `type`    | Event type (see catalog).                                          |
| `version` | Schema version of `data`. Evolves additively.                      |
| `ts`      | When the event occurred (ISO 8601, UTC).                           |
| `tenant`  | Origin tenant (uuid).                                              |
| `data`    | Event-specific payload.                                            |

## Headers

| Header                  | Description                                            |
| ----------------------- | ------------------------------------------------------ |
| `X-SalesOS-Event`       | Event type — route without parsing the body.           |
| `X-SalesOS-Event-Id`    | Equals `id`; stable across retries (idempotency).      |
| `X-SalesOS-Delivery-Id` | Per-attempt id (changes on retry; for debugging).      |
| `X-SalesOS-Timestamp`   | Unix seconds — part of the signature (anti-replay).    |
| `X-SalesOS-Tenant`      | Origin tenant id.                                      |
| `X-SalesOS-Signature`   | `sha256=<hmac>` — see [Verify](#verify-the-signature). |

## Verify the signature

<Warning>
  Always verify the signature before trusting a delivery. Without it, anyone who learns your URL could
  forge events.
</Warning>

On the event path, the signature covers **`{id}.{timestamp}.{rawBody}`** (Standard-Webhooks style),
so it authenticates the payload **and** the timestamp (anti-replay). Recompute the HMAC with your
secret, compare in constant time, and reject if the timestamp is more than **300s** off.

<CodeGroup>
  ```js Node.js theme={null}
  import crypto from "node:crypto";

  // raw = the exact raw request body (do NOT JSON.parse first)
  export function verify(req, secret) {
    const id  = req.headers["x-salesos-event-id"];
    const ts  = req.headers["x-salesos-timestamp"];
    const sig = req.headers["x-salesos-signature"]; // "sha256=<hex>"
    const raw = req.rawBody;

    if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // replay window

    const expected =
      "sha256=" + crypto.createHmac("sha256", secret).update(`${id}.${ts}.${raw}`).digest("hex");

    const a = Buffer.from(sig), b = Buffer.from(expected);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }
  ```

  ```python Python theme={null}
  import hmac, hashlib, time

  def verify(headers, raw_body: bytes, secret: str) -> bool:
      event_id = headers["X-SalesOS-Event-Id"]
      ts       = headers["X-SalesOS-Timestamp"]
      sig      = headers["X-SalesOS-Signature"]  # "sha256=<hex>"

      if abs(time.time() - int(ts)) > 300:        # replay window
          return False

      msg = f"{event_id}.{ts}.{raw_body.decode()}".encode()
      expected = "sha256=" + hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
      return hmac.compare_digest(sig, expected)
  ```
</CodeGroup>

<Note>
  **Test deliveries** (the "Test Webhook" button) and legacy workflow triggers currently use a simpler
  scheme: the body is the raw rendered template (not the envelope) and the signature header is
  `X-Signature: <prefix><hmac(body)>` — **without** the `id.timestamp.` prefix, so it has no
  anti-replay. Prefer the event path above for production.
</Note>

## Event catalog

| `type`                           | When it fires                           | `data`                      |
| -------------------------------- | --------------------------------------- | --------------------------- |
| `lead.offer_created`             | A lead is offered to a broker           | lead/opportunity payload    |
| `lead.accepted`                  | A broker accepts a lead                 | lead/opportunity payload    |
| `lead.offer_expired`             | An offer expires before acceptance      | lead/opportunity payload    |
| `mission.completed`              | A mission is completed                  | mission payload             |
| `ranking.weekly.published`       | Weekly ranking is published (scheduled) | leaderboard (rows by email) |
| `ranking.consolidated.published` | Consolidated ranking is published       | leaderboard (rows by email) |
| `message.dispatch`               | A message is dispatched to a user/group | `{ to, m }`                 |
| `notification.push.dispatch`     | A push is dispatched to a user/group    | `{ to, n }`                 |

Example `data` for `ranking.weekly.published`:

```json theme={null}
{
  "p": { "type": "weekly", "label": "Semana 23/2026", "start": "2026-05-25", "end": "2026-05-31" },
  "org_unit": null,
  "totals": { "users": 843, "points": 1820400, "missions": 5120 },
  "rows": [
    { "rank": 1, "delta": 2, "email": "maria@loja.com", "points": 4820, "missions": 37, "org_unit": "ou_12" }
  ]
}
```

Recipients are keyed by **email** — names and ids are not sent; enrich on your side if needed.

## Reliability

**Respond fast.** Return a `2xx` status quickly (before heavy processing) to acknowledge receipt.

| Your response                      | What SalesOS does                                                                               |
| ---------------------------------- | ----------------------------------------------------------------------------------------------- |
| `2xx`                              | Marks the delivery completed. No retry.                                                         |
| `4xx` (400, 401, 422…)             | Treated as a permanent rejection. **No retry** (except `429`, which respects backoff).          |
| `5xx` / timeout / connection error | Retried with **exponential backoff** (`2^n` minutes, up to 5 attempts), then **dead-lettered**. |

**Idempotency.** The same event may be delivered more than once (retries). Deduplicate on
`X-SalesOS-Event-Id` — it stays the same across retries.

<Tip>
  Failed deliveries land in the **Deliveries** panel (Dashboard → Integrations → Webhooks) where you can
  inspect status, attempts and the last error, and resend from the dead-letter queue.
</Tip>
