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-webhooksThe 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:
- verify the HMAC over
<ts>.<rawBody>withVOLTRO_WEBHOOK_SECRET_ORDERS→401on mismatch - reject a
tolder than the replay window →401 - decode the body against
payload→422on a bad shape - claim the
Idempotency-Key→ a replay returns200 {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" # → 401Outgoing — 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: undefinedunless 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
- Plugins overview —
@voltro/plugin-webhooks+ the full catalogue - App templates — the full catalogue