# RAG White-Label API

<strong>You are building a product whose features get better the more they know about each customer: their website, their documentation, the data they push through your product. Thousands of pages of it, different for every account.</strong>

The RAG White-Label plan gives you **search by meaning** over each user's own context, so your features can draw on it. Say you ship a blog-post outlining feature: before it writes anything, it can ask that user's context about the things the post mentions (their features, their positioning, the opinions they have already published) and produce an outline that is actually *theirs*. Not the generic answer every other customer of every other tool gets. **Personalisation through the customer's own context**, rather than a cleverer prompt.

The supporting half is that you don't have to build any of the machinery: no retrieval, chunking, embedding, crawling or grounded answering. You hold **many isolated end-customer indexes inside one Context Link organisation**, provision them programmatically, and pay for them in bundles that scale with your customer count.

Everything your end customers touch is server-to-server. They never see Context Link: no UI, no emails, no branding. **No token ever reaches a browser.**

You still get a UI: an **API accounts** area inside Context Link that shows the fleet you provisioned and every query it has run.

<figure style="margin: 1rem 0;">
<video src="/docs/white-label/white-label-fleet-walkthrough.mp4" autoplay loop muted playsinline style="max-width: 100%; border-radius: 8px;"></video>
<figcaption style="margin-top: 0.5rem; font-size: 0.875rem; color: #6b7280;">The API accounts screen, searching the fleet, and opening one customer query in the fleet query log.</figcaption>
</figure>

## Two ways to read a user's context

| Endpoint | Reach for it when |
|---|---|
| `GET /context` | You want **a lot of material**. It returns the matching passages as markdown: rich raw input for your own LLM or agent to work from. |
| `GET /question` | You want **a concise, grounded answer**. One short paragraph plus citations: right for showing directly in your UI, and for protecting an agent's context window instead of dumping the user's full context into it. |

Each has its own endpoint: [`GET /context`](/docs/white-label/query-customer) and [`GET /question`](/docs/white-label/ask-question).

There is a third, cheaper signpost that needs no query at all: every account carries a `top_level_context` paragraph describing who that customer is, written automatically once their index has content. Read it off [the account's status](/docs/white-label/account-status) when you want to personalise a screen without asking a question first.

## Two credentials, different jobs

| Credential | Scope | Where it comes from |
|---|---|---|
| **Partner provisioning token** | One per partner organisation. Manages the fleet (create / list / status / delete / re-sync API accounts) and can query across **all** your customers at once. | **Settings → Partner provisioning token** |
| **Per-customer token** | One per user account. Both authenticates *and* identifies that customer: querying and pushing content both use it. | Returned when the API account is created (and again on an idempotent re-POST of the same uid) |

Store `uid` + `token` per customer, plus a `CONN_ID` if you [push content](/docs/white-label/upsert-post). All captured at signup time.

> **The provisioning token is the high-privilege path.** The same secret manages the whole fleet, reads every customer's index, and can permanently delete any of them along with all of its content. Keep it in your core backend only: never in a per-customer context, never in a browser.

## Quick reference

| Property | Value |
|----------|-------|
| **Base URL** | `https://context-link.ai/api/v1` |
| **Authentication** | `Authorization: Bearer <token>` |
| **Format** | JSON in, JSON out |
| **Write rate limit** | 5 requests per 10 seconds (per user and per organisation) on `POST` / `DELETE` / `resync` |
| **Query rate limits** | 5 per 10s on `/context`, 2 per 10s on `/question`, **per token**, so one busy customer never starves another |
| **Billing** | $50/month per bundle of 10 API accounts. Bundles are added automatically on overflow. |
| **Crawl cap** | 500 pages per API account website |

## How the rest of this guide is laid out

The pages in the left sidebar follow the order you will build in: get your token, provision a customer, wait for their index, query on their behalf, push extra content, and clean up when they churn. The reference pages at the end carry the full status-code and limit tables.

---

# Getting started

Sign up to Context Link normally and pick the **RAG White-Label** plan. The last onboarding step walks you through getting the token, provisioning your first customer, and querying on a customer's behalf, and links straight to the docs and the fleet screen. You can revisit all of it here.

Then open **Settings** and scroll to **Partner provisioning token**.

<figure style="margin: 1rem 0;">
<img src="/docs/white-label/white-label-provisioning-token-settings.png" alt="The Partner provisioning token section of Context Link settings, showing a masked provisioning token, the default webhook URL field, a masked webhook signing secret, and API accounts and Rotate token buttons." style="max-width: 100%; border-radius: 8px;">
</figure>

**Generate token** does three things at once:

1. mints your **provisioning token** (copy it now, it is only readable here);
2. saves the **default webhook URL** typed alongside it;
3. generates your **webhook signing secret**, shown underneath.

Both secrets are masked in the field and revealed on focus. The token is server-generated (there is no value to hand-type), and rotating it invalidates the previous one **immediately**, so update your backend in the same deploy.

<figure style="margin: 1rem 0;">
<video src="/docs/white-label/white-label-provisioning-token.mp4" autoplay loop muted playsinline style="max-width: 100%; border-radius: 8px;"></video>
<figcaption style="margin-top: 0.5rem; font-size: 0.875rem; color: #6b7280;">Setting a default webhook URL and rotating the provisioning token. The token and signing secret are blurred here; in the app they are masked until you focus the field.</figcaption>
</figure>

The webhook URL must be a publicly routable http(s) URL; see [SSRF constraints on the webhook URL](/docs/white-label/webhooks). Setting a private or credential-bearing URL is rejected here rather than accepted and quietly dropped.

---

# Admin MCP: manage your fleet from Claude/Codex

Everything in this guide is an HTTP call your backend makes. The **admin MCP** exposes the same capabilities as tools, so you (or Claude working with you) can provision customers, inspect the fleet, query a customer's index and push content from a chat. 
<strong>
With `plan_integration` you can also hand the AI this entire guide plus your live account state so it can plan the build.
</strong>

<br>
    
| Property | Value |
|---|---|
| **Server URL** | `https://www.context-link.ai/admin_mcp` |
| **Transport** | Streamable HTTP |
| **Authentication** | OAuth 2.1 (PKCE + Dynamic Client Registration), signed in as an **organisation admin** on the RAG White-Label plan |
| **Scopes** | `context:read`, `context:write`, `offline_access` |
| **Tools** | 11, listed below |

## Connect it to Claude

1. In Claude, go to **Settings → Connectors → Add custom connector**.
2. Name it **Context Link Admin** and paste the connector URL: `https://www.context-link.ai/admin_mcp`
3. Under **Authentication**, choose **Required** when the server asks, keep the OAuth client on **Use Anthropic's hosted client metadata**, then click **Add**.
4. First use opens a browser tab. Sign in as your organisation admin and click **Accept**.

The consent screen looks identical to the regular connector's; the difference is the server behind it. You can have both connected at once; they hold separate tokens.

### Claude Code

```bash
claude mcp add --transport http context-link-admin https://www.context-link.ai/admin_mcp
```

Run `/mcp`, pick **context-link-admin** and choose **Authenticate**.

### ChatGPT (developer mode) and other clients

Any client that supports remote MCP servers over HTTP works. Use the URL above wherever the client asks for one; the [MCP server](/docs/mcp-server) page shows the config shape for Cursor, VS Code, Codex and stdio-only clients. Swap `/mcp` for `/admin_mcp`.

## The tools

| Tool | Writes? | What it does |
|---|---|---|
| `plan_integration` | no | Your live account state (plan, whether the provisioning token and webhook secret exist, bundles, free slots) followed by this whole guide as markdown. **Ask Claude to call it first** when planning or building. Optional `sections` trims the guide. |
| `list_api_accounts` | no | The fleet, paginated, with sync status, post/chunk counts and `usage`. Optional `q` filters by uid, label or site. Mirrors `GET /api_accounts`. |
| `get_api_account` | no | One account by `uid`, including `index_ready`, `last_synced_at` and `top_level_context`. Mirrors `GET /api_accounts/:uid`. |
| `create_api_account` | yes | Provision a customer (`uid`, optional `label`, `site_url`, `webhook_url`). Idempotent on `uid`. Returns the **customer token**. Refuses with `bundle_required` when the fleet is full unless `allow_bundle_add` is true. Mirrors `POST /api_accounts`. |
| `resync_api_account` | yes | Re-crawl the customer's website. Mirrors `POST /api_accounts/:uid/resync`. |
| `delete_api_account` | yes | Delete the account and its whole index. **Irreversible.** Mirrors `DELETE /api_accounts/:uid`. |
| `get_customer_context` | no | Semantic search over one customer's index (`uid`), or the whole fleet when `uid` is omitted. Mirrors `GET /context` with the customer token or the provisioning token respectively. |
| `ask_customer_question` | no | A concise cited answer from one customer's index, or fleet-wide. Mirrors `GET /question`. Counts against your monthly question allowance. |
| `list_customer_posts` | no | The documents pushed into a customer's custom connections. |
| `push_content` | yes | Upsert a markdown document (`post_uid`, `title`, `body` ≤ 100KB) into a customer's index. Creates the customer's custom connection on first push. Mirrors `PUT /custom_connections/:id/posts/:uid`. |
| `delete_content` | yes | Remove one pushed document. Pass `connection_name` when the same `post_uid` lives in more than one of the customer's connections. **Irreversible.** |

Every tool returns a `reason` field: `ok`, or why it declined: `auth_required`, `admin_required`, `white_label_required`, `write_scope_required`, `not_found`, plus tool-specific values such as `bundle_required`, `already_syncing`, `quota_exceeded`.

## Things to know

- **Admins only.** A team member who is not an organisation admin can connect the server but every tool returns `admin_required`. Reads keep working for an organisation that has left the plan while it still holds API accounts; writes need an active RAG White-Label subscription and the `context:write` scope, which the consent screen grants by default.
- **Tokens land in the chat.** `create_api_account` returns the customer token in the tool result, exactly as the API does. Treat that transcript as a secret and move the token into your backend; re-running `create_api_account` with the same `uid` shows it again if you lose it.
- **Spend is opt-in.** The REST API adds a bundle of 10 accounts ($50/month) automatically when you provision past your allowance. The MCP tool stops and asks instead. Pass `allow_bundle_add: true` once you have confirmed.
- **Deletes are final.** `delete_api_account` and `delete_content` remove the data immediately. Claude will normally check with you first because the tools are marked destructive.
- **Same limits as the API.** Pushed bodies are capped at 100KB, connections at 1,000 posts and 100MB, crawls at 500 pages, and questions count against the monthly allowance described under [errors and limits](/docs/white-label/errors-and-limits).

---

# Create an API account

An **api_account is a user account: one of your customers**, with their own isolated index. It is distinct from you, the white-label organisation, which holds all of these user accounts and the provisioning token that manages them. Create one per customer, usually at signup.

`uid` is required: **your own stable customer id**. It is the idempotency key and the address for every later call, so you never need to store a Context Link id.

`site_url` is optional: include it if you know the customer's website; omit it for a push-only account you fill via [custom connections](/docs/white-label/upsert-post).

```bash
curl -X POST "https://context-link.ai/api/v1/api_accounts" \
  -H "Authorization: Bearer $PROVISIONING_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "uid": "tf-cust-8841",
    "label": "Acme Supply Co",
    "site_url": "https://acme.com",
    "webhook_url": "https://your-app.com/hooks/context-link"
  }'
```

`201 Created`. **Store the token now**:

```json
{
  "api_account": {
    "id": 4211,
    "uid": "tf-cust-8841",
    "label": "Acme Supply Co",
    "site_url": "https://acme.com",
    "connection_id": 9876,
    "sync_status": "syncing",
    "webhook_url": "https://your-app.com/hooks/context-link",
    "created_at": "2026-08-31T10:15:00Z"
  },
  "token": "6yTk2mWq8rVd4nXb...",
  "bundle_added": false
}
```

## Request fields

| Field | Type | Description |
|---|---|---|
| `uid` | string | Required. Your own stable customer id: `a-z`, `0-9`, hyphen, underscore or dot, 1 to 255 characters, unique **per organisation**. It is the idempotency key and the address for every later call. |
| `label` | string | Optional display name, 255 characters or fewer, defaulting to the uid. This is the "label" column on the fleet screen. |
| `site_url` | string | Optional http(s) URL. When present a website index is created and the first crawl starts immediately. We keep the **host**, plus a non-root path which becomes the crawl's starting point, and always crawl over `https` on the default port. A URL carrying an explicit port or embedded credentials is rejected rather than silently trimmed. |
| `webhook_url` | string | Optional http(s) URL. Overrides your organisation default for this customer only. |

## Response fields

| Field | Type | Description |
|---|---|---|
| `api_account` | object | The account that was provisioned or replayed. |
| `api_account.id` | integer | Context Link's own id for the account; you address it by `uid`, so you never need to store this. |
| `api_account.uid` | string | The customer id you provisioned the account with. |
| `api_account.label` | string | The account's display name, falling back to the uid when you sent none. |
| `api_account.site_url` | string | The website that will be crawled, `null` when the account was created without a `site_url`. |
| `api_account.connection_id` | integer | The website connection's id, `null` when the account has no website. |
| `api_account.sync_status` | string | The crawl state: `unsynced`, `syncing`, `synced`, `error_syncing`, or `no_site` for an account with no website. |
| `api_account.webhook_url` | string | The per-customer webhook override, `null` when the account falls back to your organisation default. |
| `api_account.created_at` | timestamp | When the account was first provisioned. |
| `token` | string | The account's query token, returned on creation and again on every replay. |
| `bundle_added` | boolean | Whether this call added a bundle of 10 slots to your subscription. |

Token-bearing responses are sent `Cache-Control: no-store`.

## Retries are safe

If the response gets lost, POST the same `uid` again. You get `200` with the existing account and **the same token**, and nothing is created twice.

A replay deliberately **ignores** a changed body, *including an invalid one*: re-POSTing with a different `site_url` or `label` will not mutate the existing account and will not `422`. So a retry needs no special casing, and needs to carry no more than the `uid`.

**Automatic growth**: if this was customer #11 on 1 bundle, a bundle is added automatically (+$50/mo, +10 slots) and the response carries `"bundle_added": true`. If the charge fails, the call returns `402` and **no** account is created. Bundles never shrink automatically; downgrade seats from billing if you want to.

---

# List the fleet

## The whole fleet

The API behind the fleet screen:

```bash
curl "https://context-link.ai/api/v1/api_accounts?page=1&per_page=25" \
  -H "Authorization: Bearer $PROVISIONING_TOKEN"
```

```json
{
  "api_accounts": [
    { "id": 4211, "uid": "tf-cust-8841", "label": "Acme Supply Co", "site_url": "https://acme.com",
      "sync_status": "synced", "index_ready": true, "posts": 214, "chunks": 1893 }
  ],
  "usage": {
    "api_users_count": 11,
    "api_users_allowance": 20,
    "bundles": 2,
    "slots_available": 9
  },
  "pagination": { "page": 1, "per_page": 25, "total": 11, "total_pages": 1 }
}
```

## Response fields

| Field | Type | Description |
|---|---|---|
| `api_accounts` | array | One summary row per api_account on this page, oldest first. |
| `api_accounts[].id` | integer | Context Link's own id for the account. |
| `api_accounts[].uid` | string | The customer id you provisioned the account with. |
| `api_accounts[].label` | string | The account's display name, falling back to the uid. |
| `api_accounts[].site_url` | string | The website being crawled, `null` for an account with no website. |
| `api_accounts[].sync_status` | string | The crawl state: `unsynced`, `syncing`, `synced`, `error_syncing`, or `no_site` for an account with no website. |
| `api_accounts[].index_ready` | boolean | Whether the account's website has completed a successful crawl. |
| `api_accounts[].posts` | integer | Documents indexed across every connection the account owns. |
| `api_accounts[].chunks` | integer | Embedded passages indexed across every connection the account owns. |
| `usage` | object | Organisation-wide totals, independent of the page of accounts returned. |
| `usage.api_users_count` | integer | api_accounts currently provisioned across your whole organisation. |
| `usage.api_users_allowance` | integer | Total api_account slots your subscription covers, which is 10 per bundle. |
| `usage.bundles` | integer | Bundles currently on your subscription, or `0` when the organisation is off the white-label plan. |
| `usage.slots_available` | integer | Slots left before the next bundle is auto-added on overflow. |
| `pagination` | object | Paging state for the `api_accounts` array. |
| `pagination.page` | integer | The page returned, starting at 1. |
| `pagination.per_page` | integer | Rows per page, capped at 100. |
| `pagination.total` | integer | api_accounts matching the request across every page. |
| `pagination.total_pages` | integer | Pages available at this `per_page`. |

`per_page` is capped at 100.

Each row is a summary. For one account's full status, its index readiness and the paragraph describing who that customer is, see [`GET /api_accounts/:uid`](/docs/white-label/account-status).

---

# One account's status

One account, by the `uid` you provisioned it with. This is also the polling fallback if you would rather not receive [the index-ready webhook](/docs/white-label/webhooks).

```bash
curl "https://context-link.ai/api/v1/api_accounts/tf-cust-8841" \
  -H "Authorization: Bearer $PROVISIONING_TOKEN"
```

```json
{
  "api_account": {
    "id": 4211,
    "uid": "tf-cust-8841",
    "label": "Acme Supply Co",
    "site_url": "https://acme.com",
    "connection_id": 9876,
    "sync_status": "synced",
    "index_ready": true,
    "last_synced_at": "2026-08-31T11:42:07Z",
    "posts": 214,
    "chunks": 1893,
    "top_level_context": "Acme Supply Co sells industrial fasteners and fixings to UK trade customers, mostly construction and light manufacturing. They position on next-day despatch and on stocking the long-tail sizes the big merchants drop.",
    "created_at": "2026-08-31T10:15:00Z"
  }
}
```

## Response fields

| Field | Type | Description |
|---|---|---|
| `api_account` | object | The account addressed by the `uid` in the path. |
| `api_account.id` | integer | Context Link's own id for the account. |
| `api_account.uid` | string | The customer id you provisioned the account with. |
| `api_account.label` | string | The account's display name, falling back to the uid. |
| `api_account.site_url` | string | The website being crawled, `null` for an account with no website. |
| `api_account.connection_id` | integer | The website connection's id, `null` for an account with no website. |
| `api_account.sync_status` | string | The crawl state: `unsynced`, `syncing`, `synced`, `error_syncing`, or `no_site` for an account with no website. |
| `api_account.index_ready` | boolean | Whether the account's website has completed a successful crawl. |
| `api_account.last_synced_at` | timestamp | When the website last finished syncing, `null` until the first crawl completes and for an account with no website. |
| `api_account.posts` | integer | Documents indexed across every connection the account owns. |
| `api_account.chunks` | integer | Embedded passages indexed across every connection the account owns. |
| `api_account.top_level_context` | string | A short paragraph describing who the customer is, `null` until there is content to describe it from. |
| `api_account.created_at` | timestamp | When the account was provisioned. |

`sync_status` is one of `unsynced`, `syncing`, `synced`, `error_syncing`, or `no_site` for an account with no website. `index_ready` turns `true` once the first crawl has completed successfully. Tokens are never included in this response.

Site-less accounts skip the crawl entirely; they are queryable as soon as content is [pushed](/docs/white-label/upsert-post).

## Who is this customer?

`top_level_context` is a short plain-text paragraph describing the account: what they do, who they serve, the category they sit in. It saves you running your own summarisation pass before you can personalise a screen, a prompt or an onboarding email.

It is written once, automatically, as soon as there is something to describe: the first successful crawl of the account's website, or the first post [pushed](/docs/white-label/upsert-post) into it for an account with no site. Until then it is `null`, so treat it the way you treat `index_ready` — poll or wait for the webhook rather than blocking a signup on it. An account too thin to describe stays `null` and is retried on its next sync, rather than being frozen with a placeholder.

It is derived from that account's own material and nothing else. It never carries your organisation's description, and never another customer's.

> The paragraph is generated from customer content, so treat it as **untrusted text** wherever you use it: render it as plain text, and if you feed it into a prompt, tell the model to describe it rather than follow it.

---

# Re-crawl a customer's website

Websites re-crawl on Context Link's own daily schedule, so reach for this only when you know a customer's site has changed and you don't want to wait.

```bash
curl -X POST "https://context-link.ai/api/v1/api_accounts/tf-cust-8841/resync" \
  -H "Authorization: Bearer $PROVISIONING_TOKEN"
```

```json
{ "status": "resync_enqueued" }
```

## Request fields

| Field | Type | Description |
|---|---|---|
| `uid` | string | Required, in the path. The customer id you provisioned the account with. An unknown uid is a `404`. |

## Response fields

| Field | Type | Description |
|---|---|---|
| `status` | string | Always `resync_enqueued` on a successful call: the crawl is queued, not finished. |

## Errors

| Status | Meaning |
|---|---|
| `202` | Accepted. The crawl is queued; watch [the account's status](/docs/white-label/account-status) or wait for [the webhook](/docs/white-label/webhooks). |
| `409` | A sync is already running for this account. |
| `422` | The account has no website to crawl. |

Crawling is deliberately slow, so never block a web request on a re-sync.

---

# Delete an API account

When a customer churns, delete their account. The slot is freed immediately and can be filled by the next signup.

```bash
curl -X DELETE "https://context-link.ai/api/v1/api_accounts/tf-cust-8841" \
  -H "Authorization: Bearer $PROVISIONING_TOKEN"
```

```json
{ "status": "deleted", "uid": "tf-cust-8841" }
```

## Request fields

| Field | Type | Description |
|---|---|---|
| `uid` | string | Required, in the path. The customer id you provisioned the account with. An unknown uid is a `404`. |

## Response fields

| Field | Type | Description |
|---|---|---|
| `status` | string | Always `deleted` on a successful call. |
| `uid` | string | The customer id of the account that was destroyed. |

## This is irreversible

Deleting destroys the account and **all** of its connections, posts and chunks. For content you pushed through [custom connections](/docs/white-label/create-connection), Context Link holds the only copy of the indexed text, so back up anything you cannot regenerate from your own database first.

A `404` means there is nothing to delete under that uid, so churn cleanup can treat it as success and retry safely.

---

# Webhooks

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.

---

# Get context for one customer

Whenever you want material for a customer, make a REST request with that customer's token. Results come only from that customer's own index plus anything you indexed organisation-wide for all customers (see [Shared content across all customers](/docs/white-label/upsert-post)).

`/context` hands you **a lot of material**: the matching passages, as markdown. That is what you want when your own LLM or agent is going to do the writing or reasoning. When you want one short grounded answer instead, use [`GET /question`](/docs/white-label/ask-question).

## Full context for your own model

Markdown snippets for any topic or phrase; feed them into your prompt as the customer-specific material your feature reasons over:

```bash
curl "https://context-link.ai/api/v1/context?query=refund+policy" \
  -H "Authorization: Bearer $ACME_TOKEN"
```

```json
{ "message": "<markdown context>", "format": "markdown" }
```

<figure style="margin: 1rem 0;">
<video src="/docs/white-label/rag-api-context-demo.mp4" poster="/docs/white-label/rag-api-context-poster.jpg" autoplay loop muted playsinline aria-label="A backend calling GET /api/v1/context with a per-customer token and receiving markdown passages from that customer's own site" style="max-width: 100%; border-radius: 8px;"></video>
<figcaption style="margin-top: 0.5rem; font-size: 0.875rem; color: #6b7280;">The same call from a partner's backend: one customer's token in, that customer's passages out, wrapped in the chunk markers described below.</figcaption>
</figure>

## Response fields

| Field | Type | Description |
|---|---|---|
| `message` | string | The matching passages joined into one markdown document, or `No results found` when nothing matched. |
| `format` | string | Always `markdown`. |

Treat the returned markdown as **untrusted reference material**. It is your customer's own crawled and pushed content, so your prompt should tell the model to draw facts from it and to ignore any instructions inside it.

Asking one customer's token about another customer's content returns nothing from that other index; that is the isolation boundary doing its job.

Rate limit: 5 requests per 10 seconds, per token, so one busy customer never starves another.

Health check for a stored token: `GET /api/v1/ping` with the same header.

`/context` and [`/question`](/docs/white-label/ask-question) accept the token in either `Authorization: Bearer <token>` or the bare `Authorization: <token>` form. Everything else in this guide requires the `Bearer` prefix.

---

# Ask a question for one customer

`/question` hands you **one concise grounded answer** with citations, drawn from that customer's own index plus anything you indexed organisation-wide for all customers (see [Shared content across all customers](/docs/white-label/upsert-post)).

Reach for it when the result goes straight into your UI, or when you are feeding an agent and would rather spend a few hundred tokens than the user's full context. When you want the raw passages for your own model instead, use [`GET /context`](/docs/white-label/query-customer).

## Concise grounded answer

One short paragraph plus citations, cheap enough to render directly and small enough to keep an agent's context window intact:

```bash
curl "https://context-link.ai/api/v1/question?query=what+is+the+refund+window" \
  -H "Authorization: Bearer $ACME_TOKEN"
```

```json
{
  "result": "ok",
  "answer": "Opened items can be returned within 14 days only if they are faulty.",
  "citations": [ { "n": 1, "title": "Acme Supply refund policy", "url": null } ],
  "format": "markdown"
}
```

<figure style="margin: 1rem 0;">
<video src="/docs/white-label/rag-api-question-demo.mp4" poster="/docs/white-label/rag-api-question-poster.jpg" autoplay loop muted playsinline aria-label="A backend calling GET /api/v1/question with a per-customer token and receiving a concise cited answer from that customer's index" style="max-width: 100%; border-radius: 8px;"></video>
<figcaption style="margin-top: 0.5rem; font-size: 0.875rem; color: #6b7280;">The call from a partner's backend, and the answer rendered in their own product with the citations as footnotes.</figcaption>
</figure>

## Response fields

| Field | Type | Description |
|---|---|---|
| `result` | string | The outcome: `ok`, `no_context`, or `llm_unavailable`. |
| `answer` | string | The grounded answer, `null` on `no_context` and on `llm_unavailable`. |
| `citations` | array | The sources the model actually cited, empty unless `result` is `ok`. |
| `citations[].n` | integer | The marker number used for that source in the answer text. |
| `citations[].title` | string | The source document's title, or `Untitled` when it has none. |
| `citations[].url` | string | The source document's URL, `null` for content with no address such as pushed or memory posts. |
| `format` | string | Always `markdown`. |

## Reading `result`

| Value | Meaning |
|---|---|
| `ok` | An answer was grounded in that customer's content. |
| `no_context` | Nothing in that customer's index was relevant. `answer` is `null`. |
| `llm_unavailable` | The model call failed. Served with a `503`. |

Asking one customer's token about another customer's content returns `no_context`; that is the isolation boundary doing its job.

Rate limit: 2 requests per 10 seconds, per token. Each request costs an LLM call of its own, which is why it is tighter than `/context`.

Health check for a stored token: `GET /api/v1/ping` with the same header.

`/question` and [`/context`](/docs/white-label/query-customer) accept the token in either `Authorization: Bearer <token>` or the bare `Authorization: <token>` form. Everything else in this guide requires the `Bearer` prefix.

---

# Save to a namespace

A namespace is a document stored under a name. It can be anything in markdown, a one-line preference or a long reference doc, and the guarantee is exactness: a query that is **exactly** that name always returns the document **verbatim**, skipping semantic search entirely.

That makes it the right home for anything your product needs back unchanged when it asks for it by name: customer preferences, a tone-of-voice guide, a summary your app maintains, a policy the assistant must quote rather than paraphrase. For content you want retrieved by meaning across many documents, use a [custom connection](/docs/white-label/upsert-post) instead.

## Save a document

The body is raw text, not JSON. The namespace goes in the query string:

```bash
curl -X POST "https://context-link.ai/api/v1/context/save?namespace=acme-preferences" \
  -H "Authorization: Bearer $ACME_TOKEN" \
  -d 'Prefers metric units. Never contact on Fridays. Main contact is the ops lead.'
```

```json
{ "message": "Saved", "namespace": "acme-preferences" }
```

## Request fields

| Field | Type | Description |
|---|---|---|
| `namespace` | string | Required, in the query string. The name you will read the document back by, lowercased with whitespace turned into hyphens. |
| body | string | Required, the raw request body. Anything text-shaped; it is converted to markdown before indexing. An empty body is a `422`. |

## Response fields

| Field | Type | Description |
|---|---|---|
| `message` | string | Always `Saved` on success, served with a `201`. |
| `namespace` | string | The stored name, after normalisation. Read the document back with this exact value. |

## Read it back

Ask [`GET /context`](/docs/white-label/query-customer) for the namespace itself, as the whole query, and the document comes back word for word, skipping semantic search entirely. Any other query goes through normal retrieval, where the document is one more piece of that customer's indexed material.

## Saving again replaces the document

A second save to the same namespace **overwrites** it: this is a whole-document write, not an append. Keep the authoritative copy in your own database and re-send the full document whenever it changes.

## Isolation

A document saved with a customer's token lands in that customer's **private** memory and no other customer can retrieve it. The [partner provisioning token](/docs/white-label/getting-started) is read-only on this endpoint and gets a `403`: an organisation-wide memory write would be fleet-visible, with no sensible owner.

---

# Create a custom connection

For customer data that isn't on their website (dashboards, help docs, CRM notes), push it in through the [Custom Connections API](/docs/custom-connections) with that customer's token.

You'll almost always want exactly **one custom connection per customer**: create it once right after provisioning and store the returned connection id as `CONN_ID` alongside the token. Then [upsert documents into it](/docs/white-label/upsert-post) whenever your source data changes.

```bash
curl -X POST "https://context-link.ai/api/v1/custom_connections" \
  -H "Authorization: Bearer $ACME_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Acme app data" }'
```

```json
{
  "connection": {
    "id": 5501,
    "name": "Acme app data",
    "scope": "personal",
    "created_by_user_id": 4211,
    "post_count": 0,
    "last_synced_at": null,
    "created_at": "2026-08-31T10:16:04Z"
  }
}
```

## Request fields

| Field | Type | Description |
|---|---|---|
| `name` | string | Required. What the connection is called in Context Link, up to 255 characters. |

## Response fields

| Field | Type | Description |
|---|---|---|
| `connection` | object | The custom connection that was created. |
| `connection.id` | integer | The connection id to store as `CONN_ID` and use in every later upsert. |
| `connection.name` | string | The name you gave the connection. |
| `connection.scope` | string | Always `personal` for an API account, so its content stays private to that customer. |
| `connection.created_by_user_id` | integer | The id of the account whose token created the connection. |
| `connection.post_count` | integer | Documents currently in the connection, so `0` on creation. |
| `connection.last_synced_at` | timestamp | When content was last pushed, `null` until the first upsert. |
| `connection.created_at` | timestamp | When the connection was created. |

Content pushed as a customer is only ever visible to that customer. **Personal scope is enforced server-side for API accounts**; there is no `scope` parameter to get wrong.

## Shared content across all customers

> **Organisation-level content is fleet-visible.** Anything indexed at organisation level is blended into **every** customer's results, and every customer's answers can cite it. That is the point (shared help docs, a product glossary, an FAQ), but it also means one customer's data must never be indexed there. If in doubt, push through the customer's token: content pushed as an API account can never leak sideways.

To make something retrievable by every customer, push it **as yourself**, not as an API account: the partner admin's own custom-connections token (from Settings) creates organisation-level connections. Tenant content in through the customer's token; fleet-wide content through yours.

The same asymmetry applies to [namespaces](/docs/white-label/namespaces): an API account's note lands in that account's **private** memory, never the organisation's.

---

# Upsert a document

Upsert markdown by a stable UID whenever the source data changes. The UID is yours, so re-pushing the same document updates it rather than adding a duplicate, and re-pushing unchanged content costs nothing.

```bash
curl -X PUT "https://context-link.ai/api/v1/custom_connections/$CONN_ID/posts/funnel-report-2026-08" \
  -H "Authorization: Bearer $ACME_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "title": "August funnel report", "body": "## Conversion...\n..." }'
```

```json
{
  "status": "created",
  "post": {
    "uid": "funnel-report-2026-08",
    "title": "August funnel report",
    "chunk_count": 7,
    "will_index": true,
    "created_at": "2026-08-31T10:20:11Z",
    "updated_at": "2026-08-31T10:20:11Z"
  }
}
```

## Request fields

| Field | Type | Description |
|---|---|---|
| `connection_id` | integer | Required, in the path. The `CONN_ID` returned when you [created the connection](/docs/white-label/create-connection). |
| `uid` | string | Required, in the path. Your own stable id for the document, 1 to 255 characters of `a-z`, `0-9`, hyphen, underscore or dot. Re-using it updates that document rather than adding another. |
| `title` | string | Required. The document's title, up to 255 characters. |
| `body` | string | Required. The document as markdown, up to 100KB. An empty body is a `422`: delete the post instead. |

## Response fields

| Field | Type | Description |
|---|---|---|
| `status` | string | What the upsert did: `created`, `updated`, or `unchanged` when the content matched what was already indexed. |
| `post` | object | The post as it now stands. |
| `post.uid` | string | The stable UID you addressed the post with. |
| `post.title` | string | The title you sent with this upsert. |
| `post.chunk_count` | integer | Embedded passages the body was split into. |
| `post.will_index` | boolean | Whether the post is included in the index; an excluded post is a `409` rather than an upsert. |
| `post.created_at` | timestamp | When the post was first pushed. |
| `post.updated_at` | timestamp | When the post was last written. |

A `201` means the post was created; an existing uid answers `200` with `status` of `updated`, or `unchanged` when the content already matched.

## No polling step

Chunking and embedding happen inside the request, so once you have a `201` that content is already searchable by [`/context`](/docs/white-label/query-customer) and [`/question`](/docs/white-label/ask-question).

For small ad-hoc notes you want back word for word rather than retrieved by meaning, use a [namespace](/docs/white-label/namespaces) instead of a document.

## Quotas

Standard custom-connection quotas apply: 1,000 posts and 100MB per connection, 100KB per post body, up to 6 custom connections per API account.

---

# Query across all your customer data

The same two endpoints called with your **provisioning token** run organisation-wide: the per-customer isolation filter is dropped, so retrieval spans every API account's index plus your organisation-level content in one search.

Example: a marketplace with many sellers offering one global search across the whole platform: each seller's own storefront widget queries with [their token](/docs/white-label/query-customer), while the marketplace's global search bar queries with the provisioning token.

```bash
curl "https://context-link.ai/api/v1/context?query=hand-thrown+ceramics" \
  -H "Authorization: Bearer $PROVISIONING_TOKEN"
```

```json
{ "message": "<markdown drawn from every customer's index plus shared organisation content>", "format": "markdown" }
```

[`GET /question`](/docs/white-label/ask-question) behaves the same way: swap the token and one answer is grounded across the whole fleet, citations included.

## Reads only

Writes are refused in this mode. [Saving a namespace](/docs/white-label/namespaces) with a provisioning token answers `403`:

`{ "error": "The provisioning token is read-only on this endpoint. Save with an api_account query token." }`

An organisation-wide memory write would be fleet-visible with no sensible owner, so save with a customer token instead and the note lands in exactly one customer's index.

A provisioning token stops authenticating the moment it stops being eligible: the holder is demoted from admin, or the organisation leaves the white-label plan. It does not degrade into a normal user-scoped credential.

---

# In-app screens

Everything in this guide can be done entirely over the API. The in-app screens exist so you can *see* the fleet without writing a dashboard. They are **read-only**: there is no create, re-sync or delete button, and no token is ever rendered on them.

They are visible to **organisation admins on the white-label plan**, from the **API accounts** item in the left rail (with your fleet count as its pip). A partner who downgrades keeps read-only access for as long as the organisation still holds API accounts.

## Fleet: `/white_label/api_accounts`

<figure style="margin: 1rem 0;">
<img src="/docs/white-label/white-label-api-accounts.png" alt="The API accounts screen listing four end-customer accounts with their uid, label, site, sync status, post and chunk counts, above a usage strip reading 4 accounts, 10 allowance, 1 bundle and 6 accounts available." style="max-width: 100%; border-radius: 8px;">
</figure>

The **usage strip** is the same data the provisioning API returns as `usage`: accounts in the fleet, your allowance, how many bundles you are paying for, and slots left before the next bundle is added automatically.

The **fleet table** is one row per API account, newest first, 25 per page:

| Column | What it shows |
|---|---|
| uid | Your own customer id, the address for every API call |
| label | The display name you passed as `label` |
| site | `https://<host>` of the crawled website, or **push only** for a site-less account |
| status | `synced` / `syncing` / `error syncing` / `no site`, the same `sync_status` the API reports |
| posts, chunks | The size of that customer's index, across every connection they own |
| created | When you provisioned them |

**Search** filters on uid, label and site domain with one query, and composes with pagination. Your input is matched literally, so a `%` finds nothing rather than everything.

Creating, re-syncing and deleting stay API-only, on purpose: provisioning is driven by your signup flow, not by hand.

## Fleet query log: `/white_label/responses`

<figure style="margin: 1rem 0;">
<img src="/docs/white-label/white-label-fleet-query-log.png" alt="The Fleet query log screen listing customer queries with the requesting account, the query text, date, which sources answered it, and a view link, with a search box above and pagination below." style="max-width: 100%; border-radius: 8px;">
</figure>

Every `/context` and `/question` call your API accounts have made, newest first, 10 per page, searchable. Clicking a row opens the response drawer: the answer that was returned, its citations, the score, and which of that customer's sources were used: the audit trail for "what did our product actually tell this customer?".

Two boundaries worth knowing:

- **Your team's own queries are not here, and fleet queries are not on `/responses`.** The split is total and applies in both directions.
- **Citations in the fleet drawer render as text, not links.** The cited source belongs to a customer's index, so the drawer names it without opening a read path into tenant content.

Org-wide queries made with the **provisioning token** are your organisation's own traffic, so they appear on `/responses`, not in the fleet log.

## Every screen at a glance

| Path | What it is |
|---|---|
| `/white_label/api_accounts` | Fleet: usage strip, searchable table, pagination |
| `/white_label/responses` | Fleet query log: every customer query, searchable, with the response drawer |
| `/user/settings#provisioning-token` | Provisioning token, default webhook URL, webhook signing secret |
| `/responses` | Your own team's queries, never fleet traffic |

---

# Errors and limits

## Endpoint summary

| Verb | Path | Auth | Purpose |
|------|------|------|---------|
| POST | `/api_accounts` | provisioning | Create (idempotent by `uid`) |
| GET | `/api_accounts` | provisioning | List + usage + pagination |
| GET | `/api_accounts/:uid` | provisioning | Sync status and index size |
| DELETE | `/api_accounts/:uid` | provisioning | Destroy the account and its content |
| POST | `/api_accounts/:uid/resync` | provisioning | Re-crawl the website |
| GET | `/context` | customer **or** provisioning | RAG context (org-wide with the provisioning token) |
| GET | `/question` | customer **or** provisioning | Grounded answer with citations |
| POST | `/context/save` | customer | Write a note to that customer's memory |
| GET | `/ping` | customer **or** provisioning | Token health check |
| * | `/custom_connections/*` | customer | Push markdown content |

## Errors

### Provisioning API (`/api_accounts`)

| Code | Meaning |
|---|---|
| 401 | Bad or missing provisioning token |
| 402 | Organisation is not on the white-label plan, its subscription has lapsed, or the automatic bundle add failed at the payment provider |
| 403 | The token holder is not an organisation admin |
| 404 | Unknown `uid`, a `uid` belonging to another organisation, or an id that is not an API account |
| 409 | `resync` while a sync is already running |
| 422 | Invalid `uid`, `label`, `site_url` (bad URL, explicit port, or embedded credentials) or `webhook_url` (bad URL, embedded credentials, or a private/loopback target), or a `resync` on an account with no website |
| 429 | Rate limited; honour `Retry-After` |

### Query API (`/context`, `/question`, `/ping`)

| Code | Meaning |
|---|---|
| 400 | Missing `query` parameter, or `/context/save` without a `namespace` |
| 401 | Missing `Authorization` header, **or** the organisation's subscription has lapsed |
| 402 | `/question` on an organisation whose plan doesn't include it |
| 403 | `/context/save` attempted with the provisioning token (org-wide mode is read-only) |
| 404 | Unrecognised token |
| 429 | Rate limited, or the monthly question allowance is spent |
| 503 | `/question` reached the model and it failed: `{"result": "llm_unavailable"}`; retry |

Note the asymmetry: a lapsed subscription reads as `401` on the query endpoints and `402` on the provisioning and custom-connections APIs. Both mean "check billing".

### Custom Connections API

Documented in full on the [Custom Connections page](/docs/custom-connections).

## Limits

| Limit | Value |
|---|---|
| API accounts | 10 per bundle; bundles auto-added on overflow |
| Website crawl | 500 pages per API account |
| Connections per API account | 1 website, 1 memory, up to 6 custom |
| Questions | 5,000 per bundle per month across the organisation |
| Custom-connection quotas | 1,000 posts / 100MB per connection; 100KB per post body |
| Write rate limit | 5 per 10s per token **and** per organisation |

The per-organisation write limit is shared across your whole fleet, so a bulk backfill across many customers serialises at 5 writes per 10 seconds. Spread large migrations out.
