Webhooks

Hexolus delivers payment lifecycle events to your registered HTTPS endpoint as POST requests signed with HMAC-SHA256. This is the channel you should use to fulfill orders, send receipt emails, or update your internal order DB — never poll GET /v1/payments/{id} in a loop instead.

This page covers:


Endpoint registration

Operator-managed for now. There is no public POST /v1/webhooks endpoint yet. To register or update your webhook URL, contact your Hexolus operator with: (1) the full HTTPS URL, (2) the event types you want to subscribe to (or * for all events). The operator will return your per-endpoint signing secret through a secure channel; treat it like an API key. A self-service /v1/webhooks API is on the roadmap and will be additive — existing operator-registered endpoints will continue to work.

Each client may register multiple endpoints (e.g. one for production, one for staging, one for a Slack-bridge notifier). Each endpoint:


Event types

Event Fires when
payment.succeeded Xendit confirms the customer paid. payment.status is now succeeded. Use this to fulfill the order.
payment.failed Payment terminally failed (e.g. e-wallet user rejected the push, charge declined). payment.status is now failed.
payment.expired Payment timed out without being paid (VA expiry, QR expiry, e-wallet checkout abandoned past TTL). payment.status is now expired.

Hexolus subscribes you to a configurable subset of these events at endpoint-registration time; if you do not care about an event type, either omit it from your event_types array (operator does this when registering on your behalf) OR return 2xx and discard it client-side.


Request headers

Header Example Notes
Content-Type application/json Always JSON.
X-Hexolus-Event payment.succeeded The event type. Match against this in your router; do NOT rely on body parsing alone.
X-Hexolus-Signature 9c4d5b7a1e3f... (64 hex chars) hex(HMAC-SHA256(raw_body, signing_secret)). See verification.
X-Hexolus-Delivery-Id 01HZX9K3M7N2BZQ7A8RVT5P3X4 Unique per delivery attempt at the row level — stable across retries. Use this to dedupe.

The signature is computed over the raw HTTP request body byte-for-byte. If your framework re-serialises the body before you hash it, the signature will not match. Always hash the bytes you actually received off the wire.


Request body

The body is JSON with this envelope:

{
  "event": "payment.succeeded",
  "event_id": "01HZX9K3M7N2BZQ7A8RVT5P3X4",
  "timestamp": "2026-05-30T14:40:10Z",
  "data": {
    "id": "8e5e9b94-3f9c-4b6c-8e7a-9c9f3d3a7e21",
    "client_id": "client_01HZX...",
    "external_reference": "order-0001",
    "method": "virtual_account",
    "channel_code": "BCA",
    "notional_minor": 50000,
    "xendit_fee_minor": 4000,
    "hexolus_markup_minor": 50,
    "client_net_minor": 45950,
    "currency": "IDR",
    "status": "succeeded",
    "payment_destination": "107669999416224",
    "customer": { "name": "Budi Santoso" },
    "description": "Hosting renewal",
    "metadata": { "order_id": "ord_9981" },
    "paid_at": "2026-05-30T14:40:08Z",
    "created_at": "2026-05-30T14:32:11Z"
  }
}
Field Notes
event Same value as the X-Hexolus-Event header. Cross-check in your handler.
event_id Unique per logical event. Equal across all retry attempts of the same delivery. Use it for dedup.
timestamp RFC 3339 UTC. When the event fired on Hexolus's side, not when it was retried.
data A subset of the payment DTO. The full DTO shape may evolve additively.

data may contain additional fields in the future. Forward-compatible consumers should parse leniently — read the fields you need, ignore unknown ones.


Signature verification

You must verify X-Hexolus-Signature on every webhook before trusting the body. The verification uses your endpoint's signing_secret:

expected = hex_lowercase( HMAC_SHA256( raw_request_body_bytes, signing_secret ) )
ok       = constant_time_compare( expected, header_signature )

Use a constant-time comparison primitive. A naive == or === comparison is vulnerable to timing attacks that can leak the signature byte by byte. Every example below uses the right primitive for its language.

Node.js

import crypto from "node:crypto";
import express from "express";

const app = express();

// Capture the raw body BEFORE any JSON parser touches it.
app.use(express.raw({ type: "application/json" }));

app.post("/webhooks/hexolus", (req, res) => {
  const signature = req.header("X-Hexolus-Signature") ?? "";
  const expected = crypto
    .createHmac("sha256", process.env.HEXOLUS_WEBHOOK_SECRET)
    .update(req.body)              // req.body is a Buffer here
    .digest("hex");

  const a = Buffer.from(signature, "utf8");
  const b = Buffer.from(expected, "utf8");
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).send("bad signature");
  }

  // Safe to parse and act on the body now.
  const payload = JSON.parse(req.body.toString("utf8"));
  // ... enqueue async work ...
  res.status(202).send("queued");
});

Python (Flask)

import hmac
import hashlib
import os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["HEXOLUS_WEBHOOK_SECRET"].encode()

@app.post("/webhooks/hexolus")
def hexolus_webhook():
    signature = request.headers.get("X-Hexolus-Signature", "")
    expected = hmac.new(SECRET, request.get_data(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(signature, expected):
        abort(401, "bad signature")

    payload = request.get_json(force=True)
    # ... enqueue async work ...
    return "", 202

Note: request.get_data() returns the raw bytes; do NOT use request.json before verification, since some Flask configurations rewrite the body.

Go

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "os"
)

var secret = []byte(os.Getenv("HEXOLUS_WEBHOOK_SECRET"))

func hexolusWebhook(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "read body", http.StatusBadRequest)
        return
    }

    sig := r.Header.Get("X-Hexolus-Signature")
    mac := hmac.New(sha256.New, secret)
    mac.Write(body)
    expected := hex.EncodeToString(mac.Sum(nil))

    if !hmac.Equal([]byte(sig), []byte(expected)) {
        http.Error(w, "bad signature", http.StatusUnauthorized)
        return
    }

    // ... enqueue async work using `body` ...
    w.WriteHeader(http.StatusAccepted)
}

PHP

<?php
$secret = getenv('HEXOLUS_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_HEXOLUS_SIGNATURE'] ?? '';

$expected = hash_hmac('sha256', $body, $secret);

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('bad signature');
}

$payload = json_decode($body, true);
// ... enqueue async work ...
http_response_code(202);

hash_equals is PHP's constant-time comparison; it is available from PHP 5.6.


Retry policy

Hexolus retries failed deliveries with exponential backoff:

Attempt Earliest next attempt (relative to previous failure)
1 immediate
2 +60s
3 +2m
4 +4m
5 +8m
6 +16m
7 +32m
8 +64m (capped at +6h)

After 8 total attempts, the delivery is abandoned — no further retries are sent and an internal operator alert is raised. The maximum backoff between any two attempts is capped at 6 hours.

A delivery counts as failed when:

A delivery counts as succeeded when your server returns a 2xx status within the timeout. Hexolus stores the first 1 KB of your response body for operational debugging — keep response bodies small.


Idempotency on the receiver side

Hexolus's event_id is stable across retries of the same logical event. If your handler crashes after processing but before responding 2xx, the same event_id will arrive again. Treat events as at-least-once and dedupe at your end:

-- One row per event_id; INSERT IGNORE makes duplicates a no-op.
CREATE TABLE webhook_events_seen (
  event_id   VARCHAR(64) PRIMARY KEY,
  received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
// Inside your handler, BEFORE doing irreversible work:
const isDuplicate = await db.tryInsertEventId(payload.event_id);
if (isDuplicate) {
  return res.status(202).send("dup");
}
// ... do the work ...

The X-Hexolus-Delivery-Id header is also unique per row, but it changes across endpoints — if you have two endpoints registered, the same event will arrive as two different Delivery-Ids. Use event_id for cross- endpoint dedup.


Operational best practices

  1. Respond 2xx fast, do work async. Aim to acknowledge in under 1 second. Queue the actual fulfillment in a background worker (Sidekiq, Bull, Cloud Tasks, etc.). A slow handler means more retries and contributes to a 10s timeout you definitely don't want to hit.
  2. Verify the signature first, parse second. A naive parser may reject a malformed body before you've had a chance to verify it, leaking timing information.
  3. Pin your secret to a single key. Rotate by registering a new endpoint, switching your fulfillment system over, then deleting the old endpoint. There is no rolling-window secret rotation.
  4. Log the prefix of the signature, never the full secret. A 12-char prefix of the signature is enough to correlate with our delivery logs.
  5. Return 5xx if your downstream is down. Hexolus will retry. Returning 2xx and silently dropping the event means you'll never see it again.
  6. Allowlist Hexolus outbound IPs if your endpoint sits behind a WAF. Contact the operator for the current IP set.

See also