Authentication & Request Signing
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
| Header | Value | When |
|---|---|---|
X-API-Key | The merchant point apiKey | Always |
X-Signature | t={unix_seconds},v1={hex_hmac_sha256} | Always |
Content-Type | application/json | Always |
Idempotency-Key | Unique per logical operation | POST, 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:
{t}
{METHOD}
{path}
{idempotency-key} โ write methods only
{sha256_hex(rawBody)}Written on one line:
{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 theX-Signatureheader. Milliseconds will parse and then fail the tolerance check.METHODโ uppercase HTTP verb.pathโ the route path only. Havala signs Express'sreq.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 isPOST,PUT,PATCHorDELETE. OnGETthere 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:
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:
1787735662
GET
/api/v1/invoices/clyv3n8x40001qh7m2k9d5b1t
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855A 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:
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_EXPIREDin 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.
| Code | HTTP | Raised when |
|---|---|---|
API_KEY_INVALID | 401 | X-API-Key header absent โ checked first, before anything else |
SIGNATURE_MISSING | 401 | X-Signature header absent |
SIGNATURE_INVALID | 401 | X-Signature does not match t=โฆ,v1=โฆ with 64 lowercase hex characters |
SIGNATURE_EXPIRED | 401 | t is more than 300 seconds from server time, in either direction |
IDEMPOTENCY_KEY_REQUIRED | 400 | Write method with no Idempotency-Key header |
API_KEY_INVALID | 401 | The key is unknown, or its merchant point is not active |
SIGNATURE_INVALID | 401 | The 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:
- Is the query string in the signed path? It must not be.
- Is the idempotency line present on a
GET, or missing on a write? - Are you hashing the same bytes you send? Log both and diff them.
- Is
tin seconds, and is it the same value in the canonical string and in the header? - Is the hex lowercase?
- 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
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
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
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
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
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#
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.
| Next | What it covers |
|---|---|
| Accepting Crypto Payments | The first real thing to sign |
| Webhooks | The same secret, used in the other direction |
| Going Live | Rotation, hardening, checklist |