Runtime: Node.js 18+
No dependencies โ node:crypto and fetch are built in on Node 18+
// 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;
}
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);
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;
}
}
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.
}