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