Webhooks
Signed, at-least-once event delivery — the events, the envelope, the HMAC verification spec with reference code, and your receiver's duties.
Webhooks push order and wallet events 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 events, 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. An endpoint receives only the event types it is subscribed to — when a new event type appears, add it to the endpoint's subscription in the dashboard.
Endpoints must be public HTTPS URLs on port 443 that respond within 5 seconds — redirects are not followed. Respond 2xx to acknowledge; anything else (including timeouts) is retried per the schedule below.
The dashboard's Send test button delivers one signed webhook.test event to an endpoint right away and shows the HTTP outcome. It is signed exactly like a real event, but it is sent once — never retried, never queued — and carries no order or wallet data:
{
"id": "0198e2c4-5d6e-7f80-9a1b-2c3d4e5f6a7b",
"type": "webhook.test",
"occurredAtUtc": "2026-09-05T12:00:00.1234567+00:00",
"data": { "message": "This is a signed Xegora test delivery. No action is required." }
}Verify it like any delivery, acknowledge it with 2xx, and otherwise ignore it.
Event types
| Type | Fires when | data.status |
|---|---|---|
order.processing | An order moves from reserved into processing — its outcome was not immediate. Sent at most once per order, for that first move only. An order delivered at the first attempt goes straight from reserved to fulfilled and never sends it. | processing |
order.under_review | An order is referred to Xegora for review (its status becomes reconciliationrequired), typically because its outcome could not be confirmed. Sent at most once per order, for the first referral. Its funds stay reserved until the review ends in order.fulfilled, order.failed or order.refunded. | reconciliationrequired |
order.fulfilled | An order is delivered (artifacts ready to reveal); funds captured | fulfilled |
order.failed | An order was not delivered — fulfillment failed, or Xegora support voided or closed the order — and its reservation was released back to your available balance. The event does not say why; read the order's statusReason for the cause when you need it (a recipient number that cannot be topped up is reported there — see Orders). | failed |
order.refunded | The order's money came back to your wallet: a delivered order was refunded and your wallet credited by its total, less any cashback share the order had paid you (taken back in the same instant), or the purchase was refunded before it was delivered and its reservation released. data.refund says which amounts moved — see The refund. It is the refund's only event (no wallet.credited accompanies it). | refunded |
wallet.credited | A deposit was credited, Xegora recorded a payment received for your workspace, or Xegora credited your wallet to put something right (a goodwill or correcting credit that two of its operators approved) — data.source says which | (wallet movement — no status field) |
Each event is sent at most once per order (or, for wallet.credited, per wallet movement): its id is derived from the order — or the wallet movement — and the event type, so the same event is never announced twice under two ids. Order events carry an order snapshot in data; its status is lowercase and fixed per event type, as tabled above.
The envelope
Every delivery is a POST with a JSON body:
{
"id": "9f8e7d6c-5b4a-4928-8706-f5e4d3c2b1a0",
"type": "order.fulfilled",
"occurredAtUtc": "2026-09-05T12:01:09.4817351+00:00",
"data": {
"orderId": "0198e000-1111-7abc-9def-222233334444",
"clientReference": "your-order-1001",
"status": "fulfilled",
"total": 58.5,
"currency": "USD",
"updatedAtUtc": "2026-09-05T12:01:09.4817351+00:00",
"lines": [
{
"productName": "Game Store Gift Card",
"variantLabel": "50",
"quantity": 1,
"unitPrice": 58.5,
"currency": "USD",
"faceValue": 50,
"faceCurrency": "EUR",
"requestedValue": null,
"productId": "0198d5c0-90aa-7d3e-8f10-3c5f60b1a222",
"variantId": "0198d5c0-91bb-7e4f-9a21-4d6e70c2b333"
}
]
}
}The body's id is a UUID; the X-Xegora-Event-Id header carries the same value without dashes (32 lowercase hex) — that header form is your dedupe key and the exact string signed inside the signature. data.updatedAtUtc is the instant of the change the event announces and equals the envelope's occurredAtUtc. Amounts in webhook payloads are written without trailing zeros (58.5, 50); timestamps are ISO 8601 with a +00:00 offset.
The refund
order.refunded adds one object to data, after lines, saying how the order's money came back:
"refund": {
"amount": 105,
"cashbackReversed": 1.05,
"netCredited": 103.95
}| Field | Meaning |
|---|---|
amount | The order's total, back on your available balance — the refund credit of a delivered order, or the reservation released for one refunded before delivery. Equal to data.total. |
cashbackReversed | The cashback share this order had paid you, taken back out of the refund in the same transaction: a cashback is earned by a sale, and the refund undid the sale. 0 when the order earned no cashback — always so for an order refunded before delivery, which never earned one. |
netCredited | What the refund left on your wallet: amount − cashbackReversed. Reconcile the refund against this figure. |
All three are in data.currency. The order resource's refund reports the same figures, and your wallet statement shows the
two movements — order_refund and cashback_reversal — at the same instant. No other order event carries this object.
wallet.credited carries instead:
"data": {
"amount": 100,
"availableBalance": 525,
"currency": "USD",
"occurredAtUtc": "2026-09-05T12:00:20.0931144+00:00",
"source": "deposit",
"movementId": "0198db4d-f2e0-7c11-8a2b-3c4d5e6f7a8b",
"reference": null
}| Field | Meaning |
|---|---|
amount | The credited amount, in currency. |
availableBalance | The available balance immediately after the credit — a snapshot at occurredAtUtc; GET /api/v1/wallet/balance is the live figure. |
source | deposit (a stablecoin deposit to one of your addresses), payment (a payment Xegora received for your workspace and recorded), or adjustment (a credit Xegora made to put something right). |
movementId | The wallet movement the credit is: the statement line with this id — for a deposit, that line names the deposit. |
reference | For an adjustment, its reference (adj_ and 32 hex digits) to quote to support; null otherwise. |
Note what is absent by design: fulfillment artifacts, buyer personal data, and anything naming how or by whom an order is fulfilled never appear in webhooks. Nor does the order's statusReason — an event says that the order reached a status, and GET /api/v1/orders/{orderId} says why, so a handler that needs the cause (to tell a buyer their number cannot be topped up, say) reads the order it is about. The order snapshot carries the settlement (total in currency) and the order's lines — what was bought, the unit price paid in the settlement currency, the denomination received (faceValue in faceCurrency, null for items that are not money), and the catalog productId and variantId it bought (variantId is null for an amount of your choosing), so you reconcile by id rather than by name. The lines — names included — are frozen at the moment the order was placed, identical on every event for the same order, and are enough to build a receipt without the quote.
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 exponential backoff — 5 s doubling per attempt, up to 8 attempts over roughly 11 minutes. A 4xx response other than 401/403/404/408/410/425/429 counts as a permanent rejection and stops retries immediately; those and exhausted deliveries are parked for operator review, never silently dropped. 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.processingororder.under_reviewretry 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.
Do not treat an event type you do not recognize as an error: acknowledge it with 2xx and ignore it.
Webhooks + polling = exactly right
Webhooks are the fast path, not the only path. Keep a low-frequency reconciliation sweep of GET /api/v1/orders?updatedFrom=… (see Orders) so a paused endpoint or dropped delivery can never strand an order in your system.
Updated 13 days ago