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
- Add to
lib/webhooks-config.ts— this file is the fork customization point:{ value: "document.created", label: "Document Created", category: "Documents" }, - Fire it from feature code with
sendWebhookEventAsync(...). - The new type registers with Svix the next time someone hits
/webhooksor 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
sendWebhookEventto call Svix directly. The wrapper handlesgetOrCreateand 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
| File | Purpose |
|---|---|
lib/webhooks.ts | sendWebhookEvent, sendWebhookEventAsync, syncWebhookEventTypes |
lib/webhooks-config.ts | Event type registry — fork customization point |
lib/svix.ts | Lazy singleton Svix client |
lib/env/svix.ts | SVIX_API_KEY access |
app/api/v1/webhooks/events/route.ts | Paginated REST list |
app/api/v1/webhooks/events/stream/route.ts | SSE stream with 2s heartbeat |
app/api/v1/webhooks/test/route.ts | Send a test event |
app/(dashboard)/webhooks/ | Embedded Svix AppPortal |
Auxiliary content
- references/original-guide.md — full canonical guide with consumer code samples (REST pagination, JS EventSource), scope table, fork checklist
- references/graph.md — when to hand off to
add-api-endpoint,sandboxed-tasks, etc. - scripts/list-webhook-surfaces.sh — enumerates current event types, files that fire them, and consumer routes; run this when auditing event coverage
- assets/event-payload-template.json — copy-paste scaffold for new event payload shapes