Webhooks and Events

Outbound webhook delivery via Svix plus a real-time events API (REST + SSE). This is the backbone for making the platform reactive — any feature that "something happened" can fire a webhook event, and clients can consume them via webhook endpoints, polling, or streaming.

Default stance

Fire a webhook event for every meaningful state change. It's cheap (fire-and-forget async), it's per-tenant by default, and downstream consumers want maximal flexibility. If you're writing a server action and asking "should this emit an event?" — the answer is almost always yes.

Use sendWebhookEventAsync (fire-and-forget) unless you genuinely need to await delivery confirmation. Blocking the request to confirm webhook delivery is the wrong tradeoff for almost every product flow.

Use this skill when

Adding a new event type, wiring events from feature code, debugging Svix portal behavior, building an SSE consumer, or designing per-org integration surfaces.

Architecture

Feature code (server actions, route handlers, webhook handlers)
  → sendWebhookEventAsync(orgId, eventType, data, "org")    # fire-and-forget
    → lib/webhooks.ts
      → Svix: getOrCreate app `org_{id}` → message.create

Clients consume events via:
  1. Traditional webhooks    — configured in /webhooks AppPortal
  2. GET  /api/v1/webhooks/events          — paginated list
  3. GET  /api/v1/webhooks/events/stream   — SSE real-time stream
  4. POST /api/v1/webhooks/test            — send a test event

Per-tenant Svix apps: org-scoped events use org_{id}, user-scoped use user_{id}. The getOrCreate call means the Svix app is provisioned lazily on first event — no setup step required when forking.

Firing events

import { sendWebhookEventAsync } from "@/lib/webhooks"

// Fire-and-forget — never blocks the request
sendWebhookEventAsync(
  orgId,
  "invoice.payment_succeeded",
  { subscriptionId: "sub_123", amountPaid: 9900 },
  "org"
)

Use the awaited variant only when delivery confirmation matters (background jobs, retry logic):

import { sendWebhookEvent } from "@/lib/webhooks"
await sendWebhookEvent(orgId, "api_key.created", { name: "prod-key" }, "org")

Adding a new event type

  1. Add to lib/webhooks-config.ts — this file is the fork customization point:
    { value: "document.created", label: "Document Created", category: "Documents" },
    
  2. Fire it from feature code with sendWebhookEventAsync(...).
  3. The new type registers with Svix the next time someone hits /webhooks or you POST to /api/webhooks/sync. No migration, no manual portal setup.

Hard rules

  • Never block a user-facing request to await webhook delivery. Use the async variant.
  • Always pass the tenant type ("org" or "user"). New code should use "org" — user-scoped is for backward compatibility with pre-org keys.
  • Don't bypass sendWebhookEvent to call Svix directly. The wrapper handles getOrCreate and tenant prefixing — bypassing means you'll forget one and fail silently.
  • Test events are real events. They go through the same pipeline. If you don't want them in the activity feed, don't send them.

Where things live

FilePurpose
lib/webhooks.tssendWebhookEvent, sendWebhookEventAsync, syncWebhookEventTypes
lib/webhooks-config.tsEvent type registry — fork customization point
lib/svix.tsLazy singleton Svix client
lib/env/svix.tsSVIX_API_KEY access
app/api/v1/webhooks/events/route.tsPaginated REST list
app/api/v1/webhooks/events/stream/route.tsSSE stream with 2s heartbeat
app/api/v1/webhooks/test/route.tsSend a test event
app/(dashboard)/webhooks/Embedded Svix AppPortal

Auxiliary content