Webhooks
Signed, at-least-once event delivery — the envelope, the HMAC verification spec with reference code, and your receiver's duties.
Webhooks push order and wallet transitions to your HTTPS endpoint moments after they happen, so you don't have to poll. Every delivery is signed with your endpoint's secret. This page specifies the envelope, the signature scheme (with reference verifiers), and the at-least-once delivery contract your receiver must be built for.
Managing endpoints
Webhook endpoints are managed in the merchant dashboard (Developers → Webhooks) — deliberately not over the API, so a leaked API key can never redirect your event stream. When you create an endpoint you choose its subscribed event types and receive a signing secret, prefixed xgwh_, shown once. Store it like a password; rotate by creating a replacement endpoint, verifying it receives traffic, then deleting the old one.
Endpoints must be public HTTPS URLs that respond within a few seconds. Respond 2xx to acknowledge; anything else (including timeouts) is retried.
Event types
| Type | Fires when | data payload |
|---|---|---|
order.processing | An order enters fulfillment | order snapshot |
order.fulfilled | An order is delivered (artifacts ready to reveal) | order snapshot |
order.failed | Fulfillment failed; the reservation was released | order snapshot |
order.refunded | A captured order was refunded to your wallet | order snapshot |
wallet.credited | A deposit (or refund) credited your wallet | wallet movement |
The envelope
Every delivery is a POST with a JSON body:
{
"id": "9f8e7d6c5b4a39281706f5e4d3c2b1a0",
"type": "order.fulfilled",
"occurredAtUtc": "2026-09-05T12:01:09Z",
"data": {
"orderId": "0198e000-1111-7abc-9def-222233334444",
"clientReference": "your-order-1001",
"status": "Fulfilled",
"total": 52.50,
"currency": "USD",
"updatedAtUtc": "2026-09-05T12:01:09Z"
}
}wallet.credited carries instead:
"data": {
"amount": 12.50,
"availableBalance": 437.50,
"currency": "USD",
"occurredAtUtc": "2026-09-05T12:02:00Z"
}Note what is absent by design: fulfillment artifacts and buyer personal data never appear in webhooks.
Delivery headers
| Header | Contents |
|---|---|
X-Xegora-Event-Id | The event's stable unique id — your deduplication key. Identical across every retry. |
X-Xegora-Timestamp | Unix seconds at signing time — your freshness check. |
X-Xegora-Signature | v1=<64 lowercase hex chars> — the HMAC below. |
X-Xegora-Delivery-Contract | xegora-webhook-at-least-once-v1 — the delivery contract version. |
Verifying the signature
The signature is HMAC-SHA256 over the UTF-8 string
{timestamp}.{eventId}.{rawBody}
keyed with your endpoint's xgwh_… secret, hex-encoded lowercase, and delivered as X-Xegora-Signature: v1=<hex>.
Verification rules — all four are mandatory:
- Compute the HMAC over the raw request bytes (before any JSON parsing — parsers re-serialize and break the signature).
- Compare against the header value (after the
v1=prefix) with a constant-time comparison. - Reject deliveries whose
X-Xegora-Timestampis more than 5 minutes from your clock (replay protection) — keep your servers on NTP. - Only then parse the JSON.
Node.js
const crypto = require("node:crypto");
function verifyXegoraWebhook(rawBody, headers, secret, maxSkewSeconds = 300) {
const timestamp = headers["x-xegora-timestamp"];
const eventId = headers["x-xegora-event-id"];
const signature = (headers["x-xegora-signature"] || "").replace(/^v1=/, "");
if (!timestamp || !eventId || signature.length !== 64) return false;
const skew = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(skew) || skew > maxSkewSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${eventId}.`)
.update(rawBody) // Buffer of the RAW request body
.digest("hex");
return (
expected.length === signature.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
);
}Python
import hashlib, hmac, time
def verify_xegora_webhook(raw_body: bytes, headers: dict, secret: str, max_skew: int = 300) -> bool:
timestamp = headers.get("X-Xegora-Timestamp", "")
event_id = headers.get("X-Xegora-Event-Id", "")
signature = headers.get("X-Xegora-Signature", "").removeprefix("v1=")
if not timestamp.isdigit() or not event_id or len(signature) != 64:
return False
if abs(time.time() - int(timestamp)) > max_skew:
return False
message = f"{timestamp}.{event_id}.".encode() + raw_body
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)C#
using System.Security.Cryptography;
using System.Text;
static bool VerifyXegoraWebhook(
byte[] rawBody, string timestampHeader, string eventId, string signatureHeader,
string secret, int maxSkewSeconds = 300)
{
var signature = signatureHeader.StartsWith("v1=") ? signatureHeader[3..] : signatureHeader;
if (!long.TryParse(timestampHeader, out var timestamp) || signature.Length != 64) return false;
var skew = Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp);
if (skew > maxSkewSeconds) return false;
var prefix = Encoding.UTF8.GetBytes($"{timestamp}.{eventId}.");
var message = new byte[prefix.Length + rawBody.Length];
prefix.CopyTo(message, 0);
rawBody.CopyTo(message, prefix.Length);
var expected = Convert.ToHexString(
HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), message)).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(signature.ToLowerInvariant()));
}At-least-once delivery — your obligations
The delivery contract (xegora-webhook-at-least-once-v1) guarantees each event is delivered at least once, with the exact same event id, body, and target on every retry. Failed or unacknowledged deliveries are retried with backoff. That imposes three duties on your receiver:
- Deduplicate durably by
X-Xegora-Event-Id(a unique index in your database, not an in-memory set). Process an id once; acknowledge repeats with2xxwithout reprocessing. - Acknowledge fast. Verify, enqueue, respond
2xxin under a couple of seconds; do the real work asynchronously. Slow handlers look like failures and cause retries. - Tolerate reordering. Retries can interleave: an
order.processingretry may arrive afterorder.fulfilled. Apply events by comparing the payload'supdatedAtUtc/occurredAtUtcagainst your stored state, or simply re-readGET /orders/{id}and trust the API as the source of truth.
Webhooks + polling = exactly right
Webhooks are the fast path, not the only path. Keep a low-frequency reconciliation sweep of GET /api/v1/orders (see Orders) so a paused endpoint or dropped delivery can never strand an order in your system.
Updated about 2 hours ago