1. Features
  2. Webhooks and change feed
Webhooks and change feed

Every change, signed, ordered and replayable.

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.

hooks/workwae.ts
import { parseWebhook } from "@workwae/sdk";

export async function POST(request: Request) {
  // Verify the raw body, before parsing it
  const body = await request.text();
  let event;
  try {
    event = await parseWebhook(secret, request.headers, body);
  } catch {
    return new Response("Invalid webhook", { status: 400 });
  }

  // Retries and replays reuse the id: deduplicate on it
  const isNew = await markSeen(event.id);

  if (isNew && event.known && event.type === "message.created") {
    await queue.add(event.id, event.data.message.subject);
  }
  return new Response(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 account
const { 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 again
await vault.put("workwae-webhook", secret);
// Try the receiver before real traffic exists
await workwae.webhooks.test({
  endpointId: endpoint.id,
  eventType: "message.created",
});

// Rotate: the old secret stops working at once
const { 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: new Date("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 day
await workwae.webhooks.update({ endpointId, isEnabled: true });
const { queued, alreadyQueued } = await workwae.webhooks.replay({
  endpointId,
  since: new Date("2026-11-02T00:00:00Z"),
  until: new Date("2026-11-03T00:00:00Z"),
});

// Or just one event, whatever the filters
await 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 handle
const 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 up
for await (const event of feed) {
  await apply(event);
}

// Saved after processing: a crash mid-page replays that page
await 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.connectedAn account was connected, or an existing account signed in again (reauthorized: true).
account.updatedIts status, externalRef, settings or connection changed; fields says which.
account.reauth_requiredThe provider refused the account's grant, with the reason. The account must sign in again.
account.initializedThe first mail sync finished. Nothing before it is announced message by message.
account.deletedAn account was deleted. Its data is purged after the retention period, or at once.
account.purgedA deleted account's data is gone for good. Names no account, since it outlives it.
Mailboxes
mailbox.createdA folder or label appeared.
mailbox.updatedA mailbox was renamed, moved or given another role. Counts change silently.
mailbox.deletedA mailbox went away.
Messages
message.createdA message was indexed: received, sent, drafted, or moved in from outside the sync window.
message.updatedA message was read, flagged, moved, or linked to the outbox entry it is a copy of.
message.deletedA message was removed; carries it and its mailboxes as last indexed.
message.bouncedA delivery status notification reported failed recipients, with the outbox id when it answers one of your sends.
message.classifiedAI classified a reply to one of your sends, when reply classification is on.
message.openedThe open-tracking pixel of a send was loaded, with who loaded it: a person or a machine.
message.clickedA tracked link of a send was followed, with the same classification.
Sends and suppressions
send.sentThe provider accepted a queued send.
send.retryingAn attempt failed and the send is queued again for nextAttemptAt.
send.failedThe send failed for good: refused, or out of attempts.
send.rescheduledA queued send got a new send time before its first attempt.
suppression.createdAn address went onto a suppression list: an unsubscribe, a permanent bounce, or a detected opt-out.
Calendars
calendar.initializedThe first calendar sync finished. Nothing before it is announced event by event.
calendar.createdA calendar appeared.
calendar.updatedA calendar changed.
calendar.deletedA calendar went away.
calendar_event.createdA single event, series or exception appeared. Unmodified occurrences of a series are never announced.
calendar_event.updatedA single event, series or exception changed; a change to a series is one update.
calendar_event.deletedA single event, series or exception went away.
Contacts
contacts.initializedThe first contacts sync finished. Nothing before it is announced contact by contact.
contact.createdA contact appeared.
contact.updatedA contact changed.
contact.deletedA contact went away.
Meetings, exports and tests
meeting_connection.connectedAn account connected, or reconnected, its own Zoom or Webex grant.
meeting_connection.reauth_requiredZoom or Webex refused the account's meeting grant. Mail and calendar sync go on.
export.completedA data export finished and can be downloaded until it expires.
export.failedA data export failed.
webhook.testSent 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.

Early access

Stop polling every provider yourself.

workwae is in early access. Tell us what you are building, and read the webhooks guide in the meantime.