A webhook for a new message or a moved meeting is the cheapest way to keep your product in step with a mailbox. It is also an HTTP request from the internet that can be forged, arrive twice, arrive late, or not arrive while your server is deploying. A receiver you can trust checks where each request came from, tolerates duplicates and reordering, and has a way to catch up on what it missed.
This article walks through each of those, using the Standard Webhooks specification as the reference, and then shows what Workwae does on its side.
Verify the raw body, then the clock.
Standard Webhooks puts three headers on every delivery: webhook-id,
webhook-timestamp (Unix seconds) and webhook-signature. The
symmetric scheme is an HMAC-SHA256 over {id}.{timestamp}.{body}, keyed with the
base64 secret after its whsec_ prefix, sent as v1,<base64>.
The specification asks receivers for three things:
- Sign what was sent. Compute the HMAC over the raw request body. A body parsed as JSON and serialized again can differ by a single space and fail, which the specification calls a very common failure mode.
- Compare in constant time. A comparison that stops at the first differing byte leaks how much of a guess was right.
- Check the timestamp. Refuse a timestamp outside an allowed tolerance of your clock, so a captured request cannot be sent again a week later.
The timestamp is the attempt’s, not the event’s. A retry three hours later is signed again with a fresh timestamp, so the tolerance protects against replay without refusing legitimate retries. Event time belongs in the body.
Expect duplicates, not order.
Delivery is at-least-once. Your server can commit its work and time out before the response
reaches the sender, which then retries. The specification’s answer is to treat
webhook-id as an idempotency key: it identifies the event and stays the same on
every retry. Store it in the same transaction as the effect, with a unique constraint, and a
second delivery becomes a no-op.
Order is the other assumption to drop. Senders deliver concurrently, and one failed event
retries on its own schedule while newer ones go through, so message.updated
can arrive before the message.created it follows. Two habits make that
harmless:
- Upsert by the resource id in the payload rather than assuming the create came first.
- When the order of two changes matters, read the resource’s current state from the API instead of replaying payloads in arrival order.
Answer fast, work later.
The sender only learns that you have the event from a 2xx response. Anything
else, including a timeout, a redirect or a reset connection, is a failure under the
specification, and the specification recommends retrying on an exponential schedule with
jitter that spans several days. So the receiver should do as little as possible before
answering: verify, record the id and body, return 204, and let a worker do the
rest.
If your endpoint is overloaded, say so. A 429, or a 503 with a
Retry-After header, which carries either seconds or an HTTP date
(RFC 9110, Retry-After),
tells a well-behaved sender to slow down rather than count a failure.
When your endpoint is down.
Retries cover a deploy or a short outage. A long one exhausts them: the specification recommends that a sender eventually stop, disable the endpoint and tell you through another channel. What you need from the sender then is a way back:
- Retention: how long it keeps events you have not received.
- Replay: a way to queue the events your endpoint gave up on again, with their original ids, so your deduplication still works.
- A change feed: a pull API with a cursor, so you can read every event in order regardless of what your endpoint saw.
A cursor feed also makes a good periodic reconciliation. Pull it on a schedule, insert each event under the same unique key your receiver uses, and any webhook that went missing fills itself in. Webhooks then become a latency optimization over a feed you already trust.
Rotate secrets with an overlap.
webhook-signature is a space-separated list because of rotation. The
specification describes signing with both the current and the previous key for a set
period, so a receiver holding either one accepts the delivery. That removes the race
between rotating the secret on the sender and deploying it on the receiver: rotate, deploy
the new secret whenever the deploy lands, and the old one simply stops appearing.
A receiver should therefore try each signature in the header and accept on the first match, and the sender should keep the overlap long enough to cover a slow deploy.
How Workwae handles it.
Workwae signs every delivery with Standard Webhooks HMAC-SHA256. The SDK’s
parseWebhook checks the signature in constant time and the timestamp against a
300-second tolerance, then returns the typed event:
import { parseWebhook, WebhookVerificationError, type WebhookEvent } from "@workwae/sdk";
// A fetch-style handler: Bun, Deno, Workers, Next.js route handlers.
export async function handleWebhook(request: Request): Promise<Response> {
// The raw body, exactly as received: re-serialized JSON does not verify.
const body = await request.text();
let event: WebhookEvent;
try {
event = await parseWebhook(process.env.WORKWAE_WEBHOOK_SECRET!, request.headers, body);
} catch (error) {
// missing_headers, timestamp, signature or payload
if (error instanceof WebhookVerificationError) return new Response(null, { status: 400 });
throw error; // a malformed secret is your bug: a 5xx, so Workwae retries
}
// Your own table, unique on the event id: retries and replays insert nothing.
await inbox.insertIfAbsent(event.id, body);
// Acknowledge now; a worker reads the inbox.
return new Response(null, { status: 204 });
}
A type added after your SDK build comes back with known: false and unchecked
data; for a known event, checking event.type narrows
event.data. Events from the console’s or the API’s test action carry
“isTest”: true. The rest of the behavior described above:
- Ids.
webhook-idis the event id, the same as the body’sid, and stays the same across retries and replays. - Retries. Your endpoint has 10 seconds to answer with a
2xx; redirects are not followed. A failed attempt is retried after 5 seconds, then with the delay doubling up to an hour, with 20 percent jitter. A delivery is given up once its next attempt would fall more than 3 days after it was queued. - Throttling. A
429, or a503withRetry-After, delays that delivery without counting as an attempt. Once a delivery succeeds again, the endpoint’s other waiting retries are sent at once instead of at their scheduled times. - Disabling. Workwae alerts you after an endpoint has failed for 24 hours
and disables it after 72 hours without a single success. The endpoint shows
failingSince,lastSuccessAtanddisabledReason. - Replay.
webhooks.replayFailedqueues every event the endpoint gave up on, once each, with the same id and body and a fresh signature;webhooks.countFailedsays how many first.webhooks.replaytakes asince/untilrange, andwebhooks.replayEventone id. The delivery log (webhooks.attempts) shows each attempt’s status, latency and the first kilobyte of your response. - Change feed.
GET /v1/changesreturns the same events, with the same ids and shape, oldest first. Its cursor is never null; an empty page means caught up.
import { changes } from "@workwae/sdk";
// Back online after a disable: re-enable, then queue what was given up on
await workwae.webhooks.update({ endpointId, isEnabled: true });
await workwae.webhooks.replayFailed({ endpointId });
// On a schedule: the feed fills in anything the endpoint never saw
const feed = changes(workwae.changes.list, { cursor: await loadCursor() });
for await (const event of feed) await inbox.insertIfAbsent(event.id, event);
await saveCursor(feed.cursor);
// Rotate: the old secret keeps signing for graceSeconds (default 24 hours)
const { secret, previousSecretExpiresAt } = await workwae.webhooks.rotateSecret({
endpointId,
graceSeconds: 86_400,
});
Rotation returns the new secret, which signs every delivery from then on. For
graceSeconds, 24 hours by default and at most 7 days, each delivery also
carries a signature by the old secret in the same header, so a receiver holding either
accepts it; previousSecretExpiresAt says when that stops. Only
graceSeconds: 0 drops the old secret at once, and a second rotation during a
grace period ends that grace.
Limits.
- 10-second timeout. Shorter than the 15 to 30 seconds the specification recommends, so do the work after answering.
- No ordering guarantee on webhooks. Deliveries run concurrently and retry independently. Use the change feed when you need events in order.
- Retention bounds recovery. Events are kept for the workspace’s
eventRetentionDays, 14 by default and between 7 and 30. Older events can neither be replayed nor read from the feed, and a single replay covers at most 1,000 events. - Symmetric signatures only. Workwae signs with
v1HMAC signatures; the specification’s asymmetricv1ascheme is not offered. - 410 is a failure like any other. The specification suggests disabling an
endpoint that answers
410 Gone; Workwae retries it. Delete or disable the endpoint through the API instead. - Alpha SDK.
@workwae/sdkis an alpha release, so its helpers may still change; the HTTP API and the signature scheme are what the receiver depends on.
Webhooks and change feed lists the event types and filters, and the webhooks guide covers verification by hand, test events and the delivery log.