Docs
GuidesWebhooks

Webhooks

Intermediate15 min

Receive payment.completed, verify X-Webhook-Signature against the raw body, stay idempotent under duplicate delivery, and reconcile what never arrives.


A buyer pays from their own wallet, on their own schedule, and then closes the tab. There is no browser redirect you can trust to tell you the money arrived. Webhooks are how Havala tells your server instead.

Configure the endpoint

Delivery is configured per merchant point, in the merchant dashboard, under that point's webhook settings. There is no public endpoint for it โ€” the gateway credentials you sign requests with cannot change where deliveries go, which is deliberate: a leaked API secret should not be able to redirect your settlement notifications.

Three constraints on the URL, all enforced:

  • It must be absolute HTTPS. http:// is rejected with "Webhook URL must use HTTPS".
  • A point holds exactly one URL. There is no event subscription list and no per-event routing โ€” every event Havala emits for that point lands on that one address. Use separate merchant points if you need separate destinations.
  • Register the final URL. Only a 2xx counts as delivered; a 3xx redirect is a failure, so a URL that redirects to your real handler never succeeds.

One trap when you want deliveries to stop. The point's active flag is not a delivery-only switch: deactivating a merchant point also stops that point authenticating on the gateway API, so you lose the ability to create invoices and read payments with those credentials at the same moment. To stop deliveries alone, clear the point's webhook URL and leave the point active.

The delivery contract

PropertyValue
MethodPOST
Timeout10 seconds
SuccessHTTP 2xx only
Content-Typeapplication/json
X-Webhook-Signaturesha256={hex} โ€” HMAC-SHA256 over the raw body, keyed on the point's apiSecret
X-Webhook-TimestampMilliseconds since epoch, the same integer as the envelope's timestamp
X-Webhook-EventThe event name, the same string as the envelope's event

The signature is computed as "sha256=" + hex(hmac_sha256(apiSecret, rawBody)). Nothing else is folded in: no method, no path, no timestamp. That has one direct consequence for your handler โ€” since the timestamp is not covered by the signature, a valid old body stays valid forever. Reject anything whose X-Webhook-Timestamp is too old yourself.

The signing key is the merchant point's apiSecret, the same value you use to sign gateway requests. One secret, both directions.

The envelope

JSON
{
  "event": "payment.completed",
  "timestamp": 1787832019204,
  "data": {
    "type": "payment",
    "payment": {
      "id": "clx7k2p9a0004qz8f6m2s9r1t",
      "status": "COMPLETED",
      "blockchain": "ethereum",
      "currency": "USDT",
      "amount": "149.500000",
      "received_amount": "149.500000",
      "address": "0x3ca7f0d51b8e94a26df1c05b7e9382cf4a0d6b71",
      "transaction_hash": "0x7b1f9c4e2a86d035b4c71ef9820ad3c65be47190f2d8ac53619b0e7f4a2c88d1",
      "confirmations": 12,
      "metadata": null
    }
  }
}

Two conventions, deliberately not one: the outer envelope is camelCase, and the keys inside data.payment are snake_case (received_amount, transaction_hash). timestamp is milliseconds, not seconds.

amount is paymentAmount and address is toAddress โ€” the one-time deposit address the buyer was given, not a merchant wallet. Both amounts are decimal strings; compare them as decimals, never as floats. blockchain arrives lowercase; status is a PaymentStatus enum value and is uppercase.

Events

Exactly two events exist today.

EventFired by
payment.completedThe confirmation tracker, once observed confirmations reach the payment's requiredConfirmations and the point has a webhookUrl
test.pingOnly ever by the dashboard's "send test webhook" action, never by anything on-chain

Nothing else is dispatched. Write your handler with a default branch that logs and returns 200 on an unrecognised event rather than throwing, so that a future event never turns into a retry storm against your endpoint.

Verify the signature

Verify against the raw request body bytes. Havala signs exactly what it puts on the wire; parsing the JSON and re-serialising it will reorder or reformat something and the comparison will fail. In most frameworks that means opting one route out of the automatic body parser.

Node.js (Express)

JavaScript
const express = require('express');
const crypto = require('node:crypto');

const app = express();
const SECRET = process.env.HAVALA_API_SECRET;
const MAX_AGE_MS = 5 * 60 * 1000;

// express.raw, not express.json โ€” the parsed object is not what was signed.
app.post('/havala/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const provided = String(req.get('X-Webhook-Signature') || '').replace(/^sha256=/, '');
  const expected = crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');

  // Length-check first: timingSafeEqual throws on a length mismatch.
  if (
    provided.length !== expected.length ||
    !crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected))
  ) {
    return res.sendStatus(401);
  }

  // The timestamp is not covered by the signature, so a replayed body verifies
  // forever unless you bound it here.
  const sentAt = Number(req.get('X-Webhook-Timestamp'));
  if (!Number.isFinite(sentAt) || Math.abs(Date.now() - sentAt) > MAX_AGE_MS) {
    return res.sendStatus(400);
  }

  const event = JSON.parse(req.body.toString('utf8'));

  // Acknowledge inside the 10s budget; do the work off the request path.
  res.sendStatus(200);
  queue.push(event);
});

Python (Flask)

Python
import hashlib
import hmac
import json
import os
import time

from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["HAVALA_API_SECRET"].encode()
MAX_AGE_MS = 5 * 60 * 1000


@app.post("/havala/webhooks")
def havala_webhook():
    # get_data() is the raw body; request.json would re-serialise differently.
    raw = request.get_data()
    provided = request.headers.get("X-Webhook-Signature", "").removeprefix("sha256=")
    expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(provided, expected):
        return "", 401

    sent_at = int(request.headers.get("X-Webhook-Timestamp", "0"))
    if abs(time.time() * 1000 - sent_at) > MAX_AGE_MS:
        return "", 400

    enqueue(json.loads(raw))
    return "", 200

PHP

PHP
<?php

$raw = file_get_contents('php://input');
$provided = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $raw, getenv('HAVALA_API_SECRET'));

if (!hash_equals($expected, $provided)) {
    http_response_code(401);
    exit;
}

$sentAt = (int) ($_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? 0);
if (abs((int) (microtime(true) * 1000) - $sentAt) > 5 * 60 * 1000) {
    http_response_code(400);
    exit;
}

$event = json_decode($raw, true);

http_response_code(200);
enqueue($event);

Retries, and why you need reconciliation anyway

The dispatcher is built to queue deliveries with three attempts and a 1m / 5m / 15m backoff ladder, and to log every attempt. In the running system, though, the payment.completed fan-out calls the sender directly rather than the queue: one attempt, ten-second timeout, and a failure is logged and dropped.

Design for that. Treat every delivery as best effort and run a reconciliation job alongside your handler:

TypeScript
// Every few minutes: anything you were expecting and did not hear about.
const stale = await db.payments.findAwaitingSettlement({ olderThanMinutes: 5 });

for (const row of stale) {
  const status = await havala('GET', `/api/v1/payments/${row.paymentId}/status`);
  if (status.status === 'COMPLETED') await settle(row, status);
}

That job is not a nicety. A deploy, a rolling restart or a ten-second stall in your handler is enough to lose a settlement notification permanently.

Staying idempotent

The same payment.completed can arrive more than once: a tracking job that re-runs against an already-COMPLETED payment re-enters the same notification path. Your reconciliation job will also re-discover payments the webhook already delivered. Both must be safe.

The envelope carries no event id โ€” nothing in the payload uniquely identifies a delivery. What it does carry is a stable identity for the fact being reported, which is what you actually want to deduplicate on:

text
event + data.payment.id + data.payment.transaction_hash

Hash that tuple, make it a unique key, and let the database enforce exactly-once:

sql
CREATE TABLE havala_events (
  event_key   TEXT PRIMARY KEY,   -- sha256(event | payment_id | transaction_hash)
  payment_id  TEXT NOT NULL,
  received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
TypeScript
async function handle(event: HavalaEvent) {
  const p = event.data.payment;
  const key = sha256(`${event.event}|${p.id}|${p.transaction_hash}`);

  const inserted = await db.query(
    'INSERT INTO havala_events (event_key, payment_id) VALUES ($1, $2) ' +
      'ON CONFLICT (event_key) DO NOTHING RETURNING event_key',
    [key, p.id]
  );
  if (inserted.rowCount === 0) return; // already processed

  await fulfil(p);
}

Insert the key in the same transaction as the fulfilment side effect. A handler that records the key first and then crashes has silently dropped an order, and one that fulfils first and then records has a window to ship twice.

Do not treat the envelope as proof on its own. It says a payment completed; it does not say the buyer sent the right amount. Compare received_amount against amount before you ship anything โ€” see Accepting Crypto Payments.

Testing your endpoint

The dashboard can send a test delivery to the point's configured URL. It posts a synthetic test.ping payload and shows you the raw HTTP result โ€” the status code and the first 500 characters of your response body โ€” which makes it a genuinely good way to prove reachability, TLS and DNS from Havala's side of the network.

It is not a rehearsal of production delivery, and the difference will bite you if you assume otherwise. A test delivery is signed with the same secret over the same raw-body rule, but everything around it is shaped differently:

Real deliveryTest delivery
Signature headerX-Webhook-Signature: sha256={hex}X-HAVALA-Signature: {hex}, no prefix
Event headerX-Webhook-EventX-HAVALA-Event
Timestamp headerX-Webhook-Timestamp, ms epochX-HAVALA-Timestamp, ISO-8601 string
timestamp in the envelopeInteger, ms since epochISO-8601 string
data{ type, payment }{ test, message, merchantPointId }

So a green result proves your URL is reachable and that your HMAC over the raw body matches. It does not exercise the header names, the timestamp parsing, or the payload branch your production handler actually runs. A handler that reads only X-Webhook-Signature will reject every test delivery while being perfectly correct, and a handler written against the test shape will fail on the first real payment. Test the real path with a real testnet payment โ€” see Testing on Testnets.

Rotating the signing secret

Rotate the signing secret from the merchant dashboard, under the merchant point's webhook settings. Rotation mints a new 64-character secret and shows it to you at that moment; some dashboard views show only its first few characters afterwards, so capture it when it appears.

There is no overlap window and no previous-secret grace period. And because the webhook signing secret and the gateway apiSecret are the same stored value, rotating it invalidates your gateway request signing at the same instant โ€” in-flight signed requests start failing with SIGNATURE_INVALID while deliveries start arriving under the new key. Deploy the new value to both halves of your integration together, in a window where you can tolerate a gap.

NextWhat it covers
Accepting Crypto PaymentsWhat the event is telling you, and the amount check
Authentication & Request SigningThe same secret in the other direction
Going LiveHardening the endpoint before production traffic