# {{capProjectName}} {{capAppName}} — first-class webhooks

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. Boots **zero-infra** (`store: 'memory'`).

Webhooks are **file-convention**, not a `plugins:[]` entry — drop a
`*.webhook.tsx`, it's auto-discovered.

## What ships

```text
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/orders.event.ts                 # OUTGOING — defineEvent + webhook:
├── 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

```tsx
// 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({ … }),
  handler: async (ctx) => { /* ctx.body is validated; ctx.idempotencyKey set */ },
})
```

The framework's middleware runs **before** `handler`: verify the HMAC over
`<ts>.<rawBody>` (→ `401`), reject a stale timestamp (→ `401`), decode the body
against `payload` (→ `422`), claim the `Idempotency-Key` (a replay → `200
{duplicate:true}`, handler skipped). You never hand-roll a signature check, and
forged traffic never reaches your code. Built-in provider presets:
`stripeProvider` / `githubProvider` / `slackProvider` / `genericProvider` (or
`defineWebhookProvider` for a custom partner).

The signing secret comes from `VOLTRO_WEBHOOK_SECRET_<UPPER_ID>` — here
`VOLTRO_WEBHOOK_SECRET_ORDERS`. No secret VALUE ships with this template — the
framework mints a unique one into your gitignored `.env.local` on first
`voltro dev`; read yours from there for the curl example below.

### Try it (curl)

```bash
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 signature → 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

```ts
// events/order.completed.webhook.tsx
export const orderCompleted = defineEvent({ name: 'order.completed', key: Schema.Struct({}), payload: Schema.Struct({ … }), webhook: { 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 })` (one 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`. The `emit`
returns immediately (the POSTs run in the background); `deliveries` is one entry
per matched target (empty until someone subscribes).

```bash
# Fulfill an order → emits order.completed (0 targets until a subscriber registers)
curl -s -X POST http://localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
  -d '{"tag":"orders.fulfill","input":{"sku":"WIDGET","totalCents":4999}}'
```

## 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 (IP allow-list, VPC) gates the route.
- **Mutating domain state synchronously in the outgoing emit path.** Mutate in the
  mutation, THEN emit — delivery is async by design (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.
