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.
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 asaccount.purged) are not delivered either. The ids must be accounts of your tenant; others answer 422 with reasonunknown_account.filters.message: criteria about the message an event concerns, below.- Change any filter at any time with
PATCH /v1/webhooks/{endpointId}and afiltersobject 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.
mailboxIdsandmailboxRoles(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 reasonunknown_mailbox) and, whenfilters.accountIdsis set, of those accounts (400 reasonmailbox_outside_accounts).fromandto: addresses, or@example.comfor every address at exactly that domain, compared regardless of case.tois 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:outboundfor a message in the sent mailbox or from one of the account's own addresses (its address and identities),inboundfor every other message; null clears it.message.deletedcarries 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.bouncedis outbound, andtois 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.
| Header | Value |
|---|---|
webhook-id | The event id, the same as the body's id. Stable across retries and replays: use it to deduplicate. |
webhook-timestamp | When this attempt was signed, in Unix seconds. |
webhook-signature | One or more space-separated v1,<base64> signatures. |
content-type | application/json |
user-agent | workwae-webhooks/1 |
{
"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.
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.
- Switch your receiver to the new secret any time before
previousSecretExpiresAt. The SDK'sverifyWebhookandparseWebhookaccept 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
429answer, or a503with aRetry-Afterheader, asks us to slow down rather than reporting a failure. That delivery is retried afterRetry-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-Afteryou 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:
isEnabledbecomes false anddisabledReasonsays why.failingSinceandlastSuccessAton 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.
- 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
eventRetentionDayscan be replayed; an unknown or expired id answers 404 with reasonevent_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.
{
"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.