Valcraven Docs
Features

Webhooks

Outbound webhooks — let your users register HTTPS endpoints and deliver HMAC-signed, retried event payloads to them, with SSRF protection built in.

Webhooks

Valcraven includes an outbound webhook system: your users register their own HTTPS endpoints, and your app delivers signed event payloads to them when something happens. This is the standard "let customers subscribe to events in my product" feature — think GitHub or Stripe webhooks, but for the app you're building.

Deliveries are HMAC-SHA256 signed, retried through the job queue on failure, recorded for inspection, and guarded against SSRF. The system is a library plus a REST API — there is no built-in admin UI; you wire emitEvent() into your own code and (optionally) build a management screen on top of the API.

This is distinct from Stripe's inbound webhook (POST /api/stripe/webhook), which receives events from Stripe. See Billing for that.

Where it lives

PiecePath
Webhook engine (CRUD, emit, deliver, sign)apps/web/lib/webhooks.ts
SSRF guardapps/web/lib/ssrf.ts (assertSafeWebhookUrl)
CRUD APIapps/web/app/api/webhooks/route.ts
Per-webhook API (get/update/delete)apps/web/app/api/webhooks/[id]/route.ts
Test + delivery historyapps/web/app/api/webhooks/[id]/test, .../deliveries
Tableswebhooks, webhook_deliveries

Emitting an event

The core of the feature is one call. Wherever something happens that you want to surface, emit an event for the user:

import { emitEvent } from "@/lib/webhooks";

await emitEvent(userId, "item.created", { itemId: "123" });

emitEvent() finds the user's active webhooks whose subscribed events include the event name (or "*" for all events), writes a webhook_deliveries row for each, and enqueues a deliver-webhook job. Delivery happens asynchronously so your request returns immediately.

Nothing is emitted by default. Valcraven doesn't call emitEvent() anywhere out of the box — it's the extension point you call from your own features. Pick an event-name convention (e.g. resource.action) and emit from the places that matter in your app.

Delivery mechanics

When the job runs, deliverWebhook():

  1. Re-validates the target URL with the SSRF guard at delivery time (not just at registration), so a URL that became unsafe later — e.g. via DNS rebinding — still can't be reached.
  2. Builds the body and an HMAC-SHA256 signature over it using the webhook's per-webhook secret.
  3. POSTs to the endpoint with a 10-second timeout and redirect: "manual".
  4. Records the response status, a truncated response body, success/failure, and attempt count on the delivery row.

The request your users receive looks like:

POST <their endpoint>
Content-Type: application/json
X-Webhook-Signature: sha256=<hmac hex>
X-Webhook-Event: item.created
X-Webhook-Id: <delivery id>

{ "event": "item.created", "payload": { "itemId": "123" }, "timestamp": 1720000000, "webhookId": "<webhook id>" }

Subscribers verify authenticity by recomputing HMAC-SHA256(secret, rawBody) and comparing it to X-Webhook-Signature. A non-2xx response (or timeout) throws, which lets the job queue retry with its backoff. A URL blocked by the SSRF guard is a permanent failure — it's recorded and not retried.

Managing webhooks (API)

Every route requires an authenticated session and is scoped to the current user. The secret is returned once, in the 201 response from POST /api/webhooks (so you can store it to verify signatures) — every subsequent read (GET list, GET /[id], PATCH) strips it out.

Method + pathPurpose
GET /api/webhooksList the current user's webhooks (secrets stripped)
POST /api/webhooksCreate a webhook (url, events: string[]) — returns 201
GET /api/webhooks/[id]Fetch one webhook
PATCH /api/webhooks/[id]Update url, events, or active
DELETE /api/webhooks/[id]Delete a webhook
POST /api/webhooks/[id]/testSend a test.ping delivery immediately
GET /api/webhooks/[id]/deliveries?limit=Recent delivery history (default 20)

On create/update, the URL must be HTTPS (plain http://localhost is allowed only in non-production for local testing) and must pass the SSRF guard. An empty events array or ["*"] means "all events".

SSRF protection

lib/ssrf.ts (assertSafeWebhookUrl) is the single source of truth. It rejects non-http(s) schemes, IP literals in private / loopback / link-local / CGNAT ranges, and DNS names that resolve to such addresses. It runs at three points: create, update, and again at delivery time — so users can't register an internal address, and can't sneak one in later via DNS rebinding.

Data model

  • webhooksurl, secret (32 random bytes, hex), events (JSON array), active (0/1), timestamps, scoped by userId.
  • webhook_deliveriesevent, payload (JSON), responseStatus, responseBody (truncated), success, attempts, lastError, createdAt / completedAt, linked to a webhook by webhookId.

Building a UI

There's no admin/settings screen for webhooks in the template — the REST API above is the contract. If you want users to manage their own webhooks, build a settings page that calls these endpoints (list, create, test, view deliveries). Show the secret to the user once at creation if you surface it at all, since it isn't returned by later reads.

  • Billing — Stripe's inbound webhook handler (the opposite direction)
  • Server-to-server auth and background jobs power the retry loop behind delivery

On this page