Thread Docs

Configuration

Every setting a developer touches — credentials, base URLs, scopes, filters, and grant constraints.

Everything you configure lives in one of three places: the developer console (your app's identity and secrets), your environment (the four credentials), and the grant (what a user lets your app see). This page is the reference for all three.

Credentials

Registering an app mints four credentials. The three secrets are shown once — store them in your secret manager immediately.

CredentialWhere it's usedRotate from
clientIdConnect flow (openThreadConnect, exchange)— (public)
clientSecretexchangeConnectCode (server-side)App detail → Rotate client secret
apiKeyThreadClient gateway callsApp detail → Rotate API key
webhookSecretparseWebhook signature checksApp detail → Rotate signing secret

Rotating shows the new value once and invalidates the old one immediately, so deploy the new secret before rotating.

Environment variables

The SDK reads nothing from the environment itself — you pass values in — but these are the names used throughout the docs:

.env
THREAD_CLIENT_ID=app_xxx
THREAD_CLIENT_SECRET=secret_xxx      # server-only
THREAD_API_KEY=key_xxx               # server-only
THREAD_WEBHOOK_SECRET=whsec_xxx      # server-only

Base URLs

Each SDK helper talks to a different Thread surface. Defaults point at local dev; set them explicitly in production.

SurfaceUsed byProductionLocal
Hosted consentopenThreadConnect, buildConnectUrlhttps://app.jointhread.comhttp://localhost:3000
API (token exchange)exchangeConnectCodehttps://api.jointhread.comhttp://localhost:3001
Gateway (data plane)ThreadClienthttps://gw.jointhread.comhttp://localhost:3002

ThreadClient's baseUrl defaults to http://localhost:3002. Always set it to the gateway URL in production.

App settings (console)

On the app detail page you configure:

  • Webhook URL — where email.classified events are POSTed. Must be https; http is allowed only for localhost. Leave empty to disable push and poll the gateway instead.
  • Redirect URIs — the allowed redirectUri values for the Connect flow. A mismatch is rejected before the consent screen renders.
  • Requested scopes — the default scopes your Connect button asks for (catalog slugs).
  • Profile — display name, description, logo, vendor, and category, shown on the consent screen and in the app directory.
  • Verification — request Thread Verified to drop the "unverified app" notice on consent. Until then your app still works.

Scopes

Scopes are slugs from the classification catalog — a versioned tree of categories, each with labels. Browse the full reference — every category and label, what each grants, and example matches — on the scope catalog page, or fetch it as JSON any time:

curl https://api.jointhread.com/catalog
{
  "version": 3,
  "categories": [
    {
      "slug": "financial",
      "displayName": "Financial",
      "description": "Receipts, invoices, statements…",
      "labels": [
        {
          "slug": "receipt",
          "displayName": "Receipt",
          "description": "Proof of a completed payment…",
          "examples": ["amazon.com — \"Your Amazon.com order receipt\""]
        }
      ]
    }
  ]
}

The scopes you request seed the filter on the consent screen. What the user approves — possibly narrower — is the grant your app actually holds.

Beyond catalog slugs, two calendar data-source scopes are requestable: calendar.read and calendar.write. They're distinct from the calendar email category (meeting-invite mail) and gate the calendar API rather than the mail filter; the user approves each one individually on the consent screen.

Pagination

messages.list is cursor-paginated: up to 100 messages per page (default 50). Pass the previous response's nextCursor back as cursor for the next page — it's opaque; never parse or construct it. nextCursor: null means the last page. Each page is one rate-counted, audited gateway call.

let cursor: string | undefined;
do {
  const page = await thread.messages.list({ accountId, fromDomain: 'amazon.com', limit: 100, cursor });
  handle(page.messages);
  cursor = page.nextCursor ?? undefined;
} while (cursor);

// Or lazily — break early to stop fetching pages:
for await (const msg of thread.messages.iterate({ accountId, fromDomain: 'amazon.com' })) {
  handle(msg);
}

Filters

When listing messages you can pass a structured filter Expression. The SDK sends it base64-encoded for you; you build it as a tree:

const { messages } = await thread.messages.list({
  accountId,
  filter: {
    kind: 'and',
    clauses: [
      { kind: 'predicate', field: 'fromDomain', operator: 'eq', value: 'amazon.com' },
      { kind: 'predicate', field: 'labels', operator: 'contains', value: 'receipts' },
    ],
  },
});

An Expression is one of:

KindShape
and / or{ kind, clauses: Expression[] }
not{ kind: 'not', expr: Expression }
predicate{ kind: 'predicate', field, operator, value }

In this phase a sender domain is required to list. Pass fromDomain, or include a fromDomain eq predicate in your filter — otherwise the gateway returns 400 VALIDATION_ERROR.

Grant constraints

Users (not developers) can attach constraints to a grant. You don't set these, but you must handle the responses they produce, so your integration degrades gracefully:

ConstraintEffect on your calls
Expiry (expiresAt)After it passes, calls return 403 FORBIDDEN.
Daily rate limit (rateLimitPerDay)Once exceeded, calls return 429 RATE_LIMITED until the next UTC day.
Schedule (days + hour window)Outside the window, calls return 403 FORBIDDEN.

Surface these to your user as "reconnect" or "access paused" states rather than hard errors. See Errors & rate limits.

Sandbox mode

A new app starts in sandbox. Sandbox apps can exercise the full signature and webhook path using Send test webhook (a real, signed sample payload) without a live mailbox — ideal for building and testing your handler before connecting real accounts.