Docs

Havala in PHP

Runtime: PHP 8.1+ (ext-curl, ext-json)

Dependencies

No dependencies โ€” hash_hmac and cURL ship with PHP 8

Set up the client

PHP
<?php
// havala_sign.php โ€” the whole of Havala gateway auth, in one function.

const HAVALA_WRITE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];

/**
 * Build 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 string you will send.
 */
function havala_sign(
    string $secret,
    string $method,
    string $path,
    string $body = '',
    ?string $idempotencyKey = null,
): string {
    $method = strtoupper($method);

    // $t is checked against server time with a +/-300s window, so sign
    // immediately before sending. A signature parked in a queue expires there.
    $t = time();

    // sha256 over the raw body bytes. No body hashes the empty string, which is
    // always e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
    $bodyHash = hash('sha256', $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) $t, $method, $path];
    if (in_array($method, HAVALA_WRITE_METHODS, true) && $idempotencyKey !== null) {
        $parts[] = $idempotencyKey;
    }
    $parts[] = $bodyHash;

    $v1 = hash_hmac('sha256', implode("\n", $parts), $secret);

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

Create an invoice

PHP
<?php

const BASE_URL = 'https://api2.havala.io';
const WRITE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];

/**
 * Signs and sends one gateway request, returning the unwrapped payload.
 */
function havala_call(string $method, string $path, ?array $payload = null, ?string $idempotencyKey = null): array
{
    // Encode once. The signature covers these exact bytes, so a second
    // json_encode of the same array is a risk with no upside.
    $body = $payload === null ? '' : json_encode($payload);

    $t = time();
    $parts = [(string) $t, $method, $path];
    if (in_array($method, WRITE_METHODS, true) && $idempotencyKey !== null) {
        $parts[] = $idempotencyKey;
    }
    $parts[] = hash('sha256', $body);

    $signature = 't=' . $t . ',v1=' . hash_hmac('sha256', implode("\n", $parts), getenv('HAVALA_API_SECRET'));

    $headers = [
        'Content-Type: application/json',
        'X-API-Key: ' . getenv('HAVALA_API_KEY'),   // merchant point apiKey, 32 hex chars
        'X-Signature: ' . $signature,
    ];
    if ($idempotencyKey !== null) {
        $headers[] = 'Idempotency-Key: ' . $idempotencyKey;
    }

    $ch = curl_init(BASE_URL . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 30,
    ]);
    if ($body !== '') {
        curl_setopt($ch, CURLOPT_POSTFIELDS, $body);   // the signed string, verbatim
    }

    $raw = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    $envelope = json_decode($raw, true);
    if ($status >= 400) {
        throw new RuntimeException($envelope['error']['code'] . ' - ' . $envelope['error']['message']);
    }

    // Every gateway response is wrapped: { success, data, timestamp, requestId }.
    return $envelope['data'];
}

// 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).
$idempotencyKey = bin2hex(random_bytes(16));

$invoice = havala_call('POST', '/api/v1/invoices', [
    '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);

printf("%s %s %s\n", $invoice['id'], $invoice['referenceId'], $invoice['status']);

// Read it back. Nothing to hash, and no idempotency line on a GET.
$fresh = havala_call('GET', '/api/v1/invoices/' . $invoice['id']);

// payments[] is included on this route and on no other: create, checkout and
// cancel return the invoice without that key at all.
printf("%s, %d payment(s)\n", $fresh['status'], count($fresh['payments'] ?? []));

Handle errors

PHP
<?php

/*
 * 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.
 */

final class HavalaError extends RuntimeException
{
    // Exception::$code is an int in PHP, so the gateway's string code needs a
    // property of its own.
    public function __construct(
        public readonly string $errorCode,
        string $message,
        public readonly int $status,
        public readonly ?string $requestId,
        public readonly array $details = [],
    ) {
        parent::__construct($message, $status);
    }
}

function havala_call(string $method, string $path, ?array $payload = null, ?string $idempotencyKey = null): array
{
    $body = $payload === null ? '' : json_encode($payload);

    $t = time();
    $parts = [(string) $t, $method, $path];
    if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true) && $idempotencyKey !== null) {
        $parts[] = $idempotencyKey;
    }
    $parts[] = hash('sha256', $body);
    $signature = 't=' . $t . ',v1=' . hash_hmac('sha256', implode("\n", $parts), getenv('HAVALA_API_SECRET'));

    $headers = [
        'Content-Type: application/json',
        'X-API-Key: ' . getenv('HAVALA_API_KEY'),
        'X-Signature: ' . $signature,
    ];
    if ($idempotencyKey !== null) {
        $headers[] = 'Idempotency-Key: ' . $idempotencyKey;
    }

    $ch = curl_init('https://api2.havala.io' . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 30,
    ]);
    if ($body !== '') {
        curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
    }

    $raw = curl_exec($ch);
    if ($raw === false) {
        // 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 new RuntimeException('transport failure: ' . curl_error($ch));
    }
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    $envelope = json_decode($raw, true);
    if ($status >= 400) {
        throw new HavalaError(
            $envelope['error']['code'],
            $envelope['error']['message'],
            $status,
            $envelope['requestId'] ?? null,
            $envelope['error']['details'] ?? [],
        );
    }

    return $envelope['data'];
}

try {
    havala_call('POST', '/api/v1/invoices', [
        'orderId' => 'order-8814',
        'amount' => '12500',
        'currency' => 'USD',
    ], bin2hex(random_bytes(16)));
} catch (HavalaError $err) {
    switch ($err->errorCode) {
        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 rather than 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 and a re-encoded body both 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 for the same order.
            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.
            print_r($err->details['errors'] ?? []);
            break;

        default:
            // 5xx and anything unmapped: retry with backoff, same key.
            throw $err;
    }
}

Verify a webhook

PHP
<?php

// 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.
$secret = getenv('HAVALA_API_SECRET');

// The untouched bytes. Havala signs the string its dispatcher put on the wire,
// so decoding and re-encoding first yields a different string and a different
// digest. Read php://input, never $_POST and never a re-encoded array.
$raw = file_get_contents('php://input');

$provided = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $raw, $secret);

// hash_equals is constant-time and length-safe; === short-circuits on the first
// differing byte and leaks the matching prefix length.
if (!hash_equals($expected, $provided)) {
    http_response_code(401);
    exit('bad signature');
}

// 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 = (int) ($_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? 0);
if (abs((int) (microtime(true) * 1000) - $sentAt) > 300000) {
    http_response_code(401);
    exit('stale delivery');
}

$event = json_decode($raw, true);
if ($event['event'] === 'payment.completed') {
    // The envelope is camelCase; the object inside data is snake_case.
    $payment = $event['data']['payment'];
    havala_fulfil_order($payment['id'], $payment['transaction_hash'], $payment['received_amount']);
}

// Havala allows 10 seconds and counts only 2xx as delivered. Acknowledge first,
// work after.
http_response_code(200);
echo 'ok';

/**
 * Hand off to your own queue; this script must finish inside 10 seconds.
 *
 * 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 โ€” compare it with bccomp, not with ==.
 *
 * The envelope is a notification, not proof of settlement. Confirm against
 * GET /api/v1/payments/{id}/status before shipping anything expensive.
 */
function havala_fulfil_order(string $paymentId, string $txHash, string $receivedAmount): void
{
}