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
- Event types
- Request headers
- Request body
- Signature verification (with code in Node, Python, Go, PHP)
- Retry policy
- Idempotency
- Operational best practices
Endpoint registration
Operator-managed for now. There is no public
POST /v1/webhooksendpoint 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/webhooksAPI 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:
- has its own
signing_secret(rotation possible per-endpoint) - subscribes to an explicit list of
event_types(or["*"]for all) - can be individually disabled without deleting
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. |
datamay 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:
- Your server returns any non-2xx HTTP status (3xx, 4xx, and 5xx all trigger retry).
- The connection times out (default HTTP timeout: 10 seconds).
- Any TCP/TLS/DNS error occurs before the response is read.
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
- 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.
- 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.
- 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.
- 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.
- Return 5xx if your downstream is down. Hexolus will retry. Returning 2xx and silently dropping the event means you'll never see it again.
- Allowlist Hexolus outbound IPs if your endpoint sits behind a WAF. Contact the operator for the current IP set.
See also
- Authentication — the bearer API key is for the forward direction (you calling Hexolus); the signing secret here is the reverse direction (Hexolus calling you).
- Payments — the
datablock in every webhook is a subset of the payment DTO. - Errors — your endpoint can return errors with any shape,
but a JSON
{"message":"..."}body helps operators debug abandoned deliveries.