Browse the docs

Webhooks

Receive events, verify their signatures, and use the delivery log, replays and test events.

Create an endpoint

An endpoint is an HTTPS URL that receives a POST for every event it subscribes to; any other URL answers 400 with reason https_required. Create one on the board's Webhooks page or through the API. The answer carries the endpoint's signing secret (whsec_...), and only this once; rotating it issues a new one.

curl -X POST "https://api.workwae.com/v1/webhooks" \
  -H "Authorization: Bearer $WORKWAE_API_KEY" \
  -H "content-type: application/json" \
  -d '{
  "url": "https://app.example.com/hooks/workwae",
  "description": "Production",
  "filters": { "eventTypes": ["message.created", "account.reauth_required"] }
}'

Filters

An endpoint's filters object decides which events it receives. Omitted filters are unset on create and left as they are on update.

  • filters.eventTypes: the event types to deliver. Empty (the default) means every type, including types added later.
  • filters.accountIds: the accounts whose events to deliver. Empty (the default) means every account. Once set, events that concern no account (such as account.purged) are not delivered either. The ids must be accounts of your tenant; others answer 422 with reason unknown_account.
  • filters.message: criteria about the message an event concerns, below.
  • Change any filter at any time with PATCH /v1/webhooks/{endpointId} and a filters object holding only the filters to change, up to 100 entries each.
  • No filter applies to a test event: it is delivered to the endpoint it was sent to, whatever that endpoint's filters. Neither do they apply to a replay of one picked event; a replay of a time range applies them all.

Message criteria

The criteria in filters.message narrow message.*, send.* and message.bounced events to some messages; every other event passes them. Every criterion you set must match, and any one value within a criterion is enough. They are matched against the event's own data, so a criterion the event cannot answer does not match it.

  • mailboxIds and mailboxRoles (inbox, sent, ...): one criterion, met when the message is in a listed mailbox or in a mailbox with a listed role. Mailbox ids must be live mailboxes of your tenant (422 reason unknown_mailbox) and, when filters.accountIds is set, of those accounts (400 reason mailbox_outside_accounts).
  • from and to: addresses, or @example.com for every address at exactly that domain, compared regardless of case. to is matched against the to and cc recipients.
  • subjectContains: phrases matched anywhere in the subject, regardless of case. hasAttachments: true or false; null clears it.
  • direction: outbound for a message in the sent mailbox or from one of the account's own addresses (its address and identities), inbound for every other message; null clears it.
  • message.deleted carries the message as last indexed and the mailboxes that held it, and is matched on them like the message was.
  • send.* events name the queue entry, not the message: they are outbound and answer no other criterion. message.bounced is outbound, and to is matched against its failed recipients.

Endpoint URLs must resolve to public addresses: deliveries to private, loopback and link-local networks are refused.

What your endpoint receives

Each delivery is a POST with a JSON body. Answer with any 2xx status within 10 seconds to acknowledge it. Redirects are not followed: a 3xx counts as a failure.

HeaderValue
webhook-idThe event id, the same as the body's id. Stable across retries and replays: use it to deduplicate.
webhook-timestampWhen this attempt was signed, in Unix seconds.
webhook-signatureOne or more space-separated v1,<base64> signatures.
content-typeapplication/json
user-agentworkwae-webhooks/1
Body
{
  "id": "1001",
  "type": "message.created",
  "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
  "createdAt": "2026-05-04T09:30:00.000Z",
  "data": {
    "message": {
      "id": "c4e6a8b0-1d3f-4a5c-8e7b-9d1f3a5c7e9b",
      "threadId": "18f2a3b4c5d6e7f8",
      "internetMessageId": "<[email protected]>",
      "mailboxIds": [
        "8a3f5c7e-2d4b-4c6e-9f1a-3b5d7f9e1c2a"
      ],
      "subject": "Quarterly report",
      "from": {
        "name": "Grace Hopper",
        "address": "[email protected]"
      },
      "to": [
        {
          "name": null,
          "address": "[email protected]"
        }
      ],
      "cc": [],
      "snippet": "Hi Ada, the numbers for the quarter are attached.",
      "receivedAt": "2026-05-04T09:30:00.000Z",
      "sentAt": "2026-05-04T09:30:00.000Z",
      "isRead": false,
      "isFlagged": false,
      "isDraft": false,
      "hasAttachments": true,
      "categories": [],
      "direction": "inbound",
      "outboxId": null,
      "reference": null,
      "importance": "high"
    },
    "mailboxes": [
      {
        "id": "8a3f5c7e-2d4b-4c6e-9f1a-3b5d7f9e1c2a",
        "name": "INBOX",
        "role": "inbox"
      }
    ]
  }
}

data depends on the type; the event catalog below has a sample for each. The account an event concerns is the envelope's accountId, which data does not repeat; it is null for events that concern no account. The change feed (GET /v1/changes) carries the same events, in the same shape and with the same ids.

Verify the signature

Signatures follow Standard Webhooks: an HMAC-SHA256, keyed with the base64-decoded part of the secret after whsec_, over {webhook-id}.{webhook-timestamp}.{body}. Any Standard Webhooks library verifies them. Always verify the raw request body, before parsing it: re-serialized JSON does not match the signature.

import { parseWebhook, type WebhookEvent } from "@workwae/sdk";

export async function POST(request: Request): Promise<Response> {
  const body = await request.text();
  let event: WebhookEvent;
  try {
    // Checks the signature and the timestamp (5 minutes of tolerance), then parses the body.
    event = await parseWebhook(process.env.WORKWAE_WEBHOOK_SECRET!, request.headers, body);
  } catch {
    return new Response("Invalid webhook", { status: 400 });
  }
  if (await alreadyHandled(event.id)) return new Response(null, { status: 204 });
  // event.createdAt is a Date. A type added after your SDK build arrives with known: false and
  // unchecked data; for a known event, checking event.type narrows event.data to that type's data.
  if (event.known && event.type === "message.created") {
    await enqueue(event.id, event.data.message.subject);
  }
  return new Response(null, { status: 204 });
}

Rotate the secret

POST /v1/webhooks/{endpointId}/rotate-secret (or Rotate secret on the board) returns a new secret, which signs every delivery from then on. For a grace period, graceSeconds (default 24 hours, at most 7 days), deliveries are also signed with the old secret: webhook-signature then carries two space-separated signatures, v1,<new> v1,<old>, as Standard Webhooks allows, and a receiver holding either secret accepts them. The answer's previousSecretExpiresAt, also on the endpoint, says when the old secret stops signing. graceSeconds: 0 stops it at once.

curl -X POST "https://api.workwae.com/v1/webhooks/$ENDPOINT_ID/rotate-secret" \
  -H "Authorization: Bearer $WORKWAE_API_KEY" \
  -H "content-type: application/json" \
  -d '{ "graceSeconds": 86400 }'
  • Switch your receiver to the new secret any time before previousSecretExpiresAt. The SDK's verifyWebhook and parseWebhook accept a delivery when any of its signatures matches the secret they are given, as every Standard Webhooks library does.
  • A rotation during another's grace ends that grace: only the secret it replaces keeps signing.

Retries and auto-disable

  • A failed attempt (no 2xx answer, a timeout, a refused connection) is retried after 5 seconds, then with the delay doubling each time up to one hour between attempts, with 20 percent jitter.
  • A 429 answer, or a 503 with a Retry-After header, asks us to slow down rather than reporting a failure. That delivery is retried after Retry-After (seconds or an HTTP date, at least 5 seconds and at most one hour), or without one after 30 seconds, doubling each time your endpoint throttles the same delivery again, up to an hour. It doesn't count as an attempt or toward the 72 hours below, though the 3-day limit still applies. Other deliveries to your endpoint aren't held back by it.
  • Once a delivery to your endpoint succeeds again, its other deliveries waiting to be retried are sent right away, at the usual concurrency, instead of at their scheduled retries up to an hour out. Deliveries holding off for a Retry-After you sent keep waiting.
  • If an attempt can't resolve your host or connect to it while workwae itself can't reach the internet or its DNS either, the outage is ours: the delivery is retried after 30 seconds without counting as an attempt or toward the 72 hours below. The attempt still appears in the delivery log.
  • A delivery is given up once its next attempt would fall more than 3 days after it was queued.
  • An endpoint that fails for 72 hours without a single success is disabled: isEnabled becomes false and disabledReason says why. failingSince and lastSuccessAt on the endpoint show how it is doing before that happens.
  • A disabled endpoint receives no new events, and its queued deliveries wait. Deliveries still queued 3 days after they were created are given up and marked failed, and failed deliveries are deleted after 30 days. Fix the receiver, then re-enable it with PATCH /v1/webhooks/{endpointId} { "isEnabled": true } (or on the board) and replay the deliveries it gave up on.

Delivery log

Every attempt is logged with its HTTP status, latency, error and the first kilobyte of your response. The log keeps the last 500 attempts per endpoint, for up to 7 days, and is on the endpoint's page in the board, where new attempts (a retry, a delivery, the final failure) appear without a reload.

curl "https://api.workwae.com/v1/webhooks/$ENDPOINT_ID/attempts?result=failed&eventType=message.created" \
  -H "Authorization: Bearer $WORKWAE_API_KEY"

Filter by eventType, eventId, accountId, result (succeeded or failed) and a since/until time range. Each attempt says whether it came from a replay (isReplay) or a test (isTest). The answer is a page, newest first: limit takes up to 500 (50 by default), and passing its nextCursor back as cursor continues with older attempts.

Replay events

A replay queues events again for one endpoint, with the same id and body as the first delivery and a fresh signature. Replay one event by id with POST /v1/webhooks/{endpointId}/events/{eventId}/replay (delivered whatever the endpoint's filters), every event in a time range [since, until) that the endpoint would receive with its current filters with POST /v1/webhooks/{endpointId}/replay, or every event the endpoint gave up on (its retries ran out, or it was disabled) with POST /v1/webhooks/{endpointId}/replay-failed, optionally only events created in [since, until). All answer 202: the events are queued, and their attempts show up in the delivery log. GET /v1/webhooks/{endpointId}/failed-deliveries/count says how many events a replay-failed would queue; the board's Replay failed shows it before you confirm.

curl -X POST "https://api.workwae.com/v1/webhooks/$ENDPOINT_ID/events/1001/replay" \
  -H "Authorization: Bearer $WORKWAE_API_KEY"
  • A range, or a replay of failed deliveries, covers at most 1000 events; more answers 400 with reason replay_range_too_large, so split it.
  • A replay of failed deliveries replaces them: calling it again does not send the same events twice.
  • Events already queued for the endpoint are not queued twice; the answer counts them in alreadyQueued.
  • Only events still within the tenant's eventRetentionDays can be replayed; an unknown or expired id answers 404 with reason event_not_found.
  • A disabled endpoint answers 409 with reason endpoint_disabled: enable it first.

Test events

Send an event of any type to one endpoint, whatever its filters, to build and check a receiver before real traffic exists. It is signed like any other delivery and carries the sample data for its type (the samples below) unless you pass data, which must match that type's data (400 otherwise). Every test delivery has "isTest": true in its body, webhook.test included. Test events are never in the change feed.

curl -X POST "https://api.workwae.com/v1/webhooks/$ENDPOINT_ID/test" \
  -H "Authorization: Bearer $WORKWAE_API_KEY" \
  -H "content-type: application/json" \
  -d '{ "eventType": "message.created" }'
Delivered body
{
  "id": "1001",
  "type": "message.created",
  "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
  "createdAt": "2026-05-04T09:30:00.000Z",
  "data": {
    "message": {
      "id": "c4e6a8b0-1d3f-4a5c-8e7b-9d1f3a5c7e9b",
      "threadId": "18f2a3b4c5d6e7f8",
      "internetMessageId": "<[email protected]>",
      "mailboxIds": [
        "8a3f5c7e-2d4b-4c6e-9f1a-3b5d7f9e1c2a"
      ],
      "subject": "Quarterly report",
      "from": {
        "name": "Grace Hopper",
        "address": "[email protected]"
      },
      "to": [
        {
          "name": null,
          "address": "[email protected]"
        }
      ],
      "cc": [],
      "snippet": "Hi Ada, the numbers for the quarter are attached.",
      "receivedAt": "2026-05-04T09:30:00.000Z",
      "sentAt": "2026-05-04T09:30:00.000Z",
      "isRead": false,
      "isFlagged": false,
      "isDraft": false,
      "hasAttachments": true,
      "categories": [],
      "direction": "inbound",
      "outboxId": null,
      "reference": null,
      "importance": "high"
    },
    "mailboxes": [
      {
        "id": "8a3f5c7e-2d4b-4c6e-9f1a-3b5d7f9e1c2a",
        "name": "INBOX",
        "role": "inbox"
      }
    ]
  },
  "isTest": true
}

Without eventType the test is a webhook.test event. The request answers 202 once the delivery is queued. The board's endpoint page has a "Send test event" action for the same thing.

Webhook tester

The board's Webhook tester (admins and owners) gives you a URL on the API that records every request sent to it, so you can watch deliveries arrive without a receiver of your own. Create an inbox, then "Create webhook endpoint" to have every event delivered to it: the inbox then checks each signature with that endpoint's secret and shows it as valid, invalid (with the reason) or unchecked. "Send test event" sends one at once.

  • The URL is the only credential: anyone who has it can send to the inbox. Treat it as a secret, and delete the inbox to retire it.
  • An inbox keeps the last 200 requests, up to 2 MB, for 24 hours after the latest; each keeps the first 16 KB of its body. Bodies over 256 KB are refused with 413, and more than 60000 requests a minute with 429. An organization's inboxes hold 10 MB together; past it, requests are refused with 507 until you clear one.
  • An organization has at most 10 inboxes. Deleting one deletes its requests and its endpoint.

Event catalog

Every event type, what it announces, and the body an endpoint receives for it. The samples are the same data a test event sends.

account

  • account.connectedAn account was connected, or an existing account signed in again (reauthorized).
    {
      "id": "1001",
      "type": "account.connected",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "email": "[email protected]",
        "provider": "gmail",
        "cloud": null,
        "externalRef": "user_42",
        "reauthorized": false
      }
    }
  • account.updatedAn account's status, externalRef, settings or connection changed.
    {
      "id": "1001",
      "type": "account.updated",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "fields": [
          "status"
        ]
      }
    }
  • account.deletedAn account was deleted; its data is purged after the retention period, or at once with purge.
    {
      "id": "1001",
      "type": "account.deleted",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "email": "[email protected]",
        "externalRef": "user_42",
        "purge": false
      }
    }
  • account.reauth_requiredThe provider refused the account's grant: the account must sign in again.
    {
      "id": "1001",
      "type": "account.reauth_required",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "email": "[email protected]",
        "provider": "gmail",
        "cloud": null,
        "externalRef": "user_42",
        "reason": "invalid_grant"
      }
    }
  • account.initializedThe account's first mail sync finished; nothing before it is announced message by message.
    {
      "id": "1001",
      "type": "account.initialized",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "mailboxes": 12,
        "messages": 1284
      }
    }
  • account.purgedA deleted account's data is gone for good (retention, or an immediate purge). The event names no account, since it outlives the account it reports on.
    {
      "id": "1001",
      "type": "account.purged",
      "accountId": null,
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
        "externalRef": "user_42"
      }
    }

mailbox

  • mailbox.createdA mailbox (folder or label) appeared.
    {
      "id": "1001",
      "type": "mailbox.created",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "mailbox": {
          "id": "8a3f5c7e-2d4b-4c6e-9f1a-3b5d7f9e1c2a",
          "name": "INBOX",
          "path": "INBOX",
          "parentId": null,
          "role": "inbox",
          "kind": "folder",
          "totalCount": 1284,
          "unreadCount": 3
        }
      }
    }
  • mailbox.updatedA mailbox was renamed, moved or given another role. Counts change silently.
    {
      "id": "1001",
      "type": "mailbox.updated",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "mailbox": {
          "id": "8a3f5c7e-2d4b-4c6e-9f1a-3b5d7f9e1c2a",
          "name": "INBOX",
          "path": "INBOX",
          "parentId": null,
          "role": "inbox",
          "kind": "folder",
          "totalCount": 1284,
          "unreadCount": 3
        },
        "changes": [
          "name"
        ]
      }
    }
  • mailbox.deletedA mailbox went away.
    {
      "id": "1001",
      "type": "mailbox.deleted",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "mailbox": {
          "id": "8a3f5c7e-2d4b-4c6e-9f1a-3b5d7f9e1c2a",
          "name": "INBOX",
          "path": "INBOX",
          "parentId": null,
          "role": "inbox",
          "kind": "folder",
          "totalCount": 1284,
          "unreadCount": 3
        }
      }
    }

message

  • message.createdA message was indexed: received, sent, drafted or moved in from outside the sync window.
    {
      "id": "1001",
      "type": "message.created",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "message": {
          "id": "c4e6a8b0-1d3f-4a5c-8e7b-9d1f3a5c7e9b",
          "threadId": "18f2a3b4c5d6e7f8",
          "internetMessageId": "<[email protected]>",
          "mailboxIds": [
            "8a3f5c7e-2d4b-4c6e-9f1a-3b5d7f9e1c2a"
          ],
          "subject": "Quarterly report",
          "from": {
            "name": "Grace Hopper",
            "address": "[email protected]"
          },
          "to": [
            {
              "name": null,
              "address": "[email protected]"
            }
          ],
          "cc": [],
          "snippet": "Hi Ada, the numbers for the quarter are attached.",
          "receivedAt": "2026-05-04T09:30:00.000Z",
          "sentAt": "2026-05-04T09:30:00.000Z",
          "isRead": false,
          "isFlagged": false,
          "isDraft": false,
          "hasAttachments": true,
          "categories": [],
          "direction": "inbound",
          "outboxId": null,
          "reference": null,
          "importance": "high"
        },
        "mailboxes": [
          {
            "id": "8a3f5c7e-2d4b-4c6e-9f1a-3b5d7f9e1c2a",
            "name": "INBOX",
            "role": "inbox"
          }
        ]
      }
    }
  • message.updatedA message changed: read, flagged, moved, or linked to the outbox entry it is a copy of.
    {
      "id": "1001",
      "type": "message.updated",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "message": {
          "id": "d5f7b9a1-2c4e-4b6d-9f8a-0c2e4b6d8f0a",
          "threadId": "18f2a3b4c5d6e7f8",
          "internetMessageId": "<[email protected]>",
          "mailboxIds": [
            "1e3a5c7b-9d1f-4b3e-8a5c-7e9b1d3f5a7c"
          ],
          "subject": "Invoice 1001",
          "from": {
            "name": "Ada Lovelace",
            "address": "[email protected]"
          },
          "to": [
            {
              "name": null,
              "address": "[email protected]"
            }
          ],
          "cc": [],
          "snippet": "Hi Grace, your invoice is attached.",
          "receivedAt": "2026-05-04T09:30:00.000Z",
          "sentAt": "2026-05-04T09:30:00.000Z",
          "isRead": true,
          "isFlagged": false,
          "isDraft": false,
          "hasAttachments": true,
          "categories": [],
          "direction": "outbound",
          "outboxId": "7b9d1f3a-5c7e-4a9b-8d1f-3a5c7e9b1d3f",
          "reference": "invoice-1001",
          "importance": "normal"
        },
        "mailboxes": [
          {
            "id": "1e3a5c7b-9d1f-4b3e-8a5c-7e9b1d3f5a7c",
            "name": "Sent",
            "role": "sent"
          }
        ],
        "changes": [
          "outboxId"
        ]
      }
    }
  • message.deletedA message was removed; carries it and its mailboxes as last indexed.
    {
      "id": "1001",
      "type": "message.deleted",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "messageId": "c4e6a8b0-1d3f-4a5c-8e7b-9d1f3a5c7e9b",
        "providerMessageId": "18f2a3b4c5d6e7f8",
        "message": {
          "id": "c4e6a8b0-1d3f-4a5c-8e7b-9d1f3a5c7e9b",
          "threadId": "18f2a3b4c5d6e7f8",
          "internetMessageId": "<[email protected]>",
          "mailboxIds": [
            "8a3f5c7e-2d4b-4c6e-9f1a-3b5d7f9e1c2a"
          ],
          "subject": "Quarterly report",
          "from": {
            "name": "Grace Hopper",
            "address": "[email protected]"
          },
          "to": [
            {
              "name": null,
              "address": "[email protected]"
            }
          ],
          "cc": [],
          "snippet": "Hi Ada, the numbers for the quarter are attached.",
          "receivedAt": "2026-05-04T09:30:00.000Z",
          "sentAt": "2026-05-04T09:30:00.000Z",
          "isRead": false,
          "isFlagged": false,
          "isDraft": false,
          "hasAttachments": true,
          "categories": [],
          "direction": "inbound",
          "outboxId": null,
          "reference": null,
          "importance": "high"
        },
        "mailboxes": [
          {
            "id": "8a3f5c7e-2d4b-4c6e-9f1a-3b5d7f9e1c2a",
            "name": "INBOX",
            "role": "inbox"
          }
        ]
      }
    }
  • message.bouncedA delivery status notification reporting failed recipients reached the account; carries the outbox id when it answers one of our sends.
    {
      "id": "1001",
      "type": "message.bounced",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "messageId": "c4e6a8b0-1d3f-4a5c-8e7b-9d1f3a5c7e9b",
        "outboxId": "7b9d1f3a-5c7e-4a9b-8d1f-3a5c7e9b1d3f",
        "reference": "invoice-1001",
        "originalMessageId": "<[email protected]>",
        "recipients": [
          {
            "address": "[email protected]",
            "status": "5.1.1",
            "diagnostic": "smtp; 550 5.1.1 <[email protected]>: Recipient address rejected"
          }
        ]
      }
    }
  • message.classifiedAI classified a reply to one of our sends (tenant setting aiReplyClassification): carries the message, its mailboxes and the classification.
    {
      "id": "1001",
      "type": "message.classified",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "message": {
          "id": "c4e6a8b0-1d3f-4a5c-8e7b-9d1f3a5c7e9b",
          "threadId": "18f2a3b4c5d6e7f8",
          "internetMessageId": "<[email protected]>",
          "mailboxIds": [
            "8a3f5c7e-2d4b-4c6e-9f1a-3b5d7f9e1c2a"
          ],
          "subject": "Automatic reply: Invoice 1001",
          "from": {
            "name": "Grace Hopper",
            "address": "[email protected]"
          },
          "to": [
            {
              "name": null,
              "address": "[email protected]"
            }
          ],
          "cc": [],
          "snippet": "I'm out of the office until Monday, May 11, with limited access to email.",
          "receivedAt": "2026-05-04T09:30:00.000Z",
          "sentAt": "2026-05-04T09:30:00.000Z",
          "isRead": false,
          "isFlagged": false,
          "isDraft": false,
          "hasAttachments": false,
          "categories": [],
          "direction": "inbound",
          "outboxId": null,
          "reference": null,
          "importance": "high"
        },
        "mailboxes": [
          {
            "id": "8a3f5c7e-2d4b-4c6e-9f1a-3b5d7f9e1c2a",
            "name": "INBOX",
            "role": "inbox"
          }
        ],
        "classification": {
          "kind": "auto_reply",
          "probabilities": {
            "auto_reply": 0.97,
            "human_reply": 0.02,
            "opt_out": 0.01,
            "bounce": 0
          },
          "optOutProbability": 0.01,
          "autoReplyCategory": "out_of_office",
          "returnDate": "2026-05-11",
          "alternateContacts": [],
          "outboxId": "7b9d1f3a-5c7e-4a9b-8d1f-3a5c7e9b1d3f",
          "model": "workwae-classifier",
          "classifiedAt": "2026-05-04T09:30:00.000Z"
        }
      }
    }
  • message.openedA recipient's client (or a proxy fetching for it) loaded the open-tracking pixel of a send. Carries the actor, its reasons and the derived signals (client, device, location, referer host, sec-fetch tuple, timing); the raw headers are only on the tracking list.
    {
      "id": "1001",
      "type": "message.opened",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "outboxId": "7b9d1f3a-5c7e-4a9b-8d1f-3a5c7e9b1d3f",
        "reference": "invoice-1001",
        "messageId": "d5f7b9a1-2c4e-4b6d-9f8a-0c2e4b6d8f0a",
        "url": null,
        "userAgent": "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0 (via ggpht.com GoogleImageProxy)",
        "actor": "proxy",
        "reasons": [
          "ua_google_image_proxy"
        ],
        "isMachine": true,
        "occurredAt": "2026-05-04T10:12:41.000Z",
        "method": "GET",
        "refererHost": null,
        "originHost": null,
        "client": "gmail_proxy",
        "device": "unknown",
        "os": null,
        "browser": null,
        "country": "US",
        "region": "California",
        "city": "Mountain View",
        "timezone": "America/Los_Angeles",
        "latitude": 37.4056,
        "longitude": -122.0775,
        "secondsAfterSend": 2561,
        "secondsSincePrevious": null,
        "sequence": 1,
        "isConditional": false,
        "isRange": false,
        "isPrefetch": false,
        "secFetch": {
          "dest": null,
          "mode": null,
          "site": null,
          "user": null
        }
      }
    }
  • message.clickedA tracked link of a send was followed; carries the same as message.opened.
    {
      "id": "1001",
      "type": "message.clicked",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "outboxId": "7b9d1f3a-5c7e-4a9b-8d1f-3a5c7e9b1d3f",
        "reference": "invoice-1001",
        "messageId": "d5f7b9a1-2c4e-4b6d-9f8a-0c2e4b6d8f0a",
        "url": "https://billing.example.com/invoices/1001",
        "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
        "actor": "human",
        "reasons": [
          "sec_fetch_user",
          "referer_webmail"
        ],
        "isMachine": false,
        "occurredAt": "2026-05-04T10:13:05.000Z",
        "method": "GET",
        "refererHost": "mail.google.com",
        "originHost": null,
        "client": "gmail_web",
        "device": "desktop",
        "os": "windows",
        "browser": "chrome",
        "country": "GB",
        "region": "England",
        "city": "London",
        "timezone": "Europe/London",
        "latitude": 51.5072,
        "longitude": -0.1276,
        "secondsAfterSend": 2585,
        "secondsSincePrevious": 24,
        "sequence": 2,
        "isConditional": false,
        "isRange": false,
        "isPrefetch": false,
        "secFetch": {
          "dest": "document",
          "mode": "navigate",
          "site": "cross-site",
          "user": true
        }
      }
    }

send

  • send.sentThe provider accepted a queued send.
    {
      "id": "1001",
      "type": "send.sent",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "outboxId": "7b9d1f3a-5c7e-4a9b-8d1f-3a5c7e9b1d3f",
        "reference": "invoice-1001",
        "attempt": 1,
        "providerMessageId": "18f2a3b4c5d6e7f9",
        "internetMessageId": "<[email protected]>",
        "sentAt": "2026-05-04T09:30:00.000Z"
      }
    }
  • send.retryingAn attempt failed and the send is queued again for nextAttemptAt.
    {
      "id": "1001",
      "type": "send.retrying",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "outboxId": "7b9d1f3a-5c7e-4a9b-8d1f-3a5c7e9b1d3f",
        "reference": "invoice-1001",
        "attempt": 1,
        "nextAttemptAt": "2026-05-04T09:31:00.000Z",
        "error": {
          "code": "throttled",
          "message": "The provider asked us to slow down"
        }
      }
    }
  • send.failedThe send failed for good: refused, or out of attempts.
    {
      "id": "1001",
      "type": "send.failed",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "outboxId": "7b9d1f3a-5c7e-4a9b-8d1f-3a5c7e9b1d3f",
        "reference": "invoice-1001",
        "attempt": 5,
        "error": {
          "code": "rejected",
          "message": "Recipient address rejected"
        }
      }
    }
  • send.rescheduledA queued send got a new sendAt before its first attempt.
    {
      "id": "1001",
      "type": "send.rescheduled",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "outboxId": "7b9d1f3a-5c7e-4a9b-8d1f-3a5c7e9b1d3f",
        "reference": "invoice-1001",
        "sendAt": "2026-05-04T14:00:00.000Z",
        "previousSendAt": "2026-05-04T09:30:30.000Z"
      }
    }

suppression

  • suppression.createdAn address went onto a suppression list: the recipient unsubscribed (source unsubscribe, listId the send's), a permanent bounce said the address is gone (source bounce, listId global), or a detected opt-out was suppressed, at once or on approval (source reply or complaint, listId the send's or global; source inactive, on approval only, listId global). Only a change is announced; API adds are audited instead. The account is the one whose send it concerns.
    {
      "id": "1001",
      "type": "suppression.created",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "listId": "weekly-news",
        "address": "[email protected]",
        "source": "unsubscribe"
      }
    }

calendar

  • calendar.initializedThe account's first calendar sync finished; nothing before it is announced event by event.
    {
      "id": "1001",
      "type": "calendar.initialized",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "calendars": 3,
        "events": 418
      }
    }
  • calendar.createdA calendar appeared.
    {
      "id": "1001",
      "type": "calendar.created",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "calendar": {
          "id": "3d5f7b9a-6c8e-4e0a-b2d4-6f8a0c2e4b6d",
          "name": "Calendar",
          "description": null,
          "owner": null,
          "isPrimary": true,
          "access": "owner",
          "canEdit": true,
          "color": "#3b82f6",
          "timeZone": "Europe/London",
          "isHidden": false
        }
      }
    }
  • calendar.updatedA calendar changed.
    {
      "id": "1001",
      "type": "calendar.updated",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "calendar": {
          "id": "3d5f7b9a-6c8e-4e0a-b2d4-6f8a0c2e4b6d",
          "name": "Calendar",
          "description": null,
          "owner": null,
          "isPrimary": true,
          "access": "owner",
          "canEdit": true,
          "color": "#3b82f6",
          "timeZone": "Europe/London",
          "isHidden": false
        },
        "changes": [
          "name"
        ]
      }
    }
  • calendar.deletedA calendar went away.
    {
      "id": "1001",
      "type": "calendar.deleted",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "calendar": {
          "id": "3d5f7b9a-6c8e-4e0a-b2d4-6f8a0c2e4b6d",
          "name": "Calendar",
          "description": null,
          "owner": null,
          "isPrimary": true,
          "access": "owner",
          "canEdit": true,
          "color": "#3b82f6",
          "timeZone": "Europe/London",
          "isHidden": false
        }
      }
    }

calendar_event

  • calendar_event.createdA single event, series or exception appeared. Unmodified occurrences of a series are never announced; an occurrence changed on its own is announced as a new exception.
    {
      "id": "1001",
      "type": "calendar_event.created",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "event": {
          "id": "e1f3a5c7-9b2d-4f6e-a8c0-2e4f6a8b0d1c",
          "calendarId": "3d5f7b9a-6c8e-4e0a-b2d4-6f8a0c2e4b6d",
          "kind": "single",
          "seriesId": null,
          "originalStart": null,
          "subject": "Planning",
          "snippet": "Agenda: roadmap, hiring.",
          "location": "Room 4",
          "start": {
            "dateTime": "2026-05-05T13:00:00.000Z",
            "date": null,
            "timeZone": "Europe/London"
          },
          "end": {
            "dateTime": "2026-05-05T14:00:00.000Z",
            "date": null,
            "timeZone": "Europe/London"
          },
          "isAllDay": false,
          "organizer": {
            "name": "Ada Lovelace",
            "address": "[email protected]"
          },
          "attendees": [
            {
              "name": "Grace Hopper",
              "address": "[email protected]",
              "type": "required",
              "response": "accepted"
            }
          ],
          "isOrganizer": true,
          "selfResponse": "organizer",
          "status": "confirmed",
          "showAs": "busy",
          "sensitivity": "normal",
          "onlineMeetingUrl": null,
          "onlineMeetingProvider": null,
          "iCalUid": "040000008200E00074C5B7101A82E008",
          "recurrence": null,
          "recurrenceTimeZone": null,
          "cancelledStarts": null,
          "categories": [],
          "hasAttachments": false,
          "reminderMinutes": 15,
          "webLink": null,
          "updatedAt": "2026-05-04T09:30:00.000Z"
        }
      }
    }
  • calendar_event.updatedA single event, series or exception changed; a change to a series is one update of the series.
    {
      "id": "1001",
      "type": "calendar_event.updated",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "event": {
          "id": "e1f3a5c7-9b2d-4f6e-a8c0-2e4f6a8b0d1c",
          "calendarId": "3d5f7b9a-6c8e-4e0a-b2d4-6f8a0c2e4b6d",
          "kind": "single",
          "seriesId": null,
          "originalStart": null,
          "subject": "Planning",
          "snippet": "Agenda: roadmap, hiring.",
          "location": "Room 4",
          "start": {
            "dateTime": "2026-05-05T13:00:00.000Z",
            "date": null,
            "timeZone": "Europe/London"
          },
          "end": {
            "dateTime": "2026-05-05T14:00:00.000Z",
            "date": null,
            "timeZone": "Europe/London"
          },
          "isAllDay": false,
          "organizer": {
            "name": "Ada Lovelace",
            "address": "[email protected]"
          },
          "attendees": [
            {
              "name": "Grace Hopper",
              "address": "[email protected]",
              "type": "required",
              "response": "accepted"
            }
          ],
          "isOrganizer": true,
          "selfResponse": "organizer",
          "status": "confirmed",
          "showAs": "busy",
          "sensitivity": "normal",
          "onlineMeetingUrl": null,
          "onlineMeetingProvider": null,
          "iCalUid": "040000008200E00074C5B7101A82E008",
          "recurrence": null,
          "recurrenceTimeZone": null,
          "cancelledStarts": null,
          "categories": [],
          "hasAttachments": false,
          "reminderMinutes": 15,
          "webLink": null,
          "updatedAt": "2026-05-04T09:30:00.000Z"
        },
        "changes": [
          "start",
          "end"
        ]
      }
    }
  • calendar_event.deletedA single event, series or exception went away.
    {
      "id": "1001",
      "type": "calendar_event.deleted",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "eventId": "e1f3a5c7-9b2d-4f6e-a8c0-2e4f6a8b0d1c",
        "calendarId": "3d5f7b9a-6c8e-4e0a-b2d4-6f8a0c2e4b6d",
        "kind": "single",
        "seriesId": null
      }
    }

contacts

  • contacts.initializedThe account's first contacts sync finished; nothing before it is announced contact by contact.
    {
      "id": "1001",
      "type": "contacts.initialized",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "contacts": 214
      }
    }

contact

  • contact.createdA contact appeared.
    {
      "id": "1001",
      "type": "contact.created",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "contact": {
          "id": "9c1e3a5b-7d9f-4b1c-8e3a-5c7e9b1d3f5a",
          "folderId": null,
          "displayName": "Grace Hopper",
          "givenName": "Grace",
          "middleName": null,
          "familyName": "Hopper",
          "nickname": null,
          "emails": [
            {
              "address": "[email protected]",
              "type": "work"
            }
          ],
          "phones": [
            {
              "number": "+1 202 555 0143",
              "type": "mobile"
            }
          ],
          "organization": "Navy",
          "jobTitle": "Rear Admiral",
          "department": null,
          "birthday": "1906-12-09",
          "notes": null,
          "hasPhoto": false,
          "updatedAt": "2026-05-04T09:30:00.000Z"
        }
      }
    }
  • contact.updatedA contact changed.
    {
      "id": "1001",
      "type": "contact.updated",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "contact": {
          "id": "9c1e3a5b-7d9f-4b1c-8e3a-5c7e9b1d3f5a",
          "folderId": null,
          "displayName": "Grace Hopper",
          "givenName": "Grace",
          "middleName": null,
          "familyName": "Hopper",
          "nickname": null,
          "emails": [
            {
              "address": "[email protected]",
              "type": "work"
            }
          ],
          "phones": [
            {
              "number": "+1 202 555 0143",
              "type": "mobile"
            }
          ],
          "organization": "Navy",
          "jobTitle": "Commodore",
          "department": null,
          "birthday": "1906-12-09",
          "notes": null,
          "hasPhoto": false,
          "updatedAt": "2026-05-04T09:30:00.000Z"
        },
        "changes": [
          "jobTitle"
        ]
      }
    }
  • contact.deletedA contact went away.
    {
      "id": "1001",
      "type": "contact.deleted",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "contactId": "9c1e3a5b-7d9f-4b1c-8e3a-5c7e9b1d3f5a",
        "folderId": null
      }
    }

export

  • export.completedA data export finished and can be downloaded until data.expiresAt. accountId is null for an export of the whole tenant.
    {
      "id": "1001",
      "type": "export.completed",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "exportId": "5e7a9c1b-3d5f-4b7e-9a1c-5e7b9d1f3a5c",
        "bytes": 48213771,
        "sha256": "9f2c4e6a8b0d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b7d9f1a",
        "expiresAt": "2026-05-11T09:30:00.000Z"
      }
    }
  • export.failedA data export failed. accountId is null for an export of the whole tenant.
    {
      "id": "1001",
      "type": "export.failed",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "exportId": "5e7a9c1b-3d5f-4b7e-9a1c-5e7b9d1f3a5c",
        "error": "The export could not be built; try again"
      }
    }

meeting_connection

  • meeting_connection.connectedAn account connected (or reconnected) its own grant at a meeting service (Zoom, Webex).
    {
      "id": "1001",
      "type": "meeting_connection.connected",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "service": "zoom",
        "email": "[email protected]",
        "reconnected": false
      }
    }
  • meeting_connection.reauth_requiredThe service refused the account's meeting grant; meetings need a new connect. Mail and calendar sync go on.
    {
      "id": "1001",
      "type": "meeting_connection.reauth_required",
      "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "service": "zoom",
        "email": "[email protected]",
        "reason": "invalid_grant"
      }
    }

webhook

  • webhook.testSent by the test action to the endpoint it names, whatever that endpoint's filters.
    {
      "id": "1001",
      "type": "webhook.test",
      "accountId": null,
      "createdAt": "2026-05-04T09:30:00.000Z",
      "data": {
        "endpointId": "2b7e9d1c-4f6a-4b8e-8d2c-9e0f1a2b3c4d"
      }
    }