- Docs
- API reference
API reference
Basics
- Base URL:
https://velpay.pro/api/v1 - Auth:
Authorization: Bearer vp_sk_live_…(orvp_sk_test_…). The key decides live or test mode. - Bodies are JSON. Field names are
snake_case, times are Unix seconds, amounts are integers in minor units. A field the request doesn't take is refused withparameter_unknownrather than ignored. - Ids carry a prefix:
ord_order,chg_charge,rfd_refund,evt_event,po_payout,we_webhook endpoint. - Publishable keys (
vp_pk_…) can only read a single order's state and amounts (no payer data or metadata); everything else needs a secret key. Keys limited to one store can't read/balanceor/payouts. - Every response has a
Request-Idheader. Include it when you contact support.
Orders
POST/orders
| Field | Type | Notes |
|---|---|---|
amount | integer | Minor units. Required unless you send line_items; then it defaults to their sum and must match it if you send both. |
currency | string | Required. ISO 4217: usd, eur, gbp, cad, aud, chf, sek, nok, dkk, pln, czk, huf, ron, jpy, nzd, sgd, hkd, mxn, brl, rub, cny, try, zar, aed, ils, inr, thb. Minor units have 2 decimals (cents), except jpy with 0: Ft 36,000 is 3600000, ¥1,500 is 1500. |
line_items | array | Up to 50 of { "name": "Mug", "quantity": 2, "unit_amount": 1450, "sku": "mug-01" }: name max 120, quantity 1–10,000, unit_amount in minor units, sku optional (max 64, not shown to the payer). The checkout lists them. Shipping and tax are items too; take discounts off the other unit amounts. |
reference | string | Your order number. Unique per mode. |
description | string | Shown to the payer. Max 200. |
payer | object | email, name, ref, country. |
methods | array | The methods to offer, in the order the checkout lists them; the first one the payer can use is preselected. Codes: crypto, card, cash_app, pix, sbp, alipay, wechat_pay, stars (Telegram Stars), gift_card. Methods your store can't use are skipped; if none is left the order is refused with no_method_enabled. Default: every method enabled for the store. |
crypto | object | { "network": "tron", "asset": "USDT" } fixes the coin: the checkout skips the coin picker and opens the address straight away. See the list below. |
success_url / cancel_url | string | Where the payer goes afterwards. May contain {order_id}, {reference}, {status}. |
notify_url | string | Extra webhook URL just for this order. |
expires_in | integer | Seconds, 900–86400. Default 3600. |
locale | string | Checkout language, e.g. de, pt-BR, zh-TW. Supported: en, de, es, fr, it, pt, nl, pl, ar, he, tr, ru, ja, zh (Simplified), zh-TW (Traditional; also zh-Hant, zh-HK), ko, hi, id, ms, vi, th, uk, ro, cs, sv, el. Other values fall back to the payer's browser language. |
metadata | object | Up to 20 string keys, returned as-is. |
Returns 201 with the order object.
{
"object": "order",
"id": "ord_7Hq2Lx9vTn3Kp0aWm1Qz8c",
"livemode": false,
"state": "awaiting_payment",
"amount": 12900,
"amount_paid": 0,
"amount_refunded": 0,
"currency": "eur",
"reference": "A-2041",
"description": "Pro plan, yearly",
"line_items": [
{ "name": "Pro plan, 12 months", "quantity": 1, "unit_amount": 10900, "sku": "pro-12m" },
{ "name": "Extra seat", "quantity": 2, "unit_amount": 1000, "sku": null }
],
"shortfall": null,
"methods": ["card", "crypto"],
"crypto": null,
"payer": { "email": "buyer@example.com", "name": null, "ref": null, "country": null, "ip_country": null },
"pay_link": "https://velpay.pro/c/ord_7Hq2Lx9vTn3Kp0aWm1Qz8c",
"charges": [],
"metadata": {},
"created": 1793400000,
"expires": 1793403600,
"paid": null
}state is one of awaiting_payment, processing, paid, underpaid, expired, voided, refunded, partially_refunded.
payer.ip_country is the country of the IP the payer opened the checkout from (two letters, null until then). line_items, methods and crypto echo what you sent ([] or null when not set; methods leaves out codes that aren't live yet).
shortfall is null unless the payer sent less than amount:
"shortfall": {
"amount": 1250, // what is missing, minor units
"top_up_until": 1793574650, // the payer can still send the rest until then (null after)
"resolution": "open", // "open" | "completed" | "refunded"
"resolved_by": null // "merchant" | "store_policy" | null
}Networks and coins for crypto
| network | asset |
|---|---|
tron (Tron) | USDTUSDCTRX |
ethereum (Ethereum) | USDTUSDCDAIETH |
bsc (BNB Smart Chain) | USDTUSDCBNB |
solana (Solana) | USDCUSDTSOL |
polygon (Polygon) | USDCUSDTDAIPOL |
base (Base) | USDCETH |
arbitrum (Arbitrum One) | USDCUSDTDAIETH |
optimism (Optimism) | USDCUSDTDAIETH |
bitcoin (Bitcoin) | BTC |
litecoin (Litecoin) | LTC |
dogecoin (Dogecoin) | DOGE |
GET/orders/:id
Returns one order including its charges and refunds.
The charge object
Each payment attempt is a charge. Crypto charges carry the on-chain details under crypto (it is null for other methods). Coin amounts are decimal strings in whole coins.
{
"object": "charge",
"id": "chg_…",
"method": "crypto",
"state": "pending",
"amount": 12900,
"currency": "eur",
"decline_code": null,
"failure_code": null,
"created": 1793400050,
"settled": null,
"crypto": {
"network": "tron",
"asset": "USDT",
"address": "TQ5…",
"amount_expected": "139.84",
"amount_received": "100",
"amount_remaining": "39.84",
"tx_hashes": ["9f2c…"],
"explorer_urls": ["https://tronscan.org/#/transaction/9f2c…"],
"confirmations": 20,
"expires": 1793401850,
"top_up_until": 1793574650
}
}GET/orders
Newest first. Query: limit (1–100, default 20), starting_after, ending_before, state, reference, payer_email, payer_ref, created[gte] and created[lte] (Unix seconds). Response: { "object": "list", "data": [...], "has_more": true }.
To find an order by your own order number, filter by reference.
POST/orders/:id/complete
Closes an underpaid order as paid at what arrived: the order becomes paid, amount_paid stays what the payer sent, shortfall.resolution becomes completed and order.paid fires. Refused while a payment is still confirming (charge_pending). Completing an order twice returns it unchanged. To give the money back instead, refund it.
POST/orders/:id/void
Stops an unpaid order from being paid. Voiding an already voided order returns it unchanged.
The payer's side
Send the payer to pay_link. They pick crypto, Cash App or card there; you get order.processing once money is seen and order.paid when it is final. Afterwards they return to success_url (paid) or cancel_url (canceled or expired). Placeholders in those URLs are filled in: {order_id}, {reference} and {status} (paid, canceled, expired), e.g. https://shop.example/return/{order_id}?s={status}. A URL without placeholders gets velpay_order and velpay_status appended instead. Treat the webhook, not the redirect, as proof of payment. A crypto payer who sends slightly less (within the store's tolerance, 0.5% unless you change it) still completes the order. After a larger shortfall the payer can send the rest until shortfall.top_up_until; if they don't, the order ends as underpaid and you complete or refund it. A store set to complete short orders skips that step: the order becomes paid at what arrived, with shortfall.resolved_by store_policy. Each store's tolerance, top-up window and choice are under Settings ▸ Stores in the dashboard.
Test mode (vp_sk_test_…) never moves money: the checkout shows buttons to simulate a payment, a half payment or the order expiring, and every event fires as in live mode.
Crypto payers see a QR code with the address and amount filled in for their wallet (BIP21 for Bitcoin and Litecoin, EIP-681 on EVM networks, Solana Pay on Solana; Tron wallets get the plain address) and an “Open in wallet” link.
Embedded checkout
Instead of sending the payer away you can show the checkout on your page. List your site's origin in the store's allowed embed origins (exact https://shop.example.com or one-level wildcard https://*.example.com); the order must belong to that store. Then:
<script src="https://velpay.pro/embed.js"></script>
<div id="pay"></div>
<script>
const pay = velpay.embed("ord_…") // from POST /orders on your server
.on("loaded", (e) => {}) // { order, state }
.on("paid", (e) => {}) // { order, state: "paid" }
.on("expired", (e) => {}) // { order, state: "expired" }
.on("closed", (e) => {}) // { order, reason: "dismissed" | "canceled" | "voided" | "unmounted" }
.mount("#pay"); // or .open() for a modal
// pay.unmount();
</script>The iframe sizes itself to its content. Return URLs open in the whole window, not the iframe. The checkout talks only to an allowed origin, over a private MessageChannel it receives from your page; embed.js documents the handshake at its top and the embedded checkout guide covers it in full. As with redirects, fulfil on the order.paid webhook, not on the paid event.
Refunds
POST/orders/:id/refunds
Body: amount (optional, defaults to the rest), reason, destination (the payer's wallet address), network (optional: tron, ethereum, bsc, solana, polygon, base, arbitrum, bitcoin, litecoin). Crypto payments are refunded on the network the payer used; Cash App and card payments in USDT on Tron unless you pass another network. The amount leaves your balance at once, is reviewed, then sent on-chain and emits refund.sent with tx_hash. A refund we can't send emits refund.failed and is credited back.
GET/orders/:id/refundsGET/refunds/:id
state is pending, sent, failed or canceled.
Events, balance, payouts
GET/eventsGET/events/:id
Every event we sent or would have sent, newest first, same pagination as orders. Filters: type, order. Use it to catch up after downtime.
POST/events/:id/resend
Delivers the event again to every endpoint subscribed to it now (and the order's notify_url), signed afresh with the same event id. Events up to 30 days old. Returns the event with "resent": true.
Webhook endpoints
GET /webhook_endpoints (paginated), POST /webhook_endpoints, GET /webhook_endpoints/:id, DELETE /webhook_endpoints/:id. An endpoint belongs to the key's mode. Body for POST: url (required, public https), events (event types; empty or absent = all), description, store (a store id; keys limited to one store always create endpoints for their store and only see those). At most 20 per workspace.
{
"object": "webhook_endpoint",
"id": "we_…",
"url": "https://shop.example/velpay/webhook",
"livemode": false,
"events": ["order.paid", "order.expired"],
"description": null,
"store": null,
"enabled": true,
"public_key": "q3W…=",
"created": 1793400000
}public_key is your workspace's Ed25519 key that verifies Velpay-Signature (see below). DELETE returns { "object": "webhook_endpoint", "id": "we_…", "deleted": true }.
GET/balance
USD cents: available (can be paid out), pending (settling or held in reserve), reserved, in_payout.
GET/payouts
Your withdrawals (live mode). Payouts are requested from the dashboard with two-factor confirmation, never over the API.
Errors
Non-2xx responses have this shape:
{
"error": {
"type": "invalid_request",
"code": "parameter_invalid",
"param": "amount",
"message": "amount must be a positive integer",
"request_id": "req_Qm3…"
}
}| type | code | Meaning |
|---|---|---|
authentication_error | missing_key | No Authorization header. |
authentication_error | invalid_key | The key is unknown or revoked. |
permission_error | account_inactive | Live mode isn't open for this workspace yet. |
permission_error | key_scope | A publishable key was used for a server-only call. |
invalid_request | parameter_invalid | A field failed validation. See param. |
invalid_request | parameter_missing | A required field is missing. See param. |
invalid_request | parameter_unknown | The body has a field this request doesn't take (often a typo). See param. |
invalid_request | amount_mismatch | amount differs from the sum of line_items. Send one or make them agree. |
invalid_request | currency_unsupported | We don't accept this currency. |
invalid_request | amount_too_small | Below the minimum for every enabled method. |
invalid_request | reference_taken | Another order in this mode already uses this reference. |
invalid_request | notify_url_invalid | notify_url must be a public https URL. |
invalid_request | crypto_unsupported | crypto.network / crypto.asset isn't a network and coin we offer (or, in live mode, not enabled). |
invalid_request | url_invalid | A webhook endpoint url must be a public https URL. |
invalid_request | endpoint_limit | The workspace already has 20 webhook endpoints. |
invalid_request | amount_invalid | Refund amount is zero or more than what is left. |
invalid_request | destination_invalid | The refund address isn't valid on that network. |
order_state | idempotency_mismatch | Same Idempotency-Key, different request body. |
order_state | idempotency_in_progress | The first request with this key is still running. |
order_state | insufficient_balance | Your balance doesn't cover the refund. |
not_found | resource_missing | No such object in this workspace and mode. |
order_state | order_not_payable | Only unpaid orders can be voided. |
order_state | order_not_refundable | Only paid orders can be refunded. |
order_state | order_not_underpaid | Only underpaid orders can be completed. |
order_state | charge_pending | A payment on this order is still confirming. Try again once it settles. |
method_unavailable | no_method_enabled | None of the requested methods is enabled. |
order_state | event_too_old | Only events from the last 30 days can be resent. |
method_unavailable | payer_blocked | Payments from this payer's email can't be accepted. |
rate_limited | too_many_requests | Slow down. Wait Retry-After seconds. |
api_error | internal | Something broke on our side. Retry with the same Idempotency-Key. |
HTTP status: 400 invalid request, 401 authentication, 403 permission, 404 not found, 409 order state / idempotency, 422 method unavailable, 429 rate limited, 5xx ours.
Idempotency
Send Idempotency-Key on any POST. For 48 hours the same key returns the first response (with Idempotent-Replay: 1). The same key with a different body returns 409 idempotency_mismatch.
Rate limits
100 requests per second per key, with bursts. Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset; a 429 also carries Retry-After (seconds).
Webhooks
Each event is POSTed as JSON:
{
"object": "event",
"id": "evt_9aZ…",
"type": "order.paid",
"livemode": true,
"created": 1793400123,
"data": { "object": { "object": "order", "id": "ord_…", "state": "paid", … } }
}Event types: order.created, order.processing, order.paid, order.underpaid, order.expired, order.voided, charge.failed, refund.created, refund.sent, refund.failed, dispute.opened, dispute.closed, payout.paid. Each delivery also carries Velpay-Id (the event id) and Velpay-Event (its type) headers; deduplicate on the event id. New event types may be added, so ignore types you don't handle.
charge.failed
Sent once when a payment attempt (a charge) ends without paying: it was declined, its method could not be started, a reported payment could not be verified, or its invoice or session timed out. The order usually stays open, so the payer can try again with another method. It is not sent for charges you or we cancel, nor for a crypto charge whose invoice timed out: its address is still watched for 7 days and a late payment is credited, so you get order.expired and later possibly order.paid or order.underpaid. When the whole order times out you get order.expired; a charge still open at that moment may not get its own charge.failed. A failed charge is not always final: money that still arrives is credited and you get order.paid or order.underpaid as usual. Besides the order, data carries the failed charge and its failure_code:
{
"object": "event",
"id": "evt_…",
"type": "charge.failed",
"livemode": true,
"created": 1793400999,
"data": {
"object": { "object": "order", "id": "ord_…", "state": "awaiting_payment", … },
"charge": { "object": "charge", "id": "chg_…", "method": "card", "state": "failed", "failure_code": "method_unavailable", … },
"failure_code": "method_unavailable"
}
}| failure_code | Meaning |
|---|---|
expired | The attempt's invoice or session timed out before anything was paid. |
declined | The payment method refused the payment. |
method_unavailable | The method could not be started right now. Another method, or a retry later, may work. |
payment_unverified | A payment was reported but could not be verified. We review it; if it checks out it is credited and you get order.paid. |
processing_error | Anything else. Treat codes you don't know the same way: new ones may be added. |
Every charge object also has failure_code (one of the codes above on failed and expired charges, null otherwise).
Verifying the signature
Velpay-Signature is ed25519= followed by a base64 Ed25519 signature. It covers four lines joined by \n: the scheme vp.whk.1, Velpay-Timestamp (Unix seconds), Velpay-Id and the raw body. Your workspace's public key is under Developers → Webhooks and in every webhook endpoint object (public_key). Reject deliveries signed more than five minutes ago.
import { createPublicKey, verify } from "node:crypto";
const PUBLIC_KEY = createPublicKey({
key: Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), Buffer.from(process.env.VELPAY_PUBLIC_KEY, "base64")]),
format: "der",
type: "spki",
});
export function verifyVelpay(rawBody, headers) {
const ts = headers["velpay-timestamp"] ?? "";
const id = headers["velpay-id"] ?? "";
const sig = headers["velpay-signature"] ?? "";
if (!/^\d+$/.test(ts) || !id || !sig.startsWith("ed25519=")) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const message = `vp.whk.1\n${ts}\n${id}\n${rawBody}`;
return verify(null, Buffer.from(message), PUBLIC_KEY, Buffer.from(sig.slice(8), "base64"));
}Retries
Any 2xx within 15 seconds counts as delivered. Otherwise we retry 12 times, with gaps growing from about 2 minutes to about 22 hours, for close to three days in total. Velpay-Attempt counts the tries and Velpay-Delivery identifies the delivery. Endpoints that keep failing are switched off and you get an email. Deliveries can be replayed from the dashboard or with POST /events/:id/resend.