Runtime: PHP 8.1+ (ext-curl, ext-json)
No dependencies โ hash_hmac and cURL ship with PHP 8
<?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}";
}
<?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'] ?? []));
<?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;
}
}
<?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
{
}