Thread Docs

Webhooks

The signed event catalog — email.classified, calendar.event.changed, connection.revoked — schema, signature, and retries.

Thread POSTs signed events to your app's webhook URL. Every event shares the same envelope, and the SDK's parseWebhook returns a discriminated union so you can exhaustively switch (event.type):

typeFires whenRequires
email.classifiedMail matching the grant arrived and was classifieda mail filter grant
calendar.event.changedA calendar event was created, updated, or cancelleda calendar.read grant
connection.revokedThe user revoked your app's access to an accountnothing — final event for the account

email.classified

{
  "id": "d_9f3c…",
  "type": "email.classified",
  "idempotencyKey": "app_123:msg_456",
  "data": {
    "accountId": "acct_8a2…",
    "messageId": "msg_456",
    "threadId": "thr_789",
    "fromAddress": "receipts@amazon.com",
    "fromDomain": "amazon.com",
    "subject": "Your order has shipped",
    "hasAttachment": false,
    "receivedAt": "2026-06-08T12:01:30.000Z",
    "labels": ["financial", "receipts"]
  }
}
FieldMeaning
idStable event id — minted once, unchanged across retries. Dedupe on it.
idempotencyKeyStable per (app, message). Equivalent dedupe key — see below.
data.accountIdWhich of your users this is (per-app pseudonymous).
data.messageId / threadIdAddress the message/thread on the gateway.
data.labelsThread's classification labels for the message.

Bodies are never included — only metadata and labels.

calendar.event.changed

{
  "id": "d_41ab…",
  "type": "calendar.event.changed",
  "idempotencyKey": "app_123:cal:ev_789:1f0a9c2d4e6b8a01",
  "data": {
    "accountId": "acct_8a2…",
    "eventId": "ev_789",
    "calendarId": "cal_primary",
    "title": "Quarterly review",
    "organizerAddress": "pm@acme.com",
    "organizerDomain": "acme.com",
    "attendeeCount": 6,
    "start": "2026-06-12T15:00:00Z",
    "end": "2026-06-12T16:00:00Z",
    "isAllDay": false,
    "status": "confirmed",
    "isRecurring": false
  }
}
FieldMeaning
data.statusconfirmed | tentative | cancelled — cancelled signals deletion.
data.attendeeCountCount only. The attendee list and the description are never pushed — read them via the gated calendar API.
idempotencyKeyStable per (app, event, observed change): the same change never delivers twice, while a later edit delivers again.

connection.revoked

{
  "id": "d_77be…",
  "type": "connection.revoked",
  "idempotencyKey": "app_123:revoked:auth_456:1781088000000",
  "data": {
    "accountId": "acct_8a2…",
    "revokedAt": "2026-06-10T12:00:00.000Z"
  }
}

The user revoked your authorization. This is the last event you will receive for this accountId (unless the user re-grants): stop expecting events, and sever any local state keyed on it. Gateway reads for the account are already being denied by the time this arrives. Only the pseudonymous accountId is disclosed — nothing else about the user.

No version field — yet

Events deliberately carry no version while the platform is pre-launch; the schema may evolve. When the first client goes live, every event gains an integer version and a documented bump policy.

Headers

HeaderValue
Content-Typeapplication/json
X-Thread-TimestampUnix epoch milliseconds when the event was signed.
X-Thread-Signaturesha256=<hex HMAC> (see below).
X-Thread-Delivery-IdSame as the body's id — the stable event id, for dedupe without parsing.
X-Thread-Idempotency-KeySame as the body's idempotencyKey.

Signature

The signature is an HMAC-SHA256 over the string `${timestamp}.${rawBody}` keyed with your app's webhookSecret, hex-encoded and prefixed with sha256=.

Compute the HMAC over the raw request body exactly as received. Re-serializing parsed JSON will change the bytes and break verification.

The SDK does this for you with parseWebhook (see Integrate). To verify by hand in any language:

verify.ts
import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(secret: string, timestamp: string, rawBody: string, header: string): boolean {
  const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
  const got = header.replace(/^sha256=/, '');
  const a = Buffer.from(expected);
  const b = Buffer.from(got);
  return a.length === b.length && timingSafeEqual(a, b);
}

The SDK also exports verifyWebhookSignature(secret, timestamp, rawBody, signature), which accepts the signature with or without the sha256= prefix and compares in constant time.

Delivery semantics

The contract, in one place:

GuaranteeWhat Thread promises
At-least-onceEvery event is delivered at least once. Duplicates are possible (retries, redelivery after a crash between your 2xx and our bookkeeping) — never silently dropped.
Stable event idevent.id (and the X-Thread-Delivery-Id header) is minted once per event and never changes across retries. Use it as your idempotency key.
No orderingEvents are not guaranteed to arrive in order — retries and concurrent dispatch can reorder them. Order by data.receivedAt (email) or data.start/your own clock (calendar), not by arrival.
One event per (app, message)The same email never fans out to your app twice; an edited calendar event delivers again with a new idempotencyKey.

Responding

Return any 2xx to acknowledge. Acknowledge fast — do your real work asynchronously — because Thread waits for your response before marking the delivery done. Anything else (including a timeout) counts as a failed attempt.

Retry schedule

Failed attempts retry with exponential backoff — base 1 second, doubling per attempt, capped at 1 hour, 5 attempts total:

Attempt12345
Retry after1s2s4s8sdead-lettered

After the final failed attempt the delivery is dead-lettered (status failed). You can inspect every attempt, status, and last error on the app's Deliveries page in the console.

Idempotency

At-least-once means your endpoint can receive the same event more than once. Dedupe on event.id (stable across retries of the same event) or idempotencyKey (stable per app and message), and make your handler safe to run twice:

const event = parseWebhook({ secret, rawBody, signature, timestamp });
if (await alreadyProcessed(event.id)) return res.sendStatus(200); // duplicate — ack and skip
await markProcessed(event.id);
handle(event);

Testing

Use Send test webhook on the app detail page to POST a real, correctly-signed sample to your endpoint — the fastest way to validate your signature check and handler before connecting a live mailbox.