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.
| Credential | Where it's used | Rotate from |
|---|---|---|
clientId | Connect flow (openThreadConnect, exchange) | — (public) |
clientSecret | exchangeConnectCode (server-side) | App detail → Rotate client secret |
apiKey | ThreadClient gateway calls | App detail → Rotate API key |
webhookSecret | parseWebhook signature checks | App 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:
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-onlyBase URLs
Each SDK helper talks to a different Thread surface. Defaults point at local dev; set them explicitly in production.
| Surface | Used by | Production | Local |
|---|---|---|---|
| Hosted consent | openThreadConnect, buildConnectUrl | https://app.jointhread.com | http://localhost:3000 |
| API (token exchange) | exchangeConnectCode | https://api.jointhread.com | http://localhost:3001 |
| Gateway (data plane) | ThreadClient | https://gw.jointhread.com | http://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.classifiedevents are POSTed. Must behttps;httpis allowed only forlocalhost. Leave empty to disable push and poll the gateway instead. - Redirect URIs — the allowed
redirectUrivalues 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:
| Kind | Shape |
|---|---|
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:
| Constraint | Effect 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.