REST API
For your own code: a script, a bot, an app. JSON over HTTPS, signed in with OAuth 2.1. If your code can run commands, the CLI with --json does all of this for you.
Basics
https://api.sixteenbridges.com/v1- Every request:
Authorization: Bearer <access token>(from the sign-in below). - Bodies are JSON. Answers are an envelope: the payload on success, the error otherwise.
{
"status": "SUCCESS",
"payload": { "user_id": "6f1c2b0e-…" }
}Authentication
OAuth 2.1 authorization code with PKCE. Your app registers itself once (no keys to request), the person approves once on sixteenbridges.com, and your app keeps a refresh token. Six steps:
Discover where to sign in
Ask the API; it names the authorization server. Discover it rather than hard-coding it.
curl https://api.sixteenbridges.com/.well-known/oauth-protected-resource # → "authorization_servers": ["https://lhoaehepgmmqlltutgos.supabase.co/auth/v1"] curl https://lhoaehepgmmqlltutgos.supabase.co/auth/v1/.well-known/oauth-authorization-serverResponse{ "issuer": "https://lhoaehepgmmqlltutgos.supabase.co/auth/v1", "authorization_endpoint": "https://lhoaehepgmmqlltutgos.supabase.co/auth/v1/oauth/authorize", "token_endpoint": "https://lhoaehepgmmqlltutgos.supabase.co/auth/v1/oauth/token", "registration_endpoint": "https://lhoaehepgmmqlltutgos.supabase.co/auth/v1/oauth/clients/register", "code_challenge_methods_supported": ["S256", "plain"], "grant_types_supported": ["authorization_code", "refresh_token"] }Register your app, once
Dynamic client registration. Public client (no secret); the redirect URI is where the browser comes back: a port on
127.0.0.1for a script or a desktop app, your own https page for a web app. Keep theclient_id.curl -X POST https://lhoaehepgmmqlltutgos.supabase.co/auth/v1/oauth/clients/register \ -H "Content-Type: application/json" \ -d '{ "client_name": "My bubbles app", "redirect_uris": ["http://127.0.0.1:53682/callback"], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none" }' # → { "client_id": "8c6c7a52-…", … } keep it: register once, not on every sign-inSend the person to approve
Make a PKCE verifier and its S256 challenge, and a random state. The person signs in and clicks Allow.
import { createHash, randomBytes } from "node:crypto"; const verifier = randomBytes(32).toString("base64url"); const challenge = createHash("sha256").update(verifier).digest("base64url"); const state = randomBytes(16).toString("base64url"); const url = new URL(as.authorization_endpoint); url.search = new URLSearchParams({ response_type: "code", client_id: client.client_id, redirect_uri, code_challenge: challenge, code_challenge_method: "S256", state, }); // Open it for the person: they sign in on sixteenbridges.com and click Allow.Take the code where the browser comes back
Your redirect URI gets
?code=…&state=…. Check the state is yours. If the person clicked Deny, it gets?error=access_deniedinstead.Swap the code for tokens
curl -X POST https://lhoaehepgmmqlltutgos.supabase.co/auth/v1/oauth/token \ -d grant_type=authorization_code \ -d code="$CODE" \ -d redirect_uri=http://127.0.0.1:53682/callback \ -d client_id="$CLIENT_ID" \ -d code_verifier="$VERIFIER"Response{ "access_token": "eyJhbGciOiJFUzI1NiIs…", "token_type": "bearer", "expires_in": 3600, "refresh_token": "v3x…" }Renew before the access token expires
About an hour. Each refresh token works once: save the new one every time, or the next renewal fails.
curl -X POST https://lhoaehepgmmqlltutgos.supabase.co/auth/v1/oauth/token \ -d grant_type=refresh_token \ -d refresh_token="$REFRESH_TOKEN" \ -d client_id="$CLIENT_ID" # → a new access_token and a NEW refresh_token: save it, the old one is spent
The whole sign-in
One file, from nothing to a list of bubbles. Run it, open the link it prints, click Allow.
// sign-in.mjs: node sign-in.mjs
import { createHash, randomBytes } from "node:crypto";
import { createServer } from "node:http";
const API = "https://api.sixteenbridges.com";
const PORT = 53682;
const redirect_uri = `http://127.0.0.1:${PORT}/callback`;
// 1. Where to sign in
const resource = await (await fetch(`${API}/.well-known/oauth-protected-resource`)).json();
const issuer = resource.authorization_servers[0];
const as = await (await fetch(`${issuer}/.well-known/oauth-authorization-server`)).json();
// 2. Register this app (once: keep client_id for next time)
const client = await (await fetch(as.registration_endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
client_name: "My bubbles app",
redirect_uris: [redirect_uri],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
}),
})).json();
// 3. PKCE, then the person approves on sixteenbridges.com
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
const state = randomBytes(16).toString("base64url");
const url = new URL(as.authorization_endpoint);
url.search = new URLSearchParams({
response_type: "code",
client_id: client.client_id,
redirect_uri,
code_challenge: challenge,
code_challenge_method: "S256",
state,
});
console.log("Open this link and click Allow:", url.href);
// 4. The browser comes back here with ?code=…&state=…
const code = await new Promise((resolve, reject) => {
const server = createServer((req, res) => {
const q = new URL(req.url, redirect_uri).searchParams;
res.end("Signed in. You can close this tab.");
server.close();
if (q.get("state") !== state) reject(new Error("state mismatch"));
else if (!q.get("code")) reject(new Error(q.get("error") ?? "no code"));
else resolve(q.get("code"));
}).listen(PORT, "127.0.0.1");
});
// 5. The code for tokens
const tokens = await (await fetch(as.token_endpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri,
client_id: client.client_id,
code_verifier: verifier,
}),
})).json();
// 6. Use it
const res = await fetch(`${API}/v1/bubbles`, {
headers: { authorization: `Bearer ${tokens.access_token}` },
});
console.log(JSON.stringify(await res.json(), null, 2));Endpoints
GET/v1/me
Who the token belongs to.
curl https://api.sixteenbridges.com/v1/me \
-H "Authorization: Bearer $TOKEN"{
"status": "SUCCESS",
"payload": { "user_id": "6f1c2b0e-…" }
}GET/v1/bubbles
The bubbles, newest first, without their content. With q, a search of titles and contents.
limitinteger | 1–50, default 20. |
|---|---|
cursorstring | The next page: the previous answer's next_cursor. |
qstring | Search words, 2–100 characters (no cursor with it). |
curl https://api.sixteenbridges.com/v1/bubbles?limit=20 \
-H "Authorization: Bearer $TOKEN"{
"status": "SUCCESS",
"payload": {
"bubbles": [{
"id": "bub_0195f3a2-8c41-7e3b-9f2a-1d4e6b8c0a00",
"title": "Lisbon trip",
"preview": "# Day 1\nTrams, then Sintra.",
"content_bytes": 27,
"source": { "kind": "claude", "label": "Trip chat", "url": null },
"created_at": "2026-10-27T09:41:00.000+00:00",
"updated_at": null
}],
"usage": { "count": 1, "max_bubbles": 20, "max_content_bytes": 524288 },
"next_cursor": null
}
}POST/v1/bubbles
A new bubble. 201 with the bubble; with an Idempotency-Key, a retry returns the same bubble (200) instead of a second one.
titlestring · required | 1–120 characters. |
|---|---|
contentstring · required | Markdown, up to 512 KB. |
source.kindstring · required | claude, chatgpt, gemini, grok, perplexity, cli, api or other. |
source.labelstring | Where it came from, up to 200 characters. |
source.urlstring | An https link back, up to 2,048 characters. |
Idempotency-Keyheader | 1–64 of A–Z a–z 0–9 _ -. Makes a retry safe. |
curl -X POST https://api.sixteenbridges.com/v1/bubbles \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: save-1234" \
-H "Content-Type: application/json" \
-d '{"title": "Lisbon trip", "content": "# Day 1\nTrams, then Sintra.", "source": {"kind": "api", "label": "My app"}}'{
"status": "SUCCESS",
"payload": { "bubble": {
"id": "bub_0195f3a2-8c41-7e3b-9f2a-1d4e6b8c0a00",
"title": "Lisbon trip",
"content": "# Day 1\nTrams, then Sintra.",
"preview": "# Day 1\nTrams, then Sintra.",
"content_bytes": 27,
"source": { "kind": "claude", "label": "Trip chat", "url": null },
"shared_from": null,
"created_at": "2026-10-27T09:41:00.000+00:00",
"updated_at": null
} }
}GET/v1/bubbles/{id}
One bubble, with its content.
curl https://api.sixteenbridges.com/v1/bubbles/bub_0195f3a2-8c41-7e3b-9f2a-1d4e6b8c0a00 \
-H "Authorization: Bearer $TOKEN"PATCH/v1/bubbles/{id}
Change the title, the content, or both. To add to the end, read it, append, and send the whole content.
curl -X PATCH https://api.sixteenbridges.com/v1/bubbles/bub_0195f3a2-8c41-7e3b-9f2a-1d4e6b8c0a00 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title": "Lisbon and Sintra"}'DELETE/v1/bubbles/{id}
To the trash: restorable for 7 days, then deleted for good. Deleting at once is only on sixteenbridges.com.
curl -X DELETE https://api.sixteenbridges.com/v1/bubbles/bub_0195f3a2-8c41-7e3b-9f2a-1d4e6b8c0a00 \
-H "Authorization: Bearer $TOKEN"{
"status": "SUCCESS",
"payload": { "id": "bub_0195f3a2-8c41-7e3b-9f2a-1d4e6b8c0a00", "deleted": true, "permanent": false, "restorable_until": "2026-11-03T09:41:00.000Z" }
}GET/v1/bubbles/deleted
The trash, most recently deleted first; each with deleted_at and restorable_until.
curl https://api.sixteenbridges.com/v1/bubbles/deleted \
-H "Authorization: Bearer $TOKEN"POST/v1/bubbles/{id}/restore
Back from the trash. Refused with BUBBLE_LIMIT_REACHED if the plan is full.
curl -X POST https://api.sixteenbridges.com/v1/bubbles/bub_0195f3a2-8c41-7e3b-9f2a-1d4e6b8c0a00/restore \
-H "Authorization: Bearer $TOKEN"POST/v1/shares
Copies of the caller's bubbles into the account that uses an email: all of them in one go, or none (RECIPIENT_LIMIT_REACHED) if that plan hasn't room. They see the sender's email. 201 with the sender's record of the share; with an Idempotency-Key, a retry returns the same share (200).
emailstring · required | The receiver's email. An address with no confirmed account is RECIPIENT_NOT_FOUND. |
|---|---|
bubble_idsstring[] · required | 1–20 of the caller's own bubbles (not in the trash), in order. |
Idempotency-Keyheader | 1–64 of A–Z a–z 0–9 _ -. Makes a retry safe. |
curl -X POST https://api.sixteenbridges.com/v1/shares \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: share-1234" \
-H "Content-Type: application/json" \
-d '{"email": "sam@example.com", "bubble_ids": ["bub_0195f3a2-8c41-7e3b-9f2a-1d4e6b8c0a00"]}'GET/v1/shares
Shares, newest first, each from the caller's side: direction (sent or received), the other person's email, its items (title, content_bytes, bubble_id), created_at, and seen_at (null on a received share not seen yet). unseen counts those.
directionstring | sent or received; both when left out. |
|---|---|
limitinteger | 1–50, default 20. |
cursorstring | The next page: the previous answer's next_cursor. |
curl https://api.sixteenbridges.com/v1/shares?direction=received \
-H "Authorization: Bearer $TOKEN"POST/v1/logout
Ends this sign-in: the access token and its refresh token stop working.
curl -X POST https://api.sixteenbridges.com/v1/logout \
-H "Authorization: Bearer $TOKEN"Errors
{
"status": "ERROR",
"status_code": 400,
"error_info": {
"error_type": "INVALID_INPUT",
"message": "Invalid input provided for field 'title'.",
"fields": [{ "field_name": "title", "reason": "must not be empty" }]
}
}| error_type | Status | When |
|---|---|---|
INVALID_INPUT | 400 | A field, parameter or header is wrong: fields says which. |
MISSING_MANDATORY_PARAMETER | 400 | A required field is missing. |
INVALID_ENUM | 400 | source.kind isn't one of the list. |
AUTHENTICATION_FAILED | 401 | No token, an expired one, or a disconnected app: renew, or sign in again. |
BUBBLE_LIMIT_REACHED | 403 | The plan's bubbles are used up. |
RECIPIENT_LIMIT_REACHED | 403 | The receiver's plan hasn't room for all of them: nothing was shared. |
NOT_FOUND | 404 | No such bubble, including anyone else's. |
RECIPIENT_NOT_FOUND | 404 | No confirmed Sixteen Bridges account uses that email. |
METHOD_NOT_ALLOWED | 405 | The path exists; the Allow header lists its methods. |
IDEMPOTENCY_CONFLICT | 409 | The Idempotency-Key was used for a different body. |
CONTENT_TOO_LARGE | 413 | Over 512 KB of content, or a body over the cap. |
SHARE_LIMIT_REACHED | 429 | 20 tries to share in 24 hours (failed ones count). |
SERVICE_UNAVAILABLE | 503 | Try again in a minute (Retry-After says when). |
Limits
Free plan
20 bubbles of up to 512 KB each; titles up to 120 characters, unique among yours (a repeat is saved as "Title (2)"). Deleted bubbles stay in the trash for 7 days. Sharing: up to 20 bubbles at once, 20 tries a day.