Runtime: Java 17+
No dependencies โ javax.crypto.Mac and java.net.http on JDK 17+
// HavalaSigner.java โ the whole of Havala gateway auth, in one method.
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Set;
public final class HavalaSigner {
private static final Set<String> WRITE_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE");
/**
* Returns the X-Signature header value: t={unix_seconds},v1={hex}.
*
* secret is the merchant point apiSecret (64 hex chars), never the API key.
* path is the path alone โ the server signs the request path, so a query
* string must be left out. body is the exact text you will send, "" when
* there is none. idempotencyKey belongs on POST/PUT/PATCH/DELETE and is null
* everywhere else.
*/
public static String sign(String secret, String method, String path, String body, String idempotencyKey)
throws Exception {
String upperMethod = method.toUpperCase();
// t is checked against server time with a +/-300s window, so sign
// immediately before sending. A signature parked in a queue expires.
long t = Instant.now().getEpochSecond();
// sha256 over the raw body bytes. No body hashes the empty string, which
// is always e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
String bodyHash = HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(body.getBytes(StandardCharsets.UTF_8)));
// {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.
StringBuilder canonical = new StringBuilder()
.append(t).append('\n')
.append(upperMethod).append('\n')
.append(path).append('\n');
if (WRITE_METHODS.contains(upperMethod) && idempotencyKey != null) {
canonical.append(idempotencyKey).append('\n');
}
canonical.append(bodyHash);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
// HexFormat.of() is lowercase; the guard's regex accepts [a-f0-9] only,
// so an uppercase digest is rejected as a malformed header.
String v1 = HexFormat.of().formatHex(
mac.doFinal(canonical.toString().getBytes(StandardCharsets.UTF_8)));
return "t=" + t + ",v1=" + v1;
}
private HavalaSigner() {
}
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.UUID;
public class CreateInvoice {
private static final String BASE_URL = "https://api2.havala.io";
private static final HttpClient HTTP = HttpClient.newHttpClient();
public static void main(String[] args) throws Exception {
String apiKey = System.getenv("HAVALA_API_KEY"); // merchant point apiKey, 32 hex chars
String secret = System.getenv("HAVALA_API_SECRET"); // merchant point apiSecret, 64 hex chars
// Hand-written JSON, on purpose: the signature covers these exact bytes,
// so the string you hash and the string you send must be the same object.
// With Jackson, serialise once into a String and reuse that String.
//
// amount minor units of currency โ 12500 is USD 125.00
// currency what you price in; the buyer picks the crypto at checkout
// expiresInMinutes 15-1440; expiresAt is stamped from this
String body = """
{
"orderId": "order-8814",
"amount": "12500",
"currency": "USD",
"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
}""";
// 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).
String idempotencyKey = UUID.randomUUID().toString();
HttpRequest create = HttpRequest.newBuilder(URI.create(BASE_URL + "/api/v1/invoices"))
.header("Content-Type", "application/json")
.header("X-API-Key", apiKey)
.header("Idempotency-Key", idempotencyKey)
.header("X-Signature", HavalaSigner.sign(secret, "POST", "/api/v1/invoices", body, idempotencyKey))
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
// Every gateway response is wrapped: { success, data, timestamp, requestId }.
String created = HTTP.send(create, HttpResponse.BodyHandlers.ofString()).body();
String invoiceId = field(created, "id");
System.out.println(invoiceId + " " + field(created, "referenceId") + " " + field(created, "status"));
// Read it back. Nothing to hash, and no idempotency line on a GET.
String path = "/api/v1/invoices/" + invoiceId;
HttpRequest read = HttpRequest.newBuilder(URI.create(BASE_URL + path))
.header("X-API-Key", apiKey)
.header("X-Signature", HavalaSigner.sign(secret, "GET", path, "", null))
.GET()
.build();
// payments[] is included on this route and on no other: create, checkout
// and cancel return the invoice without that key at all.
System.out.println(HTTP.send(read, HttpResponse.BodyHandlers.ofString()).body());
}
/**
* Pulls one string field out of the compact JSON the gateway emits. Enough
* to lift an id out of a known-shape response and no more โ reach for
* Jackson or Gson the moment you branch on anything richer.
*/
private static String field(String json, String key) {
String needle = "\"" + key + "\":";
int at = json.indexOf(needle);
if (at < 0) {
return null;
}
int start = json.indexOf('"', at + needle.length()) + 1;
return json.substring(start, json.indexOf('"', start));
}
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.UUID;
/*
* 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.
*/
public class HandleErrors {
public static void main(String[] args) throws Exception {
String body = "{\"orderId\":\"order-8814\",\"amount\":\"12500\",\"currency\":\"USD\"}";
String path = "/api/v1/invoices";
String idempotencyKey = UUID.randomUUID().toString();
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api2.havala.io" + path))
.header("Content-Type", "application/json")
.header("X-API-Key", System.getenv("HAVALA_API_KEY"))
.header("Idempotency-Key", idempotencyKey)
.header("X-Signature", HavalaSigner.sign(
System.getenv("HAVALA_API_SECRET"), "POST", path, body, idempotencyKey))
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response;
try {
response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
} catch (java.io.IOException transportFailure) {
// Never reached the server, or never read the answer. The write may
// still have landed: retry with the SAME Idempotency-Key and Havala
// replays the first response rather than creating a second invoice.
throw transportFailure;
}
if (response.statusCode() < 400) {
return;
}
switch (field(response.body(), "code")) {
case "API_KEY_INVALID" ->
// 401. Unknown key, or the merchant point was deactivated.
// Nothing about this request will succeed on a retry.
System.err.println("credentials rejected");
case "SIGNATURE_MISSING" ->
// 401. X-Signature never arrived. Usually a proxy or a CDN
// dropping unknown headers rather than a bug in your signer.
System.err.println("header stripped in transit");
case "SIGNATURE_INVALID" ->
// 401. Either the header did not match the t=/v1= form 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.
System.err.println("canonical string mismatch");
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.
System.err.println("clock drift");
case "IDEMPOTENCY_KEY_REQUIRED" ->
// 400. A write method without the header. The guard rejects
// before the handler runs, so nothing was created.
System.err.println("missing Idempotency-Key");
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.
System.err.println("key reused with different parameters");
case "VALIDATION_ERROR" ->
// 422. The DTO layer runs with forbidNonWhitelisted, so an
// unrecognised property fails the request instead of being
// dropped quietly. error.details.errors[] names the field.
System.err.println(response.body());
default ->
// 5xx and anything unmapped: retry with backoff, same key.
System.err.println("retryable: " + response.body());
}
}
private static String field(String json, String key) {
String needle = "\"" + key + "\":";
int at = json.indexOf(needle);
if (at < 0) {
return "";
}
int start = json.indexOf('"', at + needle.length()) + 1;
return json.substring(start, json.indexOf('"', start));
}
}
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
public class HavalaWebhook {
// 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.
private static final byte[] SECRET =
System.getenv("HAVALA_API_SECRET").getBytes(StandardCharsets.UTF_8);
public static void main(String[] args) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress(3000), 0);
server.createContext("/havala/webhooks", HavalaWebhook::handle);
server.start();
}
private static void handle(HttpExchange exchange) throws IOException {
// Read the untouched bytes. Havala signs the string its dispatcher put
// on the wire, so a framework that parses the body and hands you an
// object has already destroyed the input. In Spring, take the body as
// @RequestBody byte[] and verify before you deserialise.
byte[] raw = exchange.getRequestBody().readAllBytes();
String expected;
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SECRET, "HmacSHA256"));
expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(raw));
} catch (Exception unreachable) {
throw new IllegalStateException("HmacSHA256 is mandatory on every JDK", unreachable);
}
String provided = exchange.getRequestHeaders().getFirst("X-Webhook-Signature");
// MessageDigest.isEqual is the JDK's constant-time comparison and is
// length-safe; String.equals short-circuits and leaks the prefix length.
if (provided == null || !MessageDigest.isEqual(
provided.getBytes(StandardCharsets.UTF_8), expected.getBytes(StandardCharsets.UTF_8))) {
reply(exchange, 401, "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.
long sentAt = Long.parseLong(exchange.getRequestHeaders().getFirst("X-Webhook-Timestamp"));
if (Math.abs(System.currentTimeMillis() - sentAt) > 300_000L) {
reply(exchange, 401, "stale delivery");
return;
}
// X-Webhook-Event carries the same string as the envelope's event
// field, so you can route without parsing the body at all.
if ("payment.completed".equals(exchange.getRequestHeaders().getFirst("X-Webhook-Event"))) {
// Inside data.payment the keys are snake_case: received_amount
// and transaction_hash. order_id and paid_at belong to the
// invoice payload, not this one.
fulfilOrder(new String(raw, StandardCharsets.UTF_8));
}
// Havala allows 10 seconds and counts only 2xx as delivered.
// Acknowledge first, work after.
reply(exchange, 200, "ok");
}
/**
* Queue the work; this handler must return inside the 10-second window.
*
* 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 data.payment.id + transaction_hash before you release anything.
*
* received_amount is a decimal string in the crypto's own unit
* ("149.500000"), not minor units โ parse it with BigDecimal, never double.
*
* The envelope is a notification, not proof of settlement. Confirm against
* GET /api/v1/payments/{id}/status before shipping anything expensive.
*/
private static void fulfilOrder(String payload) {
}
private static void reply(HttpExchange exchange, int status, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
}
}