Docs
GuidesAuthentication & Request Signing

Authentication & Request Signing

Beginner15 min

How the canonical string is assembled and signed with HMAC-SHA256, including the idempotency line that only write methods carry.


Every call to the gateway API (/api/v1/โ€ฆ) is signed with HMAC-SHA256 using the merchant point's apiSecret. The secret is never transmitted. What travels is a timestamp and a digest, so a captured request cannot be replayed outside a five-minute window and cannot be modified at all without invalidating it.

This is the only auth the gateway accepts, and the path prefix โ€” not the hostname โ€” decides which scheme applies. The buyer-facing checkout API (/checkout/api/v1/โ€ฆ) is the other public surface, and it is unauthenticated by design: it runs in the buyer's browser, where there is no secret to hold. Havala's merchant dashboard has an API of its own behind its own session auth, but that is an internal frontend contract rather than an integration surface and is not documented here. Nothing crosses the boundary: a dashboard session cannot create an invoice on the gateway, and an apiKey cannot sign into the dashboard.

Headers

HeaderValueWhen
X-API-KeyThe merchant point apiKeyAlways
X-Signaturet={unix_seconds},v1={hex_hmac_sha256}Always
Content-Typeapplication/jsonAlways
Idempotency-KeyUnique per logical operationPOST, PUT, PATCH, DELETE

X-Signature is matched against ^t=(\d+),v1=([a-f0-9]{64})$. The hex must be lowercase and exactly 64 characters. Uppercase hex fails the regex before the HMAC is ever computed, which is why some libraries' default hex output produces a SIGNATURE_INVALID that looks like a key problem.

The canonical string

The bytes you sign are these fields joined with \n โ€” a literal newline, not the two characters \ and n:

text
{t}
{METHOD}
{path}
{idempotency-key}      โ† write methods only
{sha256_hex(rawBody)}

Written on one line:

text
{t}\n{METHOD}\n{path}\n[{idempotency-key}\n]{sha256_hex(rawBody || "")}

Field by field:

  • t โ€” Unix time in seconds, as a decimal string. The same value goes into the X-Signature header. Milliseconds will parse and then fail the tolerance check.
  • METHOD โ€” uppercase HTTP verb.
  • path โ€” the route path only. Havala signs Express's req.path, which excludes the query string. Sign /api/v1/payments, never /api/v1/payments?status=COMPLETED. Filters ride along unsigned.
  • idempotency-key โ€” present as its own line only when the method is POST, PUT, PATCH or DELETE. On GET there is no such line, and adding one produces a signature the server will not match.
  • sha256_hex(rawBody) โ€” SHA-256 of the raw request body bytes, hex-encoded. With no body that is the hash of the empty string: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.

Then:

text
signature = hex(hmac_sha256(apiSecret, canonicalString))
X-Signature: t={t},v1={signature}

The server recomputes the digest from the raw body buffer it received, not from a re-serialised object. Serialise your body once, hash that string, and send that same string. A JSON encoder that reorders keys, adds whitespace, or escapes slashes differently between the two steps produces a valid-looking request that fails verification every time.

A worked GET

For GET /api/v1/invoices/clyv3n8x40001qh7m2k9d5b1t at t=1787735662, the canonical string is exactly four lines:

text
1787735662
GET
/api/v1/invoices/clyv3n8x40001qh7m2k9d5b1t
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

A worked POST

For POST /api/v1/invoices with Idempotency-Key: 9f2b1c44-7a0e-4d63-9f18-2c5b7ea31d04, it is five lines, and the last one is the SHA-256 of the body bytes you are about to send:

text
1787735662
POST
/api/v1/invoices
9f2b1c44-7a0e-4d63-9f18-2c5b7ea31d04
{sha256_hex of the JSON body}

The ยฑ300 second window

The guard compares Math.abs(nowSeconds - t) against 300 and rejects anything outside it with SIGNATURE_EXPIRED. The window is symmetric, so a clock that runs five minutes fast fails exactly as hard as one that runs slow.

Two consequences worth designing for:

  • Run NTP on any host that signs requests. A container host with a drifting clock is the most common source of intermittent SIGNATURE_EXPIRED in production, and it fails in bursts rather than steadily, which makes it look like a network problem.
  • Sign at send time, not at build time. If you queue a request and it waits six minutes for a worker, re-sign it before it goes out โ€” the timestamp is part of the digest, so you cannot patch the header alone.

Idempotency keys

Idempotency-Key is required on every write. The guard rejects a write without one with 400 IDEMPOTENCY_KEY_REQUIRED before your request reaches a handler. Use a UUID, one per logical operation โ€” one per checkout attempt, not one per HTTP retry.

Behind the guard, an interceptor stores the response under idempotency:{merchantPointId}:{idempotencyKey} for 24 hours, along with a request fingerprint of sha256(METHOD \n path \n sha256(body)):

  • Same key, same fingerprint, already completed โ€” the stored status code and body are replayed byte for byte. This is what makes a timed-out create safe to retry: retry with the same key and you get the original invoice back rather than a second one.
  • Same key, different fingerprint โ€” 409 IDEMPOTENCY_KEY_CONFLICT. You reused a key for a different operation.
  • Same key, request still in flight โ€” also 409 IDEMPOTENCY_KEY_CONFLICT, with a message saying it is currently being processed. Back off and retry rather than treating it as fatal.
  • Handler threw โ€” the in-flight entry is deleted, so the same key can be used again for a genuine retry.

Keys are scoped per merchant point, so two points can use the same key value without colliding.

Error codes

The guard runs its checks in a fixed order, which is useful for triage: the code you get back tells you how far the request got.

CodeHTTPRaised when
API_KEY_INVALID401X-API-Key header absent โ€” checked first, before anything else
SIGNATURE_MISSING401X-Signature header absent
SIGNATURE_INVALID401X-Signature does not match t=โ€ฆ,v1=โ€ฆ with 64 lowercase hex characters
SIGNATURE_EXPIRED401t is more than 300 seconds from server time, in either direction
IDEMPOTENCY_KEY_REQUIRED400Write method with no Idempotency-Key header
API_KEY_INVALID401The key is unknown, or its merchant point is not active
SIGNATURE_INVALID401The recomputed HMAC did not match the one you sent

API_KEY_INVALID and SIGNATURE_INVALID each appear twice, at different stages. A SIGNATURE_INVALID raised before the timestamp check is a formatting problem; the same code after it is a digest mismatch. To tell them apart, send a deliberately malformed header value โ€” a format failure reproduces instantly, a digest failure does not.

When the digest is the problem, work through this in order:

  1. Is the query string in the signed path? It must not be.
  2. Is the idempotency line present on a GET, or missing on a write?
  3. Are you hashing the same bytes you send? Log both and diff them.
  4. Is t in seconds, and is it the same value in the canonical string and in the header?
  5. Is the hex lowercase?
  6. Was the secret rotated? Rotation takes effect immediately, with no overlap window โ€” see Going Live.

Working clients

Each of these signs correctly for both reads and writes, including the conditional idempotency line. They are deliberately one function rather than a framework: drop it in and call it.

Node.js

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

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

export async function havala(method: string, path: string, body?: unknown) {
  // One serialisation, used for both the hash and the wire.
  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();
}

Python

Python
import hashlib
import hmac
import json
import os
import time
import uuid

import requests

BASE = "https://api2.havala.io"
WRITE = {"POST", "PUT", "PATCH", "DELETE"}


def havala(method: str, path: str, body=None) -> requests.Response:
    # separators= drops the spaces json.dumps adds by default. Which form you
    # pick does not matter; that these are the bytes you post does.
    raw = b"" if body is None else json.dumps(body, separators=(",", ":")).encode()
    t = str(int(time.time()))
    body_hash = hashlib.sha256(raw).hexdigest()

    headers = {
        "X-API-Key": os.environ["HAVALA_API_KEY"],
        "Content-Type": "application/json",
    }

    parts = [t, method, path]
    if method in WRITE:
        idempotency_key = str(uuid.uuid4())
        headers["Idempotency-Key"] = idempotency_key
        parts.append(idempotency_key)
    parts.append(body_hash)

    signature = hmac.new(
        os.environ["HAVALA_API_SECRET"].encode(),
        "\n".join(parts).encode(),
        hashlib.sha256,
    ).hexdigest()
    headers["X-Signature"] = f"t={t},v1={signature}"

    return requests.request(method, BASE + path, headers=headers, data=raw, timeout=30)

PHP

PHP
<?php

const HAVALA_BASE = 'https://api2.havala.io';
const HAVALA_WRITE = ['POST', 'PUT', 'PATCH', 'DELETE'];

function havala(string $method, string $path, ?array $body = null): array
{
    // json_encode once. Flags change the bytes, and the bytes are the digest.
    $raw = $body === null ? '' : json_encode($body, JSON_UNESCAPED_SLASHES);
    $t = (string) time();
    $bodyHash = hash('sha256', $raw);

    $headers = [
        'X-API-Key: ' . getenv('HAVALA_API_KEY'),
        'Content-Type: application/json',
    ];

    $parts = [$t, $method, $path];
    if (in_array($method, HAVALA_WRITE, true)) {
        $idempotencyKey = bin2hex(random_bytes(16));
        $headers[] = 'Idempotency-Key: ' . $idempotencyKey;
        $parts[] = $idempotencyKey;
    }
    $parts[] = $bodyHash;

    // hash_hmac returns lowercase hex, which is what the header regex demands.
    $signature = hash_hmac('sha256', implode("\n", $parts), getenv('HAVALA_API_SECRET'));
    $headers[] = 'X-Signature: t=' . $t . ',v1=' . $signature;

    $ch = curl_init(HAVALA_BASE . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_POSTFIELDS     => $raw,
        CURLOPT_RETURNTRANSFER => true,
    ]);

    $response = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    return ['status' => $status, 'body' => json_decode($response, true)];
}

Go

Go
package havala

import (
	"bytes"
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

const baseURL = "https://api2.havala.io"

func isWrite(method string) bool {
	switch method {
	case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
		return true
	}
	return false
}

// Do signs and sends one gateway request. body may be nil for GET and DELETE.
func Do(method, path string, body any) (*http.Response, error) {
	raw := []byte{}
	if body != nil {
		encoded, err := json.Marshal(body)
		if err != nil {
			return nil, err
		}
		raw = encoded
	}

	t := strconv.FormatInt(time.Now().Unix(), 10)
	sum := sha256.Sum256(raw)

	req, err := http.NewRequest(method, baseURL+path, bytes.NewReader(raw))
	if err != nil {
		return nil, err
	}
	req.Header.Set("X-API-Key", os.Getenv("HAVALA_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	parts := []string{t, method, path}
	if isWrite(method) {
		buf := make([]byte, 16)
		if _, err := rand.Read(buf); err != nil {
			return nil, err
		}
		idempotencyKey := hex.EncodeToString(buf)
		req.Header.Set("Idempotency-Key", idempotencyKey)
		parts = append(parts, idempotencyKey)
	}
	parts = append(parts, hex.EncodeToString(sum[:]))

	mac := hmac.New(sha256.New, []byte(os.Getenv("HAVALA_API_SECRET")))
	mac.Write([]byte(strings.Join(parts, "\n")))
	req.Header.Set("X-Signature", fmt.Sprintf("t=%s,v1=%s", t, hex.EncodeToString(mac.Sum(nil))))

	return http.DefaultClient.Do(req)
}

Java

Java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Set;
import java.util.UUID;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public final class HavalaClient {

    private static final String BASE = "https://api2.havala.io";
    private static final Set<String> WRITE = Set.of("POST", "PUT", "PATCH", "DELETE");
    private static final HttpClient HTTP = HttpClient.newHttpClient();

    private final String apiKey;
    private final String apiSecret;

    public HavalaClient(String apiKey, String apiSecret) {
        this.apiKey = apiKey;
        this.apiSecret = apiSecret;
    }

    /** json is the already-serialised body, or "" for GET and DELETE. */
    public HttpResponse<String> send(String method, String path, String json) throws Exception {
        byte[] raw = json.getBytes(StandardCharsets.UTF_8);
        String t = Long.toString(Instant.now().getEpochSecond());

        // HexFormat emits lowercase, which the X-Signature regex requires.
        HexFormat hex = HexFormat.of();
        String bodyHash = hex.formatHex(MessageDigest.getInstance("SHA-256").digest(raw));

        HttpRequest.Builder req = HttpRequest.newBuilder(URI.create(BASE + path))
                .method(method, HttpRequest.BodyPublishers.ofByteArray(raw))
                .header("X-API-Key", apiKey)
                .header("Content-Type", "application/json");

        StringBuilder canonical = new StringBuilder()
                .append(t).append('\n')
                .append(method).append('\n')
                .append(path).append('\n');

        if (WRITE.contains(method)) {
            String idempotencyKey = UUID.randomUUID().toString();
            req.header("Idempotency-Key", idempotencyKey);
            canonical.append(idempotencyKey).append('\n');
        }
        canonical.append(bodyHash);

        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(apiSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        String signature = hex.formatHex(
                mac.doFinal(canonical.toString().getBytes(StandardCharsets.UTF_8)));

        req.header("X-Signature", "t=" + t + ",v1=" + signature);
        return HTTP.send(req.build(), HttpResponse.BodyHandlers.ofString());
    }
}

C#

C#
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;

public sealed class HavalaClient(string apiKey, string apiSecret)
{
    private static readonly HttpClient Http =
        new() { BaseAddress = new Uri("https://api2.havala.io") };

    private static readonly HashSet<string> Write = ["POST", "PUT", "PATCH", "DELETE"];

    /// <param name="json">The already-serialised body, or "" for GET and DELETE.</param>
    public async Task<HttpResponseMessage> SendAsync(
        HttpMethod method, string path, string json = "")
    {
        var raw = Encoding.UTF8.GetBytes(json);
        var t = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();

        // Convert.ToHexString returns uppercase; the header regex accepts only
        // lowercase, so both digests are lowered here.
        var bodyHash = Convert.ToHexString(SHA256.HashData(raw)).ToLowerInvariant();

        var request = new HttpRequestMessage(method, path);
        request.Headers.Add("X-API-Key", apiKey);

        var parts = new List<string> { t, method.Method, path };
        if (Write.Contains(method.Method))
        {
            var idempotencyKey = Guid.NewGuid().ToString();
            request.Headers.Add("Idempotency-Key", idempotencyKey);
            parts.Add(idempotencyKey);
        }
        parts.Add(bodyHash);

        var signature = Convert.ToHexString(
            HMACSHA256.HashData(
                Encoding.UTF8.GetBytes(apiSecret),
                Encoding.UTF8.GetBytes(string.Join("\n", parts)))
        ).ToLowerInvariant();
        request.Headers.Add("X-Signature", $"t={t},v1={signature}");

        if (raw.Length > 0)
        {
            request.Content = new ByteArrayContent(raw);
            request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
        }

        return await Http.SendAsync(request);
    }
}

Keeping the secret secret

The apiSecret is symmetric and it does two jobs: it signs your outbound requests and it verifies Havala's inbound webhook deliveries. One compromised secret therefore lets an attacker both spend against your account and forge settlement notifications to your endpoint.

  • Keep it server-side. It has no browser-safe use.
  • Never log the canonical string next to the signature in production. Together they are a signing oracle for that request.
  • Rotating it invalidates request signing and webhook verification at the same instant, with no grace period. Plan the swap before you trigger it โ€” see Going Live.
NextWhat it covers
Accepting Crypto PaymentsThe first real thing to sign
WebhooksThe same secret, used in the other direction
Going LiveRotation, hardening, checklist