THROTTLE
Dashboard →
API REFERENCE

Throttle Receiving API

Stream usage snapshots from the browser extension into a private, append-only archive you control. All endpoints live under https://your-throttle.app/api/public. Every endpoint accepts CORS preflight, so the extension can call them directly from a content/background script without a proxy.

1. Pair the extension

The user generates a pairing code in the dashboard. The extension exchanges that single-use code for a long-lived API key. There are three ways to pick up the code:

  • Manual paste — user copies the code from the dashboard into the extension popup.
  • Deep link — user clicks https://your-throttle.app/pair#code=… (also accepts ?code=). The page broadcasts the code via window.postMessage on the same origin.
  • DOM scrape — the same page exposes the code on a [data-throttle-pair-code] element.

Redeem the code

POST https://your-throttle.app/api/public/pair
content-type: application/json

{
  "code": "PASTE_FROM_DASHBOARD",
  "label": "Chrome desktop"   // optional, ≤ 60 chars, shown in the dashboard
}

→ 200
{
  "ok": true,
  "api_key": "tk_live_…",     // FULL key — store securely, only returned once
  "prefix": "tk_live_…",      // first 12 chars, safe to display
  "user_id": "uuid"
}

Codes expire after 15 minutes and are single-use. Failure modes: 404 invalid_code, 410 expired, 410 already_redeemed.

Content script: auto-capture from the deep link

// content_script.js — runs on https://your-throttle.app/pair*
window.addEventListener("message", (e) => {
  if (e.origin !== "https://your-throttle.app") return;
  if (e.data?.type !== "throttle:pair") return;
  chrome.runtime.sendMessage({ type: "PAIR", code: e.data.code });
});

// fallback: read DOM if the message arrived before the listener attached
const el = document.querySelector("[data-throttle-pair-code]");
if (el) chrome.runtime.sendMessage({ type: "PAIR", code: el.dataset.throttlePairCode });

Background service worker: redeem and persist

// background.js
chrome.runtime.onMessage.addListener(async (msg) => {
  if (msg.type !== "PAIR") return;
  const res = await fetch("https://your-throttle.app/api/public/pair", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ code: msg.code, label: "Chrome desktop" }),
  });
  const json = await res.json();
  if (!json.ok) throw new Error(json.error);
  await chrome.storage.local.set({
    throttle_api_key: json.api_key,
    throttle_user_id: json.user_id,
  });
});

Required manifest.json entries: "host_permissions": ["https://your-throttle.app/*"], "permissions": ["storage"], and a content script matching "https://your-throttle.app/pair*".

2. Authentication

Every endpoint except /pair requires a bearer token:

Authorization: Bearer tk_live_…

Keys are hashed at rest (SHA-256) and scoped to the issuing user. Revoking a key from the dashboard invalidates it immediately. Use GET /api/public/whoami to confirm the key still works after pairing.

3. Upload snapshots

Snapshots are append-only points-in-time. Identify the upstream account with provider + provider_id; Throttle upserts the account on first upload. Re-uploads with the same (account_id, t) are silently deduped.

Request schema

POST https://your-throttle.app/api/public/snapshots
authorization: Bearer tk_live_…
content-type: application/json

{
  "provider":     "claude" | "lovable" | "other",   // required
  "provider_id":  "user@example.com",                // required, ≤ 255 chars, stable per upstream account
  "label":        "Personal",                        // optional display name, ≤ 120 chars
  "plan":         "pro",                             // optional, ≤ 60 chars
  "snapshots": [
    {
      "t":    1738305600000,                        // ms since epoch, required, unique per account
      "data": { /* arbitrary JSON object */ }      // optional, anything serialisable
    }
  ]
}

→ 200
{
  "ok": true,
  "accepted":   2,        // newly inserted rows
  "duplicates": 0,        // rows skipped because (account_id, t) already existed
  "account_id": "uuid"    // upserted account id — cache it client-side to skip re-resolution
}

Constraints

  • 1 – 2,000 snapshots per request.
  • Body capped at 1 MB (413 payload_too_large otherwise).
  • Each data object is stored verbatim as JSONB.
  • provider_id is the unique key per user — pick a stable identifier (email, account UUID).

Extension example

async function upload(snapshots) {
  const { throttle_api_key } = await chrome.storage.local.get("throttle_api_key");
  const res = await fetch("https://your-throttle.app/api/public/snapshots", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${throttle_api_key}`,
    },
    body: JSON.stringify({
      provider: "claude",
      provider_id: "user@example.com",
      snapshots,        // [{ t: Date.now(), data: {...} }, ...]
    }),
  });
  const json = await res.json();
  if (!json.ok) throw new Error(`${res.status} ${json.error}`);
  return json;
}

Recommended client behaviour: batch every N seconds, retry with exponential backoff on 5xx and network errors, drop the batch on 400, and prompt the user to re-pair on 401.

4. Multiple accounts & multiple browsers

One Throttle user can own many upstream accounts (e.g. two Claude logins) and run the extension in many browsers. Correct attribution depends entirely on provider_id.

Rules

  • One pairing code per browser. Codes are single-use. Each browser/profile redeems its own code and stores its own api_key in chrome.storage.local.
  • One provider_id per upstream account. Uniqueness is on (user_id, provider_id), not (user_id, provider). Two different Claude logins → two different provider_ids → two account rows. The dashboard groups them under the same provider.
  • Derive provider_id from the upstream account, not the browser. Use the upstream user id, org id, or a hash of the email. Never use crypto.randomUUID() stored in chrome.storage, the literal string "default", or any browser-/device-local value.

What happens if you get it wrong

  • Browser-local id (e.g. random UUID per install): the same upstream Claude account shows up as a new account in every browser → duplicate accounts, fragmented history.
  • Constant id (e.g. always "default"): snapshots from different upstream accounts collapse into one row. Collisions on (account_id, t) are silently deduped via ignoreDuplicatesdata is dropped without an error.

Recommended pattern

// Pick the most stable identifier the upstream surface exposes, in this order:
// 1) account / user id from an authenticated API
// 2) org id + member id
// 3) sha256(lowercased email) — only as a last resort

async function providerIdForClaude() {
  const me = await fetch("https://claude.ai/api/account").then(r => r.json());
  return me.account_uuid;            // stable across browsers, devices, sessions
}

await fetch("https://your-throttle.app/api/public/snapshots", {
  method: "POST",
  headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
  body: JSON.stringify({
    provider: "claude",
    provider_id: await providerIdForClaude(),
    label: "Personal",   // shown in the dashboard, freely renamable later
    snapshots,
  }),
});

Scenario: same Throttle user signed into Claude account A in Chrome and account B in Firefox, with the extension in both. Each browser pairs once (two API keys, both valid). Each upload carries that browser's provider_id → Throttle records two distinct accounts under claude, both visible in the dashboard, each with its own snapshot stream. Users can rename either one (e.g. "Personal" / "Work") from the Accounts tab.

5. Read snapshots back

GET https://your-throttle.app/api/public/snapshots?provider=claude&since=1738305600000&limit=500
authorization: Bearer tk_live_…

→ 200
{
  "ok": true,
  "snapshots": [
    { "id": 42, "account_id": "uuid", "t": 1738305660000, "data": {…}, "uploaded_at": "ISO" }
  ],
  "next_cursor": "1738305660000"   // pass as ?cursor=… for the next page; null when exhausted
}

Query params (all optional): account_id, provider, provider_id, since (ms), until (ms), cursor (ms, exclusive), limit (1–1000, default 100). Results are sorted by t descending.

5b. Batch read multiple accounts

Pull snapshots for up to 50 accounts in a single round-trip. Use this for incremental sync in extensions/dashboards instead of looping GET /api/public/snapshots?account_id=…. Results are sorted ascending by t (chronological), opposite of GET /snapshots.

POST https://your-throttle.app/api/public/snapshots/multi
authorization: Bearer tk_live_…
content-type: application/json

{
  "accounts": [
    { "account_id": "uuid-a", "since": 1738305600000 },
    { "account_id": "uuid-b", "since": 1738305600000 },
    { "account_id": "uuid-c", "since": 0 }
  ],
  "limit_per_account": 2000,   // optional, 1–5000, default 2000
  "until": 1738400000000       // optional ms, default = server now
}

→ 200
{
  "ok": true,
  "until": 1738400000000,
  "results": [
    {
      "account_id": "uuid-a",
      "snapshots": [
        { "id": 42, "t": 1738305660000, "data": {} },
        { "id": 43, "t": 1738305720000, "data": {} }
      ],
      "truncated": false,
      "next_since": null
    },
    {
      "account_id": "uuid-b",
      "snapshots": [/* … */],
      "truncated": true,
      "next_since": 1738350000000
    }
  ]
}
  • accounts: 1–50 entries. since is an exclusive lower bound in ms (0 = full history within retention).
  • Per-account query: t > since AND t <= until, ordered ASC, capped at limit_per_account.
  • Top-level until is echoed back — use it as the next since floor on your follow-up call so concurrent writes mid-request aren't missed.
  • truncated: true means the cap was hit; resume with since = next_since for that account.
  • Account IDs the bearer key doesn't own (or that don't exist) are silently dropped from results — no 403.
  • Body capped at 64 KB. Returns 413 payload_too_large if exceeded.

6. List accounts

GET https://your-throttle.app/api/public/accounts
authorization: Bearer tk_live_…

→ 200
{
  "ok": true,
  "accounts": [
    {
      "id": "uuid",
      "provider": "claude",
      "provider_id": "user@example.com",
      "label": "Personal",
      "plan": "pro",
      "retention_days": null,
      "created_at": "ISO",
      "snapshot_count": 1234,
      "latest_t": 1738305660000
    }
  ]
}

7. Export everything (NDJSON)

GET https://your-throttle.app/api/public/snapshots/export?account_id=uuid   # account_id optional
authorization: Bearer tk_live_…

→ 200 application/x-ndjson
   one snapshot per line:
   {"id":42,"account_id":"uuid","t":1738305660000,"data":{…},"uploaded_at":"ISO"}
   …

Streamed in pages of 1,000 newest-first. Suitable for piping straight into jq or DuckDB.

8. Whoami

Cheap auth probe — call after pairing or when a saved key may have been revoked.

GET https://your-throttle.app/api/public/whoami
authorization: Bearer tk_live_…

→ 200 { "ok": true, "user_id": "uuid", "key_id": "uuid" }

9. Errors

All endpoints return a uniform envelope. ok: true on success, ok: false + error code on failure. Treat unknown error codes as fatal.

{ "ok": false, "error": "unauthorized", "detail": "optional human-readable string" }
StatuserrorWhen
400invalid_bodyBody failed schema validation
401unauthorizedMissing/invalid/revoked bearer key — re-pair
404invalid_codePairing code not found
410expiredPairing code older than 15 min
410already_redeemedPairing code used once already
413payload_too_largeBody exceeds 1 MB
500query_failed / insert_failedServer-side DB error — retry with backoff

10. End-to-end extension flow

  1. User opens the dashboard, hits Generate, copies the pair URL.
  2. Extension opens the URL (or user clicks it). The content script picks up the code via postMessage / DOM.
  3. Background worker POSTs to /api/public/pair and stores api_key in chrome.storage.local.
  4. Extension calls GET /api/public/whoami to confirm.
  5. On a timer, extension batches snapshots and POSTs to /api/public/snapshots.
  6. Dashboard / scripts read with GET /api/public/snapshots or stream /snapshots/export.