Runtime: .NET 8+
No dependencies โ System.Security.Cryptography on .NET 8+
// 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();
}
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");
}
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;
}
}
}
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;