Everything we send goes to the account's `webhook_url`, falling back to your organisation default, signed the same way. No URL configured means no webhooks ([polling](/docs/white-label/account-status) still works).

| Event | Fires when |
|---|---|
| `api_account.index_ready` | An account's website finished its first crawl. |
| `connection.authorised` | A connected source completed its OAuth exchange, or a direct source was created and validated. |
| `connection.auth_failed` | An exchange failed, or a refresh token died during a scheduled sync. |
| `connection.disconnected` | You deleted one connected source. |
| `connection_type.revoked` | A connection type was withdrawn from your organisation, destroying every connection of it across your fleet. |

## `api_account.index_ready`

Crawling is deliberately slow (anti-detection), so don't block on it. This event only applies to accounts created with a `site_url`.

When the first crawl finishes we `POST`:

```json
{
  "event": "api_account.index_ready",
  "api_account_id": 4211,
  "uid": "tf-cust-8841",
  "connection_id": 9876,
  "posts": 214,
  "chunks": 1893,
  "synced_at": "2026-08-31T11:42:07Z"
}
```

## Response fields

| Field | Type | Description |
|---|---|---|
| `event` | string | Always `api_account.index_ready` for this webhook. |
| `api_account_id` | integer | Context Link's own id for the account whose index is ready. |
| `uid` | string | The customer id you provisioned the account with. |
| `connection_id` | integer | The website connection that finished crawling. |
| `posts` | integer | Documents indexed by that website connection. |
| `chunks` | integer | Embedded passages indexed by that website connection. |
| `synced_at` | timestamp | When the crawl finished, as a UTC ISO 8601 string. |

## The connection lifecycle events

The three `connection.*` events share one payload shape and go to the webhook URL of the customer the source belongs to. They are how you keep your own "connected sources" screen honest without polling.

```json
{
  "event": "connection.auth_failed",
  "api_account_uid": "tf-cust-8841",
  "connection_id": 9013,
  "connection_type": "google_doc",
  "auth_status": "auth_failed",
  "reason": "invalid_grant: Token has been expired or revoked."
}
```

| Field | Type | Description |
|---|---|---|
| `event` | string | `connection.authorised`, `connection.auth_failed` or `connection.disconnected`. |
| `api_account_uid` | string | The customer id you provisioned the account with. |
| `connection_id` | integer | Context Link's id for the connection. |
| `connection_type` | string | The type, in the same form you sent it (`google_doc`, `youtube`). |
| `auth_status` | string | The connection's authorisation state at the moment the event fired. |
| `reason` | string | Only on failures: why, truncated to 200 characters. Present as a support message, not as something to branch on. |

`connection.auth_failed` is the one that matters operationally. Refresh tokens die silently, and across a fleet of thousands that is invisible rot: we can see it, but you are the only one who can ask your customer to reconnect. Route it somewhere a human or an email sequence will act on, then send them through [re-authorisation](/docs/white-label/connect-source#re-authorising-a-dead-connection).

For Google Drive, a successful OAuth exchange alone does **not** emit `connection.authorised`. The browser first returns to you with `connection_status=selection_required`; the event fires only after your backend commits a valid Picker selection. Cancelling Picker emits no lifecycle event and leaves the connection waiting for selection.

`connection.disconnected` fires as the row is destroyed, so by the time you receive it the source and its content are already gone. It carries the same shape with no `reason`.

## `connection_type.revoked`

A connection type can be withdrawn from your organisation, either by an admin of yours on the [Connection types screen](/docs/white-label/customer-sources) or by us. When that happens every connection of that type across your entire fleet is destroyed. You get **one** org-level event, not one per customer.

```json
{
  "event": "connection_type.revoked",
  "connection_type": "google_doc",
  "connections_destroyed": 1204
}
```

This goes to your organisation default webhook URL, because it is not about one customer. Fanning it out as 1,204 signed deliveries in a burst would read as an outage at your end; instead, reconcile per customer at your own pace with [`GET .../connections`](/docs/white-label/connection-status).

## Signatures and delivery

Every delivery carries these headers, shown in the examples rail:

```
X-ContextLink-Event: connection.authorised
X-ContextLink-Timestamp: 1788176527
X-ContextLink-Signature: sha256=<hex>
```

## Verifying the signature

The signature is `HMAC-SHA256(signing_secret, "<timestamp>.<raw body>")`, hex-encoded. Verify it against the **raw** request body before parsing, compare with a constant-time function, and reject a timestamp skewed more than 5 minutes from your clock.

A Ruby implementation of that check:

```ruby
def context_link_webhook_verified?(request)
  timestamp = request.headers["X-ContextLink-Timestamp"].to_i
  return false if (Time.now.to_i - timestamp).abs > 300

  expected = "sha256=" + OpenSSL::HMAC.hexdigest(
    "SHA256",
    ENV.fetch("CONTEXT_LINK_SIGNING_SECRET"),
    "#{timestamp}.#{request.raw_post}"
  )

  ActiveSupport::SecurityUtils.secure_compare(
    expected, request.headers["X-ContextLink-Signature"].to_s
  )
end
```

The same check in Node, using Express with the raw body preserved:

```js
const crypto = require("crypto");

// app.post("/hooks/context-link", express.raw({ type: "application/json" }), handler)
function contextLinkWebhookVerified(req) {
  const timestamp = Number(req.get("X-ContextLink-Timestamp"));
  if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;

  const expected =
    "sha256=" +
    crypto
      .createHmac("sha256", process.env.CONTEXT_LINK_SIGNING_SECRET)
      .update(`${timestamp}.${req.body}`) // req.body is the raw Buffer
      .digest("hex");

  const received = req.get("X-ContextLink-Signature") || "";
  const a = Buffer.from(expected);
  const b = Buffer.from(received);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

## Delivery contract

Delivery is **at least once** for every event: under normal operation you get exactly one call, but a queue or crash at the wrong moment can repeat it. Key on `(uid, event)` for `api_account.index_ready`, and on `(connection_id, event)` for the connection events, and make your handler idempotent.

Return any `2xx` **directly** to acknowledge. Anything else is a failure and is retried about five times with exponential backoff, **including a redirect**: `3xx` is treated as a failure, not followed, so point the webhook at its final URL.

## SSRF constraints on the webhook URL

Your endpoint must be a publicly routable http(s) URL. These are refused outright (logged and never retried) rather than delivered:

- any scheme other than `http` or `https`;
- a URL with embedded credentials (`https://user:pass@…`);
- a host resolving to loopback, private (RFC 1918), CGNAT, link-local or multicast space, IPv4 or IPv6.

The same check runs when you *set* the URL, so a bad target is a `422` at configuration time instead of a silent dead letter later.
