One feed records every change to your customers' mailboxes, calendars and contact
books. Pull it with a cursor, or let workwae push each event to your endpoints: signed
per Standard Webhooks, filtered, retried and logged.
import { parseWebhook } from"@workwae/sdk";
export async functionPOST(request: Request) {
// Verify the raw body, before parsing itconst body = await request.text();
let event;
try {
event = awaitparseWebhook(secret, request.headers, body);
} catch {
return newResponse("Invalid webhook", { status: 400 });
}
// Retries and replays reuse the id: deduplicate on itconst isNew = awaitmarkSeen(event.id);
if (isNew && event.known && event.type === "message.created") {
await queue.add(event.id, event.data.message.subject);
}
return newResponse(null, { status: 204 });
}
What you get
Delivery you can verify, filter and recover
Webhooks and the change feed carry the same events, with the same ids and the same
envelope: id, type,
accountId, createdAt and
data.
Standard Webhooks signatures
An HMAC-SHA256 over the id, timestamp and body, in the
webhook-id, webhook-timestamp and
webhook-signature headers. Any Standard Webhooks library
verifies it, or the SDK's parseWebhook, with five minutes of
clock tolerance.
Secrets you can rotate
Each endpoint has its own whsec_ signing secret, shown only
when the endpoint is created. Rotating issues a new one and the old secret stops working
at once. Secrets are sealed with keys held in Vault.
Filters per endpoint
Pick event types and accounts, then narrow message events by mailbox or mailbox role,
sender, recipient, subject text, attachments and direction. Up to 100 values per
filter, changeable at any time.
Retries with backoff
A failed attempt is retried after 5 seconds, then with the delay doubling up to an hour,
with jitter. A delivery is given up only once its next attempt would fall more than 3
days after it was queued.
Throttling honored
A 429, or a 503 with
Retry-After, makes that delivery wait instead of counting as
a failure. The rest of your endpoint's deliveries keep going.
Failure tracking
Every endpoint reports failingSince and
lastSuccessAt. One that fails for 72 hours without a single
success is disabled with a disabledReason, and its queued
deliveries wait for you.
A delivery log
Every attempt with its HTTP status, latency, error and the first kilobyte of your
answer: the last 500 per endpoint, for up to 7 days. Filter by event type, event,
account, result and time.
Replay
Queue one event again, whatever the endpoint's filters, or every event in a time range
its filters let through, up to 1,000 per call. The id and body stay the same; the
signature is fresh.
Test events and a tester
Send a signed test event of any type, with sample or your own data, before real traffic
exists. The console's webhook tester gives you a URL that records every delivery and
checks its signature.
How it works
What happens to each delivery
Each delivery is a POST with a JSON body. Answer with any 2xx status within 10 seconds to
acknowledge it, and do the work in a queue of your own.
Your endpoint
What workwae does
Answers 2xx within 10 seconds
The delivery succeeded. The attempt is logged with its status and latency.
Answers another status, a redirect, times out or refuses the connection
A failure. Retried after 5 seconds, then with the delay doubling each time up to one
hour between attempts, with 20 percent jitter. Redirects are never followed.
Answers 429, or 503 with Retry-After
A request to slow down. That delivery waits the Retry-After
(seconds or an HTTP date, kept between 5 seconds and an hour), or 30 seconds without
one, doubling each time the same delivery is throttled again. It counts neither as an
attempt nor toward the 72 hours, and other deliveries to the endpoint are not held back.
Succeeds again after failing
Its other deliveries waiting out a backoff are sent right away, at the usual
concurrency, instead of at retries up to an hour out. Deliveries you throttled keep
waiting until their Retry-After has passed.
Cannot be reached while workwae's own network or DNS is down
The outage is ours: the delivery is retried after 30 seconds without counting against
your endpoint. The attempt still appears in the log.
Still failing 3 days after an event was queued
That delivery is given up and marked failed. Failed deliveries are deleted after 30 days.
Fails for 72 hours without a single success
The endpoint is disabled: isEnabled becomes false and
disabledReason says why. It receives no new events, and
its queued deliveries wait. Re-enable it, then replay the time it missed.
The change feed
GET /v1/changes pages by transaction, so an event
committed after a later one is never skipped.
A page is { items, nextCursor }. The cursor is never null:
an empty page means caught up, and you keep polling from it.
Ask for only the event types you handle with types.
The SDK's changes helper moves its cursor past a page only
once every event in it was consumed, so saving it after processing gives at-least-once
handling.
Events are kept for your tenant's eventRetentionDays,
between 7 and 30 days. Replays reach back as far.
Test events are never in the feed.
Delivery safeguards
Endpoint URLs must be HTTPS; anything else is refused with
https_required.
Deliveries to private, loopback and link-local addresses are refused.
The hostname is resolved once and the connection pinned to the addresses that passed
the check, while TLS still verifies the hostname, so DNS rebinding cannot redirect a
delivery.
Requests in flight to any one endpoint are capped, and workspaces share delivery
capacity evenly, so a slow endpoint never holds up the others.
An endpoint's deliveries are dispatched oldest first, but arrival order is not
guaranteed. When order matters, read the change feed.
An endpoint receives events created after the endpoint itself. Every request carries
user-agent: workwae-webhooks/1.
Build it
Receive only the mail that matters
Filters run before anything is sent, so your endpoint never has to discard events it did
not want.
Every criterion you set must match, and any one value within a
criterion is enough.
Message criteria narrow message.* and send.*
events; every other event passes them. A criterion an event cannot answer does not match
it.
Addresses match regardless of case, and @example.com
matches every address at that domain. to covers to and cc recipients, and a
bounce's failed recipients.
Direction is outbound for mail in Sent or from one of the
account's own addresses, and for every send.* event.
message.deleted carries the message as last indexed and the
mailboxes that held it, so it matches like the message did.
Test events skip filters, so you can try a receiver with any type.
endpoint.ts
// New inbound mail to support@, from one accountconst { endpoint, secret } = await workwae.webhooks.create({
url: "https://helpdesk.example.com/hooks/workwae",
description: "Support inbox",
filters: {
eventTypes: ["message.created"],
accountIds: [supportAccount.id],
message: {
mailboxRoles: ["inbox"],
direction: "inbound",
to: ["support@example.com", "@help.example.com"],
},
},
});
// Store the secret now: it is not shown againawait vault.put("workwae-webhook", secret);
// Try the receiver before real traffic existsawait workwae.webhooks.test({
endpointId: endpoint.id,
eventType: "message.created",
});
// Rotate: the old secret stops working at onceconst { secret: next } = await workwae.webhooks.rotateSecret({
endpointId: endpoint.id,
});
Build it
Recover from a receiver outage
When your endpoint goes down, workwae keeps the events. Find out what happened, fix the
receiver, and send what it missed.
Watch the endpoint.failingSince tells you a streak has
started long before the 72-hour limit.
Read the log. Each attempt has its status, error and the start of your
response, and says whether it came from a replay or a test.
Replay a range. Events already queued for the endpoint are not queued
twice; the answer counts them in alreadyQueued.
Deduplicate on the id. A replay carries the id you may have processed,
so handling keyed on webhook-id keeps it safe to run.
recover.ts
// Which endpoints are failing, or were disabled?const { items } = await workwae.webhooks.list();
const unwell = items.filter((e) => !e.isEnabled || e.failingSince);
// What did the receiver answer?const log = await workwae.webhooks.attempts({
endpointId,
result: "failed",
since: newDate("2026-11-02T00:00:00Z"),
});
for (const a of log.items) {
console.log(a.statusCode, a.error, a.responseExcerpt);
}
// Fixed: enable it again and deliver the missed dayawait workwae.webhooks.update({ endpointId, isEnabled: true });
const { queued, alreadyQueued } = await workwae.webhooks.replay({
endpointId,
since: newDate("2026-11-02T00:00:00Z"),
until: newDate("2026-11-03T00:00:00Z"),
});
// Or just one event, whatever the filtersawait workwae.webhooks.replayEvent({
endpointId,
eventId: "1001",
});
Build it
Pull the feed instead
No public endpoint, a batch job, or a store that must apply changes in commit order: read
the change feed with a saved cursor.
Same events. A feed item and a webhook body are the same envelope, with
the same id, so one handler serves both.
Quiet first syncs. A new account's existing mail, calendars and contacts
are not announced item by item: account.initialized,
calendar.initialized and contacts.initialized mark the end of
each first sync.
Forward compatible. A type added after your SDK build arrives with
known: false and its data unchecked, so a new event never breaks a receiver.
feed.ts
import { changes } from"@workwae/sdk";
// Resume where you left off, with only the types you handleconst feed = changes(workwae.changes.list, {
cursor: await db.getCursor(),
types: [
"calendar_event.created",
"calendar_event.updated",
"calendar_event.deleted",
],
});
// Stops at an empty page: caught upfor await (const event of feed) {
awaitapply(event);
}
// Saved after processing: a crash mid-page replays that pageawait db.saveCursor(feed.cursor);
Event catalog
Every event type
The data of each type is typed in the SDK, and the
webhooks guide has a
sample body for each. Events that concern no account carry a null
accountId.
Event
When it is sent
Accounts
account.connected
An account was connected, or an existing account signed in again (reauthorized: true).
account.updated
Its status, externalRef, settings or connection changed; fields says which.
account.reauth_required
The provider refused the account's grant, with the reason. The account must sign in again.
account.initialized
The first mail sync finished. Nothing before it is announced message by message.
account.deleted
An account was deleted. Its data is purged after the retention period, or at once.
account.purged
A deleted account's data is gone for good. Names no account, since it outlives it.
Mailboxes
mailbox.created
A folder or label appeared.
mailbox.updated
A mailbox was renamed, moved or given another role. Counts change silently.
mailbox.deleted
A mailbox went away.
Messages
message.created
A message was indexed: received, sent, drafted, or moved in from outside the sync window.
message.updated
A message was read, flagged, moved, or linked to the outbox entry it is a copy of.
message.deleted
A message was removed; carries it and its mailboxes as last indexed.
message.bounced
A delivery status notification reported failed recipients, with the outbox id when it answers one of your sends.
message.classified
AI classified a reply to one of your sends, when reply classification is on.
message.opened
The open-tracking pixel of a send was loaded, with who loaded it: a person or a machine.
message.clicked
A tracked link of a send was followed, with the same classification.
Sends and suppressions
send.sent
The provider accepted a queued send.
send.retrying
An attempt failed and the send is queued again for nextAttemptAt.
send.failed
The send failed for good: refused, or out of attempts.
send.rescheduled
A queued send got a new send time before its first attempt.
suppression.created
An address went onto a suppression list: an unsubscribe, a permanent bounce, or a detected opt-out.
Calendars
calendar.initialized
The first calendar sync finished. Nothing before it is announced event by event.
calendar.created
A calendar appeared.
calendar.updated
A calendar changed.
calendar.deleted
A calendar went away.
calendar_event.created
A single event, series or exception appeared. Unmodified occurrences of a series are never announced.
calendar_event.updated
A single event, series or exception changed; a change to a series is one update.
calendar_event.deleted
A single event, series or exception went away.
Contacts
contacts.initialized
The first contacts sync finished. Nothing before it is announced contact by contact.
contact.created
A contact appeared.
contact.updated
A contact changed.
contact.deleted
A contact went away.
Meetings, exports and tests
meeting_connection.connected
An account connected, or reconnected, its own Zoom or Webex grant.
meeting_connection.reauth_required
Zoom or Webex refused the account's meeting grant. Mail and calendar sync go on.
export.completed
A data export finished and can be downloaded until it expires.
export.failed
A data export failed.
webhook.test
Sent by the test action to the endpoint it names. Never in the change feed.
FAQ
Questions about delivery
Can the same event arrive twice?
Yes. Retries and replays deliver the same event id again, in the webhook-id header and the body. Make your handling idempotent on that id.
Do events arrive in order?
An endpoint's deliveries are dispatched oldest first, but retries wait out their backoff without holding back the rest, so arrival order is not guaranteed. The change feed returns events in commit order and never skips one.
Will I get an event for every message when an account connects?
No. The first sync of a new account is quiet and ends with one account.initialized (and calendar.initialized or contacts.initialized when those are on). Read the existing mail through the API; every change after that is announced.
How far back can I replay?
As far as your tenant's event retention, which you set between 7 and 30 days. A range replay queues at most 1,000 events per call, so split larger ranges.
What happens to deliveries while an endpoint is disabled?
It receives no new events and its queued deliveries wait. Deliveries still queued 3 days after they were created are given up. Re-enable the endpoint, then replay the time it missed.