Webhooks
Webhooks tell your server what happened: a payment succeeded, a refund settled, a checkout expired. Vitix POSTs a signed JSON event to each endpoint you register and retries until you acknowledge it.
Event types
| Type | data.object | When it is sent |
|---|---|---|
| checkout.completed | checkout | The customer paid. The checkout now has payment_status paid and a payment id. Sent together with payment.succeeded. |
| checkout.expired | checkout | The checkout timed out or was expired through the API before being paid. |
| payment.succeeded | payment | Money arrived. This is the event to credit your customer from. |
| payment.refunded | payment | A refund succeeded. refunded_usd and status on the payment reflect the new totals (partially_refunded or refunded). |
| refund.succeeded | refund | A specific refund settled. Sent just before payment.refunded. |
| refund.rejected | refund | Vitix staff rejected a refund request. No money moved; do not revoke credits. |
| refund.failed | refund | The payment provider could not process the refund. The payment is unchanged. |
| ping | ping | Sent by POST /v1/webhook_endpoints/:id/ping so you can test connectivity and signatures. |
New event types may be added over time; ignore types you do not handle. Subscribe to specific types per endpoint with the events list, or to everything with ["*"].
Payload
The body is an event object: the resource is under data.object in the state it had when the event was created. Always use livemode to route test events away from production data.
{ "id": "evt_live_dBbRDc4vUYcwjyp93GkDKtyF", "object": "event", "livemode": true, "type": "payment.succeeded", "created_at": "2026-10-14T06:35:02.455Z", "data": { "object": { "id": "py_live_4YMjlAzqiKxSYAIsAWEToqvL", "object": "payment", "livemode": true, "status": "succeeded", "checkout": "cs_live_0aWdSNgZbvGU0BjvCISLh07o", "amount": 600000, "currency": "vnd", "amount_usd": 2308, "fee_usd": 132, "net_usd": 2176, "refunded_usd": 0, "description": "Acme credits", "customer": { "id": "user_42", "email": "[email protected]", "name": "Ana Nguyen" }, "reference": "topup_8f1c", "metadata": { "plan": "pro" }, "invoice_available": true, "created_at": "2026-10-14T06:35:02.410Z" } }}Verifying signatures
Every delivery is signed following the Standard Webhooks specification, so any compliant library can verify it. Three headers are sent:
| Header | Value |
|---|---|
| webhook-id | The event id (evt_…). Identical on every retry and redelivery of the same event: use it to de-duplicate. |
| webhook-timestamp | Unix seconds when this attempt was sent. |
| webhook-signature | One or more space-separated v1,<base64> entries. |
The signature is HMAC-SHA256 over the string {webhook-id}.{webhook-timestamp}.{raw body}, keyed by the base64-decoded bytes of your endpoint secret after the whsec_ prefix, then base64-encoded. Compare in constant time, accept any listed entry (there can be two while a secret rotates), and reject timestamps older than 5 minutes.
POST /api/vitix/webhook HTTP/1.1Host: app.example.comContent-Type: application/jsonUser-Agent: Vitix-Webhooks/1.0 (+https://vitix.net/developers/webhooks)webhook-id: evt_live_dBbRDc4vUYcwjyp93GkDKtyFwebhook-timestamp: 1791959702webhook-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE={"id":"evt_live_dBbRDc4vUYcwjyp93GkDKtyF","object":"event","livemode":true,"type":"payment.succeeded",…}With the SDK
Webhooks.constructEvent(rawBody, headers, secret) verifies and parses in one call and throws WebhookSignatureError on any mismatch. Webhooks.verify(...) returns a boolean if you prefer. Both run on Node 18+, Cloudflare Workers and the Edge runtime.
// app/api/vitix/webhook/route.tsimport { Webhooks, WebhookSignatureError, type Event } from "@vitix/pay";import { db } from "@/lib/db";export async function POST(req: Request) { // 1. Read the RAW body. Parsing it first would change the bytes and break the signature. const body = await req.text(); // 2. Verify the signature and parse the event. let event: Event; try { event = await Webhooks.constructEvent(body, req.headers, process.env.VITIX_WEBHOOK_SECRET!); } catch (err) { if (err instanceof WebhookSignatureError) return new Response("Invalid signature", { status: 400 }); throw err; } // 3. Handle it idempotently: the same event id may arrive more than once. switch (event.type) { case "payment.succeeded": { const payment = event.data.object; await db.ledger.upsert({ where: { externalId: payment.id }, // py_… is unique per payment create: { externalId: payment.id, userId: payment.customer!.id, usdCents: payment.amount_usd }, update: {}, // already credited → no-op }); break; } case "refund.succeeded": { const refund = event.data.object; const payment = await vitix.payments.retrieve(refund.payment); // Debit this refund only, not the cumulative payment.refunded_usd total. await db.ledger.debit({ userId: payment.customer!.id, usdCents: refund.amount_usd, key: refund.id }); break; } case "payment.refunded": case "refund.rejected": case "checkout.expired": case "refund.failed": case "ping": break; } // 4. Acknowledge quickly. Do slow work (emails, syncs) in a queue after responding. return new Response("ok");}Without the SDK
import { createHmac, timingSafeEqual } from "node:crypto";/** Standard Webhooks verification without the SDK. `headers` is a plain object or a Headers instance. */export function verifyVitixSignature(rawBody: string, headers: Record<string, string | undefined>, secret: string): boolean { const id = headers["webhook-id"]; const ts = headers["webhook-timestamp"]; const sig = headers["webhook-signature"]; if (!id || !ts || !sig) return false; // Reject stale or replayed deliveries (5-minute tolerance). if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // The HMAC key is the base64-decoded part after "whsec_". const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); const expected = createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest("base64"); // The header may list several "v1,<sig>" entries (e.g. during secret rotation); any match is valid. return sig.split(" ").some((part) => { const [version, value] = part.split(","); if (version !== "v1" || !value || value.length !== expected.length) return false; return timingSafeEqual(Buffer.from(value), Buffer.from(expected)); });}Delivery, retries and redelivery
- Respond with any 2xx status within 10 seconds. Redirects are not followed and count as failures.
- On a timeout, network error or non-2xx response, the delivery is retried after
1 min,5 min,30 min,2 hand12 h: six attempts over roughly 15 hours. - Deliveries to one endpoint are independent of other endpoints. Events are not strictly ordered; use timestamps and the resource state, not arrival order.
- After the last attempt the delivery is marked
failed. You can resend any event at any time with POST /v1/events/:id/redeliver, or catch up by paging GET /v1/events. - Every attempt is recorded: GET /v1/events/:id/deliveries shows the status code and the first 1,000 characters of your response, which makes debugging a 500 in your handler straightforward.
Best practices
- Verify first, parse second. Read the raw body as text; frameworks that auto-parse JSON must be bypassed for the webhook route.
- Be idempotent. Key your ledger on
payment.id(or the event id) with a unique constraint; anINSERT … ON CONFLICT DO NOTHINGturns duplicates into no-ops. - Acknowledge fast. Persist the event, return 200, then do emails, syncs and analytics asynchronously.
- Separate modes. Create one endpoint with a test key for staging and one with a live key for production; never point both at the same database.
- Rotate safely. Call
rotate_secret, deploy the new secret, and keep accepting the old one for a few minutes if you run multiple instances. - Monitor failures. Alert when
GET /v1/events/:id/deliveriesshows repeated failures or when you see nopayment.succeededfor longer than usual.