Hosted checkout
Max or Business
Create a one-time checkout URL, redirect the payer, and fulfill from the webhook.
Production contracts for hosted checkout, embedded checkout, headless card and crypto payments, and signed merchant webhooks.
Non-custodial by design.
Crypto settles directly to merchant-controlled wallets. Card providers may perform payer-side identity checks before converting fiat to crypto.
01 / Start here
Create all merchant-owned resources from your server. Never expose a Vulta API key in browser, mobile, widget, or bot client code.
Hosted checkout
Max or Business
Create a one-time checkout URL, redirect the payer, and fulfill from the webhook.
Widget or iframe
Max or Business
Create a reusable payment link, then open it inside your site with the official widget or embed route.
Headless API
Business
Build your own UI and receive either a crypto address or card-provider URL.
Payment links, payout destinations, payment requests, hosted sessions, and headless checkout accept either a user JWT or a Business API key. Send API keys as a bearer token or through X-API-Key.
Authorization: Bearer vlt_live_...
# Also accepted on merchant resource endpoints
X-API-Key: vlt_live_...JWT-only account settings
/api/apikeys, /api/settings/webhook, webhook dead letters, and replay require an authenticated user JWT. API keys cannot create/revoke other keys or change webhook settings.
curl https://vulta.one/api/apikeys \
-X POST \
-H "Authorization: Bearer YOUR_USER_JWT" \
-H "Content-Type: application/json" \
-d '{ "label": "production store" }'
{
"key": "vlt_live_...",
"api_key": {
"id": "uuid",
"key_prefix": "vlt_live_a1b2c3",
"label": "production store",
"created_at": "..."
}
}The raw key is returned once. Store it in a server secret manager. Never send it to Vulta support or commit it to source control.
02 / Hosted checkout
Recommended SaaS and e-commerce flow. Your backend creates the session; the payer opens the returned Vulta checkout; your server fulfills from the signed webhook.
curl https://vulta.one/api/checkout/sessions \
-H "Authorization: Bearer vlt_live_..." \
-H "Content-Type: application/json" \
-d '{
"amount_fiat": "29.00",
"fiat_currency": "USD",
"external_reference_id": "order_123",
"use_all_payout_destinations": true,
"payer_email": "customer@example.com"
}'amount_fiatyesPositive decimal string. Do not send a JSON number.fiat_currencyyesUppercase currency code such as USD or EUR.external_reference_idrecommendedYour order or subscription ID. Returned unchanged in the webhook.use_all_payout_destinationsone ofSet true, or provide payout_destination_ids instead. Do not provide both.payer_emailoptionalStored with the request for merchant workflow.expires_atoptionalFuture RFC 3339 timestamp. Omit for the server default.{
"id": "018f4f3e-2e8b-7a21-9f0c-0b6f56a7d3b1",
"checkout_url": "https://vulta.one/pay/018f4f3e-2e8b-7a21-9f0c-0b6f56a7d3b1",
"payment_request": {
"id": "018f4f3e-2e8b-7a21-9f0c-0b6f56a7d3b1",
"pricing_mode": "fixed_fiat",
"amount_fiat": "29.00",
"fiat_currency": "USD",
"status": "OPEN",
"expires_at": "..."
}
}// Browser code calls your backend, never Vulta with a secret key.
const response = await fetch('/api/store/orders/123/vulta-checkout', {
method: 'POST'
});
if (!response.ok) throw new Error('Checkout creation failed');
const { checkout_url } = await response.json();
window.location.assign(checkout_url);Browser redirects are not proof of payment
Do not fulfill from the return page, widget success message, polling alone, or card-provider UI. Commit fulfillment only after a valid signed payment.confirmed webhook.
03 / Widget & iframe
Embeds use a reusable payment link ID—not an API key and not a payment-request ID. Create the payment link first, then use one of these two supported integrations.
<script src="https://vulta.one/widget.js"></script>
<button type="button" data-vulta-link="PAYMENT_LINK_ID">
Pay now
</button>The widget finds clickable elements carrying data-vulta-link and opens a secure modal. Putting that attribute on the script tag alone does nothing.
<script src="https://vulta.one/widget.js"></script>
<button type="button" onclick="openVultaCheckout()">Pay now</button>
<script>
function openVultaCheckout() {
Vulta.open('PAYMENT_LINK_ID', {
onSuccess: function (event) {
// UI signal only. Fulfill the order from the signed server webhook.
console.log(event.paymentRequestId);
}
});
}
</script>onSuccess is a user-interface signal. Always verify and fulfill through the server webhook.
<iframe
src="https://pay.vulta.one/embed/link/PAYMENT_LINK_ID?parentOrigin=https%3A%2F%2Fshop.example"
title="Secure checkout"
width="420"
height="680"
style="border:0;border-radius:24px"
sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-same-origin"
allow="payment"
></iframe>Use pay.vulta.one/embed/link/…. The hosted /pay/… route deliberately blocks framing.
Restrict embed origins
Add exact HTTPS origins through allowed_embed_origins on the payment link, for example https://shop.example. Pass the same origin in the URL-encoded parentOrigin query parameter.
04 / Headless checkout
Business merchants can create an already-selected crypto transaction or a card-to-crypto transaction without showing Vulta checkout UI.
curl https://vulta.one/api/checkout/crypto \
-H "Authorization: Bearer vlt_live_..." \
-H "Content-Type: application/json" \
-d '{
"network": "TRON",
"asset": "USDT",
"amount_crypto": "150.00",
"external_reference_id": "order_456"
}'{
"payment_request_id": "uuid",
"payment_request_option_id": "uuid",
"address": "TJRyWwFs9wTFGZg3JbrVriFbNfCug5tDeC",
"network": "TRON",
"asset": "USDT",
"amount_fiat": null,
"fiat_currency": null,
"amount_crypto": "150.00",
"encoded_amount": null,
"amount_to_send": "150.00"
}Send either amount_crypto, or amount_fiat with fiat_currency. Show the payer address and amount_to_send. Wallet pools may add a small encoded suffix for deterministic matching.
400
409
422
curl https://vulta.one/api/checkout/card \
-H "Authorization: Bearer vlt_live_..." \
-H "Content-Type: application/json" \
-d '{
"amount_fiat": "75.00",
"currency": "EUR",
"external_reference_id": "order_789"
}'{
"provider_url": "https://provider.example/...",
"payment_request_id": "uuid",
"provider": "moonpay"
}currency defaults to USD. provider is optional; omit it for routing. Provider availability and minimums change by currency and region. Query GET /api/checkout/providers?country=US¤cy=USD for the current catalog.
Card settlement rule
Card checkout requires an active Polygon USDC destination. Vulta confirms only after callback authentication, sufficient forwarded value, and a successful Polygon USDC transfer to that merchant destination. A provider completion screen alone does not confirm an order.
Provider URL
The returned URL may be a direct provider URL or an approved PayGate checkout intermediary. It contains no Vulta checkout UI. Card/on-ramp providers may require payer-side KYC or additional verification.
Invalid input returns 400. Setup or provider-routing failures—including a missing Polygon USDC destination—currently return 502 with an error message.
05 / Merchant resources
These endpoints accept JWT or API-key authentication. IDs always belong to the authenticated merchant workspace.
curl https://vulta.one/api/payout-destinations \
-H "Authorization: Bearer vlt_live_..." \
-H "Content-Type: application/json" \
-d '{
"network": "Polygon",
"asset": "USDC",
"address": "0xYourWallet",
"entry_method": "manual",
"label": "Store settlements"
}'Supported pairs: Bitcoin/BTC; Ethereum/ETH, USDT, USDC; Base/ETH, USDC; Arbitrum/ETH, USDT, USDC; Polygon/POL, USDT, USDC; BSC/BNB, USDT, USDC; Solana/SOL, USDT, USDC; TRON/TRX, USDT.
entry_method is manual or connected_wallet. DELETE deactivates the destination; it does not erase historical payment records.
curl https://vulta.one/api/payment-links \
-H "Authorization: Bearer vlt_live_..." \
-H "Content-Type: application/json" \
-d '{
"title": "Pro plan",
"pricing_mode": "fixed_fiat",
"fixed_amount_fiat": "49.00",
"fixed_amount_currency": "USD",
"is_hybrid": true,
"payout_destination_ids": ["POLYGON_USDC_DESTINATION_ID"],
"allowed_embed_origins": ["https://shop.example"]
}'Pricing modes are fixed_fiat and open_amount. Every link needs at least one eligible destination. Card-only or hybrid links must include an active Polygon USDC destination.
curl https://vulta.one/api/payment-requests \
-H "Authorization: Bearer vlt_live_..." \
-H "Content-Type: application/json" \
-d '{
"one_off": {
"pricing_mode": "fixed_fiat",
"amount_fiat": "250.00",
"fiat_currency": "USD",
"external_reference_id": "invoice_1042",
"use_all_payout_destinations": true
}
}'This Max endpoint returns the payment-request object only. It creates an OPEN request; it does not select a crypto option. Use hosted checkout, or fetch the public request and call POST /api/payment-requests/{id}/select-option.
06 / Payment lifecycle
Not every payment passes through every intermediate state. Terminal and recovery states are included so merchant systems can model the complete contract.
OPENCreated; no option selected.
OPTION_SELECTEDPayer selected a crypto destination.
PENDING_PAYMENTCard/on-ramp checkout initiated.
PARTIALLY_DETECTEDPayment observed but incomplete.
PAIDObserved; confirmation threshold not reached.
CONFIRMEDSafe fulfillment boundary.
BOUNCEDProvider flow failed; payer may retry.
EXPIREDRequest can no longer be paid.
CANCELLEDMerchant/system cancelled request.
These routes are public because they operate only on existing merchant-created IDs. They cannot create arbitrary merchant checkouts.
/api/payment-requests/from-link/{linkId}Create a payer request from a linkpublic/api/payment-requests/{id}Read request and optionspublic/api/payment-requests/{id}/select-optionLock the chosen optionpublic/api/payment-requests/{id}/statusRead current request, options, and selectionpublic/api/checkout/card/statusRead stored card settlement status by polling tokenpublicPassword-protected links
POST /api/payment-links/{id}/verify-password returns {"valid": true}. It does not issue a session token. The hosted flow sends the password when creating a request from the link; public request reads use the raw password in X-Link-Password.
07 / Webhooks
Business webhooks use a durable database outbox. Delivery is at least once, so duplicate delivery is expected and must be safe.
curl https://vulta.one/api/settings/webhook \
-X PUT \
-H "Authorization: Bearer YOUR_USER_JWT" \
-H "Content-Type: application/json" \
-d '{
"webhook_url": "https://shop.example/api/vulta/webhook",
"webhook_secret": "replace-with-a-long-random-secret"
}'The URL must be public HTTPS and must not resolve to loopback or private infrastructure. A non-empty signing secret is required whenever the URL is enabled. The secret is write-only.
{
"event_id": "9ef824be-50d4-4b75-97c0-f6bc3cc39e5f",
"event_type": "payment.confirmed",
"occurred_at": "2026-07-24T12:00:00Z",
"payment_request_id": "a1b2c3d4-e5f6-...",
"external_reference_id": "order_123",
"amount_fiat": "29.00",
"fiat_currency": "USD",
"tx_hash": "0xabc123...",
"status": "CONFIRMED",
"merchant_id": "c3d4e5f6-..."
}event_id is stable across retries. tx_hash, fiat fields, and external_reference_id may be absent when the underlying request does not contain them.
X-Vulta-Event-ID: <event_id>
X-Vulta-Timestamp: <unix-seconds>
X-Vulta-Signature-V2: <hex HMAC-SHA256(timestamp + "." + raw_body)>
X-Vulta-Signature: <legacy body-only hex HMAC>Use Signature V2. Reject timestamps more than five minutes from your server clock. The body must be the exact raw bytes received—not parsed and re-serialized JSON.
const crypto = require('crypto');
const express = require('express');
const app = express();
function validHexDigest(value) {
return typeof value === 'string' && /^[0-9a-f]{64}$/i.test(value);
}
function verifyVultaWebhook(rawBody, headers, secret) {
const timestamp = headers['x-vulta-timestamp'];
const signature = headers['x-vulta-signature-v2'];
const eventId = headers['x-vulta-event-id'];
const unixTime = Number(timestamp);
if (!eventId || !Number.isFinite(unixTime)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - unixTime) > 300) return false;
if (!validHexDigest(signature)) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(timestamp)
.update('.')
.update(rawBody)
.digest();
const provided = Buffer.from(signature, 'hex');
return provided.length === expected.length &&
crypto.timingSafeEqual(provided, expected);
}
app.post('/api/vulta/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verifyVultaWebhook(req.body, req.headers, process.env.VULTA_WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body.toString('utf8'));
// In one DB transaction:
// 1. INSERT event.event_id into a UNIQUE column (ignore duplicates).
// 2. Fulfill event.external_reference_id.
// 3. Commit.
return res.status(204).end();
});import hashlib
import hmac
import time
def verify_vulta_webhook(raw_body: bytes, headers, secret: str) -> bool:
timestamp = headers.get("X-Vulta-Timestamp", "")
signature = headers.get("X-Vulta-Signature-V2", "")
event_id = headers.get("X-Vulta-Event-ID", "")
try:
if not event_id or abs(int(time.time()) - int(timestamp)) > 300:
return False
provided = bytes.fromhex(signature)
except (TypeError, ValueError):
return False
expected = hmac.new(
secret.encode(),
timestamp.encode() + b"." + raw_body,
hashlib.sha256,
).digest()
return hmac.compare_digest(provided, expected)Delivery schedule
Vulta makes six total attempts: immediately, then after approximately 1, 5, 15, 30, and 60 minutes. Any HTTP 2xx acknowledges delivery. Redirects are not followed.
Idempotency
Store event_id under a database UNIQUE constraint in the same transaction as order fulfillment. Return 2xx only after that transaction commits.
/api/settings/webhook/dead-lettersList last 50 exhausted eventsJWT · Business/api/settings/webhook/dead-letters/{eventId}/replayReset an exhausted event for deliveryJWT · Business08 / Endpoint reference
Protected resource routes below accept JWT or API keys unless explicitly marked JWT-only.
/api/payout-destinationsList receiving wallets/api/payout-destinationsAdd receiving wallet/api/payout-destinations/{id}Update mutable fields/api/payout-destinations/{id}Deactivate wallet/api/payment-linksList reusable links/api/payment-linksCreate reusable link/api/payment-links/{id}Get merchant link/api/payment-links/{id}Update merchant link/api/payment-links/{id}Deactivate merchant link/api/payment-requestsList transactions/api/payment-requestsCreate one-off requestMax+/api/checkout/sessionsCreate hosted checkout URLMax+/api/checkout/cryptoCreate selected crypto checkoutBusiness/api/checkout/cardCreate card-provider checkoutBusiness/api/checkout/providersLive provider catalog and minimumspublic/api/checkout/countriesProvider-supported countriespublic/api/apikeysList API keysJWT · Business/api/apikeysCreate API keyJWT · Business/api/apikeys/{id}Revoke API keyJWT · Business/api/settings/webhookRead webhook settingsJWT · Business/api/settings/webhookUpdate webhook URL and secretJWT · Business400Malformed or invalid request.
401Missing, expired, or invalid credentials.
403Current plan does not include the feature.
404Resource or polling token not found.
409State conflict or exhausted wallet-pool capacity.
422No eligible crypto destination.
429Rate limit exceeded.
500 / 502 / 503Internal, provider, or temporary dependency failure.
Creation retries
Merchant creation endpoints do not currently provide general idempotency keys. Persist successful responses and avoid blind retries after a network timeout. external_reference_id is returned for correlation but is not a uniqueness key. The payer-only from-link route supports X-Idempotency-Key for 24 hours.
Current limits: global 120 requests/minute per process; authentication endpoints 10/minute per IP; payer from-link creation 10/minute per IP; password verification 5 attempts per 10 minutes per link.
Test one low-value card payment and one crypto payment before opening checkout to customers.
Verify the destination wallet receives funds, the event signature passes, duplicate delivery is harmless, and fulfillment commits before your endpoint returns 2xx.