Skip to content
Documentation menu

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

Typedata.objectWhen it is sent
checkout.completedcheckoutThe customer paid. The checkout now has payment_status paid and a payment id. Sent together with payment.succeeded.
checkout.expiredcheckoutThe checkout timed out or was expired through the API before being paid.
payment.succeededpaymentMoney arrived. This is the event to credit your customer from.
payment.refundedpaymentA refund succeeded. refunded_usd and status on the payment reflect the new totals (partially_refunded or refunded).
refund.succeededrefundA specific refund settled. Sent just before payment.refunded.
refund.rejectedrefundVitix staff rejected a refund request. No money moved; do not revoke credits.
refund.failedrefundThe payment provider could not process the refund. The payment is unchanged.
pingpingSent 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.

POST body
{  "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:

HeaderValue
webhook-idThe event id (evt_…). Identical on every retry and redelivery of the same event: use it to de-duplicate.
webhook-timestampUnix seconds when this attempt was sent.
webhook-signatureOne 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.

Example delivery
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

ts
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 h and 12 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

  1. Verify first, parse second. Read the raw body as text; frameworks that auto-parse JSON must be bypassed for the webhook route.
  2. Be idempotent. Key your ledger on payment.id (or the event id) with a unique constraint; an INSERT … ON CONFLICT DO NOTHING turns duplicates into no-ops.
  3. Acknowledge fast. Persist the event, return 200, then do emails, syncs and analytics asynchronously.
  4. 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.
  5. Rotate safely. Call rotate_secret, deploy the new secret, and keep accepting the old one for a few minutes if you run multiple instances.
  6. Monitor failures. Alert when GET /v1/events/:id/deliveries shows repeated failures or when you see no payment.succeeded for longer than usual.

Questions or a missing feature? Email [email protected]