Docs
GuidesGetting Started

Getting Started

Beginner10 min

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 as X-API-Key. It identifies the point.
  • apiSecret โ€” never sent. It is the HMAC key that produces X-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.

Shell
export HAVALA_API_KEY=2d90a4c7e1b56f387ac03e94b12d6f50
export HAVALA_API_SECRET=5e3a91c7b04d28f61a7c9e05d3b862f4c081d5a637e9b24c7f30e6b19a5d48c2

One signed request, end to end

Every gateway call carries three or four headers:

HeaderValue
X-API-KeyThe point's apiKey
X-Signaturet={unix_seconds},v1={hex_hmac_sha256}
Content-Typeapplication/json
Idempotency-KeyRequired on POST, PUT, PATCH and DELETE only

The signature is an HMAC-SHA256 over a \n-joined canonical string:

text
{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:

TypeScript
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);
JSON
{
  "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:

CodeHTTPCause
API_KEY_INVALID401X-API-Key missing, unknown, or on an inactive point
SIGNATURE_MISSING401No X-Signature header at all
SIGNATURE_INVALID401Header malformed, or the HMAC did not match
SIGNATURE_EXPIRED401t is more than 300 seconds from server time
IDEMPOTENCY_KEY_REQUIRED400Write method without an Idempotency-Key

Authentication & Request Signing walks each one back to the line of code that raises it.

NextWhat it covers
Authentication & Request SigningThe canonical string byte for byte, plus clients in six languages
Accepting Crypto PaymentsInvoice lifecycle, checkout, confirmations, settlement
WebhooksReceiving payment.completed and verifying it
Testing on TestnetsRunning the whole flow without real coins
API referenceEvery endpoint, request and response shape