HotVoucher Merchant API v1 Download OpenAPI (JSON) Plain-text guide
HotVoucher Merchant API

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.

Base URLhttps://hotvoucher.com/api/v1
AuthenticationAuthorization: Bearer hvk_YOUR_TOKEN
FormatJSON in and out; times are Unix milliseconds

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

Get started

Quick start in 4 steps

From nothing to a verified test webhook in about fifteen minutes.

  1. Create an API token

    In the HotVoucher app open Voucher → API token for your site and fill in:

    FieldWhat to enter
    NameYour site or store. It may be shown on voucher lookups if you allow it.
    IP addressesThe public IP addresses of your servers. Requests from any other address are refused.
    SectionsTick only what you use (see sections below). Payout and swap are off by default.
    Callback URLThe https address on your server that receives webhooks.

    You get two secrets: the token hvk_... for calling the API and the callback secret hvcs_... for checking webhooks. Keep both on your server; you can reveal or regenerate them in the app at any time.

  2. 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'];
    Answer
    {
      "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" }
    }
  3. 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
  4. 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.

SectionLets 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
Plan

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 PayMethod by method
What your customer seesThe HotVoucher payment page with Crypto, Wallet and Voucher tabs (screenshots)Your own pages, in your own design
Payment methodsEvery active coin and network, the HotVoucher wallet, HotVoucher vouchers and UUSD uVouchersThe ones you build: crypto deposit addresses, voucher codes, uVouchers
Your workOne API call, a redirect and a webhook handlerA small flow per method (an address box, a code box ...)
Main endpointsPOST /invoices GET /invoices/{id}POST /customers/address POST /redeem POST /customers/uusd-voucher
Webhookspayment.received invoice.paid invoice.expiredpayment.detected payment.received uvoucher.failed
Best forShops and orders with a price; the fastest startSites 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.

Get started

Security checklist

  • The token hvk_... and the callback secret hvcs_... 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-Signature on every webhook before you trust it; answer 2xx quickly and process each X-HotVoucher-Delivery only 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 idempotencyKey for 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.
Accept payments - all methods

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.

Buyer (browser) Your server HotVoucher 1 clicks "Pay" on your checkout 2 POST /invoices 3 invoice id + payUrl 4 redirect to payUrl 5 pays on the HotVoucher page: crypto, wallet or voucher 6 webhook payment.received 7 credit the order 8 "Back to shop" opens your returnUrl
Your server talks to HotVoucher; the buyer's browser only visits the payment page. The webhook (6) is the proof of payment - the return to your site (8) is only for the buyer's comfort.

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:

yourshop.example/checkout
Order #1042 - $25.00
Pay with HotVoucher
CryptoHotVoucher walletVoucher code

An example of your page - the design is yours.

  1. Create an invoice on your server

    When the buyer clicks Pay, your server creates the invoice. customer is your own user id (it decides the buyer's permanent deposit addresses). Leave amount out 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;
    Answer
    {
      "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=..."
    }
  2. Send the buyer to payUrl

    Use a normal full-page redirect or link. The payment page is not meant to be put in a frame or opened by a script.

  3. Handle the webhooks

    In the webhook endpoint from the quick start, react to the payment events. payment.received comes for every payment that lands in your account; invoice.paid when 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;
      }
    }
  4. 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

TopicHow it works
Any amount is yoursFull, 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-upNo amount = the buyer sends any amount. Ideal for "add funds" pages.
CurrencyUSD 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.
ExpiryexpiresInMin from 5 to 10080 minutes.
Languagelang: "en" or "fa".
FeesThe 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 confirmationsA 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.

Accept payments - method by method

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.

MethodYour customerYour server callsYou learn about the money by
Crypto deposit addresssends coins to their permanent addressPOST /customers/addresspayment.detected + payment.received
HotVoucher vouchertypes a USD- codePOST /check + POST /redeemthe answer of /redeem (instant)
UUSD uVouchertypes a Utopia uVoucher codePOST /customers/uusd-voucherpayment.received (or uvoucher.failed)
HotVoucher walletpays from their HotVoucher accountthrough HotVoucher Pay onlypayment.received
Method

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.

yourshop.example/deposit
Add funds

Coin and network

Tether (USDT) - TRC20

Your deposit address

TQ7d...uZo5G
permanently yours
Minimum deposit1 USDT
NetworkTRC20 only
Processing - 3 of 19 confirmations
+50 USDT added to your balance

An example of your own deposit page (sample data).

Customer Your server HotVoucher opens Add funds /customers/address address, min, fee address + QR sends coins (any wallet / exchange) payment.detected payment.received top up balance "+50 USDT"
The dashed arrow is the blockchain. Credit the balance only on payment.received.
  1. 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.status webhook 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;
    }
  2. Get the customer's address

    The first call creates it, later calls return the same one. Show the memo too 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']]);
    Answer
    { "customer": "user-1042", "coin": "USDT", "network": "TRC20",
      "address": "T...", "memo": null, "minDeposit": 1, "networkFeeUsd": 0.35, "others": [ ... ] }
  3. Show "processing" while the page is open (optional)

    Poll GET /payments/pending with watch=1 every 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'];
  4. Credit the balance on payment.received

    The same webhook as for invoices. customer tells you whose balance to top up; credited is 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.

Method

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.

yourshop.example/deposit
Pay with a voucher
USD-XXXX-XXXX-XXXX-XXXX-XXXXRedeem
Voucher value25 HTV
Statusactive
25 HTV added to your balance

An example of your own page (sample data).

customer types a code POST /check found not found POST /redeem /customers/uusd-voucher credited at onceHotVoucher voucher pending, then webhookUUSD uVoucher
Both kinds of code start with USD-. One code box can take both: /check finds HotVoucher vouchers; anything else is tried as a uVoucher.
  1. 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');
  2. 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.

Method

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.

  1. 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';
  2. Credit on the webhook

    A valid code always arrives as payment.received (coin UUSD, network Utopia) - credit it there exactly like a crypto deposit. If the network does not activate it, uvoucher.failed follows within about ten minutes.

AnswerMeaning
400 VOUCHER_USEDThe code already went through HotVoucher.
409 VOUCHER_BUSYThe same customer's first try is still being checked.
400 HOTVOUCHER_VOUCHERIt is a HotVoucher voucher - use POST /redeem instead.
Method

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.

Accept payments

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.

  1. 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';
  2. 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 is 400 HASH_REQUIRED; a code sent without kind that is not a HotVoucher voucher answers found: false, needHash: true.

Voucher inquiry

What the answer says

FieldMeaning
kindhotvoucher or uvoucher
checkedByhotvoucher = a HotVoucher voucher, answered at once; utopia-network = a uVoucher, the network's own record
statusactive = 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, coinThe value - HTV for HotVoucher vouchers, UUSD for uVouchers
createdAt, redeemedAtMilliseconds, when known (HotVoucher vouchers: only for your own, with the parties - as POST /check)
cachedThe 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.

Voucher inquiry

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.

AnswerWhat to do
200The answer is ready (it came within about 15 seconds, or from memory).
202 queued / checkingWait 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_LIMITYou already have 5 inquiries waiting - ask again when they are answered.
503 QUEUE_FULLMany inquiries are waiting - try again after Retry-After.
504 VERIFY_TIMEOUTThe 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.

Send money

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

yourshop.example/withdraw
Withdraw

Amount

50 USDT

Network and address

TRC20T...your address
Network fee1.20 USDT
You receive48.80 USDT
Confirm withdrawal

An example of your page: the fee and "You receive" come from POST /payouts/quote (sample numbers).

POST /payouts approved reviewlarge: HotVoucher checks sendingsigned, txId set sent (final) failed (final)failReason; refund your customer
Every change arrives as the payout.status webhook. A payout to a HotVoucher account is sent at once, with no network fee.
  1. 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');
  2. 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']);
  3. 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?

feePayerThe address receivesYour balance paysTypical use
"customer" (default)amount - feeamountNormal withdrawals: the customer bears the fee
"merchant"exactly amountamount + feeA 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.

Send money

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']]);
Operate

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.

What arrives (example values)
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

EventWhendata
payment.detectedA crypto payment is seen on the network, before confirmation (once per transaction)customer, invoice, orderId, coin, network, amount, confirmations, required, txId, explorer, seenAt
payment.receivedA 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.paidThe invoice amount is coveredthe invoice, with credited per coin
invoice.expiredThe invoice ran out of timethe invoice, with credited per coin
payout.statusEvery status change of a payoutthe payout
coin.statusA coin or network was switched on or offthe coin, as in GET /coins/{coin}
redeemA voucher was redeemed through the APIthe /redeem answer
voucher_created / voucher_cancelledA voucher was made or cancelled through the APIthe API answer
uvoucher.failedA uVoucher did not activate (about 10 minutes later)customer, reference, voucher (masked), at
testYou called POST /webhooks/testmessage, 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-Delivery ids you handled, or the data.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.
Operate

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]);
ConversionRate
Any coin → HTV+4.5 % bonus (already inside the rate)
Any coin → UUSD+4 % bonus
HTV or UUSD → any other coin6 % fee
UUSD → HTV / HTV → UUSDfree (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.

Operate

Errors and limits

Every error answers JSON { "error": "...", "code": "..." } with an HTTP status.

StatusCodesWhat to do
400BAD_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_VOUCHERFix the request; the message says why. Do not retry unchanged.
401NO_TOKEN, BAD_TOKENSend Authorization: Bearer hvk_...; check the token was not regenerated.
403IP_NOT_ALLOWED, SECTION_NOT_ALLOWED, ACCOUNT_BLOCKED, SECURITY_LOCK, LOCKEDWhitelist the server IP or tick the section in the app.
404NOT_FOUNDWrong id or code.
409ALREADY_USED, CONFLICT, IDEMPOTENCY_MISMATCH, VOUCHER_BUSYThe resource is in another state; IDEMPOTENCY_MISMATCH = same key, different body.
410VOID, EXPIREDThe voucher cannot be used any more.
423RISK_REVIEWThe account is under review (for example after the emergency stop) - contact HotVoucher.
429RATE_LIMITSlow down; retry with a growing pause.
503FROZENMaintenance; retry later.

Rate limits per token (standard tier)

WhatLimit
All requests600 per minute
New customer addresses500 per day
Voucher create600 per hour
Voucher redeem300 per 10 minutes
Payouts and swaps300 per minute

Higher tiers on request.

Operate

Go-live checklist

  • GET /me answers 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/test arrives, 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 /redeem answers), each payment once.
  • Payouts: quote shown to the customer, idempotencyKey = your withdrawal id, failed refunds the customer, one dryRun passed.
  • 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).
API reference

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.