Skip to main content

PUBLISHER ACCOUNT

Webhooks

When a payment or user validation occurs, Xsolla sends an HTTP request with the event data to your backend.

How webhook validation works​

Server-side validation means Xsolla notifies your backend about each purchase, refund, and chargeback, and your backend grants or revokes the entitlement accordingly. The game client never decides fulfillment.

  1. Player completes a purchase through the SDK.
  2. Xsolla sends a webhook (HTTP POST) to your backend.
  3. Your backend verifies the signature, then processes the event.
  4. Your backend grants the entitlement and returns a success code (200/201/204) to Xsolla.
  5. Your backend notifies the game through your own channel — the SDK is not involved in this step.

For the client side of this flow — launching the purchase and treating the SDK's purchase-completed callback as UI feedback — see the purchase guide for your platform: Android, iOS, Unity, Windows.

Don't grant items on the client when webhooks are enabled

The SDK has no knowledge of your Publisher Account webhook settings, so its purchase-completed callback still fires on the client after a purchase. When you handle fulfillment through webhooks, treat that callback as UI feedback only — granting the item there as well would award the purchase twice. Client-side handling (purchase callback → validate → consume) is the alternative model, used only when you have no backend.

The purchase-completed callback differs by SDK. Whichever one your client uses, leave it as UI feedback when webhooks own fulfillment:

PlatformPurchase-completed callbackClient-side validate → consume (alternative model)
AndroidonPurchasesUpdated()validateAsync() → consumeAsync() — Client-side validation
iOSpaymentQueue(_:updatedTransactions:) (.purchased)validateTransaction(_:completion:) → finishTransaction(_:) — Client-side validation
Unity (with / without IAP)ProcessPurchase(...) / PurchaseProduct(...)ValidatePurchase(...) → ConsumeProduct(...) — Client-side validation
WindowsPurchaseProduct(...)ValidatePurchase(...) → ConsumeProduct(...) — Client-side validation

Setting up webhooks in Publisher Account​

webhooks settings in Publisher Account

Enabling webhooks​

  • Open your project in the Publisher Account.
  • In the side menu, go to Settings and select Webhooks.
  • In the Webhook server field, specify the URL of your server where you want webhooks to be sent (e.g. https://www.my-backend.com/webhooks).
  • A secret key for signing webhooks is generated by default. To add or rotate keys:
    1. In the Secret key section, click Add key.
    2. Enter a name and click Create key.
    3. Copy the generated value (shown once).
    4. When rotating: click Set as active on the new key. Up to 5 keys can exist per project; remove the old key after confirming signatures pass.
  • Click Enable webhooks.

Disabling webhooks​

  • Open your project in the Publisher Account.
  • In the side menu, go to Settings and select Webhooks.
  • Click Disable webhooks.

Which webhooks will you receive?​

The webhook types your project receives depend on when the project was registered. Newer projects use a combined order model, while legacy projects use separate payment and refund notifications.

Project registeredYou receiveNotes
After Jan 22, 2025order_paid, order_canceled (combined)Do not process payment / refund.
Before Jan 22, 2025payment, refund (separate) + order_paid / order_canceled with item dataLegacy separate model.
How to check which model you're on

In Publisher Account → Project Settings → Webhooks, if the test section only shows order_paid examples, you're on the new (combined) model.

Verifying the signature​

Every webhook carries an Authorization: Signature <value> header. Verify it before processing any event.

  1. Read the signature from the Authorization header (format: Signature <value>).
  2. Take the raw request body exactly as received. Do not parse or re-serialize the JSON — re-encoding changes the bytes and the signature will not match.
  3. Concatenate: raw_body + secret_key.
  4. Apply SHA-1; take the lowercase hex digest.
  5. Compare to the header value using constant-time comparison to prevent timing attacks.
Use the raw request body

Signature verification must run on the exact bytes Xsolla sent. Capture the raw body before any JSON middleware parses it — parsing and re-serializing changes the bytes and the signature will never match. Each framework below shows how to get the raw body.

const crypto = require('crypto');

// Capture the RAW body, not parsed JSON:
// app.use('/webhooks', express.raw({ type: 'application/json' }));
function verifyXsollaSignature(rawBody, authHeader, secretKey) {
const provided = String(authHeader || '').replace(/^Signature\s+/i, '').trim();
const expected = crypto
.createHash('sha1')
.update(rawBody.toString('utf8') + secretKey)
.digest('hex');
const a = Buffer.from(provided);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Responding to webhooks​

Your handler must reply with the right HTTP code.

ResponseMeaning
200 / 201 / 204Success — webhook accepted.
400 (any 4xx) + error codeProblem with the request. Include an error body with code and message. Common codes: INVALID_SIGNATURE, INVALID_USER, INVALID_PARAMETER.
5xxTemporary problem on your side — Xsolla will retry.

When you reject with a 4xx, include an error body alongside the status code. It follows this shape — set code and message to whatever fits the rejection:

{
"error": {
"code": "INVALID_SIGNATURE",
"message": "Signature mismatch"
}
}
Refunds on 4xx

If automatic refunds are enabled for your project, a 4xx response to order_paid triggers an automatic refund to the player. Reject with 4xx only when you genuinely want to decline the purchase — for transient problems on your side, return 5xx so Xsolla retries instead.

Retries​

  • order_paid / order_canceled: if your endpoint doesn't return success, Xsolla retries on an increasing schedule (2 attempts every 5 minutes, 7 every 15 minutes, then 10 every 60 minutes), for up to 20 delivery attempts (the initial one plus retries) within 12 hours.
  • payment / refund (legacy model): also retried on an increasing schedule within 12 hours. See the webhooks reference ↗ for the exact counts.
  • user_validation is a gate, not a retry: if you return 400/5xx or don't respond, the webhook is not resent, the player sees an error, and the subsequent payment/order_paid webhooks are not sent. Return success only for users you actually accept.

Working with webhooks​

Each webhook tells you to do something specific. Verify the signature on every request first (see Verifying the signature), then act on the notification_type.

User Validation (user_validation)​

A gate sent before payment to confirm the user may purchase. Return 204 to allow the purchase to proceed, or 400 with INVALID_USER to block it. This webhook is not retried, and no payment webhook follows a rejected validation — so return success only for users that exist in your system.

The player's id is in user.id here and in the legacy payment webhook. The combined order_paid webhook identifies the player by user.external_id instead — key your logic off the right field per webhook type.

Example
{
"notification_type": "user_validation",
"settings": {
"project_id": 18404,
"merchant_id": 2340
},
"user": {
"ip": "127.0.0.1",
"phone": "18777976552",
"email": "email@example.com",
"id": "1234567",
"name": "John Smith",
"country": "US"
}
}

For the full field list, see the user_validation reference ↗.

Order Paid (order_paid)​

This is your fulfillment trigger and the primary webhook for projects registered after Jan 22, 2025. In the combined model it carries both the items to grant and the payment data. Read items[] (each has a sku, type, and quantity), grant them to user.external_id, and return 204. Deduplicate on order.id so retries never grant twice. Test orders arrive with order.mode: "sandbox" — skip real state changes for those.

Example
{
"notification_type": "order_paid",
"items": [
{
"sku": "money.100",
"type": "virtual_good",
"quantity": 1,
"amount": "9.99",
"promotions": [
{
"amount_without_discount": "19.99",
"amount_with_discount": "9.99",
"sequence": 1
}
],
"is_pre_order": false,
"custom_attributes": {}
}
],
"order": {
"id": 123456789,
"mode": "default",
"currency_type": "real",
"currency": "USD",
"amount": "9.99",
"status": "paid",
"platform": "xsolla",
"invoice_id": "inv_12345",
"promotions": [],
"coupons": [],
"promocodes": []
},
"user": {
"external_id": "user_12345",
"email": "user@example.com",
"country": "US"
},
"billing": {
"notification_type": "payment",
"settings": {
"project_id": 18404,
"merchant_id": 2340
},
"purchase": {
"total": {
"currency": "USD",
"amount": 9.99
}
},
"transaction": {
"id": 555666777,
"external_id": "ext_trans_001"
}
}
}

Subscription and gift purchases include additional billing.purchase.subscription / billing.purchase.gift objects. For the exhaustive field list, see the order_paid reference ↗.

Order Canceled (order_canceled)​

The reversal trigger for projects registered after Jan 22, 2025, sent on a refund or chargeback. It mirrors the order_paid structure: look up the original grant by order.id, revoke or claw back the entitlement, and return 204. It is retried on the same schedule as order_paid, so dedupe on order.id to avoid double-revoking. Test reversals arrive with order.mode: "sandbox".

Example
{
"notification_type": "order_canceled",
"items": [
{
"sku": "money.100",
"type": "virtual_good",
"quantity": 1,
"amount": "9.99",
"is_pre_order": false,
"custom_attributes": {}
}
],
"order": {
"id": 123456789,
"mode": "default",
"currency_type": "real",
"currency": "USD",
"amount": "9.99",
"status": "canceled",
"platform": "xsolla",
"invoice_id": "inv_12345"
},
"user": {
"external_id": "user_12345",
"email": "user@example.com",
"country": "US"
},
"billing": {
"notification_type": "refund",
"settings": {
"project_id": 18404,
"merchant_id": 2340
},
"transaction": {
"id": 555666777,
"external_id": "ext_trans_001"
}
}
}

For the full payload, see the order_canceled reference ↗.

Payment (payment)​

This webhook carries the financial record (with a line-item summary) in the legacy (separate) model — projects registered before Jan 22, 2025. A separate order_paid carries the authoritative item-grant data. Newer projects don't receive payment at all. Deduplicate on transaction.id; test transactions arrive with transaction.dry_run: 1. The legacy reversal counterpart is the refund webhook.

Example
{
"notification_type": "payment",
"settings": {
"project_id": 18404,
"merchant_id": 2340
},
"purchase": {
"subscription": {
"plan_id": "b5dac9c8",
"subscription_id": "10",
"product_id": "Demo Product",
"date_create": "2014-09-22T19:25:25+04:00",
"date_next_charge": "2014-10-22T19:25:25+04:00",
"currency": "USD",
"amount": 9.99
},
"checkout": {
"currency": "USD",
"amount": 50
},
"total": {
"currency": "USD",
"amount": 200
},
"promotions": [
{
"technical_name": "Demo Promotion",
"id": 853
}
],
"coupon": {
"coupon_code": "ICvj45S4FUOyy",
"campaign_code": "1507"
},
"order": {
"id": 1234,
"lineitems": [
{
"sku": "ticket.10",
"quantity": 1,
"price": {
"currency": "EUR",
"amount": 6.5
}
}
]
}
},
"user": {
"ip": "127.0.0.1",
"phone": "18777976552",
"email": "email@example.com",
"id": "1234567",
"name": "John Smith",
"country": "US"
},
"transaction": {
"id": 1,
"external_id": 1,
"payment_date": "2014-09-24T20:38:16+04:00",
"payment_method": 1,
"payment_method_name": "PayPal",
"payment_method_order_id": 1234567890123456789,
"dry_run": 1,
"agreement": 1
},
"payment_details": {
"payment": {
"currency": "USD",
"amount": 230
},
"vat": {
"currency": "USD",
"amount": 0,
"percent": 20
},
"sales_tax": {
"currency": "USD",
"amount": 0,
"percent": 0
},
"direct_wht": {
"currency": "USD",
"amount": 0,
"percent": 0
},
"payout_currency_rate": "1",
"payout": {
"currency": "USD",
"amount": 200
},
"xsolla_fee": {
"currency": "USD",
"amount": 10
},
"payment_method_fee": {
"currency": "USD",
"amount": 20
},
"repatriation_commission": {
"currency": "USD",
"amount": 10
}
},
"custom_parameters": {
"parameter1": "value1",
"parameter2": "value2"
}
}

For the full field list, see the payment reference ↗.

Refund (refund)​

The reversal trigger in the legacy (separate) model, and the counterpart to payment, sent on a refund or chargeback. Look up the original grant by transaction.id, revoke or claw back the entitlement, and return 204. Deduplicate on transaction.id so retries never revoke twice. Test refunds arrive with transaction.dry_run: 1. Newer projects receive order_canceled instead and don't get refund at all.

Example
{
"notification_type": "refund",
"settings": {
"project_id": 18404,
"merchant_id": 2340
},
"purchase": {
"total": {
"currency": "USD",
"amount": 9.99
},
"order": {
"id": 1234,
"lineitems": [
{
"sku": "ticket.10",
"quantity": 1,
"price": {
"currency": "USD",
"amount": 9.99
}
}
]
}
},
"user": {
"id": "1234567",
"email": "email@example.com",
"country": "US"
},
"transaction": {
"id": 1,
"external_id": "ext_trans_001",
"payment_date": "2014-09-24T20:38:16+04:00",
"dry_run": 1
},
"refund_details": {
"code": 1,
"reason": "Test refund"
}
}

For the exhaustive field list, see the refund reference ↗.

Putting it together​

Below is a minimal handler that verifies the signature, routes on notification_type, and responds with the right codes. Each language tab reuses the verifyXsollaSignature() function from Verifying the signature:

Two things the samples can't show inline
  • Key off the right field. user_validation identifies the player by user.id; order_paid / order_canceled use user.external_id. Using the wrong one is the most common integration bug.
  • Don't return 4xx for transient failures. If automatic refunds are enabled, a 4xx to order_paid can trigger a refund. Return 5xx for problems on your side so Xsolla retries.
const express = require('express');
const app = express();

// Raw body is required for signature verification.
app.use('/webhooks', express.raw({ type: 'application/json' }));

app.post('/webhooks', (req, res) => {
const secretKey = process.env.XSOLLA_WEBHOOK_SECRET;

if (!verifyXsollaSignature(req.body, req.get('authorization'), secretKey)) {
return res.status(400).json({
error: { code: 'INVALID_SIGNATURE', message: 'Signature mismatch' },
});
}

const event = JSON.parse(req.body.toString('utf8'));

switch (event.notification_type) {
case 'user_validation':
return userExists(event.user.id)
? res.sendStatus(204)
: res.status(400).json({
error: { code: 'INVALID_USER', message: 'User not found' },
});

case 'order_paid':
if (event.order.mode !== 'sandbox') {
enqueueFulfillment(event); // grant items async; dedupe on event.order.id
}
return res.sendStatus(204);

case 'order_canceled':
if (event.order.mode !== 'sandbox') {
enqueueRevocation(event); // revoke entitlement async; dedupe on event.order.id
}
return res.sendStatus(204);

default:
// Acknowledge unhandled types so Xsolla doesn't retry them.
return res.sendStatus(204);
}
});

Best practices & testing​

  • Verify the signature before doing anything else. Reject mismatches with 400 INVALID_SIGNATURE.
  • Respond fast, process async. Return 204 immediately after verification, then do business logic (granting items, DB writes) in a background queue. Xsolla recommends handlers respond within 1–3 s; on failure they retry up to 20 times over 12 hours.
  • Be idempotent. Xsolla may deliver the same webhook more than once (e.g. after a timeout). Deduplicate by transaction.id or order.id so you never grant an item twice.
  • Handle test transactions. Publisher Account can send real webhooks for testing — payment marks them with transaction.dry_run: 1, and order_paid marks them with order.mode: "sandbox". The signature is real, but no money moves. Skip state changes (item grants, DB writes) for these, or route them to a sandbox path.
  • Restrict by source IP as defense in depth alongside signature checks. Xsolla sends webhooks from 185.30.20.0/24–185.30.23.0/24 plus a set of Google Cloud IPs; see the official list ↗ for the authoritative, current set before relying on it.
  • Testing gotcha: if you use webhook.site or a similar request inspector, it returns 200 to everything — it can't produce the 400 Xsolla expects for a bad signature. Use a real handler to test signature rejection.

Alternative: serverless via Events API​

The Xsolla Events API is an alternative to webhooks for projects without a backend. Instead of Xsolla POSTing events to your server, Xsolla stores the event payloads on its side and the SDK retrieves them directly from the app — so the entire transaction flow can run client-side.

Events API and webhooks are mutually exclusive

Opting for the Xsolla Events API disables your project's webhooks — you cannot use both simultaneously.

Events API settings in Publisher Account

To enable the Events API for your project:

  • Open your project in the Publisher Account.
  • In the side menu, go to Settings and select Webhooks.
  • Select Use API.

To initialize the SDK in Events API mode, see the platform-specific guides:

For the complete list of supported webhook types, see the Xsolla webhooks reference ↗.