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):
type | Fires when | Requires |
|---|---|---|
email.classified | Mail matching the grant arrived and was classified | a mail filter grant |
calendar.event.changed | A calendar event was created, updated, or cancelled | a calendar.read grant |
connection.revoked | The user revoked your app's access to an account | nothing — 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"]
}
}| Field | Meaning |
|---|---|
id | Stable event id — minted once, unchanged across retries. Dedupe on it. |
idempotencyKey | Stable per (app, message). Equivalent dedupe key — see below. |
data.accountId | Which of your users this is (per-app pseudonymous). |
data.messageId / threadId | Address the message/thread on the gateway. |
data.labels | Thread'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
}
}| Field | Meaning |
|---|---|
data.status | confirmed | tentative | cancelled — cancelled signals deletion. |
data.attendeeCount | Count only. The attendee list and the description are never pushed — read them via the gated calendar API. |
idempotencyKey | Stable 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
| Header | Value |
|---|---|
Content-Type | application/json |
X-Thread-Timestamp | Unix epoch milliseconds when the event was signed. |
X-Thread-Signature | sha256=<hex HMAC> (see below). |
X-Thread-Delivery-Id | Same as the body's id — the stable event id, for dedupe without parsing. |
X-Thread-Idempotency-Key | Same 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:
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:
| Guarantee | What Thread promises |
|---|---|
| At-least-once | Every 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 id | event.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 ordering | Events 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:
| Attempt | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| Retry after | 1s | 2s | 4s | 8s | dead-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.