Integrations for your customers (experimental)
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 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
Section titled “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
Section titled “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.
import { createHash } from "node:crypto";
export function graphFor(customerId: string): string { return `c-${createHash("sha256").update(customerId).digest("hex").slice(0, 24)}`;}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
Section titled “Create the client”baseUrl is the stack endpoint from the console’s Connect view.
integrationsUrl is https://api.littlebigbrain.com by default.
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);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
Section titled “Ask for the credential”For Notion, GitHub and Slack, use the opt-in
developer-owned app flow 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 |
const { connectors } = await lbb.integrations.connectors();const hubspot = connectors.find((connector) => connector.kind === "hubspot");console.log(hubspot?.credentialFields.map((field) => field.name));The Integrations guide says where a customer creates each credential.
Create a connection
Section titled “Create a connection”await lbb.integrations.create({ graph, id: "hubspot", kind: "hubspot", credentials: { HUBSPOT_TOKEN: token },});lbb.integrations.create( graph=graph, id="hubspot", kind="hubspot", credentials={"HUBSPOT_TOKEN": token},)The call:
- seals the credential, bound to your stack, the graph and the connection;
- creates the graph when it does not exist;
- applies the connector’s starter ontology (
starter: "skip"leaves it out); - checks how the source fits the ontology;
- sends the first sync (
start: falseleaves 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.conflictslists 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
Section titled “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.
- 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.
- Send the customer to the consent URL. It asks for read access to Drive, with offline access.
- On your callback, check
stateand exchange the code. Send the three values tocreate.
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"] },});import osimport 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.
folderstakes folder or shared drive links. Leave it out to sync every file the customer can read.- Google lists
drive.readonlyas 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
Section titled “Show the status”const { connections } = await lbb.integrations.list({ graph });for (const connection of connections) { console.log(connection.id, connection.status.kind, connection.status.detail);}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
Section titled “Sync now”await lbb.integrations.sync("hubspot", { graph, idempotencyKey: "nightly-2026-10-03" });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
Section titled “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. Until
you accept one, the fields it covers are not written.
The suggestions live on your stack endpoint. The client reads them there.
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.",});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
Section titled “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.
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`,});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.
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
Section titled “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
Section titled “Erase a customer”await lbb.integrations.erase(graph, { confirm: graph });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
Section titled “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
Section titled “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
Section titled “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 of your stack
endpoint.