# Integrations for your customers (experimental)

Source: https://docs.littlebigbrain.com/guides/integrations-for-your-customers/

Your product runs on LBB, and your customers want their own sources in it.
Each customer gets one graph in your stack. For Notion, GitHub and Slack,
your backend creates a consent link for an app your company owns. Other
connectors accept the customer's credential. LBB stores credentials sealed,
applies the connector's starter ontology and runs syncs. See
[Integrations](https://docs.littlebigbrain.com/guides/integrations/) for what it writes.

The routes live on the integrations API at `https://api.littlebigbrain.com`.
They take your stack API key. One client of the SDK calls both that API and
your stack endpoint.

## Before you start

- **Ask for developer access.** An operator turns it on per stack. Write to
  hello@littlebigbrain.com and name the stack. Until then every route answers
  `403 integrations_api_disabled`.
- **Keep the key on your servers.** Any key of the stack reads every graph of
  the stack. Your backend is the boundary between your customers.
- **Use a second stack for tests**, so test graphs do not count toward the
  limits of your product's stack.

## One graph per customer

A graph id has 1 to 32 lowercase letters, digits, `-` or `_`. Derive an opaque
id from your own customer id: `c-` and the first 24 hex digits of its SHA-256.
It keeps names and email addresses out of storage keys and logs.

```ts
import { createHash } from "node:crypto";

export function graphFor(customerId: string): string {
  return `c-${createHash("sha256").update(customerId).digest("hex").slice(0, 24)}`;
}
```

```python
import hashlib

def graph_for(customer_id: str) -> str:
    return "c-" + hashlib.sha256(customer_id.encode()).hexdigest()[:24]
```

Set the graph from your own record of the signed-in customer. Never take it
from a field the customer sends.

## Create the client

`baseUrl` is the stack endpoint from the console's **Connect** view.
`integrationsUrl` is `https://api.littlebigbrain.com` by default.

```ts
import { LbbClient } from "@littlebigbrain/client";

const lbb = new LbbClient({
  baseUrl: process.env.LBB_URL!,
  apiKey: process.env.LBB_API_KEY!,
});
const graph = graphFor(customer.id);
```

```python
import os

from lbb import LbbClient

lbb = LbbClient(os.environ["LBB_URL"], api_key=os.environ["LBB_API_KEY"])
graph = graph_for(customer.id)
```

`AsyncLbbClient` has the same methods, with `await`. The option is
`integrations_url` in Python.

## Ask for the credential

For **Notion, GitHub and Slack**, use the opt-in
[developer-owned app flow](https://docs.littlebigbrain.com/guides/developer-owned-integrations/) instead:
register your app, call `providerApps()` and `createConnectSession()`, then
verify completion with `getConnectSession()`. Customers consent and select
resources without an LBB account. Hosted connections for these providers
refuse pasted tokens. The guide includes provider setup, return URLs and a
runnable local example with disconnect.

The credential flow below applies to HubSpot, Linear, SharePoint and Google Drive.

`connectors()` lists each connector with the fields a customer fills in. Show
those fields in your product.

| Connector | `kind` | Credential fields |
| --- | --- | --- |
| HubSpot | `hubspot` | `HUBSPOT_TOKEN` |
| Linear | `linear` | `LINEAR_API_KEY` |
| SharePoint | `sharepoint` | `SHAREPOINT_TENANT_ID`, `SHAREPOINT_CLIENT_ID`, `SHAREPOINT_CLIENT_SECRET` |
| Google Drive | `google_drive` | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REFRESH_TOKEN`; or `GOOGLE_SERVICE_ACCOUNT_KEY` |

```ts
const { connectors } = await lbb.integrations.connectors();
const hubspot = connectors.find((connector) => connector.kind === "hubspot");
console.log(hubspot?.credentialFields.map((field) => field.name));
```

The [Integrations](https://docs.littlebigbrain.com/guides/integrations/#connectors) guide says where a
customer creates each credential.

## Create a connection

```ts
await lbb.integrations.create({
  graph,
  id: "hubspot",
  kind: "hubspot",
  credentials: { HUBSPOT_TOKEN: token },
});
```

```python
lbb.integrations.create(
    graph=graph,
    id="hubspot",
    kind="hubspot",
    credentials={"HUBSPOT_TOKEN": token},
)
```

The call:

1. seals the credential, bound to your stack, the graph and the connection;
2. creates the graph when it does not exist;
3. applies the connector's starter ontology (`starter: "skip"` leaves it out);
4. checks how the source fits the ontology;
5. sends the first sync (`start: false` leaves it to you).

The same request again answers success and queues one first sync, so a retry
is safe. The connection syncs every hour. `everyMs` (`every_ms`) sets another
interval, at least 60,000 ms. `null` (`None`) syncs only on request.

Two answers create nothing:

- `409 starter_conflict`: the graph holds a starter term differently.
  `details.conflicts` lists the terms.
- `409 integration_exists`: the id has other credentials or settings in the
  graph. Choose another id, or replace the credentials.

## Connect Google Drive with OAuth

Your customers connect Google Drive through your own Google app. Your
backend asks the customer for consent. It sends the refresh token to LBB.
LBB renews the access token before each sync.

1. In Google Cloud, create an OAuth client of type web application. Add your
   callback URL as a redirect URI. Turn on the Google Drive API.
2. Send the customer to the consent URL. It asks for read access to Drive,
   with offline access.
3. On your callback, check `state` and exchange the code. Send the three
   values to `create`.

```ts
import { randomBytes } from "node:crypto";
import { googleDrive } from "@littlebigbrain/client";

const clientId = process.env.GOOGLE_CLIENT_ID!;
const clientSecret = process.env.GOOGLE_CLIENT_SECRET!;
const redirectUri = "https://app.example.com/google/callback";

// The Connect Google Drive button. Keep `state` with the customer's session.
const state = randomBytes(16).toString("hex");
const consent = googleDrive.authorizeUrl({ clientId, redirectUri, state });

// The callback, after `state` matches.
const { credentials } = await googleDrive.exchangeCode({
  clientId,
  clientSecret,
  redirectUri,
  code,
});
await lbb.integrations.create({
  graph,
  id: "google-drive",
  kind: "google_drive",
  credentials,
  config: { folders: ["https://drive.google.com/drive/folders/1AbCdEfGhIj"] },
});
```

```python
import os
import secrets

from lbb import google_drive

client_id = os.environ["GOOGLE_CLIENT_ID"]
client_secret = os.environ["GOOGLE_CLIENT_SECRET"]
redirect_uri = "https://app.example.com/google/callback"

# The Connect Google Drive button. Keep `state` with the customer's session.
state = secrets.token_hex(16)
consent = google_drive.authorize_url(
    client_id=client_id, redirect_uri=redirect_uri, state=state
)

# The callback, after `state` matches.
grant = google_drive.exchange_code(
    client_id=client_id,
    client_secret=client_secret,
    redirect_uri=redirect_uri,
    code=code,
)
lbb.integrations.create(
    graph=graph,
    id="google-drive",
    kind="google_drive",
    credentials=grant.credentials,
)
```

A refused code raises `GoogleOAuthError` (TypeScript and Python) with
Google's `code`, such as `invalid_grant`. It also raises when Google
returns no refresh token, or when the customer did not grant Drive access.
`exchange_code_async` is the Python coroutine.

- `folders` takes folder or shared drive links. Leave it out to sync every
  file the customer can read.
- Google lists `drive.readonly` as a restricted scope. Your app needs
  Google's verification before other Google accounts can use it.
- While your app's publishing status is Testing, a refresh token stops
  working after 7 days.
- A customer can use a service account instead. They paste its JSON key as
  `GOOGLE_SERVICE_ACCOUNT_KEY`. They share the folders with its email.

## Show the status

```ts
const { connections } = await lbb.integrations.list({ graph });
for (const connection of connections) {
  console.log(connection.id, connection.status.kind, connection.status.detail);
}
```

```python
for connection in lbb.integrations.list(graph=graph)["connections"]:
    print(connection["id"], connection["status"]["kind"], connection["status"]["detail"])
```

`status.kind` is for your code and `status.detail` is a sentence for a
person. The console shows the same status.

| `kind` | Meaning |
| --- | --- |
| `new` | It has not synced yet. |
| `starting`, `syncing`, `working` | A turn runs or waits to start. |
| `current` | Up to date. |
| `review` | Some streams wait for an ontology change. The rest keep syncing. |
| `retrying` | A turn failed and runs again. |
| `delayed` | A turn waits on LBB's side. It needs nothing from you. |
| `attention` | A turn failed. Reauthorize a provider-app connection with a new connect session; other connectors can replace a refused credential with `setCredentials`. |
| `paused` | Scheduled syncs wait until you resume. |

`get(id, { graph })` adds the settings, the current turn with its error, and
the fit. Each answer also carries the last `run`, the `totals` and the counts
of the `fit`. Read the list once a minute per graph. The host checks for
scheduled syncs at that rate.

## Sync now

```ts
await lbb.integrations.sync("hubspot", { graph, idempotencyKey: "nightly-2026-10-03" });
```

```python
lbb.integrations.sync("hubspot", graph=graph, idempotency_key="nightly-2026-10-03")
```

A request with the same idempotency key answers the same turn and queues
nothing new. Without a key, the client makes one per call, so its own retries
queue one sync. `full: true` reads every stream in full.

## Review ontology changes

A connection created through this API runs in `auto` mode. Changes with
strong support and a passing dry run apply without a person. Every change
only adds terms, so queries on the starter's terms keep working. The other
changes wait as [ontology suggestions](https://docs.littlebigbrain.com/guides/ontology-suggestions/). Until
you accept one, the fields it covers are not written.

The suggestions live on your stack endpoint. The client reads them there.

```ts
const open = await lbb.integrations.suggestions("hubspot", { graph, status: "open" });
for (const suggestion of open.suggestions) console.log(suggestion.title);

await lbb.integrations.accept(open.suggestions[0].suggestion_id, { graph, sync: true });
await lbb.integrations.dismiss(open.suggestions[1].suggestion_id, {
  graph,
  reason: "We do not use this field.",
});
```

```python
open_ = lbb.integrations.suggestions("hubspot", graph=graph, status="open")
for suggestion in open_.suggestions:
    print(suggestion.title)

lbb.integrations.accept(open_.suggestions[0].suggestion_id, graph=graph, sync=True)
lbb.integrations.dismiss(
    open_.suggestions[1].suggestion_id, graph=graph, reason="We do not use this field."
)
```

With `sync: true`, `accept` then sends the connection a sync. The records
that waited are written now. The sync's message id is
`sync-after-<suggestion id>`, so accepting twice queues one sync. Pass
`change` to accept an edited change. To keep `review` mode, where every
change waits, create the connection with `ontologyMode: "review"`.

## Read the records

The starter's terms are the same in every customer's graph. Query them with
SPARQL through the typed constants of the starter.

```ts
import { starters } from "@littlebigbrain/client";

const { classes, properties } = starters.crm;
const { rows } = await lbb.withScope({ graph }).sparqlRows({
  query: `
    SELECT ?name ?domain WHERE {
      ?o a/<http://www.w3.org/2000/01/rdf-schema#subClassOf>* <${classes.Organization.iri}> ;
         <http://www.w3.org/2000/01/rdf-schema#label> ?name .
      OPTIONAL { ?o <${properties.domain.iri}> ?domain }
    } LIMIT 10`,
});
```

```python
from lbb.starters import crm

query = f"""
    SELECT ?name ?domain WHERE {{
      ?o a/<http://www.w3.org/2000/01/rdf-schema#subClassOf>* <{crm.classes.Organization.iri}> ;
         <http://www.w3.org/2000/01/rdf-schema#label> ?name .
      OPTIONAL {{ ?o <{crm.properties.domain.iri}> ?domain }}
    }} LIMIT 10
"""
with LbbClient(os.environ["LBB_URL"], api_key=os.environ["LBB_API_KEY"], graph=graph) as reader:
    for row in reader.sparql(query):
        print(row["name"], row.get("domain"))
```

The same query then serves every customer whose source maps onto the CRM
starter. See [Ontology starters](https://docs.littlebigbrain.com/guides/ontology-starters/).

Reads are eventual by default. Right after a sync ends, the new records can
take a few seconds to appear. To read them at once, pass
`{ consistency: "strong" }` as the second argument of `sparqlRows`, or
`consistency="strong"` to `sparql` in Python.

## Change, pause or delete a connection

| TypeScript | Python | Does |
| --- | --- | --- |
| `setCredentials(id, { graph, credentials })` | `set_credentials(id, graph=, credentials=)` | Replaces the credential, then checks it |
| `setSettings(id, { graph, config })` | `set_settings(id, graph=, config=)` | Replaces the connector settings |
| `pause(id, { graph })` | `pause(id, graph=)` | Stops scheduled syncs and queued work |
| `resume(id, { graph })` | `resume(id, graph=)` | Continues them |
| `delete(id, { graph })` | `delete(id, graph=)` | Deletes the connection's state, credential and turn history |

A deleted connection's records stay in the graph. `setSettings` answers
`409 credentials_required` when the stored credential does not open under the
new settings. Replace the credential first.

Notion, GitHub and Slack app connections reject direct credential/settings
replacement. Reconnect through a new session with the existing connection ID
and the same resources. Changing resources requires a **new graph and rebuild**;
changing only the connection ID retains old data. Disconnect fences new provider reads through LBB
and requests provider token revocation. Failed revocations retry independently of
new connection capacity, with at most eight attempts or 24 hours before retaining
a sealed diagnostic record. Exact tokens held by another live grant or consent
for the same app are protected. Slack never uninstalls the workspace app.
Imported data remains until graph erasure.

## Erase a customer

```ts
await lbb.integrations.erase(graph, { confirm: graph });
```

```python
lbb.integrations.erase(graph, confirm=graph)
```

Erase deletes every connection of the graph with its credential, then deletes
the graph. Reads and writes of the graph stop at once. A reclaim job removes
the graph's stored objects later. Each pass in `reclaims` has a `mode`:
`apply` deletes, `plan` only counts. Erasing again answers zero counts.
For provider apps, local access is fenced and revocation is queued before erasing
the graph; provider outages or deleted app credentials do not block erasure.

## Limits

| Limit | Default | Past it |
| --- | --- | --- |
| Graphs with integrations per stack | 50 | `409 integration_limit_reached`; ask us for more |
| Connections created per stack per hour | 20 | `429 rate_limited` with `Retry-After` |
| Requests per stack | 600 per minute, 60 at once | `429 rate_limited` with `Retry-After` |

A retried credential-based create of a connection that exists does not count.
Each connect-session creation counts and is not automatically retried. A graph
that has integrations takes more connections.

## Errors

A failed call raises `LbbError`. It has `status` (`status_code` in Python),
`code` and a message. A refusal adds `details`. On a `429`,
`retryAfterSeconds` (`retry_after_seconds`) holds the wait. For reads and safe
writes, the client retries a `429` or a `5xx`. Its retries stop after 60
seconds.

| Status | `code` | Meaning |
| --- | --- | --- |
| 401 | `invalid_api_key` | No key, or an unknown, revoked or inactive one |
| 402 | `subscription_required` | The account has no active subscription. Reads, pause, delete and erase still work. |
| 403 | `integrations_api_disabled` | The stack has no developer access yet |
| 403 | `stack_deletion_pending` | The stack is being deleted |
| 404 | | No such connection in this graph of your stack |
| 409 | `starter_conflict`, `integration_exists`, `integration_limit_reached`, `credentials_required` | See above |
| 429 | `rate_limited` | Past a limit; wait `Retry-After` seconds |
| 503 | `data_plane_busy`, `stack_endpoint_not_ready` | Briefly unavailable; retry |

## Routes

| Method | Path | TypeScript | Python |
| --- | --- | --- | --- |
| `GET` | `/v1/integrations/connectors` | `connectors()` | `connectors()` |
| `GET` | `/v1/integrations/apps` | `providerApps()` | `provider_apps()` |
| `POST` | `/v1/integrations/connect-sessions` | `createConnectSession()` | `create_connect_session()` |
| `GET` | `/v1/integrations/connect-sessions/{id}` | `getConnectSession()` | `get_connect_session()` |
| `POST` | `/v1/integrations/connections` | `create()` | `create()` |
| `GET` | `/v1/integrations/connections?graph=` | `list()` | `list()` |
| `GET` | `/v1/integrations/connections/{id}?graph=` | `get()` | `get()` |
| `PUT` | `/v1/integrations/connections/{id}/credentials` | `setCredentials()` | `set_credentials()` |
| `PUT` | `/v1/integrations/connections/{id}/settings` | `setSettings()` | `set_settings()` |
| `POST` | `/v1/integrations/connections/{id}/sync` | `sync()` | `sync()` |
| `POST` | `/v1/integrations/connections/{id}/pause`, `…/resume` | `pause()`, `resume()` | `pause()`, `resume()` |
| `DELETE` | `/v1/integrations/connections/{id}?graph=` | `delete()` | `delete()` |
| `POST` | `/v1/integrations/graphs/{graph}/erase?confirm={graph}` | `erase()` | `erase()` |

Each route takes `Authorization: Bearer <stack API key>`. `suggestions()`,
`accept()` and `dismiss()` call the
[suggestion routes](https://docs.littlebigbrain.com/guides/ontology-suggestions/#routes) of your stack
endpoint.
