Webhooks
Vexutopia notifies your server in real time when a payment completes or fails. Pass a webhook_url when creating a payment and we will send a POST request to it once the payment status changes.
How it works
- 1Pass
webhook_urlwhen creating a payment - 2Customer completes (or fails) payment through the checkout page
- 3Vexutopia sends a POST request to your
webhook_urlwith the payment result - 4Your server returns HTTP 200 to acknowledge receipt
Webhook Payload
A POST request is sent to your webhook_url with a JSON body:
Look up orders by id, payment_id, or order_id — all three are the same value.
When customers switch payment methods on the hosted checkout (e.g. abandon card → pay with WeChat), we create internal child transactions to track each rail. All three identifier fields on the webhook payload — id, payment_id, and order_id — are identical and always point at the root session ID, the value originally returned by POST /v1/payments. Use any of them for order lookups; they will never differ.
Historical note: Before 2026-05-09, the top-level id on mixed-checkout payloads was set to the internal child rail ID (a value the merchant had never seen), which silently broke fulfilment for handlers that looked up orders by payload.id. That was fixed; all three fields are now the same. Either of the snippets below will work for every rail going forward.
✓ Either field works
const order = await db.orders.findOne({
payment_id: payload.id // === payment_id === order_id
});
order.markPaid(payload.amount);✓ Defensive (compatible with old payloads)
const order = await db.orders.findOne({
payment_id: payload.order_id
?? payload.payment_id
});
order.markPaid(payload.amount);{
"event": "payment.completed",
"id": "tx_b84050910b493a1e",
"payment_id": "tx_b84050910b493a1e",
"order_id": "tx_b84050910b493a1e",
"status": "completed",
"amount": "20",
"currency": "USD",
"payment_method": "gift_card",
"processor": "gift_card",
"timestamp": "2026-04-06T20:17:05.000Z",
"livemode": true,
"metadata": { "order_id": "1234" }
}{
"event": "payment.completed",
"id": "tx_75f4cfc818d73ff2", // your root session
"payment_id": "tx_75f4cfc818d73ff2", // identical
"order_id": "tx_75f4cfc818d73ff2", // identical
"status": "completed",
"amount": "5",
"currency": "USD",
"payment_method": "wxpay",
"processor": "china_pay",
"timestamp": "2026-05-08T06:22:45.358Z",
"livemode": true,
"settled_via": {
"rail": "WXPAY",
"amount": "34.08",
"currency": "CNY"
},
"metadata": { "credits": "30" }
}{
"event": "payment.failed",
"id": "tx_b84050910b493a1e",
"payment_id": "tx_b84050910b493a1e",
"order_id": "tx_b84050910b493a1e",
"status": "failed",
"amount": "20",
"currency": "USD",
"payment_method": "pix",
"processor": "regional",
"timestamp": "2026-04-06T20:17:05.000Z",
"failure_code": "provider_unavailable",
"failure_reason": "Temporary processor issue — the payment did not go through. Safe for the customer to retry.",
"metadata": { "order_id": "1234" }
}{
"event": "payment.failed",
"id": "tx_cbc0a40768eca4ce", // your root session — same on every event
"payment_id": "tx_cbc0a40768eca4ce", // identical aliases
"order_id": "tx_cbc0a40768eca4ce",
"status": "failed",
"amount": "24.99",
"currency": "USD",
"payment_method": "qris",
"processor": "regional",
"timestamp": "2026-05-08T17:32:14.122Z",
"livemode": true,
"failure_code": "provider_unavailable",
"failure_reason": "Temporary processor issue — the payment did not go through. Safe for the customer to retry.",
"settled_via": {
"rail": "QRIS",
"amount": "350000",
"currency": "IDR"
},
"metadata": { "purchase_id": "01aa43ab-393a-4ef5-9b7e-845fc78d7f78", "user_id": "..." }
}The mixed-checkout correlation applies to both payment.completed and payment.failed. Whether the customer's payment succeeds or fails on a switched rail, the webhook payload always carries your root session ID (in payment_id and order_id) and your original metadata. You write your handler once and it works for every status and every rail.
| Parameter | Type | Description |
|---|---|---|
event | string | Event type: payment.completed, payment.failed, payment.partial (crypto and gift cards: the customer paid less than the invoice, see Partial payments), or payment.refunded. payment.refunded fires on ALL rails (card, crypto, regional, Alipay/WeChat, Telegram Stars) whenever a completed payment is reversed — both cardholder chargebacks/disputes and voluntary refunds. Use the type field to tell them apart. |
type | string | Only on payment.refunded: "chargeback" (an involuntary reversal — cardholder dispute, Alipay/WeChat refund, or Telegram Stars refund) or "refund" (a voluntary refund, e.g. crypto). Branch on this to decide whether to reverse the customer's credit and flag the dispute. |
id | string | Your root session ID — identical to payment_id and order_id. Use any of the three for order lookups. |
payment_id | string | Your root session ID (the value returned by POST /v1/payments). Identical alias of id and order_id. |
order_id | string | Your root session ID. Identical alias of id and payment_id. |
status | string | Final status: completed, failed, or refunded |
amount | string | The payment amount in the original (root-session) currency. If you accepted an underpaid payment, this is the amount actually received. |
amount_requested | string | Only on payment.completed for a short payment: the original order amount. amount is what the customer paid and what your balance was credited for, before fees. Gift card orders of at least $2 set this on their own when the 24-hour window ends. Deliver for amount, not for amount_requested. See Gift Card → What to deliver. |
amount_received | string | On payment.partial, and on payment.expired for a gift card order under $2 that was part-paid: what the customer has paid so far, in the same currency as amount. Not credited yet, so do not fulfil on it. A gift card order of $2 or more completes on its own when its window ends. |
currency | string | The original currency code (the one you passed to POST /v1/payments) |
payment_method | string | Method the customer ultimately paid with. Common values: card (crypto on-ramp — you settle in USDT), pix, c2c, sbp, qris, gcash, maya, telegram_stars, crypto, alipay, wxpay, gift_card, cashapp. See /docs/payments for the full table. |
processor | string | Brand-neutral rail code (crypto_onramp, china_pay, regional, etc.) |
settled_via | object | Present only on mixed-checkout payments. Contains the actual rail / amount / currency the customer settled in (may differ from the top-level amount/currency for FX-converted rails like WXPAY in CNY). |
livemode | boolean | true if the payment was made with a live API key, false for test-mode payments. Use this to distinguish sandbox traffic on a shared endpoint. |
timestamp | string | ISO 8601 timestamp the event was emitted |
failure_code | string | Stable enum (only on payment.failed). Switch on this for programmatic handling — see the table below. |
failure_reason | string | Human-readable explanation of the failure, in Vexutopia's own wording (only on payment.failed). Use for human display; not a stable interface. |
metadata | object | Your original metadata from POST /v1/payments |
Failure codes
failure_code is the documented programmatic interface for failed payments — switch on it in your fraud rules and dashboards instead of the human-readable failure_reason text. Failures that match no more specific code are reported as provider_error; new codes may be added in the future, so handle that case in your switch.
| Parameter | Type | Description |
|---|---|---|
card_declined | code | Issuing bank refused the card (do-not-honour, blocked card, generic decline). |
insufficient_funds | code | Bank or wallet explicitly reported insufficient balance. |
card_unsupported | code | Card BIN blocked, country block, or card type unsupported on the crypto on-ramp. |
authentication_failed | code | 3D Secure challenge failed or was abandoned. |
invoice_expired | code | Checkout session aged out without payment. |
underpayment | code | Customer paid less than expected (or zero) on a crypto on-ramp. |
currency_unsupported | code | Rail-specific currency mismatch (e.g. a local method that only accepts its home currency). |
cancelled_by_customer | code | Customer hit cancel or navigated away during checkout. |
provider_unavailable | code | Payment processing temporarily unavailable (timeouts, outages). Safe for the customer to retry. |
provider_error | code | Catch-all when the failure did not match a more specific code. |
Verifying signatures
Every webhook we send is signed with HMAC-SHA256 so you can verify it came from Vexutopia and hasn't been tampered with. The signature is sent in the X-Vexutopia-Signature header.
Header format
The header value contains a timestamp and a hex-encoded signature, comma-separated:
X-Vexutopia-Signature: t=1715164620,v1=4a1b9c8d…t— Unix timestamp (seconds) when the signature was generated.v1— Hex-encoded HMAC-SHA256 of`{timestamp}.{rawRequestBody}`.
Where the signing secret comes from
Register a webhook endpoint via POST /v1/webhooks. The response returns a secret once, at creation time — store it in your secret manager. We never expose it again. That secret is used to sign every delivery to that endpoint.
curl -X POST https://vexutopia.com/api/v1/webhooks \
-H "X-API-Key: vex_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yoursite.com/webhooks/vexutopia",
"events": ["payment.completed", "payment.failed"],
"mode": "live"
}'
# Response (only time you'll see "secret"):
# {
# "id": "we_…",
# "url": "https://yoursite.com/webhooks/vexutopia",
# "secret": "whsec_…",
# "events": ["payment.completed", "payment.failed"],
# "mode": "live",
# "is_enabled": true,
# "created_at": "2026-05-08T11:00:00.000Z"
# }Endpoint mode (test / live / both)
Endpoints carry a mode that controls which traffic they receive. Defaults to "live".
- •
"live"— receives only payments made with live API keys. - •
"test"— receives only payments made with test API keys (e.g.vex_test_…). - •
"both"— receives both. Use the payload'slivemodefield to distinguish them in your handler.
How to verify
- Read the
X-Vexutopia-Signatureheader and split on the comma to gettandv1. - Compute
HMAC_SHA256(secret, `{t}.{rawRequestBody}`)and hex-encode it. - Compare against
v1using a constant-time equality check. - Reject the request if
|now - t| > 5 minutesto mitigate replay attacks.
Use the raw request body
The signature is computed over the exact bytes we sent. If your framework parses the JSON and re-serializes it before you hash, you'll get a mismatch. In Express, mount express.raw({ type: 'application/json' }) on the webhook route.
import express from 'express';
import crypto from 'crypto';
const app = express();
// Stripe-style: capture the RAW body for signature verification.
app.post(
'/webhooks/vexutopia',
express.raw({ type: 'application/json' }),
(req, res) => {
const header = req.header('X-Vexutopia-Signature') || '';
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.trim().split('='))
);
const t = parts.t;
const v1 = parts.v1;
if (!t || !v1) return res.status(400).send('missing signature');
// Replay window: reject anything older than 5 minutes.
const ageSec = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
if (!Number.isFinite(ageSec) || ageSec > 300) {
return res.status(400).send('stale signature');
}
// The raw body is a Buffer when express.raw() is used.
const rawBody = req.body.toString('utf8');
const expected = crypto
.createHmac('sha256', process.env.VEX_WEBHOOK_SECRET)
.update(`${t}.${rawBody}`)
.digest('hex');
const ok =
expected.length === v1.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
if (!ok) return res.status(401).send('bad signature');
// Verified — now it's safe to JSON.parse and process.
const payload = JSON.parse(rawBody);
res.status(200).send('OK');
// ...handle payload.event...
}
);
app.listen(3000);import hmac, hashlib, time
def verify_vex_signature(secret: str, header: str, raw_body: bytes) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1:
return False
if abs(int(time.time()) - int(t)) > 300:
return False
signing_string = f"{t}.{raw_body.decode('utf-8')}".encode()
expected = hmac.new(secret.encode(), signing_string, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)Receiving Webhooks
import express from 'express';
const app = express();
app.use(express.json());
app.post('/webhooks/vexutopia', (req, res) => {
// Respond immediately before processing
res.status(200).send('OK');
const { event, id, metadata, failure_code } = req.body;
if (event === 'payment.completed') {
console.log('Payment completed:', id);
const orderId = metadata?.order_id;
// Fulfill the order, send confirmation email, etc.
}
if (event === 'payment.failed') {
console.log('Payment failed:', id, failure_code);
// Switch on the stable code, not the human-readable failure_reason text.
switch (failure_code) {
case 'card_declined':
case 'insufficient_funds':
// Prompt the customer to try a different card
break;
case 'invoice_expired':
// Email the customer a fresh checkout link
break;
default:
// provider_error / unknown — log and notify the customer
}
}
});
app.listen(3000);Setting your webhook URL
Pass webhook_url at payment creation time:
curl -X POST https://vexutopia.com/api/v1/payments \
-H "X-API-Key: vex_test_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"amount": "20",
"currency": "USD",
"return_url": "https://yoursite.com/success",
"webhook_url": "https://yoursite.com/webhooks/vexutopia",
"metadata": { "order_id": "1234" }
}'Testing webhooks
You can test your webhook handler end to end with a vex_test_… API key — no real card, wallet, or funds are involved. A payment created with a test key opens a test checkout that fires a real webhook to your webhook_url when you simulate an outcome, so the payload your server receives is exactly the shape it will get in production.
- 1Create a payment with a
vex_test_…key. The returnedcheckout_urlpoints at/pay/test, not the live checkout. - 2Open the test checkout in a browser and click Simulate Success or Simulate Failure.
- 3Your
webhook_urlreceives a signedpayment.completedorpayment.failedevent, just as it would in production.
{
"event": "payment.completed",
"id": "tx_…", // the id returned by POST /v1/payments
"status": "completed",
"amount": "20",
"currency": "USD",
"test": true, // simulate endpoint marks the event as test
"livemode": false, // false for test keys, true for live keys
"metadata": { "order_id": "1234" }
}The simulated payment.failed event carries failure_reason: "Simulated failure" in place of a real failure explanation.
Routing test traffic to a dedicated endpoint
Register the endpoint with mode: "test" and it will receive only test-key traffic — your production endpoint keeps its mode: "live" and never sees simulated payments. Use mode: "both" on a single endpoint and branch on the payload's livemode field instead.
Re-firing a real webhook
For a completed live payment, you can re-send the payment.completed event at any time with POST /v1/payments/:id/resend-webhook (requires a live key). This is useful for exercising your handler against a genuine production payload after you have fixed a bug. It re-fires the event to every registered endpoint and to the payment's webhook_url, so make your handler idempotent. See the Payments API for the full details.
Inspecting deliveries during development
Your webhook URL must be a publicly reachable HTTPS address (we refuse localhost and private ranges). To test against your local server, tunnel it with a tool like ngrok, or point your webhook_url at a temporary webhook.site URL to watch the raw signed payload arrive.
Managing endpoints
Registered endpoints are stable URLs that receive every event matching their events filter, for the lifetime of the endpoint. Use the routes below to create, list, and delete them.
| Parameter | Type | Description |
|---|---|---|
POST /v1/webhooks | create | Register a new endpoint. The signing secret is returned ONCE in the response. Requires the webhooks:write scope. |
GET /v1/webhooks | list | List all endpoints registered for your organization. |
GET /v1/webhooks/{id} | retrieve | Retrieve a single endpoint by id. Returns 404 if the id does not belong to your organization. |
DELETE /v1/webhooks/{id} | delete | Delete an endpoint. Returns HTTP 204 with an empty body on success. After deletion, no further events are sent to that URL. |
Create an endpoint
See Verifying signatures for the full create flow — the response includes the secret field, returned only on creation.
List endpoints
curl https://vexutopia.com/api/v1/webhooks \
-H "X-API-Key: vex_live_your_api_key"
# 200 OK
# {
# "data": [
# {
# "id": "we_…",
# "url": "https://yoursite.com/webhooks/vexutopia",
# "events": ["payment.completed", "payment.failed"],
# "is_enabled": true,
# "description": null,
# "created_at": "2026-05-08T11:00:00.000Z"
# }
# ]
# }Retrieve an endpoint
curl https://vexutopia.com/api/v1/webhooks/we_abc123 \
-H "X-API-Key: vex_live_your_api_key"
# 200 OK
# {
# "id": "we_abc123",
# "url": "https://yoursite.com/webhooks/vexutopia",
# "events": ["payment.completed", "payment.failed"],
# "is_enabled": true,
# "description": null,
# "created_at": "2026-05-08T11:00:00.000Z"
# }
#
# 404 Not Found
# { "error": "Webhook endpoint not found" }Delete an endpoint
curl -X DELETE https://vexutopia.com/api/v1/webhooks/we_abc123 \
-H "X-API-Key: vex_live_your_api_key"
# 204 No Content (success — empty body)
# 404 Not Found (endpoint does not exist or belongs to another org)Deletion is immediate and irreversible — any in-flight retries for that endpoint stop and the signing secret is destroyed. To rotate a secret, delete the endpoint and create a new one.
Best Practices
Return 200 immediately
Respond with 200 before doing any processing. Long-running handlers may cause timeouts.
Handle duplicates
Use the payment id to deduplicate — the same event may be delivered more than once.
Use HTTPS
Your webhook endpoint must be a publicly accessible HTTPS URL.
Testing locally
Use a tool like ngrok to expose your local server for testing, or poll GET /v1/payments/:id to check status instead.