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.
- Connect — the browser sends the user to Thread's consent page; Thread redirects back with a
code. - Exchange — your server swaps the
codefor a stableaccountId. - Receive — Thread POSTs a signed
email.classifiedwebhook when matching mail is classified. - 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.
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.
Popup mode
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.
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.
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,
});| Field | Meaning |
|---|---|
accountId | Stable, per-app pseudonymous handle for this mailbox. Store it against your user. |
mailbox | The email address, when the grant includes it — otherwise null. |
scopes | The scopes the user approved. |
appId | Your 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.
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;
}
});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.
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.