Private packages

@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_delivery row 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_retries and then dead-letters. Endpoints that already succeeded for an event are skipped on retry.
  • Five consecutive failures, or a 410 Gone response, 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-id is 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-signature carries 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.

On this page