REST API - phiên bản v1

API nạp game và thẻ nạp dành cho đại lý

Một REST API duy nhất cho nạp game, nạp tiền game và mã thẻ: đọc danh mục, kiểm tra người chơi, tạo đơn hàng rồi nhận kết quả qua webhook hoặc truy vấn trạng thái.

Đây là API nạp game kết nối máy chủ với máy chủ (H2H) đứng sau SHOP2TOPUP. API dùng JSON thuần qua HTTPS, xác thực Bearer, khóa idempotency dạng UUID, giới hạn tần suất gọi được công bố và mã lỗi ổn định, máy đọc được. Nhờ vậy, một shop, một cửa hàng online hay một bot đều có thể bán credit game tự động bằng code mà không cần thư viện client. Chính giao diện này đã giao thành công 3,000,000+ đơn hàng qua 5+ năm hoạt động liên tục.

Thông tin về giá, phương thức thanh toán và cách vận hành tài khoản đối tác có trong bài tổng quan chương trình đại lý SHOP2TOPUP.

Gọi API lần đầu, chưa cần ký gì cả

Mỗi endpoint chỉ là một request HTTPS bình thường kèm một header. Không cần cài SDK, không phải nhúng thư viện client, cũng không thêm bước build nào. Dán lệnh bên dưới, điền khóa của bạn vào là terminal trả về ngay danh mục thật.

Yêu cầu - cURL
curl -s "https://shop2topup.com/api/endpoints/v1/catalog/categories?bigCategoryId=1" \
  -H "Authorization: Bearer YOUR_KEY_ID.YOUR_KEY_SECRET"
Phản hồi - 200 OK
{
  "success": true,
  "categories": [
    {
      "id": 12,
      "name": "Free Fire",
      "description": "Garena Free Fire diamonds",
      "big_category_id": 1,
      "big_category_name": "Mobile Games"
    }
  ]
}

URL gốc

https://shop2topup.com/api/endpoints/v1

Mọi đường dẫn trong tài liệu đều tính từ URL gốc này. Phản hồi ở dạng JSON và luôn có trường boolean success, nên client của bạn chỉ cần một nhánh để tách trường hợp thành công khỏi mọi trường hợp còn lại.

Cấu trúc phản hồi lỗi

Lỗi luôn giữ cùng một cấu trúc ở mọi mã trạng thái. Hãy rẽ nhánh theo error.code vì trường này thuộc hợp đồng API. Đừng bao giờ rẽ nhánh theo error.message: nội dung này viết cho người đọc và có thể đổi câu chữ bất cứ lúc nào.

Phản hồi lỗi
{
  "success": false,
  "error": {
    "code": "PRICE_INCREASED",
    "message": "Price has increased beyond expected",
    "action": "FIX_INPUT",
    "retryable": false,
    "retry_after": null,
    "details": {
      "expected_unit_price": "0.950000",
      "current_unit_price": "0.980000"
    }
  }
}

Toàn bộ API gói gọn trong một màn hình

11 endpoint bao trọn cả quá trình tích hợp: đọc danh mục, lấy giá sản phẩm, kiểm tra người chơi, đặt đơn và đối soát sau đó. Mỗi dòng dẫn thẳng tới tài liệu tham khảo đầy đủ, có đủ mọi tham số, request mẫu và response mẫu.

Phương thứcĐường dẫnChức năngTài liệu
GET/accountLấy thông tin tài khoảnXem tài liệu
GET/catalog/big-categoriesDanh sách danh mục lớnXem tài liệu
GET/catalog/categoriesDanh sách danh mụcXem tài liệu
GET/catalog/subcategoriesDanh sách danh mục con (sản phẩm)Xem tài liệu
GET/catalog/subcategory/:itemId/priceLấy giá sản phẩmXem tài liệu
GET/catalog/category/:categoryId/requirementsLấy trường yêu cầu của danh mụcXem tài liệu
POST/player/validateKiểm tra ID người chơiXem tài liệu
POST/orders/createTạo đơn hàngXem tài liệu
GET/orders/:orderIdXem trạng thái đơn hàngXem tài liệu
POST/orders/batchLấy nhiều đơn hàng cùng lúcXem tài liệu
GET/ordersDanh sách đơn hàngXem tài liệu

Ngoài ra còn có webhook trạng thái đơn hàng, được mô tả đầy đủ ở phần dưới trang này.

Xác thực và kiểm soát truy cập

Xác thực chỉ cần một HTTP header. Mỗi request mang header Authorization chứa thông tin Bearer gồm key ID và secret, được cấp tại trang API Access trong bảng điều khiển đại lý. Key ID xác định tài khoản. Secret chứng minh bạn là chủ sở hữu và được máy chủ kiểm tra bằng HMAC.

Secret chỉ hiển thị một lần khi tạo và sau đó không thể xem lại. Cất nó vào trình quản lý secret mà hệ thống của bạn đang dùng, truyền vào dưới dạng biến môi trường và không commit vào kho mã nguồn. Lỡ làm mất thì bạn tạo khóa mới thay thế, không có cách khôi phục.

Không có OAuth redirect, session cookie hay refresh token, cũng chẳng có gì cần lên lịch. Với tích hợp tự động, điều này rất đáng giá: một cron job hay queue worker có thể dùng cùng một thông tin xác thực suốt nhiều năm mà không cần gia hạn, và không có hạn dùng nào bắt bạn dậy lúc ba giờ sáng.

Bạn có thể gắn danh sách IP được phép cho từng khóa. Khi danh sách có ít nhất một địa chỉ, mọi request từ địa chỉ khác đều bị từ chối với IP_NOT_ALLOWED và HTTP 403 trước khi logic nghiệp vụ kịp chạy, nên thông tin xác thực có bị lộ cũng vô dụng ngoài máy chủ của bạn. Đổi khóa chỉ là một lần deploy, không phải một đợt migration: tạo khóa mới, triển khai, rồi xóa khóa cũ.

Mỗi khóa thuộc về một tài khoản đại lý và trừ tiền từ ví của tài khoản đó. Vì vậy, đừng bao giờ đưa secret vào trình duyệt, gói ứng dụng di động hay repo công khai. Gọi API từ backend, còn front end của bạn thì giao tiếp với backend đó. Toàn bộ tài liệu ở đây giả định bên gọi là máy chủ gọi máy chủ.

Header của request
Authorization: Bearer <keyId>.<secret>

Kiểm tra khóa chỉ với một lệnh gọi

GET /account là bước kiểm tra nhanh cho thông tin xác thực. Lệnh này trả lời ba câu hỏi cùng lúc: khóa có xác thực được không, tài khoản có đang hoạt động không, và số dư ví có đủ cho đơn bạn sắp đặt không. Nên gọi khi khởi động và trước mỗi đợt xử lý hàng loạt.

Xem tài liệu xác thực và tài khoản

Tích hợp hoàn chỉnh từ đầu đến cuối

Một tích hợp chạy được gồm bốn bước. Các mẫu bên dưới chạy đủ cả bốn bước trên API thật bằng cURL, Node và PHP. Thay thông tin xác thực của bạn vào là chúng chạy đúng như đã viết.

  1. 1

    Đọc danh mục

    Duyệt từ danh mục lớn xuống danh mục rồi danh mục con, sau đó lấy giá hiện tại của sản phẩm bạn sắp bán. Cây danh mục thì lưu cache, còn giá thì luôn lấy mới.

  2. 2

    Kiểm tra người chơi

    Tra ID để lấy tên trong game và khu vực trước khi có bất kỳ khoản tiền nào bị trừ. Đây là bước bắt lỗi gõ sai và nhầm khu vực.

  3. 3

    Tạo đơn hàng

    Tạo một UUID, lưu lại, rồi gửi nó trong trường order_id. Đây là khóa idempotency của bạn và cũng là cách an toàn duy nhất để gửi lại request tạo đơn.

  4. 4

    Xác nhận giao hàng

    Chờ webhook trạng thái đơn hàng. Đơn nào vẫn chờ xử lý sau khoảng thời gian chờ bạn tự đặt thì đọc trạng thái trực tiếp.

cURL - toàn bộ quy trìnhHiện 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"
Luồng hoàn chỉnh bằng Node.jsHiện 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 - toàn bộ quy trìnhHiện 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'];

Có hai chi tiết nên làm theo y nguyên: lưu UUID của đơn hàng trước lệnh tạo đơn chứ không phải sau đó, và đọc lại giá thay vì tin vào con số đã cache. Hai thói quen này loại bỏ gần như mọi trường hợp trừ tiền hai lần và mọi lỗi từ chối PRICE_INCREASED.

Webhook: callback trạng thái đơn hàng

Đăng ký một endpoint HTTPS, nền tảng sẽ đẩy kết quả đơn hàng tới đó ngay khi có. Cửa hàng của bạn không cần polling nữa mà chỉ việc xử lý khi nhận được. Callback có chữ ký, nên bạn kiểm chứng được payload đến từ chúng tôi trước khi hành động.

Sự kiện

Sự kiệnKích hoạt khi
order.completedMột đơn vị trong đơn hàng đã được giao. Xem data.status để biết trạng thái của cả đơn hàng.
order.refundedMột đơn vị trong đơn hàng đã được hoàn tiền vào ví của bạn. Khi tất cả đơn vị đều đã hoàn tiền, trường data.status nhận giá trị refunded.
webhook.testBạn đã gửi thử webhook có chữ ký từ bảng điều khiển để kiểm tra hàm xử lý.

Header gửi kèm

Tên headerGiá trị
Content-Typeapplication/json
X-Shop2Topup-Signaturesha256=<hex>: HMAC-SHA256 của body request ở dạng thô
X-Shop2Topup-EventTên sự kiện, ví dụ order.completed (đơn hàng đã hoàn tất)
User-Agentshop2topup-webhook/1.0

Nội dung callback

Mọi callback đều có cùng ba trường cấp cao nhất là event, timestamp và data, trong đó data giống hệt đối tượng đơn hàng mà endpoint trạng thái trả về. Mã thẻ chỉ xuất hiện khi đơn đã hoàn tất, player_name có thể là null, còn sub_transaction_summary có mặt bất cứ khi nào đơn được chia thành nhiều đơn vị.

Nội dung POST khi đơn hàng hoàn tất (order.completed)
{
  "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
    }
  }
}

Xác minh chữ ký

Mỗi lần gửi đều có header X-Shop2Topup-Signature, dạng sha256= rồi đến chuỗi hex HMAC-SHA256 của body thô, ký bằng webhook secret của bạn. Hãy xác minh trên byte thô, trước mọi bước parse JSON, và so sánh theo kiểu constant-time. Handler parse trước rồi mới xác minh có thể bị qua mặt bằng một body đã được serialize lại.

Xác minh và phản hồi ack (Node.js)
// 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);
  },
);

Gửi và thử lại

Mỗi callback chỉ được gửi một lần và endpoint của bạn có mười giây để phản hồi. Hệ thống không tự động thử lại, cũng không có exponential backoff. Đây là chủ ý: endpoint trạng thái mới là nguồn dữ liệu chuẩn, nên việc đối soát nên dựa vào lệnh poll do bạn kiểm soát, không phải một lịch thử lại bạn không nhìn thấy. Hãy trả về 200 ngay và xử lý công việc trên hàng đợi của riêng bạn.

Handler idempotent

Dùng order_id làm khóa cho handler, để nhận lại cùng một callback sẽ không làm thay đổi gì. Dù chỉ có một lần gửi, hạ tầng của chính bạn thỉnh thoảng vẫn giao cùng một message cho hai worker. Một đơn hàng cộng tiền cho khách hai lần là lỗi nặng hơn nhiều so với cộng tiền chậm.

Đăng ký và secret

Đăng ký URL callback, gửi thử một lần giao có chữ ký và đổi signing secret đều làm trong trang quản lý đại lý sau khi đăng nhập, không qua API công khai. Bắt buộc dùng HTTPS, địa chỉ private sẽ bị từ chối.

Mở API Access trong trang quản lý đại lý

Giới hạn tần suất và xử lý lỗi

Giới hạn được công bố, áp dụng theo từng tài khoản và tính theo cửa sổ trượt. Các thao tác đọc catalog nhiều có mức cho phép rộng nhất. Endpoint trạng thái bị giới hạn chặt có chủ ý, vì chúng là phương án dự phòng để đối soát chứ không dùng để poll liên tục.

Đường dẫnSố yêu cầuKhung thời gianTính theo
GET /catalog/* (cả 5 route)500060 giâymỗi tài khoản
POST /orders/create500060 giâymỗi tài khoản
GET /orders/:orderId360 giâymỗi tài khoản + ID đơn hàng
POST /orders/batch260 giâymỗi tài khoản
GET /orders260 giâymỗi tài khoản
GET /account6060 giâymỗi tài khoản
POST /player/validatehạn mức kiểm tra người chơi mỗi ngày24 giờmỗi tài khoản (không có header X-RateLimit)

Các endpoint danh mục, đơn hàng và tài khoản trả về X-RateLimit-Limit, X-RateLimit-Remaining và X-RateLimit-Reset trong mọi phản hồi, nên client không cần đoán mình còn bao nhiêu lượt. Hãy theo dõi bộ đếm còn lại và giảm tốc trước khi bị chặn, đừng đợi bị chặn rồi mới xử lý. Riêng kiểm tra người chơi thì khác: chức năng này tính theo hạn mức kiểm tra người chơi hằng ngày, không gửi header X-RateLimit nào và báo hạn mức ngay trong body của phản hồi 429.

Phản hồi khi bị giới hạn - 429
HTTP/1.1 429 Too Many Requests
Retry-After: 45
X-RateLimit-Limit: 2
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1765000000

{
  "success": false,
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Rate limit exceeded. Try again after retry_after seconds.",
    "action": "RETRY_LATER",
    "retryable": true,
    "retry_after": 45,
    "details": {
      "limit": 2,
      "remaining": 0,
      "retry_after": 45,
      "window_seconds": 60
    }
  }
}

Những lỗi bạn sẽ gặp đầu tiên và cách xử lý

Khi có lỗi, phản hồi luôn trả về mã lỗi cố định kèm mã trạng thái HTTP. Dưới đây là những lỗi bạn thường gặp trong tuần đầu tích hợp, cùng cách điều chỉnh để khắc phục từng lỗi.

MãHTTP statusÝ nghĩaCách khắc phục
INVALID_API_KEY401Khóa API không hợp lệKhóa API bạn gửi không khớp với khóa nào đang hoạt động trong hệ thống.Kiểm tra lại key ID và secret. Nếu cần, hãy tạo lại khóa trong mục API Access.action: FIX_INPUT · retryable: false · retry_after: null
IP_NOT_ALLOWED403Địa chỉ IP không nằm trong danh sách cho phépYêu cầu được gửi từ một địa chỉ IP không có trong danh sách cho phép của khóa API.Thêm IP máy chủ của bạn vào danh sách cho phép trong mục API Access, hoặc bỏ giới hạn IP.action: FIX_INPUT · retryable: false · retry_after: null
MISSING_REQUIRED_FIELD400Thiếu trường bắt buộcBody của yêu cầu thiếu một trường bắt buộc. Với các trường thông tin người chơi, details.field cho biết trường bị thiếu.Đọc tài liệu của endpoint (các trường người chơi xem tại GET /catalog/category/:categoryId/requirements), sau đó gửi đầy đủ mọi trường bắt buộc.action: FIX_INPUT · retryable: false · retry_after: null
INVALID_UUID_FORMAT400UUID sai định dạngorder_id không đúng định dạng UUID.Dùng bất kỳ UUID hợp lệ nào (v1, v4, v7...). Định dạng: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.action: FIX_INPUT · retryable: false · retry_after: null
PLAYER_NOT_FOUND400Không tìm thấy người chơiGame đã xác nhận ID người chơi này không tồn tại. Mã này chỉ được trả về khi có xác nhận rõ ràng. Nếu không kiểm tra được ID, bạn sẽ nhận PLAYER_CHECK_UNAVAILABLE.Kiểm tra lại ID người chơi. Xem game này có yêu cầu zone_id hay không.action: FIX_INPUT · retryable: false · retry_after: null
PLAYER_CHECK_UNAVAILABLE503Hiện chưa thể kiểm tra ID người chơi của game nàyLúc này hệ thống không kiểm tra được ID người chơi của game này. ID người chơi KHÔNG bị từ chối.Gửi lại đúng yêu cầu đó sau retry_after giây. Đừng báo với khách rằng ID bị sai.action: RETRY_LATER · retryable: true · retry_after: 60
REGION_MISMATCH400Sản phẩm này không hỗ trợ khu vực của người chơi.Không thể giao sản phẩm này đến khu vực của người chơi. Thông tin lỗi bao gồm player_region và player_name.Chọn sản phẩm dành cho khu vực của người chơi.action: CHOOSE_OTHER · retryable: false · retry_after: null
INSUFFICIENT_BALANCE400Số dư ví không đủVí của bạn không đủ tiền để hoàn tất đơn hàng này.Nạp tiền vào ví tại trang Reload trước khi đặt hàng.action: TOP_UP · retryable: false · retry_after: null
OUT_OF_STOCK400Sản phẩm đã hết hàngSản phẩm bạn yêu cầu hiện đang hết hàng.Thử lại sau hoặc chọn sản phẩm khác.action: RETRY_LATER · retryable: true · retry_after: null
PRODUCT_UNAVAILABLE409Sản phẩm này hiện tạm ngừng bánSản phẩm vẫn tồn tại nhưng hiện chưa thể bán.Vui lòng thử lại sau, hoặc chọn sản phẩm khác.action: RETRY_LATER · retryable: true · retry_after: null
DUPLICATE_ORDER409Trùng ID đơn hàngĐã có đơn hàng dùng UUID này. Đây là cơ chế chống trùng lặp (idempotency).Tạo UUID mới cho mỗi đơn hàng mới. Nếu gặp lỗi này khi gửi lại, nghĩa là lần gửi đầu đã được ghi nhận: hãy xem đơn đó bằng GET /orders/{orderId} thay vì tạo đơn khác.action: CHECK_ORDER · retryable: false · retry_after: null
PRICE_INCREASED400Giá đã vượt mức dự kiếnĐơn giá hiện tại cao hơn expected_unit_price bạn đã gửi.Lấy giá mới nhất bằng GET /catalog/subcategory/:id/price và cập nhật expected_unit_price.action: FIX_INPUT · retryable: false · retry_after: null
RATE_LIMIT_EXCEEDED429Vượt quá giới hạn yêu cầuBạn đã vượt giới hạn của endpoint này. Các nhóm giới hạn theo phút (catalog, orders, account) trả về X-RateLimit-Limit, X-RateLimit-Remaining và X-RateLimit-Reset trong mọi phản hồi, còn details lặp lại limit, remaining (0), retry_after và window_seconds. Riêng POST /player/validate được tính theo hạn mức kiểm tra người chơi hằng ngày của bạn: endpoint này không gửi header X-RateLimit, và phản hồi 429 của nó chứa limit, current, remaining, reset_at và reset_in_seconds bên trong error. Ở cả hai trường hợp, error.retry_after và header Retry-After đều cho biết số giây cần chờ.Chờ đủ số giây trong retry_after hoặc header Retry-After rồi mới gửi lại. Với các endpoint giới hạn theo phút, hãy theo dõi X-RateLimit-Remaining để không chạm giới hạn.action: RETRY_LATER · retryable: true · retry_after: giây nữa sẽ đặt lại
INTERNAL_ERROR500Lỗi máy chủ nội bộMáy chủ gặp lỗi không mong muốn.Gửi lại yêu cầu sau retry_after giây. Với POST /orders/create, hãy dùng lại đúng order_id cũ để đơn hàng không bao giờ bị tạo hai lần. Nếu lỗi vẫn còn, hãy liên hệ bộ phận hỗ trợ.action: RETRY_LATER · retryable: true · retry_after: 30
Xem bảng mã lỗi đầy đủ: cả 42 mã kèm cách khắc phục

Câu hỏi thường gặp khi tích hợp

Những câu lập trình viên hay hỏi khi tích hợp: xác thực, thử lại, idempotency, giới hạn tần suất, callback và xử lý lỗi.

API đại lý xác thực yêu cầu bằng cách nào?

Chỉ bằng một header HTTP: Authorization: Bearer <keyId>.<secret>. Cặp khóa được cấp tại trang API Access trong bảng điều khiển đại lý. Secret chỉ hiện một lần lúc tạo, và server kiểm tra nó bằng HMAC ở mỗi lần gọi. Không có chuyển hướng OAuth, không có session cookie, cũng không có token cần làm mới, nên cùng một header dùng được cho yêu cầu đầu tiên và mọi yêu cầu sau đó.

Client của tôi cần gọi base URL và phiên bản API nào?

Chỉ có một base URL và đó là môi trường thật. Không có host sandbox riêng để phát triển rồi đổi sang sau. Trỏ client vào base URL v1 ở trên, gửi Authorization: Bearer <keyId>.<secret> trong mọi yêu cầu, và gọi GET /account trước tiên để xác nhận khóa cùng định dạng header. POST /orders/create là lệnh gọi duy nhất động đến tiền, nên hãy để nó sau cùng, chạy thử với mệnh giá nhỏ nhất và gửi kèm expected_unit_price trong lúc client còn đang chạy thử.

Trường nào chứa khóa idempotency khi gọi POST /orders/create?

Trường order_id: một UUID do bạn tạo phía client và gửi trong body khi tạo đơn. Nếu gửi lại yêu cầu tạo đơn với order_id đã tồn tại, API trả HTTP 409 kèm DUPLICATE_ORDER chứ không mở đơn thứ hai, nên việc thử lại không bao giờ khiến bạn bị trừ tiền lần nữa. Đơn gốc xem lại bằng GET /orders/:orderId. Hãy tạo và lưu UUID trước lần gửi đầu tiên, vì UUID sinh ra sau khi timeout là một khóa mới chứ không phải khóa thử lại, và server sẽ không gộp hai giá trị order_id dù chúng cùng chỉ một đơn.

Yêu cầu bị timeout. Đơn hàng đã được tạo chưa?

Đừng đoán, hãy kiểm tra trạng thái. Gọi GET /orders/:orderId với đúng UUID đã gửi: nếu đơn được trả về, nghĩa là timeout xảy ra sau khi đơn đã được tiếp nhận và bạn không nên gửi lại. Nếu nhận ORDER_NOT_FOUND, chưa có khoản nào bị trừ và bạn có thể gửi lại yêu cầu tạo đơn với chính UUID đó.

Làm sao xác minh chữ ký của webhook callback?

Tính lại HMAC trên body thô của yêu cầu rồi so với header X-Shop2Topup-Signature. Header có dạng sha256=<hex>: dùng webhook secret của bạn để tính HMAC-SHA256 trên đúng các byte nhận được, thêm tiền tố sha256= rồi so sánh bằng hàm timing-safe. Đừng so với object JSON đã serialize lại, vì parse rồi stringify sẽ làm thay đổi byte và kết quả không bao giờ khớp. Chữ ký không khớp thì trả 401, lần gửi hợp lệ thì trả 200 trong vòng mười giây, sau đó xử lý ghi nhận kết quả trong hàng đợi riêng của bạn.

Khi vượt giới hạn tần suất gọi thì sao?

Bạn nhận HTTP 429 với mã RATE_LIMIT_EXCEEDED và header Retry-After. Body phản hồi lặp lại con số đó trong retry_after, kèm thêm limit, remaining và window_seconds. Chờ đúng số giây đó rồi thử lại một lần. Đừng thử lại ngay, cũng đừng chia cùng một lệnh gọi ra nhiều khóa, vì bộ đếm tính theo tài khoản chứ không theo kết nối.

POST /player/validate trả về những trường nào?

Trả về player_name, cùng zone_id và region nếu game cung cấp, nằm trong object data của phản hồi. Khi tạo đơn, hệ thống tự tra và kiểm tra khu vực của người chơi; yêu cầu sai khu vực bị từ chối với REGION_MISMATCH (HTTP 400) trước khi ví bị trừ tiền, nên lệnh gọi này không phải thứ bảo vệ số dư của bạn. Bạn nên gọi nó vì đọc lại player_name là cách thực tế duy nhất để cho người mua thấy tài khoản sắp được nạp và phát hiện ID gõ nhầm trước khi họ xác nhận. Tên luôn lấy từ game, không bao giờ từ nội dung người mua nhập.

Làm sao đưa dữ liệu requirements vào payload đặt hàng?

Dùng field_name làm khóa: mỗi mục mà GET /catalog/category/:categoryId/requirements trả về sẽ thành một thuộc tính trong object requirements bạn gửi lên POST /orders/create. Mỗi mục còn có data_type, để form biết cần hiển thị gì và server biết cần ép kiểu ra sao. Các mục single_select và multi_select đi kèm danh sách giá trị hợp lệ, hãy cho người dùng chọn từ đó thay vì ô nhập tự do. Lưu cache danh sách theo từng danh mục thay vì đọc lại mỗi lần đặt đơn, và đọc lại khi lệnh tạo đơn trả về MISSING_REQUIRED_FIELD.

Client nên xử lý phản hồi PRICE_INCREASED thế nào?

Đọc lại giá, quyết định, rồi mới gửi lại. Đừng gửi lại nguyên body cũ. PRICE_INCREASED (HTTP 400) nghĩa là đơn giá hiện tại cao hơn expected_unit_price bạn gửi, nên yêu cầu tạo đơn bị từ chối và chưa có khoản nào bị trừ. Phần chi tiết lỗi có cả expected_unit_price lẫn current_unit_price; thử lại mù quáng với con số cũ thì lần nào cũng bị từ chối. Gọi GET /catalog/subcategory/:itemId/price, cân nhắc giá mới có còn phù hợp không, rồi gửi lại yêu cầu tạo đơn với expected_unit_price đã cập nhật và giữ nguyên order_id, vì lần bị từ chối không dùng mất order_id đó. Bỏ trống expected_unit_price là tắt hẳn cơ chế bảo vệ này, điều mà một hệ thống tích hợp tự động gần như không bao giờ muốn.

Đơn giao được một phần hiển thị thế nào trong phản hồi đơn hàng?

Đơn có status partial, và phần chia cụ thể nằm trong sub_transaction_summary. Object này đếm đơn vị theo completed, refunded hoặc pending, tổng ba số luôn bằng total; còn sub_transactions liệt kê từng đơn vị kèm trạng thái riêng. Đơn vị chưa giao và chưa hoàn tiền sẽ giữ trạng thái pending. Coi pending là trạng thái chưa kết thúc và tiếp tục chờ; coi completed, partial và refunded là trạng thái cuối. Các bộ đếm failed, processing và retrying chỉ giữ lại để tương thích và luôn bằng 0. Callback order.completed và order.refunded mang cùng bản tóm tắt này, nên payload đã xác minh đủ để ghi nhận kết quả mà không cần đọc lại.

Yêu cầu từ địa chỉ IP ngoài danh sách cho phép sẽ nhận về gì?

Nhận HTTP 403 với mã IP_NOT_ALLOWED, trả về trước khi bất kỳ logic nghiệp vụ nào chạy. Việc kiểm tra áp dụng mỗi khi khóa có danh sách IP cho phép không rỗng, nên khóa lỡ bị lộ cũng vô dụng ở mọi nơi ngoài các địa chỉ egress của chính bạn. Hãy rẽ nhánh theo error.code thay vì nội dung thông báo. Nếu production đột ngột gặp IP_NOT_ALLOWED, hãy hiểu đó là địa chỉ egress đã thay đổi (ví dụ NAT gateway mới hoặc subnet worker mới) chứ không phải khóa bị hỏng. Chỉ để trống danh sách khi địa chỉ đi ra của bạn thực sự không cố định.

Một đơn hàng mất bao lâu để hoàn tất?

Hãy xem việc hoàn tất là bất đồng bộ, không phải tức thì. POST /orders/create trả về ngay khi đơn được tiếp nhận và ví bị trừ tiền, thường với status pending. Sau đó các worker riêng xử lý đơn, và trạng thái cuối sẽ đến với bạn qua webhook hoặc ở lần đọc trạng thái kế tiếp. Đừng giữ request HTTP của khách để chờ trạng thái cuối: tiếp nhận đơn, phản hồi khách, rồi ghi nhận kết quả ở luồng riêng.

Tạo tài khoản, tạo key, chạy thật

Không có hàng chờ duyệt, cũng không phải đợi từ lúc đăng ký đến lúc gọi API. Tạo tài khoản đại lý, tạo key ở trang API Access, nạp tiền vào ví, và đơn hàng đầu tiên có thể gửi đi ngay trong ngày.

Bạn không phụ trách kỹ thuật cho dự án này? Phần kinh doanh có trên trang chương trình đại lý SHOP2TOPUP.

API nạp game và mã thẻ cho đại lý | Tài liệu REST API