Docs

Havala in .NET

Runtime: .NET 8+

Dependencies

No dependencies โ€” System.Security.Cryptography on .NET 8+

Set up the client

C#
// HavalaSigner.cs โ€” the whole of Havala gateway auth, in one method.
using System.Security.Cryptography;
using System.Text;

public static class HavalaSigner
{
    private static readonly HashSet<string> WriteMethods =
        new(StringComparer.Ordinal) { "POST", "PUT", "PATCH", "DELETE" };

    /// <summary>
    /// Returns the X-Signature header value: t={unix_seconds},v1={hex}.
    /// </summary>
    /// <param name="secret">Merchant point apiSecret, 64 hex chars. Never the API key.</param>
    /// <param name="path">Path only โ€” the server signs Request.Path, so leave the query string out.</param>
    /// <param name="body">The exact text you will send; "" when there is none.</param>
    /// <param name="idempotencyKey">Set on POST/PUT/PATCH/DELETE, null everywhere else.</param>
    public static string Sign(
        string secret,
        string method,
        string path,
        string body = "",
        string? idempotencyKey = null)
    {
        method = method.ToUpperInvariant();

        // t is checked against server time with a ยฑ300s window, so sign
        // immediately before sending. A signature parked in a queue expires.
        var t = DateTimeOffset.UtcNow.ToUnixTimeSeconds();

        // sha256 over the raw body bytes. No body hashes the empty string, which
        // is always e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
        var bodyHash = Hex(SHA256.HashData(Encoding.UTF8.GetBytes(body)));

        // {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.
        var parts = new List<string> { t.ToString(), method, path };
        if (WriteMethods.Contains(method) && idempotencyKey is not null)
        {
            parts.Add(idempotencyKey);
        }
        parts.Add(bodyHash);

        var v1 = Hex(HMACSHA256.HashData(
            Encoding.UTF8.GetBytes(secret),
            Encoding.UTF8.GetBytes(string.Join("\n", parts))));

        return $"t={t},v1={v1}";
    }

    // Convert.ToHexString returns uppercase. The guard's regex accepts [a-f0-9]
    // only, so an uppercase digest is rejected as a malformed header.
    private static string Hex(byte[] bytes) => Convert.ToHexString(bytes).ToLowerInvariant();
}

Create an invoice

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

const string BaseUrl = "https://api2.havala.io";

var apiKey = Environment.GetEnvironmentVariable("HAVALA_API_KEY")!;      // 32 hex chars
var apiSecret = Environment.GetEnvironmentVariable("HAVALA_API_SECRET")!; // 64 hex chars
var http = new HttpClient();

// 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).
var idempotencyKey = Guid.NewGuid().ToString();

// Serialise once. The signature covers these exact bytes, so a second
// JsonSerializer.Serialize of the same object is a risk with no upside.
var body = JsonSerializer.Serialize(new
{
    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
});

var created = await Call(HttpMethod.Post, "/api/v1/invoices", body, idempotencyKey);
var invoiceId = created.GetProperty("id").GetString();
Console.WriteLine($"{invoiceId} {created.GetProperty("referenceId")} {created.GetProperty("status")}");

// Read it back. Nothing to hash, and no idempotency line on a GET.
// payments[] is included on this route and on no other: create, checkout and
// cancel return the invoice without that key at all.
var fresh = await Call(HttpMethod.Get, $"/api/v1/invoices/{invoiceId}");
Console.WriteLine(fresh.GetProperty("status"));

async Task<JsonElement> Call(HttpMethod method, string path, string body = "", string? idempotencyKey = null)
{
    var request = new HttpRequestMessage(method, BaseUrl + path);
    request.Headers.Add("X-API-Key", apiKey);
    request.Headers.Add("X-Signature", HavalaSigner.Sign(apiSecret, method.Method, path, body, idempotencyKey));
    if (idempotencyKey is not null)
    {
        request.Headers.Add("Idempotency-Key", idempotencyKey);
    }
    if (body.Length > 0)
    {
        request.Content = new StringContent(body, Encoding.UTF8);
        request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
    }

    var response = await http.SendAsync(request);
    var envelope = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());

    if (!response.IsSuccessStatusCode)
    {
        var error = envelope.GetProperty("error");
        throw new Exception($"{error.GetProperty("code")} - {error.GetProperty("message")}");
    }

    // Every gateway response is wrapped: { success, data, timestamp, requestId }.
    return envelope.GetProperty("data");
}

Handle errors

C#
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

/*
 * 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 sealed class HavalaException(string code, string message, int status, string? requestId, JsonElement? details)
    : Exception($"{code} - {message}")
{
    public string Code { get; } = code;
    public int Status { get; } = status;
    public string? RequestId { get; } = requestId;
    public JsonElement? Details { get; } = details;
}

public static class InvoiceClient
{
    private static readonly HttpClient Http = new();

    public static async Task<JsonElement> Call(
        HttpMethod method, string path, string body = "", string? idempotencyKey = null)
    {
        var request = new HttpRequestMessage(method, "https://api2.havala.io" + path);
        request.Headers.Add("X-API-Key", Environment.GetEnvironmentVariable("HAVALA_API_KEY"));
        request.Headers.Add("X-Signature", HavalaSigner.Sign(
            Environment.GetEnvironmentVariable("HAVALA_API_SECRET")!, method.Method, path, body, idempotencyKey));
        if (idempotencyKey is not null)
        {
            request.Headers.Add("Idempotency-Key", idempotencyKey);
        }
        if (body.Length > 0)
        {
            request.Content = new StringContent(body, Encoding.UTF8);
            request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
        }

        var response = await Http.SendAsync(request);
        var envelope = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());

        if (response.IsSuccessStatusCode)
        {
            return envelope.GetProperty("data");
        }

        var error = envelope.GetProperty("error");
        throw new HavalaException(
            error.GetProperty("code").GetString()!,
            error.GetProperty("message").GetString()!,
            (int)response.StatusCode,
            envelope.TryGetProperty("requestId", out var id) ? id.GetString() : null,
            error.TryGetProperty("details", out var details) ? details : null);
    }

    public static async Task Run()
    {
        var body = JsonSerializer.Serialize(new { orderId = "order-8814", amount = "12500", currency = "USD" });

        try
        {
            await Call(HttpMethod.Post, "/api/v1/invoices", body, Guid.NewGuid().ToString());
        }
        catch (HavalaException err)
        {
            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, not a bug in your signer.
                    break;

                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.
                    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.
                    break;

                case "IDEMPOTENCY_KEY_REQUIRED":
                    // 400. A write method without the header. The guard rejects
                    // before the handler runs, so nothing was created.
                    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.WriteLine(err.Details);
                    break;

                default:
                    // 5xx and anything unmapped: retry with backoff, same key.
                    throw;
            }
        }
        catch (HttpRequestException)
        {
            // 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;
        }
    }
}

Verify a webhook

C#
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

// Minimal API, so the project needs Microsoft.NET.Sdk.Web โ€” no NuGet package.
var app = WebApplication.Create(args);

// 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.
var secret = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("HAVALA_API_SECRET")!);

app.MapPost("/havala/webhooks", async (HttpRequest request) =>
{
    // Copy the untouched bytes. Havala signs the string its dispatcher put on
    // the wire, so model binding โ€” which parses and would re-serialise โ€” has
    // already destroyed the input. Bind nothing; read the stream.
    using var buffer = new MemoryStream();
    await request.Body.CopyToAsync(buffer);
    var raw = buffer.ToArray();

    var expected = "sha256=" + Convert.ToHexString(HMACSHA256.HashData(secret, raw)).ToLowerInvariant();
    var provided = request.Headers["X-Webhook-Signature"].ToString();

    // FixedTimeEquals is constant-time and returns false on a length mismatch
    // rather than throwing; == short-circuits and leaks the prefix length.
    if (!CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(provided), Encoding.UTF8.GetBytes(expected)))
    {
        return Results.Unauthorized();
    }

    // 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.
    if (!long.TryParse(request.Headers["X-Webhook-Timestamp"], out var sentAt) ||
        Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - sentAt) > 300_000)
    {
        return Results.Unauthorized();
    }

    var envelope = JsonSerializer.Deserialize<JsonElement>(raw);
    if (envelope.GetProperty("event").GetString() == "payment.completed")
    {
        // The envelope is camelCase; the object inside data is snake_case.
        var payment = envelope.GetProperty("data").GetProperty("payment");
        _ = FulfilOrder(
            payment.GetProperty("id").GetString()!,
            payment.GetProperty("transaction_hash").GetString()!,
            payment.GetProperty("received_amount").GetString()!);
    }

    // Havala allows 10 seconds and counts only 2xx as delivered. Acknowledge
    // first, work after.
    return Results.Ok("ok");
});

app.Run();

// Queue the work; the handler above 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
// paymentId + txHash before you release anything.
//
// receivedAmount is a decimal string in the crypto's own unit ("149.500000"),
// not minor units โ€” parse it with decimal.Parse, never double.
//
// The envelope is a notification, not proof of settlement. Confirm against
// GET /api/v1/payments/{id}/status before shipping anything expensive.
static Task FulfilOrder(string paymentId, string txHash, string receivedAmount) => Task.CompletedTask;