REST API - v1
Game Top-Up & Voucher API for Resellers
One REST API for game top-ups, game recharge and voucher codes: read the catalog, validate a player, create an order, and get the result by webhook or status poll.
This is the host-to-host (H2H) top-up API behind SHOP2TOPUP. It is a plain JSON API over HTTPS with Bearer authentication, UUID idempotency keys, published rate limits and stable machine-readable error codes, so a shop, a storefront or a bot can sell digital game credit programmatically without a client library. The same interface has carried 3,000,000+ orders delivered over 5+ years of continuous operation.
Looking for pricing, payment methods and how the partner account itself works? Read the SHOP2TOPUP reseller programme overview.
Your first call, before you sign anything
Every endpoint is an ordinary HTTPS request carrying one header. There is no SDK to install, no client library to vendor and no build step to add. Paste the call below, drop in your key, and you have a live catalog response in your terminal.
curl -s "https://shop2topup.com/api/endpoints/v1/catalog/categories?bigCategoryId=1" \
-H "Authorization: Bearer YOUR_KEY_ID.YOUR_KEY_SECRET"{
"success": true,
"categories": [
{
"id": 12,
"name": "Free Fire",
"description": "Garena Free Fire diamonds",
"big_category_id": 1,
"big_category_name": "Mobile Games"
}
]
}Base URL
https://shop2topup.com/api/endpoints/v1
Every documented path is relative to this base. Responses are JSON and always carry a boolean success field, so one branch in your client separates the happy path from everything else.
Error envelope
Failures keep the same shape at every status code. Branch on error.code, which is part of the contract; never branch on error.message, which is written for humans and can be reworded at any time.
{
"success": false,
"error": {
"code": "PRICE_INCREASED",
"message": "Price has increased beyond expected",
"details": {
"expected_unit_price": "0.950000",
"current_unit_price": "0.980000"
}
}
}The whole API surface on one screen
Eleven endpoints cover the entire integration: read the catalog, price an item, check a player, place an order and reconcile it afterwards. Each row links straight to the full reference, with every parameter, an example request and an example response.
| Method | Path | What it does | Reference |
|---|---|---|---|
| GET | /account | Get Account Info | Read the reference |
| GET | /catalog/big-categories | List Big Categories | Read the reference |
| GET | /catalog/categories | List Categories | Read the reference |
| GET | /catalog/subcategories | List Subcategories (Products) | Read the reference |
| GET | /catalog/subcategory/:itemId/price | Get Item Price | Read the reference |
| GET | /catalog/category/:categoryId/requirements | Get Category Requirements | Read the reference |
| POST | /player/validate | Validate Player | Read the reference |
| POST | /orders/create | Create Order | Read the reference |
| GET | /orders/:orderId | Get Order Status | Read the reference |
| POST | /orders/batch | Batch Get Orders | Read the reference |
| GET | /orders | List Orders | Read the reference |
Plus order-status webhooks, documented in full further down this page.
Authentication and access control
Authentication is a single HTTP header. Every request carries an Authorization header holding a Bearer credential made of a key ID and a secret, issued from the API Access page of your reseller panel. The key ID identifies the account; the secret proves you own it and is verified server-side with an HMAC check.
The secret is displayed once, at creation time, and is never retrievable afterwards. Put it in whatever secret manager your stack already uses, inject it as an environment variable, and keep it out of source control. If you lose it, you rotate rather than recover.
There is no OAuth redirect, no session cookie, no refresh token and nothing to schedule. That matters for automated integrations: a cron job or a queue worker can hold the same credential for years without a renewal path, and there is no expiry clock to wake up to at three in the morning.
An IP allowlist can be attached to a key. Once it is non-empty, requests from any other address are rejected with IP_NOT_ALLOWED and HTTP 403 before any business logic runs, so a leaked credential is useless off your own servers. Rotation is a deploy, not a migration: create the new key, ship it, delete the old one.
Keys belong to one reseller account and spend that account wallet, so never ship a secret to a browser, a mobile bundle or a public repository. Call the API from your backend and let your own front end talk to your backend. Everything documented here assumes a server-to-server caller.
Authorization: Bearer <keyId>.<secret>Confirm a key in one call
GET /account is the health check for a credential. It answers three questions at once: does the key authenticate, is the account enabled, and does the wallet hold enough to cover what you are about to order. Run it at boot and before any batch run.
Read the authentication and account referenceA complete integration, end to end
A working integration is four steps. The samples below run all of them against the live API in cURL, Node and PHP. Swap in your own credential and they execute as written.
- 1
Read the catalog
Walk big categories to categories to subcategories, then read the current price for the item you are about to sell. Cache the tree; price the item fresh.
- 2
Validate the player
Resolve the identifier to an in-game name and a region before any money moves. This is where typos and cross-region mistakes get caught.
- 3
Create the order
Generate a UUID, persist it, then send it as order_id. It is your idempotency key, and it is the only safe way to retry a create request.
- 4
Confirm delivery
Wait for the order-status webhook, and fall back to a status read for anything still pending after your own timeout window.
cURL - full flowShow codeHide code
export S2T_KEY="YOUR_KEY_ID.YOUR_KEY_SECRET"
export S2T_BASE="https://shop2topup.com/api/endpoints/v1"
# 1. Find the item you want to sell.
curl -s "$S2T_BASE/catalog/subcategories?categoryId=12" \
-H "Authorization: Bearer $S2T_KEY"
# 2. Read the exact price you will be charged for it.
curl -s "$S2T_BASE/catalog/subcategory/999/price" \
-H "Authorization: Bearer $S2T_KEY"
# 3. Confirm the player exists BEFORE any money moves.
curl -s -X POST "$S2T_BASE/player/validate" \
-H "Authorization: Bearer $S2T_KEY" \
-H "Content-Type: application/json" \
-d '{"sub_category_id": 999, "player_id": "123456789", "server": "Asia"}'
# 4. Create the order. order_id is YOUR uuid and YOUR idempotency key.
curl -s -X POST "$S2T_BASE/orders/create" \
-H "Authorization: Bearer $S2T_KEY" \
-H "Content-Type: application/json" \
-d '{
"order_id": "01912345-6789-7abc-8def-0123456789ab",
"sub_category_id": 999,
"quantity": 1,
"requirements": { "player_id": "123456789", "server": "Asia" },
"expected_unit_price": "0.950000"
}'
# 5. Read the order back until it leaves "pending".
curl -s "$S2T_BASE/orders/01912345-6789-7abc-8def-0123456789ab" \
-H "Authorization: Bearer $S2T_KEY"Node.js - full flowShow codeHide code
// Node 18+ β no dependencies, global fetch and global crypto.
const BASE = 'https://shop2topup.com/api/endpoints/v1';
const KEY = process.env.S2T_KEY; // "<keyId>.<secret>", server-side only.
async function call(path, init = {}) {
const res = await fetch(BASE + path, {
...init,
headers: {
Authorization: `Bearer ${KEY}`,
'Content-Type': 'application/json',
...(init.headers || {}),
},
});
if (res.status === 429) {
const wait = Number(res.headers.get('Retry-After') || 5);
await new Promise((r) => setTimeout(r, wait * 1000));
return call(path, init);
}
const body = await res.json();
if (!res.ok || body.success === false) {
throw Object.assign(new Error(body?.error?.code || 'HTTP_' + res.status), {
code: body?.error?.code,
status: res.status,
});
}
return body;
}
async function sell({ itemId, categoryId, playerId, server }) {
// 1 + 2. Catalog and current price.
const { subcategories } = await call(
`/catalog/subcategories?categoryId=${categoryId}`,
);
const item = subcategories.find((s) => s.item_id === itemId);
const { price } = await call(`/catalog/subcategory/${itemId}/price`);
// 3. Player check before charging.
const { player } = await call('/player/validate', {
method: 'POST',
body: JSON.stringify({
sub_category_id: itemId,
player_id: playerId,
server,
}),
});
// 4. Create the order. Persist orderId BEFORE the call so a crash mid-flight
// can be replayed with the same uuid instead of double-charging.
const orderId = crypto.randomUUID();
await saveIntent(orderId, itemId, playerId);
const { order } = await call('/orders/create', {
method: 'POST',
body: JSON.stringify({
order_id: orderId,
sub_category_id: itemId,
quantity: 1,
requirements: { player_id: playerId, server },
expected_unit_price: price.unit_price,
}),
});
// 5. Confirm. A webhook usually beats the poll; the poll is the safety net.
let status = order.status;
while (status === 'pending') {
await new Promise((r) => setTimeout(r, 20000));
status = (await call(`/orders/${orderId}`)).order.status;
}
return { orderId, status, itemName: item?.name, playerName: player.player_name };
}PHP - full flowShow codeHide code
<?php
// PHP 8 β plain cURL, no SDK required.
const S2T_BASE = 'https://shop2topup.com/api/endpoints/v1';
function s2t(string $path, ?array $body = null): array
{
$ch = curl_init(S2T_BASE . $path);
$headers = [
'Authorization: Bearer ' . getenv('S2T_KEY'),
'Content-Type: application/json',
];
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 30,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($raw, true) ?: [];
if ($status >= 400 || ($data['success'] ?? false) === false) {
throw new RuntimeException($data['error']['code'] ?? 'HTTP_' . $status);
}
return $data;
}
// 1 + 2. Catalog, then the exact price for the item.
$catalog = s2t('/catalog/subcategories?categoryId=12');
$price = s2t('/catalog/subcategory/999/price');
// 3. Player check before charging.
$player = s2t('/player/validate', [
'sub_category_id' => 999,
'player_id' => '123456789',
'server' => 'Asia',
]);
// 4. Create the order with your own uuid as the idempotency key.
$orderId = sprintf(
'%04x%04x-%04x-7%03x-%04x-%04x%04x%04x',
random_int(0, 0xffff), random_int(0, 0xffff),
random_int(0, 0xffff), random_int(0, 0x0fff),
random_int(0x8000, 0xbfff),
random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff)
);
$order = s2t('/orders/create', [
'order_id' => $orderId,
'sub_category_id' => 999,
'quantity' => 1,
'requirements' => ['player_id' => '123456789', 'server' => 'Asia'],
'expected_unit_price' => $price['price']['unit_price'],
]);
// 5. Read it back until it settles.
do {
sleep(20);
$latest = s2t('/orders/' . $orderId);
} while ($latest['order']['status'] === 'pending');
echo $latest['order']['status'];Two details are worth copying verbatim: persist the order UUID before the create call, not after it, and re-read the price rather than trusting a cached number. Those two habits remove almost every double-charge and every PRICE_INCREASED rejection.
Webhooks: order-status callbacks
Register one HTTPS endpoint and the platform pushes order outcomes to it as they happen, so your storefront stops polling and starts reacting. The callback is signed, so you can prove the payload came from us before you act on it.
Events
| Event | Fires when |
|---|---|
| order.completed | The order finished and everything it owed the buyer was delivered. |
| order.failed | The order failed and no sub-transaction is left pending or processing. |
| order.refunded | The order was fully refunded back to your wallet. |
| webhook.test | You triggered a signed test delivery from the panel to check your handler. |
Delivery headers
| Header | Value |
|---|---|
| Content-Type | application/json |
| X-Shop2Topup-Signature | sha256=<hex> β HMAC-SHA256 of the raw request body |
| X-Shop2Topup-Event | Event name, e.g. order.completed |
| User-Agent | shop2topup-webhook/1.0 |
Callback payload
Every callback has the same three top-level fields β event, timestamp and data β and data mirrors the order object returned by the status endpoint. Voucher codes are present only on a completed order, player_name can be null, and sub_transaction_summary appears whenever the order was split into units.
{
"event": "order.completed",
"timestamp": "2026-03-11T14:30:00.000Z",
"data": {
"order_id": "01912345-6789-7abc-8def-0123456789ab",
"status": "completed",
"player_id": "123456789",
"player_name": "ProGamer99",
"subcategory_name": "FF 100 Diamonds",
"quantity": 1,
"charged_amount": "0.950000",
"currency": "USD",
"created_at": "2026-03-11T14:28:00.000Z",
"completed_at": "2026-03-11T14:30:00.000Z",
"vouchers": [
{
"code": "XXXX-YYYY-ZZZZ",
"serial_number": "SN-9928371",
"expiry_date": "2027-03-11"
}
],
"sub_transaction_summary": {
"total": 1,
"completed": 1,
"failed": 0,
"pending": 0,
"processing": 0,
"retrying": 0,
"refunded": 0
}
}
}Signature verification
Each delivery carries X-Shop2Topup-Signature, formed as sha256= followed by the hex HMAC-SHA256 of the raw request body under your webhook secret. Verify against the raw bytes, before any JSON parsing, and compare in constant time. A handler that parses first and verifies second can be fooled by a re-serialised body.
// Express β verify the signature against the RAW body, not the parsed object.
const crypto = require('crypto');
app.post(
'/shop2topup/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.get('X-Shop2Topup-Signature') || '';
const expected =
'sha256=' +
crypto
.createHmac('sha256', process.env.S2T_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).end();
}
const payload = JSON.parse(req.body.toString('utf8'));
// Answer fast, then do the work. The delivery times out after 10 seconds.
res.status(200).end();
enqueueSettlement(payload.data.order_id, payload.event);
},
);Delivery and retries
A callback is attempted once and your endpoint has ten seconds to answer. There is no automatic retry and no exponential backoff behind it, which is deliberate: the status endpoint is the authoritative record, so reconciliation belongs to a poll you control rather than to a retry schedule you cannot see. Return 200 immediately and do the work on your own queue.
Idempotent handlers
Key your handler on order_id and make replaying a callback a no-op. Even with a single delivery attempt, your own infrastructure will occasionally hand the same message to two workers, and an order that credits a customer twice is a far worse bug than one that credits late.
Registration and secrets
Registering the callback URL, sending a signed test delivery and rotating the signing secret all happen inside your signed-in reseller panel, not over the public API. HTTPS is required and private addresses are refused.
Open API access in the reseller panelRate limits and error handling
Limits are published, per account, and enforced with a rolling window. Read-heavy catalog work gets the widest allowance; the status endpoints are tight on purpose, because they exist as a reconciliation fallback rather than as a polling loop.
| Route | Requests | Window | Counted per |
|---|---|---|---|
| GET /catalog/* (all five) | 80 | 60s | per account |
| POST /orders/create | 120 | 60s | per account |
| GET /orders/:orderId | 3 | 60s | per account + order id |
| POST /orders/batch | 2 | 60s | per account |
| GET /orders | 2 | 60s | per account |
| GET /account | 60 | 60s | per account |
| POST /player/validate | dedicated limiter | β | per account |
Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset, so a well-behaved client never has to guess where it stands. Watch the remaining counter and slow down before you are throttled rather than after.
HTTP/1.1 429 Too Many Requests
Retry-After: 45
X-RateLimit-Limit: 80
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1765000000
{
"success": false,
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Rate limit exceeded. Try again in 45 seconds.",
"details": {
"limit": 80,
"remaining": 0,
"retry_after": 45,
"window_seconds": 60
}
}
}Errors you will meet first, and their fixes
Failures always return a stable code alongside the HTTP status. These are the ones a new integration hits in its first week; each is paired with the change that resolves it.
| Code | HTTP | What it means | How to fix it |
|---|---|---|---|
| INVALID_API_KEY | 401 | Invalid API keyThe provided API key does not match any active key in the system. | Verify your key ID and secret are correct. Regenerate the key from the API Access panel if needed. |
| IP_NOT_ALLOWED | 403 | IP address not in allowlistThe request originated from an IP address not in your API key allowlist. | Add your server IP to the allowlist in the API Access panel, or remove IP restrictions. |
| MISSING_REQUIRED_FIELD | 400 | Missing required fieldA required field is missing from the request body. | Check the endpoint documentation for required fields. |
| INVALID_UUID_FORMAT | 400 | Invalid UUID formatThe order_id is not a valid UUID format. | Use any valid UUID format (v1, v4, v7, etc). Format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx. |
| PLAYER_NOT_FOUND | 400 | Player not foundThe player ID could not be found in the game system. | Verify the player ID is correct. Check if a zone_id is required for the game. |
| REGION_MISMATCH | 400 | Region mismatchThe player belongs to a different region than expected. | Use the correct zone_id or genshin_zone for the player region. |
| INSUFFICIENT_BALANCE | 400 | Insufficient wallet balanceYour wallet does not have enough funds to complete this order. | Top up your wallet from the Reload page before placing the order. |
| OUT_OF_STOCK | 400 | Product is out of stockThe requested product is currently unavailable. | Try again later or choose a different product. |
| DUPLICATE_ORDER | 409 | Duplicate order IDAn order with this UUID already exists. This is an idempotency protection. | Generate a new UUID for a new order. If retrying, use the same UUID to get the existing order. |
| PRICE_INCREASED | 400 | Price has increased beyond expectedThe current unit price is higher than the expected_unit_price you provided. | Fetch the latest price with GET /catalog/subcategory/:id/price and update expected_unit_price. |
| RATE_LIMIT_EXCEEDED | 429 | Rate limit exceededYou have exceeded the rate limit for this endpoint. Every response includes X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers. When exceeded, the 429 response body contains: limit, remaining (0), retry_after (seconds to wait), and window_seconds. A Retry-After header is also set. | Wait for the number of seconds indicated by retry_after or the Retry-After header before retrying. Monitor X-RateLimit-Remaining headers to avoid hitting limits. |
| SERVICE_UNAVAILABLE | 503 | Service temporarily unavailableThe service is temporarily down for maintenance or overloaded. | Wait and retry after a few minutes. |
Integration questions, answered
The questions developers actually ask while wiring this up β authentication, retries, idempotency, throttling, callbacks and failure handling.
How does the reseller API authenticate requests?
With one HTTP header: Authorization: Bearer <keyId>.<secret>. The key pair is issued from the API Access page of your reseller panel, the secret is shown once at creation time, and the server verifies it with an HMAC check on every call. There is no OAuth redirect, no session cookie and no token to refresh, so the same header works for the first request and for every request after it.
Which base URL and API version does my client target?
One base URL, and it is the live one β there is no separate sandbox host to build against and swap out later. Point the client at the v1 base URL above, send Authorization: Bearer <keyId>.<secret> on every request, and start with GET /account to prove the key and the header shape before anything else. POST /orders/create is the only call that moves money, so leave it until last, run it against your smallest denomination, and send expected_unit_price with it while the client is still being shaken out.
Which field carries the idempotency key on POST /orders/create?
order_id β a UUID you generate client-side and send in the create body. Replay a create with an order_id that already exists and the API answers HTTP 409 with DUPLICATE_ORDER instead of opening a second order, so a retry can never become a second charge; read the original back with GET /orders/:orderId. Generate and persist the UUID before the first attempt, because a UUID minted after a timeout is a new key rather than a retry key, and nothing on the server will merge two order_id values that meant the same thing.
My request timed out. Was the order created?
Find out with a status read instead of guessing. Call GET /orders/:orderId using the same UUID you sent: if the order comes back, the timeout happened after it was accepted and you should not resend. If you get ORDER_NOT_FOUND, nothing was charged and it is safe to repeat the create request with that same UUID.
How do I verify the signature on a webhook callback?
Recompute the HMAC over the raw request body and compare it against the X-Shop2Topup-Signature header. The header carries sha256=<hex>: HMAC-SHA256 the exact bytes you received with your webhook secret, prefix sha256=, and compare with a timing-safe check β never against a re-serialised JSON object, because parsing and stringifying changes the bytes and the comparison will never match. Reject a mismatch with 401, answer a valid delivery with 200 inside ten seconds, and do the settlement work on your own queue afterwards.
What happens when I hit a rate limit?
You get HTTP 429 with the code RATE_LIMIT_EXCEEDED and a Retry-After header. The response body repeats the same figure in retry_after and adds limit, remaining and window_seconds. Sleep for exactly that many seconds and retry once; do not retry immediately and do not spread the same call across several keys, because the counter is scoped to the account, not the connection.
Which fields does POST /player/validate return?
player_name, plus zone_id and region where the game exposes them, inside the data object of the response. Order creation resolves and region-checks the player itself, and a cross-region attempt is refused with REGION_MISMATCH (HTTP 400) before any wallet debit happens, so this call is not what protects your balance. Call it because reading player_name back is the only practical way to show a buyer the account they are about to top up and to catch a mistyped identifier before they commit β the name always comes from the game, never from anything the buyer typed.
How do I map the requirements response into my order payload?
Key it by field_name: every entry GET /catalog/category/:categoryId/requirements returns becomes one property of the requirements object you send to POST /orders/create. Each entry also carries data_type, so your form knows what to render and your server knows what to cast, and single_select and multi_select entries ship their allowed values β offer those instead of a free-text box. Cache the list per category rather than reading it per order, and re-read it when a create call comes back with MISSING_REQUIRED_FIELD.
How should my client handle a PRICE_INCREASED response?
Re-read the price, decide, then resend β never replay the same body. PRICE_INCREASED (HTTP 400) means the live unit price sits above the expected_unit_price you sent, so the create was refused and nothing was charged; the error details carry both expected_unit_price and current_unit_price, and a blind retry with the old figure is refused every time. Read GET /catalog/subcategory/:itemId/price, decide whether the new number still works for you, and resend the create with the updated expected_unit_price and the same order_id β a refused attempt never consumed it. Leaving expected_unit_price out switches the guard off entirely, which is almost never what an automated integration wants.
How does a partly delivered order appear in the order response?
As status partial, with the split spelled out in sub_transaction_summary. That object counts units by state β completed, refunded, failed, pending, processing, retrying β and sub_transactions carries the row behind each count, so your settlement logic reads numbers instead of inferring them from one word. Treat pending and processing as non-terminal and keep waiting; treat completed, partial, failed and refunded as terminal. The order.completed, order.failed and order.refunded callbacks carry the same summary, so a verified payload can settle without a second read.
What does a request from an address outside the allowlist get back?
HTTP 403 with the code IP_NOT_ALLOWED, returned before any business logic runs. The check applies whenever the key has a non-empty IP allowlist, which leaves a leaked key inert anywhere except your own egress addresses. Branch on error.code rather than on the message text, and read a sudden IP_NOT_ALLOWED in production as a changed egress address β a new NAT gateway or worker subnet β rather than a broken key. Leave the allowlist empty only when your outbound address genuinely is not stable.
How long does an order take to complete?
Treat completion as asynchronous, never as instant. POST /orders/create returns as soon as the order is accepted and the wallet is debited, normally with status pending; fulfilment then runs on separate workers and the terminal state reaches you by webhook or on your next status read. Do not block a customer-facing HTTP request on the final state β accept it, answer your customer, and settle out of band.
Create an account, generate a key, go live
There is no approval queue and no waiting period between signing up and calling the API. Create the reseller account, generate a key on the API Access page, fund the wallet, and your first order can go out the same day.
Not the developer on this project? The commercial side is covered on the SHOP2TOPUP reseller programme page.