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

  1. 1Pass webhook_url when creating a payment
  2. 2Customer completes (or fails) payment through the checkout page
  3. 3Vexutopia sends a POST request to your webhook_url with the payment result
  4. 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);
payment.completed (simple — single rail)
{
  "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" }
}
payment.completed (mixed checkout — customer switched rails)
{
  "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" }
}
payment.failed
{
  "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" }
}
payment.failed (mixed checkout — failed leg of a switched rail)
{
  "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.

Fields
ParameterTypeDescription
eventstringEvent 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.
typestringOnly 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.
idstringYour root session ID — identical to payment_id and order_id. Use any of the three for order lookups.
payment_idstringYour root session ID (the value returned by POST /v1/payments). Identical alias of id and order_id.
order_idstringYour root session ID. Identical alias of id and payment_id.
statusstringFinal status: completed, failed, or refunded
amountstringThe payment amount in the original (root-session) currency. If you accepted an underpaid payment, this is the amount actually received.
amount_requestedstringOnly 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_receivedstringOn 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.
currencystringThe original currency code (the one you passed to POST /v1/payments)
payment_methodstringMethod 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.
processorstringBrand-neutral rail code (crypto_onramp, china_pay, regional, etc.)
settled_viaobjectPresent 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).
livemodebooleantrue 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.
timestampstringISO 8601 timestamp the event was emitted
failure_codestringStable enum (only on payment.failed). Switch on this for programmatic handling — see the table below.
failure_reasonstringHuman-readable explanation of the failure, in Vexutopia's own wording (only on payment.failed). Use for human display; not a stable interface.
metadataobjectYour 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.

Codes
ParameterTypeDescription
card_declinedcodeIssuing bank refused the card (do-not-honour, blocked card, generic decline).
insufficient_fundscodeBank or wallet explicitly reported insufficient balance.
card_unsupportedcodeCard BIN blocked, country block, or card type unsupported on the crypto on-ramp.
authentication_failedcode3D Secure challenge failed or was abandoned.
invoice_expiredcodeCheckout session aged out without payment.
underpaymentcodeCustomer paid less than expected (or zero) on a crypto on-ramp.
currency_unsupportedcodeRail-specific currency mismatch (e.g. a local method that only accepts its home currency).
cancelled_by_customercodeCustomer hit cancel or navigated away during checkout.
provider_unavailablecodePayment processing temporarily unavailable (timeouts, outages). Safe for the customer to retry.
provider_errorcodeCatch-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:

text
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
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's livemode field to distinguish them in your handler.

How to verify

  1. Read the X-Vexutopia-Signature header and split on the comma to get t and v1.
  2. Compute HMAC_SHA256(secret, `{t}.{rawRequestBody}`) and hex-encode it.
  3. Compare against v1 using a constant-time equality check.
  4. Reject the request if |now - t| > 5 minutes to 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.

Node.js / Express — verification middleware
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);
Python — verification helper
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

Node.js / Express
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
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.

  1. 1Create a payment with a vex_test_… key. The returned checkout_url points at /pay/test, not the live checkout.
  2. 2Open the test checkout in a browser and click Simulate Success or Simulate Failure.
  3. 3Your webhook_url receives a signed payment.completed or payment.failed event, just as it would in production.
Test-mode payment.completed webhook
{
  "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.

Endpoints
ParameterTypeDescription
POST /v1/webhookscreateRegister a new endpoint. The signing secret is returned ONCE in the response. Requires the webhooks:write scope.
GET /v1/webhookslistList all endpoints registered for your organization.
GET /v1/webhooks/{id}retrieveRetrieve a single endpoint by id. Returns 404 if the id does not belong to your organization.
DELETE /v1/webhooks/{id}deleteDelete 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
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
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
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.