Webhook signatures

Verify that each webhook delivery came from Workwae, and rotate an endpoint's signing secret without downtime.

Every webhook delivery is signed with its endpoint's secret. Verify the signature before trusting the body, and rotate the secret with a grace period when you need a new one.

Verify the signature

Signatures follow Standard Webhooks, so any Standard Webhooks library verifies them:

  • The algorithm is HMAC-SHA256, keyed with the base64-decoded part of the secret after whsec_.
  • The signed content is {webhook-id}.{webhook-timestamp}.{body}.
  • webhook-signature may carry several space-separated v1,<base64> signatures; one match is enough.

Verify the raw body

Verify the request body exactly as received, before parsing it. Re-serialized JSON does not match the signature.

parseWebhook from the TypeScript SDK checks the signature and the timestamp (5 minutes of tolerance), then parses the body. verifyWebhook only returns a boolean.

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

export async function POST(request: Request): Promise<Response> {
  const body = await request.text();
  let event: WebhookEvent;
  try {
    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 });
  if (event.known && event.type === "message.created") {
    await enqueue(event.id, event.data.message.subject);
  }
  return new Response(null, { status: 204 });
}

What parseWebhook returns:

  • event.createdAt is a Date.
  • For a known type, checking event.type narrows event.data to that type's data.
  • A type added after your SDK build arrives with known: false and unchecked data.

Rotate the secret

POST /v1/webhooks/{endpointId}/rotate-secret (or Rotate secret in the console) returns a new secret, which signs every delivery from then on.

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 }'

During the grace period the old secret keeps signing too:

graceSecondsEffect
Omitted24 hours of grace.
Up to 604800 (7 days)Deliveries carry two signatures, v1,<new> v1,<old>; a receiver holding either secret accepts them.
0The old secret stops signing at once.
  • previousSecretExpiresAt, in the response and on the endpoint, says when the old secret stops signing. Switch your receiver to the new secret before then.
  • verifyWebhook, parseWebhook and every Standard Webhooks library accept a delivery when any of its signatures matches the secret they are given.
  • A rotation during another rotation's grace ends that grace: only the secret it replaces keeps signing.

On this page