Docs

Havala in Node.js

Runtime: Node.js 18+

Dependencies

No dependencies โ€” node:crypto and fetch are built in on Node 18+

Set up the client

TypeScript
// havala-sign.ts โ€” the whole of Havala gateway auth, in one function.
import { createHash, createHmac } from 'node:crypto';

const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);

export interface SignOptions {
  method: string;
  /** Path only. The server signs req.path, so a query string must be left out. */
  path: string;
  /** The exact bytes you will put on the wire. '' when there is no body. */
  body?: string;
  /** Required on POST/PUT/PATCH/DELETE, and meaningless anywhere else. */
  idempotencyKey?: string;
  /** Merchant point apiSecret, 64 hex chars. Never the API key. */
  secret: string;
}

/** Returns the X-Signature header value: t={unix_seconds},v1={hex}. */
export function signRequest(o: SignOptions): string {
  const method = o.method.toUpperCase();

  // t is checked against server time with a ยฑ300s window, so sign immediately
  // before sending. A signature parked in a queue expires where it sits.
  const t = Math.floor(Date.now() / 1000);

  // sha256 over the raw body bytes. No body hashes the empty string, which is
  // always e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
  const bodyHash = createHash('sha256').update(o.body ?? '', 'utf8').digest('hex');

  // {t}\n{METHOD}\n{path}\n[{idempotencyKey}\n]{sha256(body)}
  // The idempotency line is a write-method line. Add it to a GET, or drop it
  // from a POST, and the two sides hash a different number of lines.
  const parts = [String(t), method, o.path];
  if (WRITE_METHODS.has(method) && o.idempotencyKey) parts.push(o.idempotencyKey);
  parts.push(bodyHash);

  const v1 = createHmac('sha256', o.secret)
    .update(parts.join('\n'), 'utf8')
    .digest('hex');

  return 't=' + t + ',v1=' + v1;
}

Create an invoice

TypeScript
import { createHash, createHmac, randomUUID } from 'node:crypto';

const BASE_URL = 'https://api2.havala.io';
const API_KEY = process.env.HAVALA_API_KEY!;        // merchant point apiKey, 32 hex chars
const API_SECRET = process.env.HAVALA_API_SECRET!;  // merchant point apiSecret, 64 hex chars

const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);

function signRequest(method: string, path: string, body: string, idempotencyKey?: string): string {
  const t = Math.floor(Date.now() / 1000);
  const parts = [String(t), method, path];
  if (WRITE_METHODS.has(method) && idempotencyKey) parts.push(idempotencyKey);
  parts.push(createHash('sha256').update(body, 'utf8').digest('hex'));

  const v1 = createHmac('sha256', API_SECRET).update(parts.join('\n'), 'utf8').digest('hex');
  return 't=' + t + ',v1=' + v1;
}

async function call<T>(method: string, path: string, payload?: unknown, idempotencyKey?: string): Promise<T> {
  // Serialise once. The signature covers these exact bytes, so handing fetch a
  // second JSON.stringify of the same object is a coin flip with no upside.
  const body = payload === undefined ? '' : JSON.stringify(payload);

  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
    'X-API-Key': API_KEY,
    'X-Signature': signRequest(method, path, body, idempotencyKey),
  };
  if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;

  const res = await fetch(BASE_URL + path, { method, headers, body: body || undefined });
  const envelope = await res.json();
  if (!res.ok) throw new Error(envelope.error.code + ' โ€” ' + envelope.error.message);

  // Every gateway response is wrapped: { success, data, timestamp, requestId }.
  return envelope.data as T;
}

// Pin the key to the operation, not to the attempt: store it beside the order
// and resend it on every retry. A fresh key opens a second invoice; the same key
// with a different body is refused with IDEMPOTENCY_KEY_CONFLICT (409).
const idempotencyKey = randomUUID();

const invoice = await call<{ id: string; referenceId: string; status: string }>(
  'POST',
  '/api/v1/invoices',
  {
    orderId: 'order-8814',
    amount: '12500',      // minor units of `currency` โ€” 12500 is USD 125.00
    currency: 'USD',      // what you price in; the buyer picks the crypto at checkout
    description: 'Pro plan, annual billing',
    customerEmail: 'buyer@example.com',
    returnUrl: 'https://merchant.example.com/checkout/success',
    cancelUrl: 'https://merchant.example.com/checkout/cancel',
    expiresInMinutes: 60, // 15โ€“1440; expiresAt is stamped from this
  },
  idempotencyKey,
);

console.log(invoice.id, invoice.referenceId, invoice.status); // -> clyv3n8xโ€ฆ  INV-260826-041C  OPEN

// Read it back. Nothing to hash, and no idempotency line on a GET.
const fresh = await call<{ status: string; payments?: unknown[] }>(
  'GET',
  '/api/v1/invoices/' + invoice.id,
);

// payments[] is included on this route and on no other: create, checkout and
// cancel return the invoice without that key at all.
console.log(fresh.status, fresh.payments);

Handle errors

TypeScript
import { createHash, createHmac, randomUUID } from 'node:crypto';

// Every failure shares one envelope, built by the gateway's exception filter:
//
//   {
//     "success": false,
//     "error": { "code": "SIGNATURE_INVALID", "message": "Invalid request signature" },
//     "timestamp": "2026-08-26T09:14:22.481Z",
//     "requestId": "3f1c0b7d-4a29-4e63-9b0c-8d17a4e92f6b"
//   }
//
// error.details is present on VALIDATION_ERROR, where it carries
// details.errors[] of { field, message, value }. requestId is the correlation id
// that also appears in Havala's logs โ€” quote it when you ask why a call failed.

class HavalaError extends Error {
  constructor(
    readonly code: string,
    message: string,
    readonly status: number,
    readonly requestId: string,
    readonly details?: Record<string, unknown>,
  ) {
    super(message);
    this.name = 'HavalaError';
  }
}

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

async function call<T>(method: string, path: string, payload?: unknown, idempotencyKey?: string): Promise<T> {
  const body = payload === undefined ? '' : JSON.stringify(payload);
  const t = Math.floor(Date.now() / 1000);

  const parts = [String(t), method, path];
  if (WRITE_METHODS.has(method) && idempotencyKey) parts.push(idempotencyKey);
  parts.push(createHash('sha256').update(body, 'utf8').digest('hex'));

  const v1 = createHmac('sha256', process.env.HAVALA_API_SECRET!)
    .update(parts.join('\n'), 'utf8')
    .digest('hex');

  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
    'X-API-Key': process.env.HAVALA_API_KEY!,
    'X-Signature': 't=' + t + ',v1=' + v1,
  };
  if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;

  const res = await fetch(BASE_URL + path, { method, headers, body: body || undefined });
  const envelope = await res.json();

  if (!res.ok) {
    throw new HavalaError(
      envelope.error.code,
      envelope.error.message,
      res.status,
      envelope.requestId,
      envelope.error.details,
    );
  }
  return envelope.data as T;
}

const idempotencyKey = randomUUID();

try {
  await call('POST', '/api/v1/invoices', { orderId: 'order-8814', amount: '12500', currency: 'USD' }, idempotencyKey);
} catch (err) {
  if (!(err instanceof HavalaError)) throw err; // network or JSON failure: safe to retry
  switch (err.code) {
    case 'API_KEY_INVALID':
      // 401. Unknown key, or the merchant point was deactivated. Nothing about
      // this request will succeed on a retry.
      break;

    case 'SIGNATURE_MISSING':
      // 401. X-Signature never arrived. Usually a proxy or a CDN dropping
      // unknown headers rather than a bug in your signer.
      break;

    case 'SIGNATURE_INVALID':
      // 401. Either the header did not match ^t=\d+,v1=[a-f0-9]{64}$ or the HMAC
      // disagreed; the guard will not say which. Check the canonical string
      // before you suspect the secret โ€” a query string left in the signed path,
      // an uppercase hex digest and a re-serialised body all land here.
      break;

    case 'SIGNATURE_EXPIRED':
      // 401. |now - t| exceeded 300s: your clock, not your key. Re-sign with a
      // fresh t and resend with the SAME Idempotency-Key, or you create a
      // second invoice for the same order.
      break;

    case 'IDEMPOTENCY_KEY_REQUIRED':
      // 400. A write method without the header. The guard rejects before the
      // handler runs, so nothing was created and nothing needs unwinding.
      break;

    case 'IDEMPOTENCY_KEY_CONFLICT':
      // 409. This key was already used with a different method, path or body
      // hash โ€” or the first attempt is still in flight. Read the original
      // invoice rather than reissuing.
      break;

    case 'VALIDATION_ERROR':
      // 422. The DTO layer runs with forbidNonWhitelisted, so an unrecognised
      // property fails the request instead of being dropped quietly.
      console.error(err.details?.errors);
      break;

    default:
      // 5xx and anything unmapped: retry with backoff, reusing the same key.
      throw err;
  }
}

Verify a webhook

TypeScript
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';

// The signing key is the merchant point apiSecret โ€” the same value that keys
// X-Signature on the gateway API. Rotating it in the dashboard invalidates both
// at the same instant; there is no overlap window.
const SECRET = process.env.HAVALA_API_SECRET!;

createServer((req, res) => {
  if (req.method !== 'POST' || req.url !== '/havala/webhooks') {
    res.writeHead(404).end();
    return;
  }

  // Collect the untouched bytes. Havala signs the string its dispatcher put on
  // the wire, so anything that parses and re-serialises first โ€” a JSON body
  // parser, a proxy that pretty-prints โ€” changes the digest. In Express use
  // express.raw({ type: 'application/json' }); in Fastify, a content-type parser
  // with parseAs 'buffer'. Never the parsed object.
  const chunks: Buffer[] = [];
  req.on('data', (chunk) => chunks.push(chunk));
  req.on('end', () => {
    const raw = Buffer.concat(chunks);

    const expected = Buffer.from('sha256=' + createHmac('sha256', SECRET).update(raw).digest('hex'));
    const provided = Buffer.from(String(req.headers['x-webhook-signature'] ?? ''));

    // timingSafeEqual throws when the lengths differ, so gate on length and only
    // then compare in constant time.
    if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
      res.writeHead(401).end('bad signature');
      return;
    }

    // Only the body is signed โ€” no timestamp, method or path is folded in โ€” so a
    // captured delivery replays forever unless you bound its age yourself.
    // X-Webhook-Timestamp is MILLISECONDS since epoch, not seconds.
    const sentAt = Number(req.headers['x-webhook-timestamp']);
    if (!Number.isFinite(sentAt) || Math.abs(Date.now() - sentAt) > 300_000) {
      res.writeHead(401).end('stale delivery');
      return;
    }

    const event = JSON.parse(raw.toString('utf8'));
    if (event.event === 'payment.completed') {
      // The envelope is camelCase; the object inside data is snake_case.
      const payment = event.data.payment;
      void fulfilOrder(payment.id, payment.transaction_hash, payment.received_amount);
    }

    // Havala allows 10 seconds and counts only 2xx as delivered. Acknowledge
    // first, work after.
    res.writeHead(200).end('ok');
  });
}).listen(3000);

async function fulfilOrder(paymentId: string, txHash: string, receivedAmount: string) {
  // payment.completed is at-least-once: the confirmation tracker re-enters the
  // notify path when a job re-runs against an already COMPLETED payment. Dedupe
  // on paymentId + txHash before you release anything.
  //
  // receivedAmount is a decimal string in the crypto's own unit ("149.500000"),
  // not minor units and not a float. Compare it as a decimal.
  //
  // The envelope is a notification, not proof of settlement. Confirm against
  // GET /api/v1/payments/{id}/status before shipping anything expensive.
}