Documentation

Webhooks: signed order events

An order that leaves POST /b2b/orders is in processing. The supplier answers seconds later, or minutes later. Webhooks are how you learn the outcome without polling every order you have ever created.

This page covers the per-order webhook channel: you pass a callbackUrl when you create the order, and we deliver a signed event to it when that order resolves.

How the channel works

There is no registration step and no dashboard to configure. You set callbackUrl on the order:

{
"items": [{ "productId": 34521, "deliveryData": { "gameUserId": "5123456789" } }],
"externalOrderId": "MP-98765",
"callbackUrl": "https://api.yourshop.com/webhooks/gamecore"
}

Your signing secret is minted for your account the first time we deliver anything, and stays fixed after that: 64 hexadecimal characters. Ask us for it — it is not shown in the order response.

Every delivery carries these headers:

HeaderMeaning
X-Webhook-Signaturesha256= followed by the lowercase hex HMAC.
X-Webhook-TimestampUnix time in seconds, the same value that was signed.
X-Idempotency-KeyEvent id. Stable across every retry of the same event.
X-Webhook-Eventorder.completed or order.failed.
User-AgentGameCore-B2B-Webhook/1.0

X-Idempotency-Key is your deduplication key. Retries reuse it, so storing it and ignoring repeats is the whole of at-least-once handling.

If you also use the GameCore storefront SDK, do not reach for its verifyWebhookSignature helper here. That function verifies a different signing scheme and will reject every event on this channel. Verify with raw HMAC, as shown below.

The payload

Exactly two event types exist: order.completed and order.failed.

A completed order, here for a top-up delivered straight to a player ID — note that there is no cdKeys field, because there was never a key:

{
"event_id": "7f3c1a9e-2b44-4d1a-9d4e-1c0f9a2b3c4d",
"event_type": "order.completed",
"occurred_at": "2026-08-18T18:31:15.432Z",
"data": {
"orderCode": "ash-XY7K3M",
"externalOrderId": "MP-98765",
"totalAmount": 1064.5,
"status": "completed",
"items": [
{ "productName": "PUBG Mobile 660 UC", "amount": 1, "price": 1064.5 }
],
"completedAt": "2026-08-18T18:31:15.432Z"
}
}

For a key-bearing product, each item additionally carries cdKeys: [{ "code": "…" }].

A failed order. It carries no items array at all — do not index into it:

{
"event_id": "1b8d4f22-6a90-41c7-8f3e-5d21c7a4b019",
"event_type": "order.failed",
"occurred_at": "2026-08-18T18:31:15.432Z",
"data": {
"orderCode": "ash-XY7K3M",
"externalOrderId": "MP-98765",
"totalAmount": 1064.5,
"status": "failed",
"error": "All items failed at supplier",
"reason": "ID игрока указан неверно",
"completedAt": "2026-08-18T18:31:15.432Z"
}
}

error is a fixed technical string. reason is a buyer-safe explanation in Russian, or null when the supplier gave none — it is the field to surface in a support UI, and the one to translate if your buyers do not read Russian.

Match on event_type, never on X-Webhook-Event alone: the header is convenience, the body is the record.

Verifying the signature

The signed string is the timestamp, a literal dot, and the raw request body:

signing_input = "{X-Webhook-Timestamp}" + "." + raw_body
signature     = "sha256=" + hex(HMAC_SHA256(secret, signing_input))

The body must be the bytes as received. Parsing the JSON and re-serialising it changes key order and number formatting, and the signature will not match. Read the raw body first, verify, then parse.

Node.js, on an Express-style handler with the raw body preserved:

import crypto from "node:crypto";
function verify(rawBody, headers, secret) {
const timestamp = Number(headers["x-webhook-timestamp"]);
const received = headers["x-webhook-signature"] ?? "";
// Reject anything outside the replay window before doing crypto work.
const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
if (!Number.isFinite(timestamp) || age > 300) return false;
const expected =
"sha256=" +
crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
// Constant-time compare — a plain === leaks the signature byte by byte.
const a = Buffer.from(expected);
const b = Buffer.from(received);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Python:

import hashlib
import hmac
import time
def verify(raw_body: bytes, headers, secret: str) -> bool:
try:
timestamp = int(headers["X-Webhook-Timestamp"])
except (KeyError, ValueError):
return False
if abs(int(time.time()) - timestamp) > 300:
return False
signing_input = f"{timestamp}.".encode() + raw_body
expected = "sha256=" + hmac.new(
secret.encode(), signing_input, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, headers.get("X-Webhook-Signature", ""))

Two details that break naive implementations: the header value carries the sha256= prefix while the HMAC itself does not, and the comparison must be constant-time.

The replay window

Reject events whose X-Webhook-Timestamp is more than 300 seconds away from your clock, in either direction. That window is a receiver-side rule — we do not enforce it on our end, and nothing stops an attacker replaying a captured request without it.

Retries do not fall out of the window: every attempt is signed fresh at the moment it is sent, so a delivery made 24 hours later still arrives with a current timestamp. A stale timestamp therefore means a replay, not a slow retry.

Keep your server's clock on NTP. Clock drift on the receiver is the most common cause of "all our webhooks suddenly fail signature verification".

Answering correctly

Return any 2xx as soon as you have stored the event. We never read your response body — only the status code — so there is nothing to put in it.

Your responseWhat we do
2xxDelivered. Done.
429 or 408Retry on the ladder below. Send Retry-After and we obey it for that one retry.
Any other 4xxDead-letter immediately. No retries.
5xxRetry on the ladder below.
Timeout or connection errorRetry on the ladder below.
3xx redirectTreated as a permanent failure and dead-lettered. Redirects are not followed.

Three consequences worth designing around:

  • 429 and 408 are the two 4xx codes that keep the event alive. They read as backpressure — you are asking us to slow down, not telling us the request was wrong — so they go on the retry ladder like a 5xx. Every other 4xx is a permanent verdict: if your handler answers 400 because the payload shape surprised it, 401 because your own auth middleware ran before your signature check, or 404 because someone moved the route, that event is gone after one attempt. When you are unsure, return 500; when you are shedding load, return 429. Both buy you the ladder.
  • 429 slows delivery down, it does not add attempts. A retry triggered by 429 consumes one of the eight, exactly like a 5xx retry. An endpoint that answers 429 to everything still dead-letters the event once the ladder runs out.
  • Point callbackUrl at its final address. A redirect from http to https, or from apex to www, is a dead-letter — not a hop.

We wait up to 10 seconds per attempt. Do the work asynchronously: acknowledge, then process. A handler that tops up a wallet and emails a receipt before answering will eventually exceed 10 seconds under load, and we will retry an event you already handled.

The retry ladder

One initial attempt plus up to eight retries — nine deliveries in the worst case, spread over roughly 46 hours:

RetryDelay after the previous attempt
11 minute
25 minutes
315 minutes
41 hour
53 hours
66 hours
712 hours
824 hours

The queue is checked every 30 seconds, so each delay can run up to half a minute long.

Retry-After is read, on 429 and 408 only. Both RFC 9110 forms work — delta-seconds (Retry-After: 120) and an HTTP-date (Retry-After: Wed, 21 Oct 2026 07:28:00 GMT). When we can parse it, that value replaces the ladder delay for that one retry; the rungs after it are the ladder's again. The value is clamped to between 5 seconds and 6 hours: anything shorter becomes 5 seconds — including a date already in the past — and anything longer becomes 6 hours, so neither a Retry-After: 0 nor a Retry-After: 999999 can turn the queue into a hot loop or park a real order for a week. A header we cannot parse is ignored and the ladder applies. On any other status code we do not look at the header at all.

What the header cannot do is buy attempts. Every retry increments the same counter whether its delay came from your header or from the table, so nine deliveries remains the ceiling.

After the last attempt

An event that exhausts all nine attempts — or hits one of the immediate-failure conditions above — is dead-lettered. It is stored, our operations team is alerted, and it is never redelivered automatically. Recovery is a manual request to us.

The immediate triggers are worth knowing because they are all configuration, not luck: any 4xx other than 429 and 408, a redirect response, an invalid callbackUrl, and a URL that resolves to a blocked address.

Design so that a dead letter is not a lost order. That means:

  • Store the order as soon as you create it, not when the webhook arrives.
  • Reconcile with GET /b2b/orders/:code on a schedule for anything still non-terminal after, say, an hour.

What callbackUrl must look like

The URL is validated when the order is created, and again before every delivery. Rejected outright:

  • Any scheme other than http or https.
  • Credentials embedded in the URL (https://user:pass@host/…).
  • Private, loopback, link-local, carrier-NAT and cloud-metadata addresses.
  • Any IPv6 literal.
  • Internal hostnames — localhost, anything under .local, and container or cloud-metadata names.

Before each delivery we resolve DNS, re-check the resulting addresses, and pin the connection to the address we validated, so a name that resolves to a public address at order time and an internal one at delivery time does not get through.

An invalid callbackUrl fails the order creation call with a 400, before anything is charged.

Partial orders send nothing

An order where some items succeeded and others failed emits no webhook on this channel. The order row moves to failed and the value of the failed lines returns to your balance, but no order.completed and no order.failed is delivered.

This is the one case where webhooks alone will leave you blind, so polling is not an optional optimisation:

  • Reconcile any order still in pending or processing after a sensible timeout with GET /b2b/orders/:code.
  • Read items[].status there, not just the order status — that is where a partial result is visible.

The same reconciliation loop covers dead letters and any outage on your side, so it is one mechanism paying for two risks.

Before you go live

  • The raw body is captured before any JSON parsing, and the signature is checked against it.
  • The comparison is constant-time and the sha256= prefix is accounted for.
  • Timestamps outside 300 seconds are rejected, and the server clock is on NTP.
  • X-Idempotency-Key is stored, and repeats are ignored rather than reprocessed.
  • The handler answers 2xx in well under 10 seconds and does its work afterwards.
  • Unexpected payloads return 5xx, and load shedding returns 429 — never a plain 4xx, which dead-letters the event on the spot.
  • A reconciliation job polls non-terminal orders, so partial results and dead letters are still caught.

Need an API key?

Tell us which games and regions you sell and we will issue a key, then walk the integration with you. Integration questions are answered by the same people who run the API.