# Webhooks | Vitix Pay

> Receive Vitix Pay events on your server: event types, payload shape, Standard Webhooks signatures, retries, redelivery and complete handler examples.

Canonical: https://vitix.net/developers/webhooks
Markdown: https://vitix.net/developers/webhooks.md


Documentation menu

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](https://vitix.net/developers/api#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": "ana@example.com", "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](https://www.standardwebhooks.com/) 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.
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](https://vitix.net/developers/api#redeliver-event), or catch up by paging [GET /v1/events](https://vitix.net/developers/api#list-events).

- Every attempt is recorded: [GET /v1/events/:id/deliveries](https://vitix.net/developers/api#event-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.

[← Node SDK](https://vitix.net/developers/sdk)[Errors →](https://vitix.net/developers/errors)
Questions or a missing feature? Email [support@vitix.net](mailto:support@vitix.net)
