HotVoucher Merchant API v1 ========================== Credentials (per token, managed in the app under Voucher -> API token for your site): Token name - a label for your site/store (may be shown publicly on voucher lookups if you enable it) Token (secret key) - hvk_... Used to call this API: Authorization: Bearer hvk_... Requests are accepted only from the IPs whitelisted for the token. Callback secret - hvcs_... Used ONLY to verify the callbacks we send to your server (never sent in requests). Both keys can be revealed or regenerated in the app at any time. Keep them on your server; never put them in client-side code. All responses are JSON. Errors: {error, code} Sections (token permissions) Each token is limited to the sections you tick for it in the app. A call outside them is refused with HTTP 403 {code: "SECTION_NOT_ALLOWED", section}. GET /api/v1/me lists the token's sections. inquiry - account info: /me, /balance, /transactions code_inquiry - voucher lookup: /check, /vouchers, /vouchers/inquiry (HotVoucher vouchers and uVouchers) code_redeem - redeem a customer's voucher: /redeem code_issue - create and cancel vouchers: /voucher/create, /voucher/cancel merchant - payment gateway: /invoices, /payments, /fees address - customer deposit addresses: /addresses, /addresses/validate, /customers/address payout - send coins out of HotVoucher: /payouts (OFF by default) swap - convert between your own coins: /swap, /swaps (OFF by default) GET /api/v1/me -> merchant and token info (name, cashback, IPs, sections, siteUrl) GET /api/v1/balance -> your balance in every coin: {balances:{HTV, USDT, BTC, ...}} POST /api/v1/check {code | ref} -> status of a voucher without redeeming it: {found, status, amount, coin, ref} POST /api/v1/vouchers/inquiry {code, hash?, kind?} -> ONE inquiry for both kinds of code, nothing moves. kind = hotvoucher | uvoucher (optional; without it HotVoucher vouchers are looked up first). A HotVoucher voucher = at once {kind:"hotvoucher", found, status, amount, coin, ref, locked} (as /check). A uVoucher ALWAYS needs hash (the transaction that created it) - kind uvoucher without hash = 400 HASH_REQUIRED, no kind = {found:false, needHash:true} - and the Utopia network checks it through a queue: 200 {kind:"uvoucher", checkedBy, found, status active|redeemed|not_found, amount, coin, createdAt, redeemedAt} when ready within 15 s, else 202 {status queued|checking, inquiryId, position, retryAfter}; 429 QUEUE_LIMIT (5 waiting per merchant), 503 QUEUE_FULL (Retry-After header); a used uVoucher is answered from memory for 24 h GET /api/v1/vouchers/inquiry/:id -> the answer of a queued inquiry (202 while it waits; kept 30 minutes) POST /api/v1/redeem {code, reference?} -> redeem a customer's voucher in favour of the merchant. Voucher codes (v965): USD-XXXX-XXXX-XXXX-XXXX-XXXX; codes made before v965 start with HTV- and stay valid (the prefix is optional). Response: {ok, amount, ref, txId, txHash, balance, cashback:{percent, amount, to, txId} | null} Cashback: the percentage configured on the token is taken from the merchant balance at that moment and credited to the account that created the voucher (the customer). Official HotVoucher vouchers (no creator) carry no cashback. GET /api/v1/transactions?limit=50 -> latest transactions of the merchant account GET /api/v1/statement?from=&to=&coin= -> your HotVoucher ledger per coin for a period (ms timestamps; default = last 30 days): {from, to, coins:[{coin, opening, closing, in, out, net, count, byType:{type:{n, amount}}}]} opening = closing - net; closing = your balance at "to" (the live balance when "to" is now). Use it to reconcile your own book against HotVoucher: pay_in = gateway payments credited, pay_fee, withdraw = payouts (API or app), swap, voucher, api_redeem, internal_in / internal_out, deposit, addr_fee. POST /api/v1/voucher/create {amount, lockTo?, reference?} -> create a HotVoucher voucher from the merchant balance (rewards / refunds). Response: {ok, code, ref, amount, lockedTo, txId, txHash, balance} lockTo (optional) = the customer's HV-... id; only that account can redeem it and the code is delivered to their notifications. POST /api/v1/voucher/cancel {ref} -> cancel an unredeemed voucher you created; the amount is returned GET /api/v1/vouchers?status=all|active|redeemed|void -> vouchers you created (with codes) GET /api/v1/docs -> this guide Payment gateway (HotVoucher Pay) -------------------------------- Accept payments on your site: your SERVER creates an invoice, the buyer pays in a HotVoucher popup (HotVoucher wallet, HotVoucher voucher code, or crypto on any active network). Whatever the buyer pays is credited to your account in the SAME COIN the buyer paid (the amount minus the fees' share) - no conversion unless you choose one: in the app (API tokens page) you can set ONE coin that every payment is converted to automatically, at the normal swap rate and fee. Every customer gets ONE permanent deposit address per network, free of charge (gateway addresses are separate from the wallet addresses of logged-in HotVoucher users). Any amount that arrives - full, partial, more, or after the invoice expired - is yours; credit your customer on your side (for example top up their balance). Fees: gateway fee % on every payment + for CRYPTO payments only the network fee of moving the coins to our hot wallet (networkFeeUsd, per coin/network, see GET /api/v1/fees). Every payment.received webhook lists feeUsd and networkFeeUsd. POST /api/v1/invoices {customer, amount?, currency?, orderId?, description?, returnUrl?, expiresInMin?, lang?} customer - REQUIRED, your user id / username (max 64 chars); decides the permanent deposit address amount - optional; with amount you must send currency "USD" or "IRR". Without amount = open top-up invoice returnUrl - optional https URL for the "Back to shop" button; lang - "en" | "fa" -> {id, status:"pending", customer, amount, currency, amountUsd, paidUsd, remainingUsd, expiresAt, payUrl} GET /api/v1/invoices/:id -> invoice + its payments + detected[] (crypto seen on the network, unconfirmed) GET /api/v1/invoices?customer=&status=pending|paid|expired&limit= GET /api/v1/payments?customer=&limit= -> every received payment (with or without invoice) GET /api/v1/fees -> {gatewayFeePct, addressFeeUsd:0, networkFees:[{coin, network, networkFeeUsd}]} GET /api/v1/addresses?customer=&coin=&network=&limit=&offset= -> the deposit addresses created for your customers {addresses:[{customer, chain, networks, address, memo, createdAt}], total, more} GET /api/v1/addresses/validate?address=&network= -> {valid, validOn:[...], mine, customer, chain, networks} valid = the address format is correct for that network; mine = it is one of YOUR customers' gateway addresses POST /api/v1/customers/address {customer, coin, network} -> the customer's permanent address (to show it in your own UI) Payment page (v779): a step flow like the large processors - 1 payment creation (choose the asset) -> 2 awaiting deposit (QR code, the amount only when the invoice has one, address, payment id) -> 3 payment processing (seen on the network, confirmations) -> 4 finished; tabs for crypto / HotVoucher wallet / voucher. Show it by sending the buyer to payUrl (a full-page redirect; the "Back to shop" button uses your returnUrl). The page is not meant to be framed or opened by a script - a browser SDK (popup / inline) is not available. Confirm the payment with the webhook or GET /invoices/:id, never from the page. Second source (v782): a crypto payment is credited only after the coin's own explorer confirms the same transaction (address, amount, confirmations). payment.detected comes when a node sees it; payment.received only after that check - a few seconds to a few minutes later. Meanwhile GET /invoices/:id and /payments/pending list it with verifying:true. GET /api/v1/payments/pending?customer=&watch=1 -> crypto payments SEEN on the network but not confirmed yet (the "processing" step) {pending:[{customer, invoice, coin, network, amount, confirmations, required, txId, explorer, seenAt}]} watch=1 with a customer = that customer is on your deposit page: their addresses are polled every 30 s for the next 3 minutes - call it while the page is open Ready-to-copy sample (checkout page, Node.js and PHP server, webhook): https:///pay-sample.html No website? Users can create "Payment links" in the app (More -> Payment link) - no API needed. Webhook events: "payment.detected" (a crypto payment seen on the network, before confirmation - once per transaction: data = {customer, invoice, orderId, coin, network, amount, confirmations, required, txId, explorer, seenAt}), "payment.received" (every payment: data = {ref, invoice, orderId, customer, method, coin, network, amount, amountUsd, feeUsd, networkFeeUsd, addressFeeUsd, credited:{coin, amount}, converted, settledHtv, txId, txHash (the chain transaction hash of a crypto payment, null for wallet / voucher)}), "invoice.paid" and "invoice.expired" (data = the invoice, with credited:[{coin, amount}] per coin). credited = what landed in your account; converted = {from, to, amountFrom, amountTo, rate, feeAmount, ref} when automatic conversion is on (null otherwise); settledHtv is kept for old integrations = credited.amount when the final coin is HTV, else 0. If an invoice has an amount and less arrives, the buyer can send the rest to the same address until it expires. Error codes: NO_TOKEN, BAD_TOKEN, IP_NOT_ALLOWED, SECTION_NOT_ALLOWED, RATE_LIMIT, BAD_CODE, NOT_FOUND, ALREADY_USED, VOID, EXPIRED, LOCKED, FROZEN, INSUFFICIENT, BAD_AMOUNT, BAD_INPUT, BAD_METHOD, IDEMPOTENCY_MISMATCH, PAYOUT_CAP, PAYOUT_LIMIT, NO_PRICE, REJECTED, BAD_COIN, SAME_COIN, PAIR_OFF, BAD_AMOUNT, INSUFFICIENT, SWAP_LIMIT, NO_CALLBACK, CALLBACK_DISABLED Rate limits per token (standard tier): 600 requests per minute; 500 new customer addresses per day; voucher create 600 per hour; voucher redeem 300 per 10 minutes; payouts and swaps 300 per minute. Higher tiers on request. The token goes in the Authorization header only (a token in the URL or body is refused). Callbacks (webhooks) -------------------- If a callback URL is set on the token, every API transaction triggers one HTTP POST with a JSON body to that URL: Body: {event: "redeem" | "voucher_created" | "voucher_cancelled" | "payment.detected" | "payment.received" | "invoice.paid" | "invoice.expired" | "coin.status" | "payout.status" | "test", at, token:{id, name}, merchant:{id}, data:{...the same object as the API response...}} Headers: X-HotVoucher-Event - the event name X-HotVoucher-Delivery - delivery id (unique per message; repeated on retries) X-HotVoucher-Attempt - attempt number (1..13) X-HotVoucher-Signature - hex HMAC-SHA256 of the raw request body, key = your CALLBACK SECRET (hvcs_...) Verify the signature on every callback: compute HMAC-SHA256(rawBody, callbackSecret) and compare it (constant time) with the header. Reject the message if it does not match. Example (Node.js): const ok = crypto.timingSafeEqual(Buffer.from(req.get('X-HotVoucher-Signature'), 'hex'), crypto.createHmac('sha256', CALLBACK_SECRET).update(rawBody).digest()); Your response: any 2xx status = received (no more retries). Otherwise we retry up to 13 times within 24 hours (immediately, then 1, 5, 10, 20, 30 minutes, 1, 2, 4, 6, 9, 14, 23 hours). If 3 consecutive transactions exhaust all attempts, the token's callback is disabled automatically; re-enable it in the app (edit the token). Messages may arrive more than once (for example when your 2xx reply is lost on the way); de-duplicate with X-HotVoucher-Delivery or data.txId. Only HTTPS URLs on public hosts are accepted; redirects are not followed; timeout 8 seconds. Payouts (send coins out) ------------------------ Send coins from YOUR HotVoucher balance to any address - for example when a customer of your site withdraws. The token must have the "payout" section enabled (API tokens page in the app; it is OFF by default) and the usual IP whitelist. Every payout goes through the same pipeline as a withdrawal in the app: network fee, per-withdrawal dollar limit, method status, admin review for large amounts above your auto-approve limit, our signer. Limits are per merchant, set by HotVoucher (GET /api/v1/payouts/limits): maxPerRequestUsd - one payout at most (default 10 000 USD); dailyUsd - rolling 24 h cap (default 0 = none, your balance is the only limit); autoApproveUsd - payouts up to it are approved at once, above it they wait for HotVoucher ("review", default 10 000 USD). POST /api/v1/payouts {idempotencyKey, coin, network, address, amount, feePayer?, memo?, customer?, reference?, dryRun?} idempotencyKey - REQUIRED (1-80 chars), unique per payout on your side (your withdrawal id). Sending the same key again returns the SAME payout (replay:true) and never pays twice; the same key with a different body = 409 IDEMPOTENCY_MISMATCH. A REJECTED request (400) moves nothing and frees the key. amount - what your customer asked for, in coin units. The network fee is ALWAYS reported separately (see feePayer). feePayer - who pays the network fee, per payout: "customer" (default) - the fee comes out of amount: the address receives amount - fee, your balance is debited amount "merchant" - the address receives exactly amount, your balance is debited amount + fee (you absorb the fee, e.g. a free withdrawal for your customer; the customer gets the full amount) feeOnTop:true is the old spelling of feePayer:"merchant" and still works. dryRun:true - run every check (section, limits, price, address, network status) and answer what WOULD happen, moving nothing: 200 {dryRun:true, ok:true, expectedStatus:"approved"|"review"|"sent", quote, amountUsd, limits}. idempotencyKey is optional in a dry run. Use it to test your integration and before showing a withdrawal form to your customer. -> 201 {payout:{id:"PO-…", status, coin, network, address, amount, fee, feeUsd, feePayer, receives, debited, amountUsd, kind:"chain"|"internal", txId, failReason, …}} amount = requested, fee = network fee actually charged, receives = what the address gets, debited = what left your balance: feePayer "customer": receives = amount - fee, debited = amount feePayer "merchant": receives = amount, debited = amount + fee (total is kept for old integrations and equals debited.) The same fields come with every payout.status webhook. Fee facts: the fee is the real cost of that transaction on that network at the moment the payout is created (it can differ a little from an earlier quote); it is 0 when the address belongs to a HotVoucher account (kind "internal"); GET /api/v1/coins lists networkFeeUsd per network and POST /api/v1/payouts/quote gives the exact fee for one payout - both are free to call, so you can show the fee to your customer before they confirm. A payout below the network minimum (quote.min) is refused. Example - one free withdrawal per customer per day, the rest at the customer's cost (your rule, decided in your code): first payout of the day for this customer -> feePayer:"merchant" (customer gets the full amount, you pay the fee) later payouts the same day -> feePayer:"customer" (customer gets amount - fee) or always send feePayer:"merchant" and charge the fee to the customer in your own ledger, using the fee from the quote. status: "approved" (queued for sending) | "review" (large amount, waiting for HotVoucher) | "sending" (signed / broadcast, txId set) | "sent" (confirmed on chain, or instant when the address belongs to a HotVoucher account) | "failed" (failReason) POST /api/v1/payouts/quote {coin, network, address, amount, feePayer?} -> {validAddress, available, reason, fee, receives, total, min, internal, maxUsd, feePayer} GET /api/v1/payouts?status=&customer=&limit= -> your payouts, newest first GET /api/v1/payouts/:id -> one payout A payout to UVOUCHER (Utopia network) carries voucher (the code, once made) and voucherState: unused | used | unknown. GET /api/v1/payouts/limits -> {maxPerRequestUsd, dailyUsd, autoApproveUsd, usedTodayUsd, remainingTodayUsd} POST /api/v1/payouts/lock -> {locked, since, reason} EMERGENCY STOP: from now on every payout of this account (and every withdrawal of its owner) waits for HotVoucher's approval and the open ones are pulled back; transfers and vouchers are refused (423 RISK_REVIEW). Only HotVoucher support releases it - use it the moment you suspect a leaked token. GET /api/v1/payouts/lock = the state. The same stop is a button on the merchant panel dashboard. HotVoucher's own risk checks can hold single payouts too: status "review". Webhook event "payout.status": every status change of a payout (data = the payout object). Treat "sent" and "failed" as final. Errors: PAYOUT_CAP (daily cap of the token reached), PAYOUT_LIMIT (per-payout cap), NO_PRICE (no live price for the coin right now), REJECTED (the wallet refused: balance, limits, address, network off - the message says why), SECTION_NOT_ALLOWED (section "payout" is off). Coins and networks ------------------ GET /api/v1/coins -> every coin HotVoucher accepts, its networks, and whether each one is ACTIVE right now {coins:[{coin, status:"active"|"disabled", reason, since, price, networks:[{network, status, reason, since, decimals, min, networkFeeUsd}]}]} GET /api/v1/coins/:coin -> one coin (404 NOT_FOUND for an unknown symbol) POST /api/v1/customers/uusd-voucher {customer, code} -> redeems a UUSD uVoucher (USD-XXXX-XXXX-XXXX-XXXX-XXXX) for the customer; credited like a deposit to the customer (payment.received); section «address» v842: a code that already went through HotVoucher is refused at once (400 VOUCHER_USED; 409 VOUCHER_BUSY while the same customer's first try is still open); {pending:true} = the network has not shown the activation yet: a valid code arrives as payment.received, otherwise the webhook "uvoucher.failed" {customer, reference, voucher (masked), at} follows within about 10 minutes v965: HotVoucher voucher codes start with USD- too - one of them is refused here with 400 HOTVOUCHER_VOUCHER (redeem it with /api/v1/redeem) POST /api/v1/customers/uusd-verify {customer, hash, code?} -> {ok, kind, info}: the Utopia network's answer about a uVoucher (hash + its code) or a transfer (hash alone); section «address»; 10 per 10 minutes per customer; the same inquiry queue (v1076): 503 VERIFY_QUEUED = in line, ask again shortly Webhook event "coin.status": sent to every token that has a callback URL whenever a coin or one of its networks switches between active and disabled (maintenance, hot-wallet limits, missing market price). data = the same object as GET /api/v1/coins/:coin. Hide or show the payment method on your site accordingly. Deposits that still arrive on a disabled network are credited as usual, but new addresses and invoices on it are refused (BAD_METHOD). After any outage, confirm the current state with GET /api/v1/coins. Prices ------ GET /api/v1/prices?coins=BTC,USDT -> live USD price of every coin (or only the ones listed) + the IRR rate {at, prices:[{coin, usd, toman, rial, source, halted}], irr:{usdtToman, usdtRial, updatedAt, stale, source}} usd = HotVoucher's current price (Binance, or the market/manual price for coins without a Binance pair); null when no price is available right now (deposits are still credited, but payouts, priced invoices and swaps involving the coin are refused: NO_PRICE). toman/rial = usd converted with the current USDT->IRR rate (the same rate the app shows); stale = that rate is older than usual. GET /api/v1/prices/:coin -> one coin (404 NOT_FOUND) Swap (convert between your own coins) ------------------------------------- Convert part of your balance from one coin to another at HotVoucher's live rate - the same rates and fees as the app. The token needs the "swap" section (OFF by default). Nothing leaves HotVoucher; only your balances change. GET /api/v1/swap/config -> {coins, pairs:{FROM:[TO, ...]}, fees:{COIN:{pct, fixed}}, maxUsd, balances} POST /api/v1/swap/quote {from, to, amount, mode?} -> {quote:{from, to, amountFrom, amountTo, rate, feePct, feeFixed, feeAmount, priceFrom, priceTo, amountUsd}, maxUsd} mode "from" (default) = amount is what you give; mode "to" = amount is exactly what you want to receive our own coins: converting INTO HTV pays +4.5 %, INTO UUSD +4 % (feePct below 0 = that bonus, already inside rate and amountTo); HTV / UUSD into any other coin costs 6 % (feePct 6); UUSD -> HTV is free (1 : 1), HTV -> UUSD costs 0.6 %. HTV and UUSD are worth 1 USDT each POST /api/v1/swap {idempotencyKey, from, to, amount, mode?, reference?} -> 201 {swap:{ref:"SW-...", from, to, amountFrom, amountTo, rate, feeAmount, amountUsd, mode, reference, createdAt}, balances} idempotencyKey works exactly as for payouts: the same key returns the same swap again (replay:true) and never converts twice; the same key with a different body = 409 IDEMPOTENCY_MISMATCH. A quote is indicative: the swap uses the rate of the moment it arrives. GET /api/v1/swaps?limit= -> your API swaps, newest first Errors: BAD_COIN, SAME_COIN, PAIR_OFF (pair disabled by HotVoucher), BAD_AMOUNT, NO_PRICE, INSUFFICIENT, SWAP_LIMIT (per-swap USD cap, maxUsd). Webhook test and delivery log ----------------------------- POST /api/v1/webhooks/test {note?} -> queues one "test" callback to the token's URL (data = {message, note, at}); verify the signature exactly as for real events. 400 NO_CALLBACK = the token has no URL, CALLBACK_DISABLED = it was switched off after repeated failures (re-enable it in the app) GET /api/v1/webhooks?status=all|pending|done|failed|stopped&limit= -> the last deliveries of this token: {deliveries:[{id, event, status, attempts, lastCode, lastError, createdAt, lastAt, nextAt, doneAt, ref, txId}]} Example (curl) -------------- curl -X POST https:///api/v1/redeem \ -H "Authorization: Bearer hvk_..." -H "Content-Type: application/json" \ -d '{"code":"USD-XXXX-XXXX-XXXX-XXXX-XXXX","reference":"order-123"}'