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

Base URL
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.
Response
{
  "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:

  1. 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-server
    Response
    {
      "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"]
    }
  2. 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.1 for a script or a desktop app, your own https page for a web app. Keep the client_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-in
  3. Send 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.
  4. 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_denied instead.

  5. 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…"
    }
  6. 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
Only tokens from this sign-in work; a sixteenbridges.com website session doesn't. The person can disconnect your app on their account page: your requests then get 401 and renewal fails, and you have to ask them again. To sign out yourself, call POST /v1/logout.

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"
Response
{
  "status": "SUCCESS",
  "payload": { "user_id": "6f1c2b0e-…" }
}

GET/v1/bubbles

The bubbles, newest first, without their content. With q, a search of titles and contents.

Parameters
limitinteger1–50, default 20.
cursorstringThe next page: the previous answer's next_cursor.
qstringSearch words, 2–100 characters (no cursor with it).
curl https://api.sixteenbridges.com/v1/bubbles?limit=20 \
  -H "Authorization: Bearer $TOKEN"
Response
{
  "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.

Parameters
titlestring · required1–120 characters.
contentstring · requiredMarkdown, up to 512 KB.
source.kindstring · requiredclaude, chatgpt, gemini, grok, perplexity, cli, api or other.
source.labelstringWhere it came from, up to 200 characters.
source.urlstringAn https link back, up to 2,048 characters.
Idempotency-Keyheader1–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"}}'
Response
{
  "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"
Response
{
  "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).

Parameters
emailstring · requiredThe receiver's email. An address with no confirmed account is RECIPIENT_NOT_FOUND.
bubble_idsstring[] · required1–20 of the caller's own bubbles (not in the trash), in order.
Idempotency-Keyheader1–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.

Parameters
directionstringsent or received; both when left out.
limitinteger1–50, default 20.
cursorstringThe 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

Error
{
  "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_typeStatusWhen
INVALID_INPUT400A field, parameter or header is wrong: fields says which.
MISSING_MANDATORY_PARAMETER400A required field is missing.
INVALID_ENUM400source.kind isn't one of the list.
AUTHENTICATION_FAILED401No token, an expired one, or a disconnected app: renew, or sign in again.
BUBBLE_LIMIT_REACHED403The plan's bubbles are used up.
RECIPIENT_LIMIT_REACHED403The receiver's plan hasn't room for all of them: nothing was shared.
NOT_FOUND404No such bubble, including anyone else's.
RECIPIENT_NOT_FOUND404No confirmed Sixteen Bridges account uses that email.
METHOD_NOT_ALLOWED405The path exists; the Allow header lists its methods.
IDEMPOTENCY_CONFLICT409The Idempotency-Key was used for a different body.
CONTENT_TOO_LARGE413Over 512 KB of content, or a body over the cap.
SHARE_LIMIT_REACHED42920 tries to share in 24 hours (failed ones count).
SERVICE_UNAVAILABLE503Try 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.