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 /webhooks handles 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

  1. Add to lib/webhooks-config.ts:
// In WEBHOOK_EVENT_TYPES array:
{ value: "document.created", label: "Document Created", category: "Documents" },
  1. Fire it from your feature code:
sendWebhookEventAsync(userId, "document.created", {
  documentId: doc.id,
  title: doc.title,
})
  1. 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 /webhooks page 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\n for each new event
  • : heartbeat\n\n every 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

EventFired byCategory
api_key.createdlib/actions/api-keys.tsAPI Keys
api_key.revokedlib/actions/api-keys.tsAPI Keys
subscription.createdStripe webhook (checkout.session.completed)Billing
subscription.updatedStripe webhook (customer.subscription.updated)Billing
subscription.canceledStripe webhook (customer.subscription.deleted)Billing
invoice.payment_succeededStripe webhookBilling
invoice.payment_failedStripe webhookBilling

Scopes

ScopeGrants
webhooks:readList events, SSE stream, view portal
webhooks:writeSend test events, manage endpoints

Files

FilePurpose
lib/webhooks.tssendWebhookEvent(), sendWebhookEventAsync()
lib/webhooks-config.tsEvent type registry (fork customization point)
lib/svix.tsLazy singleton Svix client
lib/env/svix.tsSVIX_API_KEY env access
app/api/v1/webhooks/events/route.tsGET /api/v1/webhooks/events — paginated list
app/api/v1/webhooks/events/stream/route.tsGET /api/v1/webhooks/events/stream — SSE
app/api/v1/webhooks/test/route.tsPOST /api/v1/webhooks/test — send test event
app/api/webhooks/portal/route.tsPortal URL for dashboard (session auth, lazy-syncs event types)
app/api/webhooks/sync/route.tsPOST — sync event types to Svix (session auth)
app/(dashboard)/webhooks/Dashboard page with embedded Svix AppPortal

When Forking

  1. Replace event types in lib/webhooks-config.ts with your product's domain events.
  2. Wire events into your feature code with sendWebhookEventAsync().
  3. Set SVIX_API_KEY in Vercel env vars (all environments).
  4. Everything else (portal, events API, SSE stream) works automatically.