Webhooks & Events
Outbound webhook delivery (Svix) + a real-time events API. 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 REST polling, SSE streaming, or traditional webhook endpoints.
When to Use
- Adding a new feature that external systems should react to — fire a webhook event.
- Building a client that needs real-time updates — use the SSE stream instead of polling.
- Letting users configure their own integrations — the Svix AppPortal at
/webhookshandles endpoint management.
Default stance: fire a webhook event for every meaningful state change. It's cheap (fire-and-forget async) and gives downstream consumers maximal flexibility.
Architecture
Feature code (server actions, webhook handlers, API routes)
→ sendWebhookEventAsync(userId, eventType, data) # fire-and-forget
→ lib/webhooks.ts
→ Svix API (getOrCreate app, create message)
Clients consume events via:
1. Traditional webhooks (configured in /webhooks dashboard)
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
Firing Events
From server actions or route handlers
import { sendWebhookEventAsync } from "@/lib/webhooks"
// Fire-and-forget — never blocks the request
sendWebhookEventAsync(session.user.id, "invoice.payment_succeeded", {
subscriptionId: "sub_123",
amountPaid: 9900,
})
When you need to await delivery confirmation
import { sendWebhookEvent } from "@/lib/webhooks"
// Throws on failure — use in background jobs or where you want retry logic
await sendWebhookEvent(userId, "api_key.created", { name: "prod-key" })
Adding a new event type
- Add to
lib/webhooks-config.ts:
// In WEBHOOK_EVENT_TYPES array:
{ value: "document.created", label: "Document Created", category: "Documents" },
- Fire it from your feature code:
sendWebhookEventAsync(userId, "document.created", {
documentId: doc.id,
title: doc.title,
})
- Run
POST /api/webhooks/sync(or redeploy) to register the new type with Svix so it appears in the AppPortal picker. This happens automatically the first time any user opens the/webhookspage after deploy.
That's it. No schema changes, no migrations, no new routes.
Consuming Events (Client-Side)
REST: List events
curl -H "Authorization: Bearer sk_live_..." \
"https://app.example.com/api/v1/webhooks/events?event_type=api_key.created&limit=10"
Response:
{
"events": [
{
"id": "msg_2xY...",
"eventType": "api_key.created",
"payload": { "type": "api_key.created", "data": { "name": "prod-key" } },
"timestamp": "2025-03-25T00:00:00.000Z"
}
],
"nextCursor": "msg_abc...",
"done": false
}
Paginate with ?cursor=msg_abc.... Filter with ?event_type=, ?before=, ?after=.
SSE: Real-time stream
curl -N -H "Authorization: Bearer sk_live_..." \
"https://app.example.com/api/v1/webhooks/events/stream"
Or in JavaScript:
const es = new EventSource(
"/api/v1/webhooks/events/stream?event_type=api_key.created",
{ headers: { Authorization: `Bearer ${apiKey}` } }
)
es.onmessage = (event) => {
const data = JSON.parse(event.data)
console.log(data.eventType, data.payload)
}
The stream sends:
data: {...}\n\nfor each new event: heartbeat\n\nevery 2 seconds to keep the connection alive
Test event
curl -X POST -H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{"eventType": "test.ping", "data": {"hello": "world"}}' \
"https://app.example.com/api/v1/webhooks/test"
Current Event Types
| Event | Fired by | Category |
|---|---|---|
api_key.created | lib/actions/api-keys.ts | API Keys |
api_key.revoked | lib/actions/api-keys.ts | API Keys |
subscription.created | Stripe webhook (checkout.session.completed) | Billing |
subscription.updated | Stripe webhook (customer.subscription.updated) | Billing |
subscription.canceled | Stripe webhook (customer.subscription.deleted) | Billing |
invoice.payment_succeeded | Stripe webhook | Billing |
invoice.payment_failed | Stripe webhook | Billing |
Scopes
| Scope | Grants |
|---|---|
webhooks:read | List events, SSE stream, view portal |
webhooks:write | Send test events, manage endpoints |
Files
| File | Purpose |
|---|---|
lib/webhooks.ts | sendWebhookEvent(), sendWebhookEventAsync() |
lib/webhooks-config.ts | Event type registry (fork customization point) |
lib/svix.ts | Lazy singleton Svix client |
lib/env/svix.ts | SVIX_API_KEY env access |
app/api/v1/webhooks/events/route.ts | GET /api/v1/webhooks/events — paginated list |
app/api/v1/webhooks/events/stream/route.ts | GET /api/v1/webhooks/events/stream — SSE |
app/api/v1/webhooks/test/route.ts | POST /api/v1/webhooks/test — send test event |
app/api/webhooks/portal/route.ts | Portal URL for dashboard (session auth, lazy-syncs event types) |
app/api/webhooks/sync/route.ts | POST — sync event types to Svix (session auth) |
app/(dashboard)/webhooks/ | Dashboard page with embedded Svix AppPortal |
When Forking
- Replace event types in
lib/webhooks-config.tswith your product's domain events. - Wire events into your feature code with
sendWebhookEventAsync(). - Set
SVIX_API_KEYin Vercel env vars (all environments). - Everything else (portal, events API, SSE stream) works automatically.