Docs

Havala in Go

Runtime: Go 1.21+

Dependencies

No dependencies โ€” crypto/hmac and net/http are standard library

Set up the client

Go
// 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)))
}

Create an invoice

Go
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))
}

Handle errors

Go
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)
	}
}

Verify a webhook

Go
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))
}