Idempotency and errors
Creating an order costs money, and every network is unreliable. This page is the contract that makes a retry safe, and the map of everything that can come back instead of a success.
Why the key is mandatory
POST /b2b/orders requires an X-Idempotency-Key header. A request without one is rejected with 400 and "X-Idempotency-Key header is required" — no charge, no order, no pricing, nothing reserved.
That check is the first thing the handler does, which puts it after two things that run before any handler: your key is authenticated, and the framework validates the shape of the body. So a call that is missing the header and unauthenticated is a 401, and one whose body does not match the schema is a 422 — the missing-header 400 is what you see once the rest of the request is well formed. Test the three cases separately rather than assuming one status covers them.
That is deliberate. The failure this prevents is not exotic: your request reaches us, we charge and create the order, and the response is lost on the way back. Without a key, your only options are to retry (and pay twice) or not to retry (and lose an order you already paid for). With a key, the retry returns the original result.
Mint one key per business intent — one buyer checkout — and persist it next to your own order before you make the call. A key generated inside the retry loop is not an idempotency key.
POST /b2b/orders X-Api-Key: gc_live_… X-Idempotency-Key: 7f3c1a9e-2b44-4d1a-9d4e-1c0f9a2b3c4d
Any opaque string works; a UUID v4 is the obvious choice. Keys are scoped to your account, so they cannot collide with another partner's.
What "the same request" means
The key alone does not decide a replay. We also fingerprint the request, and the fingerprint is narrower than the whole body.
It is built from items alone — productId, quantity and deliveryData, normalised: schema keys sorted, values trimmed, and a username lowercased with a leading @ removed, so @Player1 and player1 are one order rather than two. Items are then sorted by productId and, within one product, by their normalised deliveryData, so shuffling the basket does not change the fingerprint.
One edge survives that sort. Two lines of the same product with identical deliveryData and different quantity compare as equal — quantity is hashed but is not part of the ordering — so their relative order is whatever you sent, and sending the same basket with those two lines swapped produces a different fingerprint and therefore a 409 rather than a replay. Merge such lines into a single line with the summed quantity, which is what they mean anyway, and the edge cannot occur.
callbackUrl and externalOrderId are deliberately outside the fingerprint. Reusing a key with the same items but a different callback URL that passes validation is therefore not a conflict — it is a replay: you get the original order back, and the new callback URL has no effect on it. Fix a callback URL before the order exists, not after.
The validation qualifier is load-bearing, because callbackUrl is checked before we look your key up. A replacement URL that fails the check — an unparseable URL, a non-http(s) scheme, embedded credentials, or a host we refuse to call (private, loopback and link-local literals, every IPv6 literal, internal hostnames) — returns 400 Invalid callbackUrl: … and never reaches the idempotency lookup at all. So a bad URL does not replay the original order, and it does not conflict either: it is refused outright, and the key stays exactly as it was.
How long we remember a key is worth stating exactly, because "24 hours" is only half of it. Two rows are written the first time a key is used successfully — one holding the response, one binding your key to the items it was used with — and both are stamped to expire 24 hours later. An attempt that failed writes neither, as the next section explains. A sweep runs once an hour and deletes them after they expire. Between expiry and that sweep the rows are still there and still decide: a replay still returns the stored response, and a mismatched basket still conflicts.
So a key is remembered for at least 24 hours, and up to about an hour longer, with no way to predict where in that band a given key falls. Only once the rows are actually gone does the same key with the same items create a second, genuinely new order — with a second charge. A retry queue that can outlive a day needs its own duplicate guard; ours is not one after that point.
Replay, conflict, and in-flight
| You send | You get |
|---|---|
| Same key, same items, after the first request succeeded | 200 with the original response body, byte for byte. No second order, no second charge. |
| Same key, same items, while the first request is still running | Your call waits on ours. When the first one finishes you get its stored 200 — or, if it ended up failing, your call goes on to execute as a first attempt. You do not have to poll. |
| Same key, different items, after the first request succeeded | 409, idempotency_key_reused_with_different_body: … |
| Same key, after the first attempt failed | A normal, fresh attempt — same items or different. Nothing is replayed, because nothing was kept. |
That last row is the one that surprises people, and it is what makes the retry advice further down work.
Both idempotency rows are written inside the same database transaction that creates the payment and the orders, and the row holding the response is filled in with the real 200 before that transaction commits. So there is no intermediate state to observe. If anything in the transaction fails — the balance is short, a product is rejected, a fault on our side — the whole transaction rolls back and takes both idempotency rows with it. The attempt leaves no trace, and the same key is free again.
Two consequences worth stating plainly. 402 Insufficient balance is retryable with the same key precisely because that key was never spent. And the 409 conflict only exists after a use that succeeded — a different basket under a key whose first attempt failed is not a conflict, it is a new order.
409 Duplicate request in progress, retry later is a defensive answer for a state the normal path does not produce; you are unlikely ever to see it. If you do, treat it as transient: wait a second or two, repeat the identical request.
idempotency_key_reused_with_different_body, on the other hand, is not something a retry can fix. It keeps returning 409 for as long as the binding is retained — at least 24 hours from the key's first, successful use — and once that row is swept the same call does something worse than fail: it buys the new basket for real, under a key you thought was spent. It means your code changed the basket under a key that was already used. Treat it as a bug in your own order state, mint a new key for the genuinely new order, and reuse an old key only to replay identical items.
Two envelopes, not one
This is the detail that bites first, so branch on the HTTP status rather than on a field:
{ "error": "Unauthorized", "message": "Invalid or missing API key" }
Authentication and authorisation failures carry { error, message } and no success field: the 401 from a missing or unusable API key, and the 403 from an account that is not a supply account or a key whose scope does not cover the endpoint. A client that checks response.success sees undefined on exactly the two failures a new integration hits first.
One 403 is not in that family. The CSRF origin check — which fires only on a write request that carries an Origin header we do not allow, so you meet it when calls go out from a browser rather than from your backend — answers { "success": false, "error": "Forbidden: Origin not allowed" }. Two 403s, two shapes: one more reason to branch on the status and treat both fields as optional.
{ "success": false, "error": "too_many_items: maximum 200 items per order" }
Business failures carry { success: false, error }, where error is a human-readable string. Some strings begin with a machine-readable token before a colon (too_many_items:, region_not_allowed:, ZERO_PRICE_ITEM:, idempotency_key_reused_with_different_body:); match on that prefix rather than on the whole sentence, which may be reworded.
Two more shapes exist at the edges. A body that violates the request schema — an unquoted number in deliveryData, a missing items — is rejected by the framework's own validation with 422 and a validation envelope that resembles neither of the above. And an internal fault returns a deliberately contentless 500: we never echo internals into an error body.
The error table
| HTTP | When | Example error |
|---|---|---|
| 400 | Missing idempotency header | X-Idempotency-Key header is required |
| 400 | Empty or oversized basket | Items list is empty · too_many_items: maximum 200 items per order |
| 400 | Bad callbackUrl | Invalid callbackUrl: … |
| 400 | Unknown, inactive or invisible product | Product 34521 not found or unavailable · Product 34521 is not available for this site |
| 400 | Product needs a file upload | Product 34521 requires an image upload, which is not supported via the B2B API |
| 400 | Delivery data fails the product's schema or a format rule | the field-level message |
| 400 | Region not allowed for your account | region_not_allowed: product 34521 is not available in your site's allowed regions |
| 400 | Product has no usable price | ZERO_PRICE_ITEM: product 34521 has a non-positive base price |
| 400 | Quantity out of range or not a whole unit | Invalid quantity for product 34521 |
| 401 | Missing, unknown, expired or domain-blocked key | Invalid or missing API key |
| 402 | Balance or credit limit | Insufficient balance · Exceeds credit limit (available: …) |
| 402 | The balance changed while this order was committing (usually a parallel call of yours) | Concurrent balance modification |
| 403 | Account is not a supply account, or key scope is wrong | This endpoint is only available for B2B API partners |
| 403 | Write request from a disallowed Origin | Forbidden: Origin not allowed |
| 404 | Unknown order code, game slug or product id | Order not found · Game not found · Product not found |
| 409 | Idempotency conflict or in-flight duplicate | see the table above |
| 422 | Request body does not match the schema | framework validation envelope |
| 429 | Rate limited | Too many requests. Please try again later. |
| 503 | A delivery rule could not be evaluated | the rule message |
| 500 | Internal fault | Internal server error |
402 covers two different problems, and the difference decides what you do next. Insufficient balance and Exceeds credit limit mean the money is not there: top up, then repeat the identical call with the same key. Concurrent balance modification means the money was there, but your balance was changed by something else — usually one of your own parallel order calls — while this one was committing, and the whole transaction rolled back, so nothing was charged and no order exists. Repeat the identical request, unchanged and with the same key, after a short pause. Topping up neither helps nor is needed. Serialising order creation per account, or simply not firing a burst of orders at the same second, makes it rare.
Rate limits
The default budget is 500 requests per minute, counted per client IP rather than per key — several servers behind one NAT share one bucket, and one server calling with two keys does not get two.
Every response that reaches the limiter carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds until the window rolls over), so you can see the ceiling approaching without hitting it. One response does not reach it: the CSRF origin check above runs before the limiter, so its 403 Forbidden: Origin not allowed arrives with none of these headers. Read the status before you read the budget. Over the limit:
{"success": false,"code": "RATE_LIMITED","error": "Too many requests. Please try again later.","retryAfter": 37}
with a Retry-After header carrying the same number of seconds. Honour it: retrying sooner just burns budget you do not have. If a legitimate workload needs more — a nightly catalog sweep, a migration backfill — ask us to raise it rather than spreading calls across IP addresses.
A retry policy that works
| Response | Retry? | How |
|---|---|---|
| Network error, timeout, no response | Yes | Same key, same body. This is what the key exists for. |
429 | Yes | After Retry-After, then exponential backoff with jitter. |
503 | Yes | Backoff. Do not strip the field the rule was evaluating. |
500 | Yes, once or twice | Backoff. If it persists, alert rather than loop. |
402 Insufficient balance · Exceeds credit limit | Yes, after topping up | Same key. Nothing was charged and no order was created. |
402 Concurrent balance modification | Yes, straight away | Same key, same body, after a short pause. Transient contention, not a money problem — do not top up. |
409 Duplicate request in progress | Yes | After a short wait, identical request. Rare — the normal path makes you wait rather than answering this. |
409 …reused_with_different_body | No | Fix your state. It cannot succeed while the binding is retained, and once it expires the retry buys the new basket for real. |
400, 403, 404, 422 | No | Deterministic. A retry changes nothing. |
Cap the total attempts and give up into an alert rather than into an infinite loop — but never give up by mutating the request, because a changed basket under the same key turns a recoverable timeout into a permanent 409.
Before you go live
- The idempotency key is generated and persisted before the call, and reused unchanged across every retry of that checkout.
- Retries never modify the basket.
- The client branches on HTTP status;
successis treated as optional. - Error matching uses the
code:prefix where one exists, not the full sentence. 409is split into its two cases, with only one of them retried.402is split too: a balance problem waits for a top-up,Concurrent balance modificationis just retried.X-RateLimit-Remainingis on a dashboard, andRetry-Afteris honoured — with the dashboard tolerating the one response that carries neither.- A retry queue that can outlive 24 hours has its own duplicate guard, because our record is swept within about an hour of expiring.
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.