# Webhooks: Vibiz tells the app when someone pays

> Set the webhook URL, verify the Vibiz-Signature on the raw body, dedupe on event id, and test it.

## 1. Hook it up

`vibiz_payments { action: "webhook", project_id, webhook_url: "http://localhost:3000/api/vibiz/webhook" }` (env development), or `vibiz payments webhook <url> --dev`. `http://localhost` works in development when the API runs on the same machine; otherwise use a public https URL such as the deployed app. Production needs nothing: the live webhook is `https://<the app's site>/api/vibiz/webhook` from the start (`<slug>.vibiz.app` on Vibiz hosting, or the app's own domain on another host once it's on the project), so put the route at `/api/vibiz/webhook`. Pointing it anywhere else is the owner's (console ▸ Payments ▸ Webhooks).

## 2. The secret: nothing to add

Setting a URL creates a signing secret, and the app needs nothing more than `VIBIZ_APP_KEY` to use it: `vibiz.webhooks.receive(req)` fetches it with the app key (and again after a rotation). It is also in the app's env as `VIBIZ_WEBHOOK_SECRET` for anyone verifying by hand, and never shown to agents. Locally, `vibiz dev` (or `vibiz_dev_env { project_id, write_to: "<app>/.env.development.local" }`) writes the development app key.

## 3. The route verifies the RAW body

Never re-serialized JSON.

```ts
// app/api/vibiz/webhook/route.ts
import { vibiz } from '@vibiz/app';

export async function POST(req: Request) {
  let event;
  try {
    event = await vibiz.webhooks.receive(req); // raw body + signature; needs only VIBIZ_APP_KEY
  } catch {
    return new Response('bad signature', { status: 400 });
  }
  switch (event.type) {
    case 'payment.succeeded': /* event.data.object: Payment { id, amount_cents, user, metadata, … } */ break;
    case 'subscription.created': case 'subscription.updated': case 'subscription.canceled': break;
    case 'invoice.paid': case 'invoice.payment_failed': break;
  }
  return new Response('ok');
}
```

Already read the body (Express with `express.raw`, a framework that hands you a string)? `vibiz.webhooks.verify(rawBody, header)` is the synchronous check with `VIBIZ_WEBHOOK_SECRET`. Header: `Vibiz-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with the secret>`; 5 minute tolerance; several `v1` may be present. Without `@vibiz/app`:

```ts
import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(raw: string, header: string | null, secret = process.env.VIBIZ_WEBHOOK_SECRET!) {
  const parts = (header ?? '').split(',').map((p) => p.trim().split('='));
  const t = Number(parts.find(([k]) => k === 't')?.[1]);
  const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) throw new Error('stale or missing signature');
  const want = Buffer.from(createHmac('sha256', secret).update(`${t}.${raw}`).digest('hex'));
  if (!sigs.some((v) => v.length === want.length && timingSafeEqual(Buffer.from(v), want)))
    throw new Error('bad signature');
  return JSON.parse(raw);
}
```

## Events

Event: `{ id: "evt_vz_…", type, created, environment, data: { object } }`. Types: `payment.succeeded`, `payment.refunded`, `payment.failed`, `subscription.created`, `subscription.updated`, `subscription.canceled`, `invoice.paid`, `invoice.payment_failed`, `dispute.created`, `dispute.closed`, `withdrawal.paid`, `withdrawal.failed`.

Answer 2xx fast. Non-2xx or a 10 s timeout is retried with backoff (1 m, 5 m, 30 m, 2 h, 6 h, then every 12 h) for 3 days, so the same event can arrive twice: **dedupe on `event.id`**.

## Test it

`vibiz_payments simulate payment` (development) sends a real `payment.succeeded`; `vibiz_payments deliveries` shows recent attempts and their status codes.
