Thread Docs

Integrate

The full connect, exchange, webhook, and read flow — end to end.

A complete Thread integration has four moving parts. The SDK gives you a helper for each, so you never assemble URLs or verify signatures by hand.

  1. Connect — the browser sends the user to Thread's consent page; Thread redirects back with a code.
  2. Exchange — your server swaps the code for a stable accountId.
  3. Receive — Thread POSTs a signed email.classified webhook when matching mail is classified.
  4. Read — your server calls the gateway with its API key for metadata and labels.

Connect

Render a Connect with Thread button. openThreadConnect sends the user to Thread's hosted consent page, where they pick a mailbox and confirm the scope. You never see their password, and Thread — not your app — runs the provider OAuth.

connect.ts
import { openThreadConnect } from '@jointhread/sdk';

openThreadConnect({
  baseUrl: 'https://app.jointhread.com',
  clientId: process.env.THREAD_CLIENT_ID!,
  redirectUri: 'https://yourapp.com/thread/callback',
  scopes: ['financial', 'travel'], // seeds the user's default filter
  state: csrfToken, // returned verbatim — verify it on callback
});

Need a server-rendered link instead of a click handler? buildConnectUrl(opts) takes the same options and returns the URL for an <a href> or a redirect.

scopes are slugs from the classification catalog (GET /catalog). They seed the filter the user sees on the consent screen — the user can widen or narrow it, and what they approve is the grant your app actually holds. See Configuration.

The full-page redirect is the default. If you'd rather keep your page open, run the consent in a popup — the result comes back to your code instead of your callback URL, and you skip the redirect roundtrip on the client.

connect-popup.ts
import { openThreadConnect, openThreadConnectPopup } from '@jointhread/sdk';

const result = await openThreadConnectPopup({ baseUrl, clientId, redirectUri, scopes, state });

if (result.status === 'granted') {
  await exchangeOnYourServer(result.code); // same server-side exchange as below
} else if (result.status === 'blocked') {
  openThreadConnect({ baseUrl, clientId, redirectUri, scopes, state }); // popup blocked → redirect
}
// 'denied' and 'closed' mean the user said no or dismissed the popup.

Both button flavors support it too. The web component dispatches a bubbling thread-connect event with the result as detail:

<thread-connect-button mode="popup" base-url="..." client-id="..." redirect-uri="...">
</thread-connect-button>

The React component takes mode and onResult:

<ThreadConnectButton mode="popup" onResult={(result) => { /* …handle result… */ }} {...opts} />

When the popup is blocked, both buttons fall back to the full-page redirect automatically. The code is the same one-time code either way — exchange it server-side exactly as in the next step. (redirectUri is still required and validated; in popup mode it anchors which origin may receive the result.)

Exchange

After consent, Thread redirects to redirectUri?code=...&state=.... Verify state, then exchange the one-time code (5-minute TTL, single use) for the account handle. This runs server-side with your client secret.

callback.ts
import { exchangeConnectCode } from '@jointhread/sdk';

const { accountId, mailbox, scopes, appId } = await exchangeConnectCode({
  baseUrl: 'https://api.jointhread.com',
  clientId: process.env.THREAD_CLIENT_ID!,
  clientSecret: process.env.THREAD_CLIENT_SECRET!,
  code: req.query.code,
});
FieldMeaning
accountIdStable, per-app pseudonymous handle for this mailbox. Store it against your user.
mailboxThe email address, when the grant includes it — otherwise null.
scopesThe scopes the user approved.
appIdYour app id (echoed for convenience).

accountId is how you address a mailbox

You never store provider tokens. Every later call — reading metadata, matching a webhook to a user — uses accountId. It's specific to your app: the same mailbox connected to two apps yields two different accountIds.

Receive webhooks

Set your app's webhook URL in the console. When mail matching a grant arrives and is classified, Thread POSTs a signed email.classified event. Verify the signature with parseWebhook, which throws ThreadWebhookSignatureError on a bad signature and returns the typed event otherwise.

Read the raw request body, not a parsed object — the signature is computed over the exact bytes Thread sent. Most frameworks need an explicit raw-body parser on this route.

webhook.ts
import express from 'express';
import { parseWebhook, ThreadWebhookSignatureError } from '@jointhread/sdk';

app.post('/thread/webhook', express.text({ type: '*/*' }), (req, res) => {
  try {
    const event = parseWebhook({
      secret: process.env.THREAD_WEBHOOK_SECRET!,
      rawBody: req.body, // the raw string
      signature: req.header('X-Thread-Signature')!,
      timestamp: req.header('X-Thread-Timestamp')!,
    });

    // event.data.accountId tells you which of YOUR users this is.
    console.log(event.type, event.data.subject, event.data.labels);
    res.sendStatus(200); // 2xx = delivered; anything else is retried
  } catch (err) {
    if (err instanceof ThreadWebhookSignatureError) return res.sendStatus(401);
    throw err;
  }
});
app/thread/webhook/route.ts
import { parseWebhook, ThreadWebhookSignatureError } from '@jointhread/sdk';

export async function POST(req: Request) {
  const rawBody = await req.text(); // raw bytes, not req.json()
  try {
    const event = parseWebhook({
      secret: process.env.THREAD_WEBHOOK_SECRET!,
      rawBody,
      signature: req.headers.get('X-Thread-Signature')!,
      timestamp: req.headers.get('X-Thread-Timestamp')!,
    });
    // …handle event.data…
    return new Response(null, { status: 200 });
  } catch (err) {
    if (err instanceof ThreadWebhookSignatureError) return new Response(null, { status: 401 });
    throw err;
  }
}

The event shape, signing scheme, and retry policy are documented in full under Webhooks. To test your handler without a live mailbox, use Send test webhook on the app detail page — it posts a real, signed sample to your endpoint.

Read

Authenticate the gateway with your API key and address the mailbox by accountId (or connectionId). All reads return metadata plus classification labels — never raw bodies by default.

client.ts
import { ThreadClient } from '@jointhread/sdk';

const thread = new ThreadClient({
  apiKey: process.env.THREAD_API_KEY!,
  baseUrl: 'https://gw.jointhread.com',
});

List messages

const { messages, resultSizeEstimate } = await thread.messages.list({
  accountId,
  fromDomain: 'amazon.com', // a sender domain is required in this phase
});

Get one message

const message = await thread.messages.get(messageId, connectionId);

Read a thread

const conversation = await thread.threads.get(threadId, connectionId);

Mutate state

await thread.messages.patch(messageId, connectionId, { op: 'mark_read' });
await thread.messages.patch(messageId, connectionId, { op: 'add_label', label: 'Receipts' });
await thread.messages.patch(messageId, connectionId, { op: 'archive' });

List labels

const labels = await thread.labels.list(connectionId);

Every call is evaluated against the user's grant. A request outside the grant returns 403 FORBIDDEN (surfaced by the SDK as ThreadDeniedError); an expired or rate-limited grant returns 403/429. See Errors & rate limits.

Full reference

Every gateway endpoint — parameters, responses, and a live "try it" console — is in the API reference, generated from the gateway's own route schemas.