API · Webhooks

First-class webhooks both ways — a signature-verified incoming *.webhook.tsx receiver (genericProvider HMAC) that rejects forged traffic before your handler runs, plus an outgoing defineOutgoingEvent a mutation emits to subscribed targets via a durable signed delivery workflow. Zero-infra boot.

Webhooks both ways, wired turnkey: a signature-verified incoming receiver that rejects forged traffic before your code runs, and an outgoing event a mutation emits to subscribed targets through a durable, signed, retried delivery workflow. Webhooks are file-convention (drop a *.webhook.tsx, it's auto-discovered) — not a plugins:[] entry. Boots zero-infra (store: 'memory'). Template id: api-webhooks.

Scaffold

voltro create-project acme --api=api-webhooks

The shipped .env supplies a DEV-ONLY VOLTRO_WEBHOOK_SECRET_ORDERS so the incoming receiver boots with a key.

What ships

apps/acme/api/
├── .env                                  # VOLTRO_WEBHOOK_SECRET_ORDERS (dev signing secret)
├── database/schema.ts                    # orders + webhookTables() bookkeeping
├── webhooks/orders.webhook.tsx           # INCOMING — signature-verified receiver
├── events/order.completed.webhook.tsx    # OUTGOING — defineOutgoingEvent
├── mutations/orders.fulfill.mutation.ts  # create order + emit (+ .server.ts)
└── queries/orders.list.query.ts          # reactive list (+ .server.ts)

Incoming — verified before your handler runs

// webhooks/orders.webhook.tsx → mounts at POST /webhooks/orders
import { defineIncomingWebhook } from '@voltro/plugin-webhooks'
import { genericProvider } from '@voltro/plugin-webhooks/providers'

export default defineIncomingWebhook({
  id: 'orders', provider: genericProvider(),
  payload: Schema.Struct({ event: Schema.String, orderId: Schema.String, sku: Schema.String, totalCents: Schema.Number }),
  handler: async (ctx) => { /* ctx.body is validated; ctx.idempotencyKey set */ },
})

The framework's incoming middleware runs before handler:

  1. verify the HMAC over <ts>.<rawBody> with VOLTRO_WEBHOOK_SECRET_ORDERS401 on mismatch
  2. reject a t older than the replay window → 401
  3. decode the body against payload422 on a bad shape
  4. claim the Idempotency-Key → a replay returns 200 {duplicate:true} and the handler is skipped

Only a request that passes all of that reaches handler. You never hand-roll a signature check, and forged traffic never runs your code. Built-in presets: stripeProvider / githubProvider / slackProvider / genericProvider (or defineWebhookProvider for a custom partner). The signing secret is VOLTRO_WEBHOOK_SECRET_<UPPER_ID> — here _ORDERS.

Try it (curl)

SECRET='devonly-webhook-secret-change-me'
BODY='{"event":"order.created","orderId":"o_1","sku":"WIDGET","totalCents":1999}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.*= //')

# valid signature → 200, handler runs
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:4000/webhooks/orders \
  -H "X-Webhook-Signature: t=$TS,v1=$SIG" -H 'content-type: application/json' \
  -H 'Idempotency-Key: evt_1' --data "$BODY"            # → 200

# no signature → 401 · forged → 401 · valid sig + wrong shape → 422
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:4000/webhooks/orders \
  -H 'content-type: application/json' --data "$BODY"     # → 401

Outgoing — emit to subscribed targets

// events/order.completed.webhook.tsx
export default defineOutgoingEvent({ id: 'order.completed', payload: Schema.Struct({ /* … */ }), version: 1 })

// mutations/orders.fulfill.mutation.server.ts — after the row commits:
const { eventId, deliveries } = await useWebhooks(ctx).emit('order.completed', { orderId, tenantId, /* … */ })

External systems subscribe at runtime — ctx.webhooks.subscribe({ event: 'order.completed', url }) (a row in _voltro_webhook_targets). On emit, the framework fans out to every matching target through a durable delivery workflow — HMAC-signing the outbound request, retrying with backoff, honouring Retry-After; each attempt lands in _voltro_webhook_deliveries. emit returns immediately (the POSTs run in the background); deliveries is one entry per matched target (empty until someone subscribes).

curl -s -X POST http://localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
  -d '{"tag":"orders.fulfill","input":{"sku":"WIDGET","totalCents":4999}}'
# → the order; the log shows: order.completed event=… → 0 target(s)

Going to production

Want… Do
A real partner (Stripe/GitHub/Slack) swap genericProvider()stripeProvider() etc.; set VOLTRO_WEBHOOK_SECRET_<ID>
Durable targets + delivery history store: 'postgres' (the _voltro_webhook_* tables survive restarts)
A subscribe API a mutation calling ctx.webhooks.subscribe({ event, url, secret?, retry? })
A custom signature scheme defineWebhookProvider({ id, signature, idempotency })

Anti-patterns

  • Trusting webhook input without verification. The framework rejects unsigned incoming requests by default — don't set signature: undefined unless a network trust boundary gates the route.
  • Mutating domain state synchronously in the outgoing emit path. Mutate in the mutation, THEN emit — delivery is async (rollback drops the queued send).
  • Re-using the idempotency TTL as the provider's retry window. Pick TTL ≥ 2× the provider's max retry window so the dedup catches the slowest retry.

See also