Testing on Testnets
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.
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 poolThe 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
networkentirely. Passing the lowercase string"mainnet"is not a valid enum value and the write fails. Passing the uppercase"MAINNET"succeeds but marks the paymentisTestnet: trueโ the flag is set by "is this anything other than omitted or lowercasemainnet", 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, soethereum+NILEis 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:
{
"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:
| Chain | Testnet | Where coins come from |
|---|---|---|
ethereum | Sepolia | The Google Cloud Web3 faucet, or Alchemy's Sepolia faucet |
polygon | Amoy | The official Polygon faucet |
bsc | BSC Testnet | The official BNB Chain faucet |
avalanche | Fuji | The official Avalanche faucet |
arbitrum | Arbitrum Sepolia | Bridge Sepolia ETH with the official Arbitrum bridge |
optimism | OP Sepolia | The Optimism superchain faucet, or bridge Sepolia ETH |
base | Base Sepolia | The Base faucet, or bridge Sepolia ETH |
tron | Nile | The Nile testnet faucet |
solana | devnet | solana airdrop 1 --url devnet |
bitcoin | testnet | A public Bitcoin testnet faucet |
ton | testnet | The 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.
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 reachesCOMPLETEDโ confirmations are what completes it โ and your handler must catch the shortfall by comparingreceived_amountagainstamount. - 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_PROGRESSand 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.
| Next | What it covers |
|---|---|
| Going Live | Verification, production credentials, the checklist |
| Chains, Currencies & Confirmations | Testnet names and confirmation counts per chain |
| Webhooks | Verification, idempotency, reconciliation |