DocsAPI reference
  1. Docs
  2. API reference

API reference

De documentatie voor ontwikkelaars is beschikbaar in het Engels.

Basics

  • Base URL: https://velpay.pro/api/v1
  • Auth: Authorization: Bearer vp_sk_live_… (or vp_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 with parameter_unknown rather 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 /balance or /payouts.
  • Every response has a Request-Id header. Include it when you contact support.

Orders

POST/orders

FieldTypeNotes
amountintegerMinor units. Required unless you send line_items; then it defaults to their sum and must match it if you send both.
currencystringRequired. 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_itemsarrayUp 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.
referencestringYour order number. Unique per mode.
descriptionstringShown to the payer. Max 200.
payerobjectemail, name, ref, country.
methodsarrayThe 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.
cryptoobject{ "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_urlstringWhere the payer goes afterwards. May contain {order_id}, {reference}, {status}.
notify_urlstringExtra webhook URL just for this order.
expires_inintegerSeconds, 900–86400. Default 3600.
localestringCheckout 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.
metadataobjectUp to 20 string keys, returned as-is.

Returns 201 with the order object.

JSONThe 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:

JavaScript
"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

networkasset
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.

JSONThe charge object
{
  "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:

HTML
<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.

JSONWebhook endpoint object
{
  "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:

JSONError response
{
  "error": {
    "type": "invalid_request",
    "code": "parameter_invalid",
    "param": "amount",
    "message": "amount must be a positive integer",
    "request_id": "req_Qm3…"
  }
}
typecodeMeaning
authentication_errormissing_keyNo Authorization header.
authentication_errorinvalid_keyThe key is unknown or revoked.
permission_erroraccount_inactiveLive mode isn't open for this workspace yet.
permission_errorkey_scopeA publishable key was used for a server-only call.
invalid_requestparameter_invalidA field failed validation. See param.
invalid_requestparameter_missingA required field is missing. See param.
invalid_requestparameter_unknownThe body has a field this request doesn't take (often a typo). See param.
invalid_requestamount_mismatchamount differs from the sum of line_items. Send one or make them agree.
invalid_requestcurrency_unsupportedWe don't accept this currency.
invalid_requestamount_too_smallBelow the minimum for every enabled method.
invalid_requestreference_takenAnother order in this mode already uses this reference.
invalid_requestnotify_url_invalidnotify_url must be a public https URL.
invalid_requestcrypto_unsupportedcrypto.network / crypto.asset isn't a network and coin we offer (or, in live mode, not enabled).
invalid_requesturl_invalidA webhook endpoint url must be a public https URL.
invalid_requestendpoint_limitThe workspace already has 20 webhook endpoints.
invalid_requestamount_invalidRefund amount is zero or more than what is left.
invalid_requestdestination_invalidThe refund address isn't valid on that network.
order_stateidempotency_mismatchSame Idempotency-Key, different request body.
order_stateidempotency_in_progressThe first request with this key is still running.
order_stateinsufficient_balanceYour balance doesn't cover the refund.
not_foundresource_missingNo such object in this workspace and mode.
order_stateorder_not_payableOnly unpaid orders can be voided.
order_stateorder_not_refundableOnly paid orders can be refunded.
order_stateorder_not_underpaidOnly underpaid orders can be completed.
order_statecharge_pendingA payment on this order is still confirming. Try again once it settles.
method_unavailableno_method_enabledNone of the requested methods is enabled.
order_stateevent_too_oldOnly events from the last 30 days can be resent.
method_unavailablepayer_blockedPayments from this payer's email can't be accepted.
rate_limitedtoo_many_requestsSlow down. Wait Retry-After seconds.
api_errorinternalSomething 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:

JSONEvent
{
  "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:

JSONcharge.failed event
{
  "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_codeMeaning
expiredThe attempt's invoice or session timed out before anything was paid.
declinedThe payment method refused the payment.
method_unavailableThe method could not be started right now. Another method, or a retry later, may work.
payment_unverifiedA payment was reported but could not be verified. We review it; if it checks out it is credited and you get order.paid.
processing_errorAnything 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.

JavaScript
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.