Docs
GuidesTesting on Testnets

Testing on Testnets

Beginner10 min

Run the whole flow without real money: TEST-mode invoices, testnet selectors, faucet funds, and an end-to-end script.


There is no separate sandbox host. Havala tests against the real API on real public testnets: same base URL, same credentials, same code paths, coins that cost nothing. That is a deliberate trade โ€” you get no fake-outcome switches, but everything you exercise is the code that will run in production, including the chain watcher and the webhook dispatcher.

The whole switch is one optional field on checkout.

Switching a payment to a testnet

POST /api/v1/invoices/{id}/checkout takes an optional network. Omit it and the payment is mainnet. Pass a testnet name and Havala leases a testnet address from the pool, records isTestnet: true, and applies that chain's testnet confirmation count.

TypeScript
const { payment } = await havala('POST', `/api/v1/invoices/${invoiceId}/checkout`, {
  blockchain: 'ethereum',
  cryptoCurrency: 'USDT',
  network: 'SEPOLIA',
});

console.log(payment.paymentAddress); // a Sepolia address, from the testnet pool

The accepted values are the NetworkEnum names, uppercase and exact:

SEPOLIA, GOERLI, HOLESKY, AMOY, MUMBAI, BSC_TESTNET, FUJI, NILE, TESTNET, DEVNET, TESTNET_GENERIC, MAINNET.

Two rules that are easy to get backwards, and both fail in ways that do not look like what they are:

For mainnet, omit network entirely. Passing the lowercase string "mainnet" is not a valid enum value and the write fails. Passing the uppercase "MAINNET" succeeds but marks the payment isTestnet: true โ€” the flag is set by "is this anything other than omitted or lowercase mainnet", so you get a live-looking payment carrying testnet confirmation counts.

Pick a network that belongs to the chain. The value is written to the column verbatim without being cross-checked against blockchain, so ethereum + NILE is accepted and then makes no sense to anything downstream.

The hosted checkout page at pay.havala.io always runs on mainnet โ€” it has no network selector. Testnet runs go through the signed gateway route.

What InvoiceMode is, and is not

The invoice model carries a mode column of type InvoiceMode (TEST or LIVE), defaulting to LIVE. It is a reporting dimension: the merchant-facing create route does not accept it, so an invoice created through POST /api/v1/invoices is always LIVE regardless of which network its payment later runs on.

Do not reach for mode to separate your test traffic. The reliable signals are on the payment: isTestnet and network. Filter your own reporting on those, and tag test invoices in metadata if you want them separated on your side:

JSON
{
  "orderId": "test-order-0001",
  "amount": "12500",
  "currency": "USD",
  "metadata": { "env": "staging" }
}

Getting testnet coins

Fund the buyer wallet you will pay from. Faucets are run by third parties and move around, so treat this as where to look rather than a fixed address:

ChainTestnetWhere coins come from
ethereumSepoliaThe Google Cloud Web3 faucet, or Alchemy's Sepolia faucet
polygonAmoyThe official Polygon faucet
bscBSC TestnetThe official BNB Chain faucet
avalancheFujiThe official Avalanche faucet
arbitrumArbitrum SepoliaBridge Sepolia ETH with the official Arbitrum bridge
optimismOP SepoliaThe Optimism superchain faucet, or bridge Sepolia ETH
baseBase SepoliaThe Base faucet, or bridge Sepolia ETH
tronNileThe Nile testnet faucet
solanadevnetsolana airdrop 1 --url devnet
bitcointestnetA public Bitcoin testnet faucet
tontestnetThe TON testnet giver bot on Telegram

Test both a native coin and a token on at least one chain. They take different code paths โ€” a native transfer and an ERC-20 transfer produce different receipts โ€” and a token has its own decimals, which is the number your formatting code will get wrong first.

A scripted end-to-end run

This creates an invoice, checks it out on Sepolia, prints the address, and then polls until the payment settles or the window closes. Run it, pay the address from a testnet wallet, and watch the confirmations climb.

TypeScript
import { createHash, createHmac, randomUUID } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';

const BASE = 'https://api2.havala.io';
const WRITE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);

async function havala(method: string, path: string, body?: unknown) {
  const raw = body === undefined ? '' : JSON.stringify(body);
  const t = Math.floor(Date.now() / 1000).toString();
  const headers: Record<string, string> = {
    'X-API-Key': process.env.HAVALA_API_KEY!,
    'Content-Type': 'application/json',
  };
  const parts = [t, method, path];
  if (WRITE.has(method)) {
    const key = randomUUID();
    headers['Idempotency-Key'] = key;
    parts.push(key);
  }
  parts.push(createHash('sha256').update(raw, 'utf8').digest('hex'));
  headers['X-Signature'] = `t=${t},v1=${createHmac('sha256', process.env.HAVALA_API_SECRET!)
    .update(parts.join('\n'))
    .digest('hex')}`;

  const res = await fetch(BASE + path, {
    method,
    headers,
    body: raw === '' ? undefined : raw,
  });
  const text = await res.text();
  if (!res.ok) throw new Error(`${method} ${path} โ†’ ${res.status} ${text}`);
  return JSON.parse(text);
}

const invoice = await havala('POST', '/api/v1/invoices', {
  orderId: `test-${Date.now()}`,
  amount: '12500',
  currency: 'USD',
  description: 'Testnet run',
  expiresInMinutes: 60,
  metadata: { env: 'staging' },
});
console.log('invoice', invoice.id, invoice.status);

const { payment } = await havala('POST', `/api/v1/invoices/${invoice.id}/checkout`, {
  blockchain: 'ethereum',
  cryptoCurrency: 'USDT',
  network: 'SEPOLIA',
});

console.log(`send to ${payment.paymentAddress}`);
console.log(`window closes ${payment.expiresAt}`);

// Poll the cheap projection, not the full read. Slower than a webhook by
// design โ€” this is the reconciliation path, exercised on purpose.
const deadline = new Date(payment.expiresAt).getTime();
while (Date.now() < deadline) {
  const s = await havala('GET', `/api/v1/payments/${payment.id}/status`);
  console.log(`${s.status} ${s.confirmations}/${s.requiredConfirmations}`);
  if (s.status === 'COMPLETED') { console.log('settled', s.completedAt); break; }
  if (s.status === 'FAILED') { console.error('failed on chain'); break; }
  await sleep(10_000);
}

Expect Sepolia to take about a minute: 6 testnet confirmations at roughly 12 seconds a block. The other chains' testnet counts are in Chains, Currencies & Confirmations.

Testing the webhook

Point the merchant point's webhookUrl at a tunnel to your laptop โ€” the URL must be HTTPS and must not redirect โ€” and let the testnet payment above fire a real payment.completed at it. That is the only way to exercise your actual verification and parsing path.

The dashboard's test-delivery action is useful for proving reachability โ€” it posts to the point's configured URL and shows you the status code and the first 500 characters of your response โ€” but it sends different headers and a different envelope than a real delivery, so a green result there does not mean your handler works. The differences are tabulated in Webhooks.

While you are here, test the ugly cases, because they are the ones production will hand you:

  • Underpayment. Send less than expectedAmount. The payment still reaches COMPLETED โ€” confirmations are what completes it โ€” and your handler must catch the shortfall by comparing received_amount against amount.
  • Overpayment. Send more. Confirm you flag it rather than silently fulfilling.
  • Duplicate delivery. Replay a captured webhook body against your endpoint with the same signature. Nothing should ship twice.
  • Expiry. Create an invoice with expiresInMinutes: 15, check it out, and do not pay. Confirm your UI stops offering the dead address.
  • Second checkout. Check out an invoice twice while the first payment is live. You should get PAYMENT_ALREADY_IN_PROGRESS and handle it as a state, not a crash.

Before you go live

Testnet coins are not scarce, so exercise the failure paths while they are free: underpayment, expiry, a webhook endpoint returning 500, a rotated secret. Each one is a two-minute experiment on a testnet and an incident in production.

NextWhat it covers
Going LiveVerification, production credentials, the checklist
Chains, Currencies & ConfirmationsTestnet names and confirmation counts per chain
WebhooksVerification, idempotency, reconciliation