@repo/webhooks
Outbound webhooks with Standard Webhooks signatures, delivered on the jobs queue.
@repo/webhooks lets each organization register endpoint URLs and receive
signed POST requests when things happen in the product. Endpoints are managed
from the /app/webhooks settings page or the public API (/api/v1/webhooks),
deliveries ride the jobs queue, and every request is signed per the
Standard Webhooks specification, so
receivers verify with an off-the-shelf library instead of custom crypto.
Emitting an event
Call emitWebhookEvent() from any server-side code (exported from
@repo/webhooks/client, and like enqueue in @repo/jobs it is
server-only). The payload is typed from the event's Zod schema:
import { emitWebhookEvent } from '@repo/webhooks/client';
await emitWebhookEvent({
organizationId: organization.id,
type: 'member.joined',
payload: { userId: user.id, userName: user.name },
});The call costs one queue send no matter how many endpoints the organization
has; the delivery job does the fan-out. The event id and timestamp are fixed
at emit time, so the webhook-id header is stable across retries and
receivers can use it to dedupe.
What receivers get
{
"type": "member.joined",
"timestamp": "2026-07-25T12:00:00.000Z",
"data": { "userId": "…", "userName": "…" }
}With the Standard Webhooks headers webhook-id, webhook-timestamp, and
webhook-signature (HMAC-SHA256, v1,…). Verification on the receiver:
import { Webhook } from 'standardwebhooks';
const webhook = new Webhook(process.env.WEBHOOK_SECRET); // whsec_…
const event = webhook.verify(await request.text(), Object.fromEntries(request.headers));Always verify the raw body; re-serializing the JSON breaks the signature.
Delivery, retries, and endpoint health
- One
webhook_deliveryrow per (event, endpoint) records status, response code, and attempt count; the settings page shows the recent ledger. - A failed delivery throws, so Cloudflare Queues redelivers with backoff up
to
max_retriesand then dead-letters. Endpoints that already succeeded for an event are skipped on retry. - Five consecutive failures, or a
410 Goneresponse, disable the endpoint. Re-enabling it from the settings page resets the counter. - Failed deliveries can be redelivered from the settings page; the original
webhook-idis reused so receivers still dedupe correctly. - A nightly cron prunes ledger rows untouched for 30 days.
- Rotating a secret keeps the previous secret valid for 24 hours; during the
window
webhook-signaturecarries both signatures.
Adding an event type
Create a definition in src/events/ and list it in src/registry.ts:
export const taskCompleted = defineWebhookEvent({
type: 'task.completed',
schema: z.object({ taskId: z.string(), title: z.string() }),
});The emitter, delivery job, and settings UI pick it up automatically. Add the
type to the inlined event enum in private/api/src/contract.ts too; the
registry is pinned to that enum with a satisfies check, so the type check
fails until both sides agree.