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

TypeFires whendata payload
order.processingAn order enters fulfillmentorder snapshot
order.fulfilledAn order is delivered (artifacts ready to reveal)order snapshot
order.failedFulfillment failed; the reservation was releasedorder snapshot
order.refundedA captured order was refunded to your walletorder snapshot
wallet.creditedA deposit (or refund) credited your walletwallet 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

HeaderContents
X-Xegora-Event-IdThe event's stable unique id — your deduplication key. Identical across every retry.
X-Xegora-TimestampUnix seconds at signing time — your freshness check.
X-Xegora-Signaturev1=<64 lowercase hex chars> — the HMAC below.
X-Xegora-Delivery-Contractxegora-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:

  1. Compute the HMAC over the raw request bytes (before any JSON parsing — parsers re-serialize and break the signature).
  2. Compare against the header value (after the v1= prefix) with a constant-time comparison.
  3. Reject deliveries whose X-Xegora-Timestamp is more than 5 minutes from your clock (replay protection) — keep your servers on NTP.
  4. 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:

  1. Deduplicate durably by X-Xegora-Event-Id (a unique index in your database, not an in-memory set). Process an id once; acknowledge repeats with 2xx without reprocessing.
  2. Acknowledge fast. Verify, enqueue, respond 2xx in under a couple of seconds; do the real work asynchronously. Slow handlers look like failures and cause retries.
  3. Tolerate reordering. Retries can interleave: an order.processing retry may arrive after order.fulfilled. Apply events by comparing the payload's updatedAtUtc/occurredAtUtc against your stored state, or simply re-read GET /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.


Did this page help you?