Getting Started
Create a merchant account, take your merchant point credentials, and make your first signed request.
Havala is a crypto payment gateway. You create an invoice denominated in a fiat currency, the buyer picks a coin and a chain, Havala hands out a one-time deposit address, watches the chain, and tells you when the money is confirmed. No card rails are involved and no card data ever reaches you.
This guide goes from a blank account to one signed request that comes back 200.
Everything in it runs against https://api2.havala.io.
What a merchant point is
A Havala account has two levels. The merchant is the company. A merchant point is a store under it โ a storefront, a mobile app, a regional site.
Every invoice, every payment and every webhook delivery belongs to exactly one merchant point, and the credentials live on the point, not on the merchant:
apiKeyโ sent asX-API-Key. It identifies the point.apiSecretโ never sent. It is the HMAC key that producesX-Signature, and the same key Havala uses to sign webhook deliveries back to you.
That scoping is the reason the gateway API needs no merchant parameter anywhere:
the key resolves a point, and the point scopes every lookup. An invoice created
by your mobile-app point is invisible to your storefront point โ reading it with
the wrong key returns 404 INVOICE_NOT_FOUND, not 403. Ownership and existence
are deliberately indistinguishable.
Use one point per surface you want to reconcile separately. Use several if you want separate webhook URLs, since a point holds exactly one delivery URL.
Create an account and a point
Account setup happens in the merchant dashboard, not over the API documented
here. Sign up, then create a merchant point for the surface you want to take
money on. Creating the point is what mints your gateway credentials โ the
dashboard shows the point's apiKey and apiSecret, and those two values are
what everything below assumes you have.
The merchant record starts unverified. You can sign in, create points and run testnet payments immediately, but verification is handled by Havala out of band; see Going Live for what that gates.
The dashboard is also where you set the point's webhook URL, see the commission percentage and fee-payer method Havala configured for it, and deactivate the point if you need to cut its traffic off. None of those have a public HTTP endpoint, and this site does not document one.
Both credential halves are hex from crypto.randomBytes. The secret is always
64 characters; the key is 32 or 64 depending on where in the dashboard the point
was created. Treat the key as an opaque string and never parse it.
Unlike most gateways, Havala does not hide the secret after issuing it โ the
dashboard shows a point's apiSecret in full every time you look at the point,
so a mislaid secret is something you go and read again rather than something you
have to rotate. The flip side is that anyone with dashboard access that can list
your merchant points can read every gateway signing secret the account holds.
Scope dashboard access accordingly.
Put the pair in your server's environment. The secret is only ever used locally, to compute a signature; no legitimate Havala request contains it.
export HAVALA_API_KEY=2d90a4c7e1b56f387ac03e94b12d6f50
export HAVALA_API_SECRET=5e3a91c7b04d28f61a7c9e05d3b862f4c081d5a637e9b24c7f30e6b19a5d48c2One signed request, end to end
Every gateway call carries three or four headers:
| Header | Value |
|---|---|
X-API-Key | The point's apiKey |
X-Signature | t={unix_seconds},v1={hex_hmac_sha256} |
Content-Type | application/json |
Idempotency-Key | Required on POST, PUT, PATCH and DELETE only |
The signature is an HMAC-SHA256 over a \n-joined canonical string:
{t}\n{METHOD}\n{path}\n[{idempotency-key}\n]{sha256_hex(rawBody || "")}The idempotency-key line is present only for write methods, because that is the
only case where the header is required. path is the route path with no query
string. Full mechanics, and the same client in five other languages, are in
Authentication & Request Signing.
Here is the whole thing in Node 18+, with no dependencies:
import { createHash, createHmac, randomUUID } from 'node:crypto';
const BASE = 'https://api2.havala.io';
const WRITE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
async function havala(method: string, path: string, body?: unknown) {
// Serialise once. The signature covers the exact bytes on the wire, so
// hashing one string and sending a differently-serialised one is the most
// common cause of SIGNATURE_INVALID.
const raw = body === undefined ? '' : JSON.stringify(body);
const t = Math.floor(Date.now() / 1000).toString();
const bodyHash = createHash('sha256').update(raw, 'utf8').digest('hex');
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 idempotencyKey = randomUUID();
headers['Idempotency-Key'] = idempotencyKey;
parts.push(idempotencyKey);
}
parts.push(bodyHash);
const signature = createHmac('sha256', process.env.HAVALA_API_SECRET!)
.update(parts.join('\n'))
.digest('hex');
headers['X-Signature'] = `t=${t},v1=${signature}`;
const res = await fetch(BASE + path, {
method,
headers,
body: raw === '' ? undefined : raw,
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
return res.json();
}
const invoice = await havala('POST', '/api/v1/invoices', {
orderId: 'order-8814',
amount: '12500',
currency: 'USD',
description: 'Pro plan, annual billing',
expiresInMinutes: 60,
});
console.log(invoice.id, invoice.referenceId, invoice.status);{
"id": "clyv3n8x40001qh7m2k9d5b1t",
"referenceId": "INV-260826-041C",
"orderId": "order-8814",
"description": "Pro plan, annual billing",
"amount": "12500",
"currency": "USD",
"status": "OPEN",
"customerEmail": null,
"customerName": null,
"customerMetadata": {},
"metadata": {},
"returnUrl": null,
"cancelUrl": null,
"expiresAt": "2026-08-26T10:14:22.481Z",
"viewedAt": null,
"paidAt": null,
"cancelledAt": null,
"createdAt": "2026-08-26T09:14:22.481Z"
}Two things about that request body.
amount is a string of minor units in the fiat currency you name โ 12500
with currency: "USD" is $125.00. It is stored as a BigInt, so it is a string
on the wire in both directions and never a float. Nothing here names a
cryptocurrency: the buyer picks that at checkout.
id is a CUID and is what you sign into every later call. referenceId
(INV-YYMMDD-NNNL) is a short human handle for support tickets and receipts;
no endpoint accepts it as a lookup key.
Where to go from here
The invoice is OPEN and does nothing until someone checks it out. That is the
subject of Accepting Crypto Payments: choosing a
chain, getting a deposit address, and watching the payment confirm.
If the request above failed, the response body carries a machine-readable
code. Look it up:
| Code | HTTP | Cause |
|---|---|---|
API_KEY_INVALID | 401 | X-API-Key missing, unknown, or on an inactive point |
SIGNATURE_MISSING | 401 | No X-Signature header at all |
SIGNATURE_INVALID | 401 | Header malformed, or the HMAC did not match |
SIGNATURE_EXPIRED | 401 | t is more than 300 seconds from server time |
IDEMPOTENCY_KEY_REQUIRED | 400 | Write method without an Idempotency-Key |
Authentication & Request Signing walks each one back to the line of code that raises it.
| Next | What it covers |
|---|---|
| Authentication & Request Signing | The canonical string byte for byte, plus clients in six languages |
| Accepting Crypto Payments | Invoice lifecycle, checkout, confirmations, settlement |
| Webhooks | Receiving payment.completed and verifying it |
| Testing on Testnets | Running the whole flow without real coins |
| API reference | Every endpoint, request and response shape |