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

**Preferred: the webhook.** When the first crawl finishes we `POST` to the account's `webhook_url`, falling back to your organisation default. No URL configured means no webhook ([polling](/docs/white-label/account-status) still works).

```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. |

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

```
X-ContextLink-Event: api_account.index_ready
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

The event fires on the API account's website's first successful sync. Delivery is **at least once**: under normal operation you get exactly one call, but a queue or crash at the wrong moment can repeat it. Key on `(uid, event)` 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.
