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

# Receive webhooks

> Register an endpoint, verify signed deliveries, pick the events you receive, and read the delivery log. Includes the retry ladder and the auto-disable rule.

Webhooks live on the Integrations service and cover events from the whole platform: every producing service (Playtables, Unibox, Signals) delivers through the one subscription you register there.

> [!TIP]
>
> **Give this to your AI agent** with your endpoint URL:
>
> "On GetSales (MCP connector at `https://mcp2.getsales.io/mcp`, or REST at beta.getsales.io with my key): register a webhook for my endpoint URL with the events I name. The event vocabulary is the enum on the create-webhook schema, so read it there rather than looking for a catalog call. Then send the test delivery and show me its status and response code."

## 1. Register an endpoint

`POST /api/webhooks` takes a `name`, your `target_url` and the `events[]` you want:

```json POST /integrations/v1/api/webhooks theme={null}
{
  "name": "Replies and finished imports",
  "target_url": "https://example.com/hooks/getsales",
  "events": ["messages.received", "data-sources.completed", "workflow-records.completed"]
}
```

* `target_url` must be `https://` on a public host, and the same URL cannot be registered twice in one workspace (`409 duplicate_rejected`).
* `events[]` is an exact list of catalog types, validated one by one; a name outside the catalog is a `422`. There is no wildcard: subscribe to the types you handle, and add new ones when you start handling them. The catalog is in [section 6](#6-the-event-catalog).
* `filters.account_sid` narrows deliveries to one sending identity (a sender profile's LinkedIn account or mailbox), so an endpoint that serves one sender is not woken by the others.

The response returns the webhook's `secret`, a 32-character hex string, exactly once. Store it: every later read answers `null` for it, and it is what you verify signatures with.

## 2. Verify deliveries

Every delivery is an HTTPS `POST` with a JSON body and these headers:

| Header | Content |
| - | - |
| `X-Webhook-Event` | The event type, for example `messages.received` |
| `X-Webhook-Signature` | `t={unix_seconds},v1={hex_hmac}` |
| `X-Webhook-Timestamp` | The same unix timestamp as `t` |
| `X-Webhook-Id` | The subscription's sid |
| `X-Webhook-Log-Id` | This delivery's sid in the delivery log |
| `X-Trace-Id` | The trace id, also present in the body |

The body is a fixed envelope, with the event's own data nested under `payload` (trimmed here):

```json theme={null}
{
  "webhook_log_sid": "in_wl_Q8rT2vW4xY6z",
  "type": "messages.received",
  "emitted_at": "2026-11-01T09:12:44+00:00",
  "team_sid": "ts_tm_Hx7kQ3mN2pL4",
  "trace_id": "0198f2ab-7c11-7e32-9a41-d2b64f2a91c3",
  "payload": {
    "sid": "ub_ms_Jk2mP9nR4qW1",
    "conversation_sid": "ub_cv_Bv9nK4xP1sD7"
  }
}
```

`payload` is the event's own row, under the field names the REST API uses for that entity: a message for `messages.*`, a conversation for `conversations.*`, the data source with its funnel counts for `data-sources.*`, the workflow record for `workflow-records.*`. The reference page of that entity is the field guide.

The signature is `HMAC-SHA256(secret, "{t}.{raw_body}")` over the raw request body, with the timestamp mixed in to block replays. Verify before parsing, and reject signatures older than about 5 minutes:

```typescript Node theme={null}
import crypto from "node:crypto";

function verify(rawBody: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=") as [string, string]),
  );
  if (!parts.t || !parts.v1) return false;
  const age = Math.abs(Date.now() / 1000 - Number(parts.t));
  if (age > 300) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");
  const received = Buffer.from(parts.v1);
  const digest = Buffer.from(expected);
  // timingSafeEqual throws on a length mismatch, so check that first.
  return (
    received.length === digest.length && crypto.timingSafeEqual(digest, received)
  );
}
```

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

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    # Parse defensively: a malformed header must return False, not raise.
    parts = dict(kv.split("=", 1) for kv in header.split(",") if "=" in kv)
    if "t" not in parts or "v1" not in parts:
        return False
    try:
        ts = int(parts["t"])
    except ValueError:
        return False
    if abs(time.time() - ts) > 300:
        return False
    expected = hmac.new(
        secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])
```

Compute the HMAC over the raw bytes you received, before any JSON re-serialization: parsing and re-encoding the body can reorder keys and break the signature.

> [!WARNING]
>
> A delivery times out at 30 seconds in total, 5 of them to connect, so acknowledge fast and do the work asynchronously. Answer `2xx` once the body is stored, not once it is processed.

## 3. Test before relying on it

`POST /api/webhooks/{sid}/test` sends one signed synthetic envelope, of type `webhooks.test` (or the type you name in `override_event_type`), to your endpoint right now and answers what came back: `http_status`, `duration_ms` and `error_kind` (`none`, `http_error` or `network`). It confirms reachability, signature handling and parsing without waiting for a real event.

Three things to know about it: it writes no row to the delivery log, moves no failure counter and fires no event; it is allowed on a `failed` webhook (the check before you revive it) and refused on one switched `off` (`409`); and it is throttled to 10 calls a minute per webhook.

## 4. Retries and auto-disable

A delivery is attempted up to **5 times**, with waits of 1 minute, 5 minutes, 30 minutes and 2 hours between attempts. The fifth failure is terminal, so a dead endpoint settles about 2.6 hours after the first attempt.

| Response | What happens |
| - | - |
| `2xx` | Delivered, done |
| `5xx`, `408`, `429`, network error or timeout | Retried on the ladder above |
| Any other `4xx` | Terminal, no retry: the request was understood and refused |
| `410 Gone` | Terminal, and it disables the subscription immediately |

After **20 consecutive failed attempts** across the subscription's deliveries (a delivered event resets the counter), or a single `410 Gone`, the subscription flips to `failed` and stops delivering: its waiting deliveries are cancelled, and the transition emits `webhooks.failed`, so a second webhook can page you when the first one dies.

Bringing it back is two calls: fix the endpoint, then `PATCH /api/webhooks/{sid}` with `{"status": "on"}`, which resets the counter and re-issues nothing. Then re-arm what the outage lost from the delivery log (next section): a delivery that ended `failed` is retried by hand, a delivery the failure cancelled is not (a cancel is final), so revive quickly. Switching a webhook `off` cancels its pending deliveries too, and `off` to `on` leaves the counter alone.

## 5. Use the delivery log

The log holds **one row per event per subscription**, not one per attempt: a retry updates that row in place, bumping `retry_count` and overwriting the response code. The row is the delivery, and its history is the counter. A row's `status` is `pending`, `in_progress`, `retrying`, `success`, `failed` or `cancelled`.

* `POST /api/webhook-logs/search` lists deliveries with status, response code and timing; filter by webhook, sending identity, event type, status or response code, and `include` the webhook.
* `POST /api/webhook-logs/metrics` aggregates outcomes over a required `period` (`from`, `to`): success and failure rates, p50, p95 and p99 latency, total retries, the last success and the last failure; `group_by` splits them by status, event type, response code, webhook or account. The rates count terminal rows only, so a delivery still on its ladder sits in `counts.groups.status.retrying` instead.
* `POST /api/webhook-logs/{sid}/retry` re-arms one delivery: status back to `pending`, due now, the original envelope to the original URL, `retry_count` kept (it counts attempts that went out). Refused on an `in_progress` row (`409`: cancel it or wait), on a `cancelled` one (that was a decision) and while the parent webhook is deleted or `off` (`422`).
* `POST /api/webhook-logs/{sid}/cancel` stops a delivery that has not settled, from `pending`, `retrying` or `in_progress`. On an `in_progress` row the attempt in flight may still land as a success, which wins over the cancel; the answer says so with `in_flight_attempt_pending: true`, so read the row again.

Both `retry` and `cancel` take an optional `expected_status`, a compare-and-set guard: when the row moved since you read it, the call answers `409 invalid_transition` instead of acting on a state you did not see.

## 6. The event catalog

33 types across the producing services, each a business fact an integrator can act on, never a per-row progress tick (a 5,000-row import tells you once, with `data-sources.completed`, not 5,000 times).

| Service | Events |
| - | - |
| Playtables | `data-sources.completed`, `data-sources.failed`, `data-sources.paused`, `data-sources.deleted`, `playtable-runs.finished`, `playtable-runs.cancelled`, `workflows.published`, `workflow-records.entered`, `workflow-records.completed`, `workflow-records.failed`, `workflow-records.exited`, `workflow-records.paused`, `workflow-records.resumed` |
| Unibox | `messages.sent`, `messages.received`, `messages.bounced`, `conversations.created`, `conversations.state-changed`, `conversations.scenario-changed`, `conversations.signals-changed`, `conversations.merged`, `conversations.person-linked`, `conversations.person-unlinked`, `tasks.created`, `tasks.completed`, `sender-profiles.created`, `sender-profiles.deleted`, `sender-profiles.health-changed`, `agents.mode-changed` |
| Signals | `signals.website-visited`, `website-sites.broken`, `website-sites.recovered` |
| Integrations | `webhooks.failed` |

The same list is the `events` enum on the [create-webhook](https://docs.getsales.io/api-reference/integrations/webhooks/create-webhook.md) schema, which is where a generated client or an agent reads it from.

## Rotating the secret

There is no self-service rotation endpoint. If a secret is exposed, delete the webhook and register a new one, then point your verifier at the new secret. Deleting and re-registering also gives you a clean delivery log boundary, which is easier to reason about than a rotation with no overlap window.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.