Runtime: Go 1.21+
No dependencies โ crypto/hmac and net/http are standard library
// Package havala carries the whole of Havala gateway auth: one function.
package havala
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)
var writeMethods = map[string]bool{"POST": true, "PUT": true, "PATCH": true, "DELETE": true}
// Sign 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 r.URL.Path, so a query string must
// be left out. body is the exact bytes you will send, nil when there are none.
// idempotencyKey belongs on POST/PUT/PATCH/DELETE and is empty everywhere else.
func Sign(secret, method, path string, body []byte, idempotencyKey string) string {
method = strings.ToUpper(method)
// t is checked against server time with a ยฑ300s window, so sign immediately
// before sending. A signature parked in a queue expires where it sits.
t := time.Now().Unix()
// sha256 over the raw body bytes. No body hashes the empty string, which is
// always e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
bodyHash := sha256.Sum256(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.
parts := []string{strconv.FormatInt(t, 10), method, path}
if writeMethods[method] && idempotencyKey != "" {
parts = append(parts, idempotencyKey)
}
parts = append(parts, hex.EncodeToString(bodyHash[:]))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strings.Join(parts, "\n")))
return fmt.Sprintf("t=%d,v1=%s", t, hex.EncodeToString(mac.Sum(nil)))
}
package main
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const baseURL = "https://api2.havala.io"
var writeMethods = map[string]bool{"POST": true, "PUT": true, "PATCH": true, "DELETE": true}
// envelope is the shape every gateway response arrives in, success or failure.
type envelope struct {
Success bool `json:"success"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
RequestID string `json:"requestId"`
}
func sign(method, path string, body []byte, idempotencyKey string) string {
t := time.Now().Unix()
parts := []string{strconv.FormatInt(t, 10), method, path}
if writeMethods[method] && idempotencyKey != "" {
parts = append(parts, idempotencyKey)
}
sum := sha256.Sum256(body)
parts = append(parts, hex.EncodeToString(sum[:]))
mac := hmac.New(sha256.New, []byte(os.Getenv("HAVALA_API_SECRET")))
mac.Write([]byte(strings.Join(parts, "\n")))
return fmt.Sprintf("t=%d,v1=%s", t, hex.EncodeToString(mac.Sum(nil)))
}
func call(method, path string, payload any, idempotencyKey string) (json.RawMessage, error) {
var body []byte
if payload != nil {
// Marshal once: the signature covers these exact bytes.
var err error
if body, err = json.Marshal(payload); err != nil {
return nil, err
}
}
req, err := http.NewRequest(method, baseURL+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", os.Getenv("HAVALA_API_KEY")) // merchant point apiKey, 32 hex chars
req.Header.Set("X-Signature", sign(method, path, body, idempotencyKey))
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if env.Error != nil {
return nil, fmt.Errorf("%s - %s (requestId=%s)", env.Error.Code, env.Error.Message, env.RequestID)
}
return env.Data, nil
}
func main() {
// 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).
seed := make([]byte, 16)
if _, err := rand.Read(seed); err != nil {
log.Fatal(err)
}
idempotencyKey := hex.EncodeToString(seed)
raw, err := call("POST", "/api/v1/invoices", map[string]any{
"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
}, idempotencyKey)
if err != nil {
log.Fatal(err)
}
var invoice struct {
ID string `json:"id"`
ReferenceID string `json:"referenceId"`
Status string `json:"status"`
}
if err := json.Unmarshal(raw, &invoice); err != nil {
log.Fatal(err)
}
fmt.Println(invoice.ID, invoice.ReferenceID, invoice.Status) // clyv3n8xโฆ INV-260826-041C OPEN
// 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.
raw, err = call("GET", "/api/v1/invoices/"+invoice.ID, nil, "")
if err != nil {
log.Fatal(err)
}
fmt.Println(string(raw))
}
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
)
// 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.
type Error struct {
Code string
Message string
Status int
RequestID string
Details map[string]any
}
func (e *Error) Error() string { return e.Code + " - " + e.Message }
var writeMethods = map[string]bool{"POST": true, "PUT": true, "PATCH": true, "DELETE": true}
type errorEnvelope struct {
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
Details map[string]any `json:"details"`
} `json:"error"`
RequestID string `json:"requestId"`
}
func call(method, path string, payload any, idempotencyKey string) (json.RawMessage, error) {
var body []byte
if payload != nil {
body, _ = json.Marshal(payload)
}
t := time.Now().Unix()
parts := []string{strconv.FormatInt(t, 10), method, path}
if writeMethods[method] && idempotencyKey != "" {
parts = append(parts, idempotencyKey)
}
sum := sha256.Sum256(body)
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, err := http.NewRequest(method, "https://api2.havala.io"+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", os.Getenv("HAVALA_API_KEY"))
req.Header.Set("X-Signature", fmt.Sprintf("t=%d,v1=%s", t, hex.EncodeToString(mac.Sum(nil))))
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
// 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.
return nil, err
}
defer res.Body.Close()
var env errorEnvelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if env.Error != nil {
return nil, &Error{env.Error.Code, env.Error.Message, res.StatusCode, env.RequestID, env.Error.Details}
}
return env.Data, nil
}
func main() {
_, err := call("POST", "/api/v1/invoices", map[string]any{
"orderId": "order-8814",
"amount": "12500",
"currency": "USD",
}, "9f2b1c44-7a0e-4d63-9f18-2c5b7ea31d04")
var apiErr *Error
if !errors.As(err, &apiErr) {
if err != nil {
log.Fatal(err) // transport failure, not an API answer
}
return
}
switch apiErr.Code {
case "API_KEY_INVALID":
// 401. Unknown key, or the merchant point was deactivated. Nothing about
// this request will succeed on a retry.
case "SIGNATURE_MISSING":
// 401. X-Signature never arrived. Usually a proxy or a CDN dropping
// unknown headers rather than a bug in your signer.
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
// and a re-marshalled body both land here.
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 for the same order.
case "IDEMPOTENCY_KEY_REQUIRED":
// 400. A write method without the header. The guard rejects before the
// handler runs, so nothing was created.
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.
case "VALIDATION_ERROR":
// 422. The DTO layer runs with forbidNonWhitelisted, so an unrecognised
// property fails the request instead of being dropped quietly.
log.Println(apiErr.Details["errors"])
default:
// 5xx and anything unmapped: retry with backoff, reusing the same key.
log.Fatal(apiErr)
}
}
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"strconv"
"time"
)
// 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 = []byte(os.Getenv("HAVALA_API_SECRET"))
type delivery struct {
Event string `json:"event"`
Timestamp int64 `json:"timestamp"`
Data struct {
Type string `json:"type"`
Payment struct {
// The envelope is camelCase; the object inside data is snake_case.
ID string `json:"id"`
Status string `json:"status"`
Blockchain string `json:"blockchain"`
Currency string `json:"currency"`
ReceivedAmount string `json:"received_amount"`
TransactionHash string `json:"transaction_hash"`
Confirmations int `json:"confirmations"`
} `json:"payment"`
} `json:"data"`
}
func handleWebhook(w http.ResponseWriter, r *http.Request) {
// Read the untouched bytes. Havala signs the string its dispatcher put on
// the wire, so unmarshalling and re-marshalling first yields a different
// string and a different digest.
raw, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "unreadable body", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, secret)
mac.Write(raw)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
// hmac.Equal is the constant-time comparison and is length-safe; == leaks
// the matching prefix length through timing.
if !hmac.Equal([]byte(r.Header.Get("X-Webhook-Signature")), []byte(expected)) {
http.Error(w, "bad signature", http.StatusUnauthorized)
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, not seconds.
sentAt, _ := strconv.ParseInt(r.Header.Get("X-Webhook-Timestamp"), 10, 64)
if age := time.Since(time.UnixMilli(sentAt)); age > 5*time.Minute || age < -5*time.Minute {
http.Error(w, "stale delivery", http.StatusUnauthorized)
return
}
var event delivery
if err := json.Unmarshal(raw, &event); err != nil {
http.Error(w, "unparseable body", http.StatusBadRequest)
return
}
if event.Event == "payment.completed" {
go fulfilOrder(event.Data.Payment.ID, event.Data.Payment.TransactionHash, event.Data.Payment.ReceivedAmount)
}
// Havala allows 10 seconds and counts only 2xx as delivered. Acknowledge
// first, work after.
w.WriteHeader(http.StatusOK)
}
// fulfilOrder runs off the request goroutine so the handler can answer 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 and not a float โ parse it with big.Rat or a decimal library.
//
// The envelope is a notification, not proof of settlement. Confirm against
// GET /api/v1/payments/{id}/status before shipping anything expensive.
func fulfilOrder(paymentID, txHash, receivedAmount string) {}
func main() {
http.HandleFunc("/havala/webhooks", handleWebhook)
log.Fatal(http.ListenAndServe(":3000", nil))
}