vibizStack
Console
Agent guides

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.

Open as .md

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.

TypeScript
// 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:

TypeScript
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.