Accept and send money with HotVoucher
Take payments in crypto, from HotVoucher wallets and with voucher codes, give every customer a permanent deposit address, and pay out withdrawals - all from your own server, with one API token.
https://hotvoucher.com/api/v1Authorization: Bearer hvk_YOUR_TOKENNeed a machine-readable spec? openapi.json (OpenAPI 3.0.3) imports into Swagger Editor, Postman, Insomnia or a client generator. Every endpoint is also described at the end of this page.
Quick start in 4 steps
From nothing to a verified test webhook in about fifteen minutes.
- Create an API token
In the HotVoucher app open Voucher → API token for your site and fill in:
Field What to enter Name Your site or store. It may be shown on voucher lookups if you allow it. IP addresses The public IP addresses of your servers. Requests from any other address are refused. Sections Tick only what you use (see sections below). Payout and swap are off by default. Callback URL The https address on your server that receives webhooks. You get two secrets: the token
hvk_...for calling the API and the callback secrethvcs_...for checking webhooks. Keep both on your server; you can reveal or regenerate them in the app at any time. - Make your first call
From your server (never from a browser - the IP check would refuse it anyway):
curl https://hotvoucher.com/api/v1/me \ -H "Authorization: Bearer hvk_YOUR_TOKEN"
// Node.js 18+ (built-in fetch). One helper for every call in this guide. const HV = 'https://hotvoucher.com/api/v1'; const TOKEN = process.env.HV_TOKEN; // hvk_... - only on the server async function hv(path, body) { const r = await fetch(HV + path, { method: body ? 'POST' : 'GET', headers: { Authorization: 'Bearer ' + TOKEN, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, }); const j = await r.json(); if (!r.ok) throw Object.assign(new Error(j.error), { status: r.status, code: j.code }); return j; } const me = await hv('/me'); console.log(me.merchant.name, me.token.sections);
<?php // One helper for every call in this guide. function hv($path, $body = null) { $ch = curl_init('https://hotvoucher.com/api/v1' . $path); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 20, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('HV_TOKEN'), 'Content-Type: application/json'], ]); if ($body !== null) { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); } $raw = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $j = json_decode($raw, true); if ($status >= 400) throw new Exception(($j['code'] ?? 'ERROR') . ': ' . ($j['error'] ?? $raw), $status); return $j; } $me = hv('/me'); echo $me['merchant']['name'];{ "merchant": { "id": "HV-ABC123", "name": "Your Shop" }, "token": { "id": 12, "name": "yourshop.example", "sections": ["inquiry", "merchant", "address"], "ips": ["203.0.113.10"], "callbackUrl": "https://yourshop.example/hotvoucher-webhook", "status": "active" } } - Receive webhooks
Add one endpoint that checks the signature and answers 200. Every event of this guide arrives here.
const crypto = require('crypto'); const express = require('express'); const app = express(); const CALLBACK_SECRET = process.env.HV_CALLBACK_SECRET; // hvcs_... // keep the RAW body: the signature is computed over the exact bytes we sent app.post('/hotvoucher-webhook', express.raw({ type: '*/*' }), async (req, res) => { const sig = String(req.get('X-HotVoucher-Signature') || ''); const mac = crypto.createHmac('sha256', CALLBACK_SECRET).update(req.body).digest('hex'); if (sig.length !== mac.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(mac))) return res.sendStatus(401); const msg = JSON.parse(req.body.toString('utf8')); // { event, at, token, merchant, data } const delivery = req.get('X-HotVoucher-Delivery'); // the same on every retry of this message if (await alreadyHandled(delivery)) return res.sendStatus(200); await handleEvent(msg); // see the guides below await markHandled(delivery); res.sendStatus(200); });<?php $raw = file_get_contents('php://input'); $sig = $_SERVER['HTTP_X_HOTVOUCHER_SIGNATURE'] ?? ''; $mac = hash_hmac('sha256', $raw, getenv('HV_CALLBACK_SECRET')); // hvcs_... if (!hash_equals($mac, $sig)) { http_response_code(401); exit; } $msg = json_decode($raw, true); // event, at, token, merchant, data $delivery = $_SERVER['HTTP_X_HOTVOUCHER_DELIVERY'] ?? ''; // the same on every retry of this message if (!already_handled($delivery)) { handle_event($msg); mark_handled($delivery); } http_response_code(200);Then ask for a test message and watch it arrive:
curl -X POST https://hotvoucher.com/api/v1/webhooks/test \ -H "Authorization: Bearer hvk_YOUR_TOKEN" -H "Content-Type: application/json" \ -d '{"note":"hello"}'await hv('/webhooks/test', { note: 'hello' }); // event "test" arrives at your callback URL<?php hv('/webhooks/test', ['note' => 'hello']); // event "test" arrives at your callback URL - Pick your integration
Read which integration fits you, then follow its guide. Both can run side by side with the same token.
Sections (token permissions)
A call outside the token's sections answers 403 SECTION_NOT_ALLOWED with the missing section in the answer.
| Section | Lets the token use |
|---|---|
| inquiry | /me /balance /transactions /statement /coins /prices /webhooks |
| code_inquiry | /check /vouchers /vouchers/inquiry - look up voucher codes (HotVoucher vouchers and uVouchers) |
| code_redeem | /redeem - take a customer's voucher as payment |
| code_issue | /voucher/create /voucher/cancel - make vouchers from your balance |
| merchant | /invoices /payments /fees - the payment gateway |
| address | /addresses /customers/address /customers/uusd-voucher /customers/uusd-verify |
| payout | /payouts and its quote, limits and emergency stop - off by default |
| swap | /swap /swaps - convert between your own coins - off by default |
Which integration fits you?
There are two ways to accept money. You can use both at the same time with the same token.
| All methods: HotVoucher Pay | Method by method | |
|---|---|---|
| What your customer sees | The HotVoucher payment page with Crypto, Wallet and Voucher tabs (screenshots) | Your own pages, in your own design |
| Payment methods | Every active coin and network, the HotVoucher wallet, HotVoucher vouchers and UUSD uVouchers | The ones you build: crypto deposit addresses, voucher codes, uVouchers |
| Your work | One API call, a redirect and a webhook handler | A small flow per method (an address box, a code box ...) |
| Main endpoints | POST /invoices GET /invoices/{id} | POST /customers/address POST /redeem POST /customers/uusd-voucher |
| Webhooks | payment.received invoice.paid invoice.expired | payment.detected payment.received uvoucher.failed |
| Best for | Shops and orders with a price; the fastest start | Sites where customers keep a balance (games, exchanges, top-ups) |
Not sure? Start with HotVoucher Pay. Every customer keeps one permanent deposit address per network, tied to the customer id you send - so if you later build your own deposit page, your customers keep paying to the same addresses.
Security checklist
- The token
hvk_...and the callback secrethvcs_...live only on your server - never in a browser, an app, a URL or a request body (a token in the URL or body is refused and reported to us). - Whitelist only your servers' public IP addresses, and tick only the sections you really use.
- Check
X-HotVoucher-Signatureon every webhook before you trust it; answer 2xx quickly and process eachX-HotVoucher-Deliveryonly once. - Confirm a payment on your server (webhook or
GET /invoices/{id}) - never because a browser came back to your return page. - Send your own id as
idempotencyKeyfor every payout and swap, so a retry can never move money twice. - Suspect a leaked token? Call POST /payouts/lock (emergency stop: every payout waits for HotVoucher), then regenerate the token in the app.
HotVoucher Pay: one page, every method
Your server creates an invoice and sends the buyer to the HotVoucher payment page. The buyer pays with crypto on any active network, from a HotVoucher wallet, or with a voucher code. You hear about it by webhook. No payment UI to build.
How it works
Swipe sideways to see the whole picture.
What the buyer sees
Real screenshots of the payment page (sample data - the address and its QR code are not real). The page works on phones and computers, in English and Persian.





Build it step by step
An example of the button on your own checkout page:
Order #1042 - $25.00
Pay with HotVoucherAn example of your page - the design is yours.
- Create an invoice on your server
When the buyer clicks Pay, your server creates the invoice.
customeris your own user id (it decides the buyer's permanent deposit addresses). Leaveamountout for an open top-up where any amount is welcome.curl -X POST https://hotvoucher.com/api/v1/invoices \ -H "Authorization: Bearer hvk_YOUR_TOKEN" -H "Content-Type: application/json" \ -d '{"customer":"user-1042","amount":25,"currency":"USD","orderId":"order-1042", "returnUrl":"https://yourshop.example/orders/1042","lang":"en"}'// your checkout route - the browser asks YOUR server, never HotVoucher directly app.post('/checkout/:orderId', async (req, res) => { const order = await db.orders.find(req.params.orderId); const inv = await hv('/invoices', { customer: String(order.userId), // your user id amount: order.total, currency: 'USD', // or leave amount out for an open top-up orderId: order.id, returnUrl: 'https://yourshop.example/orders/' + order.id, lang: 'en', // "en" or "fa" }); await db.orders.update(order.id, { invoiceId: inv.id }); res.redirect(inv.payUrl); // step 2: a full-page redirect });
<?php $inv = hv('/invoices', [ 'customer' => (string)$order['user_id'], // your user id 'amount' => $order['total'], 'currency' => 'USD', // or leave amount out for an open top-up 'orderId' => $order['id'], 'returnUrl' => 'https://yourshop.example/orders/' . $order['id'], 'lang' => 'en', ]); save_invoice_id($order['id'], $inv['id']); header('Location: ' . $inv['payUrl']); // step 2: a full-page redirect exit;{ "id": "...", "status": "pending", "customer": "user-1042", "orderId": "order-1042", "amount": 25, "currency": "USD", "amountUsd": 25, "paidUsd": 0, "remainingUsd": 25, "expiresAt": 1767225600000, "payUrl": "https://hotvoucher.com/pay.html?i=..." } - Send the buyer to
payUrlUse a normal full-page redirect or link. The payment page is not meant to be put in a frame or opened by a script.
- Handle the webhooks
In the webhook endpoint from the quick start, react to the payment events.
payment.receivedcomes for every payment that lands in your account;invoice.paidwhen the invoice amount is covered.async function handleEvent(msg) { const d = msg.data; switch (msg.event) { case 'payment.detected': // crypto seen on the network, not confirmed yet await db.orders.setStatus(d.orderId, 'processing'); break; case 'payment.received': // money credited to YOUR HotVoucher account if (await db.payments.exists(d.ref)) break; // handled already await db.payments.insert({ ref: d.ref, order: d.orderId, customer: d.customer, usd: d.amountUsd, coin: d.credited.coin, amount: d.credited.amount }); break; case 'invoice.paid': // the invoice amount is fully paid await db.orders.markPaid(d.orderId); break; case 'invoice.expired': // nothing (or not enough) arrived in time await db.orders.setStatus(d.orderId, d.paidUsd > 0 ? 'partly paid' : 'expired'); break; } }<?php function handle_event($msg) { $d = $msg['data']; switch ($msg['event']) { case 'payment.detected': // crypto seen on the network, not confirmed yet set_order_status($d['orderId'], 'processing'); break; case 'payment.received': // money credited to YOUR HotVoucher account if (payment_exists($d['ref'])) break; // handled already save_payment($d['ref'], $d['orderId'], $d['customer'], $d['amountUsd'], $d['credited']); break; case 'invoice.paid': // the invoice amount is fully paid mark_order_paid($d['orderId']); break; case 'invoice.expired': // nothing (or not enough) arrived in time set_order_status($d['orderId'], $d['paidUsd'] > 0 ? 'partly paid' : 'expired'); break; } } - Show the result when the buyer comes back
On your return page, read the invoice from your server - it also lists crypto that is still confirming (
detected).curl https://hotvoucher.com/api/v1/invoices/INVOICE_ID \ -H "Authorization: Bearer hvk_YOUR_TOKEN"
const inv = await hv('/invoices/' + encodeURIComponent(order.invoiceId)); // inv.status: "pending" | "paid" | "expired"; inv.paidUsd, inv.remainingUsd, // inv.payments[] (credited), inv.detected[] (seen, still confirming)<?php $inv = hv('/invoices/' . rawurlencode($order['invoice_id'])); // $inv['status']: pending | paid | expired; paidUsd, remainingUsd, payments[], detected[]
Good to know
| Topic | How it works |
|---|---|
| Any amount is yours | Full, partial, more, or after the invoice expired - every payment is credited and reported by payment.received. If less arrives, the buyer can send the rest to the same address until the invoice expires. |
| Open top-up | No amount = the buyer sends any amount. Ideal for "add funds" pages. |
| Currency | USD or IRR for the amount. Whatever the buyer pays is credited in the same coin, unless you choose one automatic conversion coin in the app. |
| Expiry | expiresInMin from 5 to 10080 minutes. |
| Language | lang: "en" or "fa". |
| Fees | The gateway fee is a percentage of every payment; crypto payments also carry the network fee of moving the coins to our hot wallet. GET /fees lists both; every payment.received shows feeUsd and networkFeeUsd. |
| Crypto confirmations | A crypto payment is credited only after the coin's own explorer confirms it. payment.detected comes when it is first seen; payment.received a few seconds to a few minutes later. |
| No website? | Create Payment links in the app (More → Payment link) - a fixed or open amount, shared as a link or QR code. No code at all. |
A ready-to-run sample (checkout page, Node.js and PHP server, webhook) is at /pay-sample.html.
Build each method into your own pages
Show payment options in your own design and talk to HotVoucher from your server. Pick only the methods you need - each one works on its own.
| Method | Your customer | Your server calls | You learn about the money by |
|---|---|---|---|
| Crypto deposit address | sends coins to their permanent address | POST /customers/address | payment.detected + payment.received |
| HotVoucher voucher | types a USD- code | POST /check + POST /redeem | the answer of /redeem (instant) |
| UUSD uVoucher | types a Utopia uVoucher code | POST /customers/uusd-voucher | payment.received (or uvoucher.failed) |
| HotVoucher wallet | pays from their HotVoucher account | through HotVoucher Pay only | payment.received |
Crypto deposits to a permanent address
Every customer gets one permanent address per network, free of charge. Show it on your deposit page; whatever arrives on it is credited to your HotVoucher account and reported to you, so you can top up that customer's balance.
Add funds
Coin and network
Your deposit address
permanently yours
An example of your own deposit page (sample data).
payment.received.- Show only the coins and networks that are open now
GET /coins lists every coin and network with its status, minimum and network fee. Hide disabled ones; the
coin.statuswebhook tells you when that changes.curl https://hotvoucher.com/api/v1/coins -H "Authorization: Bearer hvk_YOUR_TOKEN"
const { coins } = await hv('/coins'); const open = coins.filter((c) => c.status === 'active') .flatMap((c) => c.networks.filter((n) => n.status === 'active').map((n) => ({ coin: c.coin, ...n }))); // [{ coin: 'USDT', network: 'TRC20', min: 1, decimals: 6, networkFeeUsd: ... }, ...]<?php $open = []; foreach (hv('/coins')['coins'] as $c) { if ($c['status'] !== 'active') continue; foreach ($c['networks'] as $n) if ($n['status'] === 'active') $open[] = ['coin' => $c['coin']] + $n; } - Get the customer's address
The first call creates it, later calls return the same one. Show the
memotoo when there is one, and the minimum.curl -X POST https://hotvoucher.com/api/v1/customers/address \ -H "Authorization: Bearer hvk_YOUR_TOKEN" -H "Content-Type: application/json" \ -d '{"customer":"user-1042","coin":"USDT","network":"TRC20"}'app.get('/deposit/address', requireLogin, async (req, res) => { const a = await hv('/customers/address', { customer: String(req.user.id), coin: req.query.coin, network: req.query.network }); res.json({ address: a.address, memo: a.memo, min: a.minDeposit }); // draw the QR code from address });<?php $a = hv('/customers/address', ['customer' => (string)$user['id'], 'coin' => 'USDT', 'network' => 'TRC20']); echo json_encode(['address' => $a['address'], 'memo' => $a['memo'], 'min' => $a['minDeposit']]);{ "customer": "user-1042", "coin": "USDT", "network": "TRC20", "address": "T...", "memo": null, "minDeposit": 1, "networkFeeUsd": 0.35, "others": [ ... ] } - Show "processing" while the page is open (optional)
Poll GET /payments/pending with
watch=1every 15-30 seconds while the customer looks at the page - it also makes us check their addresses more often for the next 3 minutes.curl "https://hotvoucher.com/api/v1/payments/pending?customer=user-1042&watch=1" \ -H "Authorization: Bearer hvk_YOUR_TOKEN"
const { pending } = await hv('/payments/pending?watch=1&customer=' + encodeURIComponent(user.id)); // [{ coin, network, amount, confirmations, required, txId, explorer, seenAt }]<?php $pending = hv('/payments/pending?watch=1&customer=' . rawurlencode($user['id']))['pending']; - Credit the balance on
payment.receivedThe same webhook as for invoices.
customertells you whose balance to top up;creditedis what landed in your account after fees.case 'payment.received': { const d = msg.data; // { ref, customer, coin, network, amount, amountUsd, feeUsd, networkFeeUsd, credited, txHash, ... } if (await db.deposits.exists(d.ref)) break; // handled already await db.deposits.insert({ ref: d.ref, user: d.customer, coin: d.coin, amount: d.amount, txHash: d.txHash }); await balances.add(d.customer, d.coin, d.amount); // your own rule: gross amount, credited amount, or its USD value break; }<?php case 'payment.received': if (deposit_exists($d['ref'])) break; // handled already save_deposit($d['ref'], $d['customer'], $d['coin'], $d['amount'], $d['txHash']); add_balance($d['customer'], $d['coin'], $d['amount']); // your own rule break;
Only coins on the shown network reach the address. A smaller amount than minDeposit is credited only when the total on that address reaches the minimum. Deposits that still arrive on a network that was switched off are credited as usual.
HotVoucher voucher codes
Your customer types a HotVoucher voucher code (USD-XXXX-XXXX-XXXX-XXXX-XXXX; older codes start with HTV-). You redeem it into your account and credit the customer at once.
Pay with a voucher
An example of your own page (sample data).
/check finds HotVoucher vouchers; anything else is tried as a uVoucher.- Look the code up (optional but friendly)
POST /check shows the value before anything moves, so you can ask the customer to confirm.
curl -X POST https://hotvoucher.com/api/v1/check \ -H "Authorization: Bearer hvk_YOUR_TOKEN" -H "Content-Type: application/json" \ -d '{"code":"USD-XXXX-XXXX-XXXX-XXXX-XXXX"}' # {"found":true,"status":"active","amount":25,"coin":"HTV","ref":"VC-8F3K2M9Q","locked":false}const c = await hv('/check', { code }); if (!c.found || c.status !== 'active') throw new Error('This code cannot be used');<?php $c = hv('/check', ['code' => $code]); if (!$c['found'] || $c['status'] !== 'active') throw new Exception('This code cannot be used'); - Redeem it and credit the customer
Send your own
reference(an order or user id) - it is stored with the transaction.curl -X POST https://hotvoucher.com/api/v1/redeem \ -H "Authorization: Bearer hvk_YOUR_TOKEN" -H "Content-Type: application/json" \ -d '{"code":"USD-XXXX-XXXX-XXXX-XXXX-XXXX","reference":"user-1042"}'app.post('/redeem', requireLogin, async (req, res) => { try { const r = await hv('/redeem', { code: req.body.code, reference: 'user-' + req.user.id }); await balances.add(req.user.id, 'HTV', r.amount); // r = { ok, amount, ref, txId, txHash, balance, cashback } res.json({ ok: true, amount: r.amount }); } catch (e) { const msg = { NOT_FOUND: 'Unknown code', ALREADY_USED: 'This code was already used', VOID: 'This code was cancelled', EXPIRED: 'This code has expired', LOCKED: 'This code belongs to another account' }[e.code]; res.status(400).json({ error: msg || 'Please try again later' }); } });<?php try { $r = hv('/redeem', ['code' => $_POST['code'], 'reference' => 'user-' . $user['id']]); add_balance($user['id'], 'HTV', $r['amount']); // $r: ok, amount, ref, txId, txHash, balance, cashback } catch (Exception $e) { // NOT_FOUND 404 - ALREADY_USED 409 - VOID / EXPIRED 410 - LOCKED 403 - RATE_LIMIT 429 show_error('This code cannot be used'); }
Cashback: if your token has a cashback percentage, that share of a redeemed voucher goes from your balance back to the customer who created the voucher. Official HotVoucher vouchers carry no cashback.
UUSD uVouchers (Utopia network)
A uVoucher is a code of the Utopia network, also in the form USD-XXXX-XXXX-XXXX-XXXX-XXXX. It is credited to your customer like a deposit, in UUSD.
- Send the code for the customer
curl -X POST https://hotvoucher.com/api/v1/customers/uusd-voucher \ -H "Authorization: Bearer hvk_YOUR_TOKEN" -H "Content-Type: application/json" \ -d '{"customer":"user-1042","code":"USD-XXXX-XXXX-XXXX-XXXX-XXXX"}'const r = await hv('/customers/uusd-voucher', { customer: String(user.id), code }); if (r.pending) showMessage('Your voucher is being activated - it will appear in a few minutes.'); else showMessage(r.amount + ' UUSD received');<?php $r = hv('/customers/uusd-voucher', ['customer' => (string)$user['id'], 'code' => $code]); echo !empty($r['pending']) ? 'Activating - it will appear in a few minutes.' : $r['amount'] . ' UUSD received'; - Credit on the webhook
A valid code always arrives as
payment.received(coinUUSD, networkUtopia) - credit it there exactly like a crypto deposit. If the network does not activate it,uvoucher.failedfollows within about ten minutes.
| Answer | Meaning |
|---|---|
| 400 VOUCHER_USED | The code already went through HotVoucher. |
| 409 VOUCHER_BUSY | The same customer's first try is still being checked. |
| 400 HOTVOUCHER_VOUCHER | It is a HotVoucher voucher - use POST /redeem instead. |
HotVoucher wallet
Payments from a HotVoucher account happen on the HotVoucher payment page (the Wallet tab): the customer signs in and confirms with a code sent by e-mail, and the payment is instant. To offer it, create an invoice and send the customer to its payUrl - see HotVoucher Pay. The money is reported by payment.received like every other payment.
Voucher inquiry: HotVoucher vouchers and uVouchers
One call tells you what a code is worth and whether it can still be used, for both kinds of code. They look the same (USD-XXXX-XXXX-XXXX-XXXX-XXXX): say which it is with kind, or send the code alone and HotVoucher looks among its own vouchers first. A uVoucher always needs its code AND the hash of the transaction that created it. Nothing is redeemed and nothing moves.
- Send the code
# a HotVoucher voucher: the code curl -X POST https://hotvoucher.com/api/v1/vouchers/inquiry \ -H "Authorization: Bearer hvk_YOUR_TOKEN" -H "Content-Type: application/json" \ -d '{"kind":"hotvoucher","code":"USD-XXXX-XXXX-XXXX-XXXX-XXXX"}' # a uVoucher: the code AND the hash of the transaction that created it curl -X POST https://hotvoucher.com/api/v1/vouchers/inquiry \ -H "Authorization: Bearer hvk_YOUR_TOKEN" -H "Content-Type: application/json" \ -d '{"kind":"uvoucher","code":"USD-XXXX-XXXX-XXXX-XXXX-XXXX","hash":"THE_TRANSACTION_HASH"}'
let r = await hv('/vouchers/inquiry', isUVoucher ? { kind: 'uvoucher', code, hash } : { kind: 'hotvoucher', code }); while (r.inquiryId && (r.status === 'queued' || r.status === 'checking')) { await new Promise((ok) => setTimeout(ok, r.retryAfter * 1000)); // the queue answers a little later r = await hv('/vouchers/inquiry/' + r.inquiryId); } if (r.found) showMessage(r.amount + ' ' + r.coin + ' - ' + (r.status === 'active' ? 'can be used' : r.status));<?php $r = hv('/vouchers/inquiry', $isUVoucher ? ['kind' => 'uvoucher', 'code' => $code, 'hash' => $hash] : ['kind' => 'hotvoucher', 'code' => $code]); if (!empty($r['inquiryId']) && in_array($r['status'], ['queued', 'checking'])) { sleep($r['retryAfter']); // or ask again later from a background job $r = hv('/vouchers/inquiry/' . $r['inquiryId']); } echo !empty($r['found']) ? $r['amount'] . ' ' . $r['coin'] . ' - ' . $r['status'] : 'Not found'; - A uVoucher: the code AND the hash
A uVoucher can only be checked with the hash of the Utopia transaction that created it (whoever made the voucher sees it in their wallet history) - ask your customer for both. With
kind: "uvoucher"and no hash the answer is400 HASH_REQUIRED; a code sent withoutkindthat is not a HotVoucher voucher answersfound: false, needHash: true.
What the answer says
| Field | Meaning |
|---|---|
kind | hotvoucher or uvoucher |
checkedBy | hotvoucher = a HotVoucher voucher, answered at once; utopia-network = a uVoucher, the network's own record |
status | active = can still be redeemed; redeemed = already used; void / expired = HotVoucher vouchers only; not_found = the network has no such voucher in that transaction; unknown = not a HotVoucher voucher and sent without hash (a uVoucher always needs it) |
amount, coin | The value - HTV for HotVoucher vouchers, UUSD for uVouchers |
createdAt, redeemedAt | Milliseconds, when known (HotVoucher vouchers: only for your own, with the parties - as POST /check) |
cached | The same question was answered a moment ago and kept; the network was not asked again |
A uVoucher answer never names the parties. A redeemed uVoucher never becomes active again, so that answer is kept for 24 hours; any other answer for 60 seconds.
uVouchers: the inquiry queue
Every inquiry that goes out to the Utopia network - from your server, from the HotVoucher app and from other merchants - waits in ONE queue, so a busy hour can never get HotVoucher blocked by the network. The queue sends one inquiry at a time, with a short pause between two.
| Answer | What to do |
|---|---|
200 | The answer is ready (it came within about 15 seconds, or from memory). |
202 queued / checking | Wait retryAfter seconds (also the Retry-After header), then call GET /vouchers/inquiry/{inquiryId}. position is your place in the line (0 = being checked). An inquiry is kept 30 minutes, only for you. |
429 QUEUE_LIMIT | You already have 5 inquiries waiting - ask again when they are answered. |
503 QUEUE_FULL | Many inquiries are waiting - try again after Retry-After. |
504 VERIFY_TIMEOUT | The network did not answer in time - send the inquiry again in a minute. |
HotVoucher vouchers never wait: they are answered at once. The older POST /customers/uusd-verify uses the same queue and answers 503 VERIFY_QUEUED while it is still in line - ask again and the answer will be ready.
Customer withdrawals (payouts)
Send coins from your HotVoucher balance to any address - for example when a customer of your site withdraws. Every payout goes through the same checks as a withdrawal in the app: network fee, limits, and a review by HotVoucher above your auto-approve limit. The token needs the payout section (off by default).
Withdraw
Amount
Network and address
An example of your page: the fee and "You receive" come from POST /payouts/quote (sample numbers).
payout.status webhook. A payout to a HotVoucher account is sent at once, with no network fee.- Quote it - show the fee before the customer confirms
curl -X POST https://hotvoucher.com/api/v1/payouts/quote \ -H "Authorization: Bearer hvk_YOUR_TOKEN" -H "Content-Type: application/json" \ -d '{"coin":"USDT","network":"TRC20","address":"T...","amount":50,"feePayer":"customer"}'const q = await hv('/payouts/quote', { coin: 'USDT', network: 'TRC20', address, amount: 50, feePayer: 'customer' }); // q = { validAddress, available, reason, fee, receives, total, min, internal, maxUsd, feePayer, limits } if (!q.validAddress || !q.available) throw new Error(q.reason || 'Withdrawals on this network are paused');<?php $q = hv('/payouts/quote', ['coin' => 'USDT', 'network' => 'TRC20', 'address' => $address, 'amount' => 50, 'feePayer' => 'customer']); if (!$q['validAddress'] || !$q['available']) throw new Exception($q['reason'] ?: 'Paused'); - Send it with your own id as the idempotency key
Use your withdrawal id as
idempotencyKey. If the call times out, simply send the same body again: you get the same payout back (replay: true), never a second one.curl -X POST https://hotvoucher.com/api/v1/payouts \ -H "Authorization: Bearer hvk_YOUR_TOKEN" -H "Content-Type: application/json" \ -d '{"idempotencyKey":"wd-88213","coin":"USDT","network":"TRC20","address":"T...","amount":50, "feePayer":"customer","customer":"user-1042","reference":"withdrawal 88213"}'const { payout } = await hv('/payouts', { idempotencyKey: 'wd-' + w.id, // your withdrawal id coin: 'USDT', network: 'TRC20', address: w.address, amount: w.amount, feePayer: 'customer', // the fee comes out of the amount customer: String(w.userId), reference: 'withdrawal ' + w.id, }); await db.withdrawals.update(w.id, { payoutId: payout.id, status: payout.status });<?php $p = hv('/payouts', [ 'idempotencyKey' => 'wd-' . $w['id'], // your withdrawal id 'coin' => 'USDT', 'network' => 'TRC20', 'address' => $w['address'], 'amount' => $w['amount'], 'feePayer' => 'customer', 'customer' => (string)$w['user_id'], 'reference' => 'withdrawal ' . $w['id'], ])['payout']; save_payout($w['id'], $p['id'], $p['status']); - Follow the status by webhook
case 'payout.status': { const p = msg.data; // the payout object const w = await db.withdrawals.byPayout(p.id); if (p.status === 'sent') await db.withdrawals.done(w.id, p.txId); if (p.status === 'failed') await db.withdrawals.refund(w.id, p.failReason); // give the money back break; }<?php case 'payout.status': $w = withdrawal_by_payout($d['id']); if ($d['status'] === 'sent') withdrawal_done($w['id'], $d['txId']); if ($d['status'] === 'failed') withdrawal_refund($w['id'], $d['failReason']); break;
Who pays the network fee?
| feePayer | The address receives | Your balance pays | Typical use |
|---|---|---|---|
| "customer" (default) | amount - fee | amount | Normal withdrawals: the customer bears the fee |
| "merchant" | exactly amount | amount + fee | A free withdrawal you offer (for example the first one each day) |
Test without moving money: add "dryRun": true to POST /payouts - every check runs and the answer says what would happen (expectedStatus, quote, limits). Your limits: GET /payouts/limits.
Emergency stop: POST /payouts/lock makes every payout of your account wait for HotVoucher and pulls back the open ones. Only HotVoucher support can release it - use it the moment you suspect a leaked token.
Vouchers as rewards and refunds
Turn part of your HTV balance into a HotVoucher voucher code and give it to a customer. With lockTo (the customer's HV-... id) only that account can redeem it, and the code is delivered to their HotVoucher notifications.
curl -X POST https://hotvoucher.com/api/v1/voucher/create \
-H "Authorization: Bearer hvk_YOUR_TOKEN" -H "Content-Type: application/json" \
-d '{"amount":10,"lockTo":"HV-ABC123","reference":"refund order 1042"}'
# {"ok":true,"code":"USD-....","ref":"VC-....","amount":10,"lockedTo":"HV-ABC123","txId":"...","txHash":"...","balance":...}
const v = await hv('/voucher/create', { amount: 10, lockTo: 'HV-ABC123', reference: 'refund order 1042' });
// cancel it while unused: await hv('/voucher/cancel', { ref: v.ref }) - the amount comes back
<?php
$v = hv('/voucher/create', ['amount' => 10, 'lockTo' => 'HV-ABC123', 'reference' => 'refund order 1042']);
// cancel it while unused: hv('/voucher/cancel', ['ref' => $v['ref']]);
Webhooks
Set a callback URL on the token and every event is sent there as a signed HTTPS POST. Only https URLs on public hosts are accepted; redirects are not followed; we wait 8 seconds for your answer.
POST /hotvoucher-webhook HTTP/1.1
Content-Type: application/json
X-HotVoucher-Event: payment.received
X-HotVoucher-Delivery: 81723
X-HotVoucher-Attempt: 1
X-HotVoucher-Signature: 5f2a... (hex HMAC-SHA256 of the raw body, key = your callback secret)
{
"event": "payment.received",
"at": 1767225600000,
"token": { "id": 12, "name": "yourshop.example" },
"merchant": { "id": "HV-ABC123" },
"data": {
"ref": "...", "invoice": "...", "orderId": "order-1042", "customer": "user-1042",
"method": "crypto", "coin": "USDT", "network": "TRC20", "amount": 25, "amountUsd": 25,
"feeUsd": 0.25, "networkFeeUsd": 0.35, "credited": { "coin": "USDT", "amount": 24.4 },
"converted": null, "txId": "...", "txHash": "..."
}
}Events
| Event | When | data |
|---|---|---|
payment.detected | A crypto payment is seen on the network, before confirmation (once per transaction) | customer, invoice, orderId, coin, network, amount, confirmations, required, txId, explorer, seenAt |
payment.received | A payment is credited to your account (crypto: after the explorer check) | ref, invoice, orderId, customer, method, coin, network, amount, amountUsd, feeUsd, networkFeeUsd, credited, converted, txId, txHash |
invoice.paid | The invoice amount is covered | the invoice, with credited per coin |
invoice.expired | The invoice ran out of time | the invoice, with credited per coin |
payout.status | Every status change of a payout | the payout |
coin.status | A coin or network was switched on or off | the coin, as in GET /coins/{coin} |
redeem | A voucher was redeemed through the API | the /redeem answer |
voucher_created / voucher_cancelled | A voucher was made or cancelled through the API | the API answer |
uvoucher.failed | A uVoucher did not activate (about 10 minutes later) | customer, reference, voucher (masked), at |
test | You called POST /webhooks/test | message, note, at |
Retries and duplicates
Answer with any 2xx status and the message is done. Otherwise we try again - immediately, then after 1, 5, 10, 20 and 30 minutes, and 1, 2, 4, 6, 9, 14 and 23 hours (13 attempts within 24 hours). If three transactions in a row use up all their attempts, the callback is switched off; switch it on again in the app (edit the token).
- Check the signature first (code in the quick start); reject the message if it does not match.
- A message can arrive twice (for example when your 2xx answer is lost). Keep the
X-HotVoucher-Deliveryids you handled, or thedata.ref/data.txId. - Answer fast and do the slow work afterwards; a timeout counts as a failed attempt.
- After an outage on your side, read the truth back: GET /payments, GET /payouts, GET /webhooks.
Balances, swap and statements
Balances and transactions
GET /balance gives your balance in every coin; GET /transactions the latest ledger lines.
Statement for reconciliation
GET /statement gives, per coin and period, the opening and closing balance and the totals by transaction type - compare it with your own books every day.
curl "https://hotvoucher.com/api/v1/statement?coin=USDT&from=1767139200000&to=1767225600000" \
-H "Authorization: Bearer hvk_YOUR_TOKEN"
# {"from":...,"to":...,"coins":[{"coin":"USDT","opening":...,"closing":...,"in":...,"out":...,"net":...,"count":...,
# "byType":{"pay_in":{"n":..,"amount":..},"withdraw":{...},"pay_fee":{...}}}]}
const day = 24 * 3600 * 1000, to = Date.now();
const st = await hv('/statement?coin=USDT&from=' + (to - day) + '&to=' + to);
<?php
$to = (int)(microtime(true) * 1000); $from = $to - 86400000;
$st = hv("/statement?coin=USDT&from=$from&to=$to");
Swap between your own coins
Convert part of your balance at HotVoucher's live rate (token section swap, off by default). Quote first, then swap with your own idempotencyKey.
curl -X POST https://hotvoucher.com/api/v1/swap/quote \
-H "Authorization: Bearer hvk_YOUR_TOKEN" -H "Content-Type: application/json" \
-d '{"from":"USDT","to":"HTV","amount":100}'
curl -X POST https://hotvoucher.com/api/v1/swap \
-H "Authorization: Bearer hvk_YOUR_TOKEN" -H "Content-Type: application/json" \
-d '{"idempotencyKey":"sw-5521","from":"USDT","to":"HTV","amount":100}'
const { quote } = await hv('/swap/quote', { from: 'USDT', to: 'HTV', amount: 100 });
const { swap, balances } = await hv('/swap', { idempotencyKey: 'sw-' + jobId, from: 'USDT', to: 'HTV', amount: 100 });
<?php
$quote = hv('/swap/quote', ['from' => 'USDT', 'to' => 'HTV', 'amount' => 100])['quote'];
$swap = hv('/swap', ['idempotencyKey' => 'sw-' . $jobId, 'from' => 'USDT', 'to' => 'HTV', 'amount' => 100]);
| Conversion | Rate |
|---|---|
| Any coin → HTV | +4.5 % bonus (already inside the rate) |
| Any coin → UUSD | +4 % bonus |
| HTV or UUSD → any other coin | 6 % fee |
| UUSD → HTV / HTV → UUSD | free (1 : 1) / 0.6 % fee |
HTV and UUSD are each worth 1 USDT. A quote is indicative: the swap uses the rate of the moment it arrives. Live prices of every coin: GET /prices.
Errors and limits
Every error answers JSON { "error": "...", "code": "..." } with an HTTP status.
| Status | Codes | What to do |
|---|---|---|
| 400 | BAD_INPUT, BAD_CODE, BAD_AMOUNT, BAD_METHOD, BAD_COIN, SAME_COIN, PAIR_OFF, INSUFFICIENT, NO_PRICE, REJECTED, PAYOUT_CAP, PAYOUT_LIMIT, SWAP_LIMIT, NO_CALLBACK, CALLBACK_DISABLED, VOUCHER_USED, HOTVOUCHER_VOUCHER | Fix the request; the message says why. Do not retry unchanged. |
| 401 | NO_TOKEN, BAD_TOKEN | Send Authorization: Bearer hvk_...; check the token was not regenerated. |
| 403 | IP_NOT_ALLOWED, SECTION_NOT_ALLOWED, ACCOUNT_BLOCKED, SECURITY_LOCK, LOCKED | Whitelist the server IP or tick the section in the app. |
| 404 | NOT_FOUND | Wrong id or code. |
| 409 | ALREADY_USED, CONFLICT, IDEMPOTENCY_MISMATCH, VOUCHER_BUSY | The resource is in another state; IDEMPOTENCY_MISMATCH = same key, different body. |
| 410 | VOID, EXPIRED | The voucher cannot be used any more. |
| 423 | RISK_REVIEW | The account is under review (for example after the emergency stop) - contact HotVoucher. |
| 429 | RATE_LIMIT | Slow down; retry with a growing pause. |
| 503 | FROZEN | Maintenance; retry later. |
Rate limits per token (standard tier)
| What | Limit |
|---|---|
| All requests | 600 per minute |
| New customer addresses | 500 per day |
| Voucher create | 600 per hour |
| Voucher redeem | 300 per 10 minutes |
| Payouts and swaps | 300 per minute |
Higher tiers on request.
Go-live checklist
GET /meanswers from every server that will call the API (their IPs are whitelisted).- The token has only the sections you use; payout and swap are on only if you need them.
POST /webhooks/testarrives, the signature check passes, and a repeated delivery is ignored.- Your deposit page shows only active coins and networks (
GET /coins), with the minimum and the memo. - You credit customers only on
payment.received(or/redeemanswers), each payment once. - Payouts: quote shown to the customer,
idempotencyKey= your withdrawal id,failedrefunds the customer, onedryRunpassed. - One small real payment and one small real payout went through end to end.
- You know where the emergency stop is (
POST /payouts/lock, or the button on the merchant panel).
All endpoints
Every endpoint with its parameters, fields and answers, drawn from openapi.json. Base URL https://hotvoucher.com/api/v1.
Loading the reference...
HotVoucher Merchant API v1 - this page never sends requests to the API. The plain-text guide (GET /api/v1/docs) says the same in more words.