Docs

Havala in Java

Runtime: Java 17+

Dependencies

No dependencies โ€” javax.crypto.Mac and java.net.http on JDK 17+

Set up the client

Java
// 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() {
    }
}

Create an invoice

Java
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));
    }
}

Handle errors

Java
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));
    }
}

Verify a webhook

Java
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();
    }
}