บริการ REST API เวอร์ชัน 1
API เติมเกมและบัตรเติมเงินสำหรับตัวแทนจำหน่าย
REST API เดียวสำหรับเติมเกม เติมเงินเกม และโค้ดบัตรเติมเงิน ดึงแคตตาล็อก ตรวจสอบผู้เล่น สร้างคำสั่งซื้อ แล้วรับผลทาง webhook หรือการเช็กสถานะ
นี่คือ API เติมเงินแบบ host-to-host (H2H) ที่อยู่เบื้องหลัง SHOP2TOPUP เป็น JSON API ธรรมดาผ่าน HTTPS ยืนยันตัวตนด้วย Bearer มี idempotency key แบบ UUID มี rate limit ที่ประกาศไว้ และมี error code คงที่ที่โปรแกรมอ่านได้ ร้านค้า หน้าร้านออนไลน์ หรือบอทจึงขายเครดิตเกมดิจิทัลผ่านโค้ดได้โดยไม่ต้องใช้ client library อินเทอร์เฟซนี้ส่งมอบคำสั่งซื้อไปแล้ว 3,000,000+ รายการ ตลอด 5+ ปีที่ให้บริการต่อเนื่อง
ข้อมูลราคา ช่องทางชำระเงิน และการทำงานของบัญชีพาร์ทเนอร์ ดูได้ที่ ภาพรวมโปรแกรมตัวแทนจำหน่ายของ SHOP2TOPUP.
เรียก API ครั้งแรก ก่อนจะเซ็นอะไรเลย
ทุก endpoint เป็น HTTPS request ธรรมดาที่แนบ header เพียงตัวเดียว ไม่ต้องติดตั้ง SDK ไม่ต้องนำ client library มาใส่ในโปรเจกต์ และไม่ต้องเพิ่มขั้นตอน build วางคำสั่งด้านล่าง ใส่คีย์ของคุณ แล้วจะเห็นข้อมูลแคตตาล็อกจริงใน 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"
}
]
}ที่อยู่หลักของ API
https://shop2topup.com/api/endpoints/v1
ทุก path ในเอกสารอ้างอิงจาก URL นี้ response เป็น JSON และมีฟิลด์ success แบบ boolean เสมอ ฝั่ง client จึงใช้เงื่อนไขเดียวแยกกรณีสำเร็จออกจากกรณีอื่นทั้งหมดได้
โครงสร้างข้อผิดพลาด
error ทุกแบบใช้โครงสร้างเดียวกันไม่ว่าจะเป็น status code ใด ให้แยกเงื่อนไขด้วย error.code ซึ่งเป็นส่วนหนึ่งของสัญญา API อย่าใช้ error.message เพราะเขียนไว้ให้คนอ่าน และอาจเปลี่ยนถ้อยคำได้ทุกเมื่อ
{
"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"
}
}
}endpoint ทั้งหมดของ API ในหน้าเดียว
11 endpoint ครอบคลุมการเชื่อมต่อทั้งหมด ตั้งแต่ดึงแคตตาล็อก เช็กราคาสินค้า ตรวจสอบผู้เล่น สร้างคำสั่งซื้อ ไปจนถึงกระทบยอดภายหลัง แต่ละแถวลิงก์ไปยังเอกสารอ้างอิงฉบับเต็ม พร้อมพารามิเตอร์ทุกตัว ตัวอย่าง request และตัวอย่าง response
| เมธอด | พาธ | การใช้งาน | เอกสารอ้างอิง |
|---|---|---|---|
| GET | /account | ดูข้อมูลบัญชี | อ่านเอกสาร |
| GET | /catalog/big-categories | รายการหมวดหมู่หลัก | อ่านเอกสาร |
| GET | /catalog/categories | รายการหมวดหมู่ | อ่านเอกสาร |
| GET | /catalog/subcategories | รายการหมวดหมู่ย่อย (สินค้า) | อ่านเอกสาร |
| GET | /catalog/subcategory/:itemId/price | ดูราคาสินค้า | อ่านเอกสาร |
| GET | /catalog/category/:categoryId/requirements | ดูฟิลด์ที่ต้องกรอกตามหมวดหมู่ | อ่านเอกสาร |
| POST | /player/validate | ตรวจสอบผู้เล่น | อ่านเอกสาร |
| POST | /orders/create | สร้างคำสั่งซื้อ | อ่านเอกสาร |
| GET | /orders/:orderId | ดูสถานะคำสั่งซื้อ | อ่านเอกสาร |
| POST | /orders/batch | ดึงคำสั่งซื้อแบบกลุ่ม | อ่านเอกสาร |
| GET | /orders | รายการคำสั่งซื้อ | อ่านเอกสาร |
นอกจากนี้ยังมี webhook แจ้งสถานะคำสั่งซื้อ ดูรายละเอียดครบได้ด้านล่างของหน้านี้
การยืนยันตัวตนและการควบคุมสิทธิ์เข้าถึง
การยืนยันตัวตนใช้ HTTP header เพียงตัวเดียว ทุก request ต้องแนบ header Authorization ที่มี credential แบบ Bearer ซึ่งประกอบด้วย key ID และ secret ออกให้จากหน้า API Access ในแผงควบคุมตัวแทนจำหน่าย key ID ใช้ระบุบัญชี ส่วน secret ใช้พิสูจน์ว่าคุณเป็นเจ้าของบัญชี และเซิร์ฟเวอร์จะตรวจด้วย HMAC
secret จะแสดงเพียงครั้งเดียวตอนสร้าง และเรียกดูย้อนหลังไม่ได้อีก เก็บไว้ใน secret manager ที่ระบบของคุณใช้อยู่ ส่งเข้าแอปเป็น environment variable และอย่าเก็บไว้ใน source control ถ้าทำหาย ต้องสร้างคีย์ใหม่แทน เพราะกู้คืนไม่ได้
ไม่มี OAuth redirect ไม่มี session cookie ไม่มี refresh token และไม่มีงานใดต้องตั้งเวลา ข้อนี้สำคัญกับระบบอัตโนมัติ cron job หรือ queue worker ใช้ credential เดิมได้เป็นปีโดยไม่ต้องมีขั้นตอนต่ออายุ และไม่มีวันหมดอายุที่ต้องลุกมาจัดการตอนตีสาม
ผูก IP allowlist กับคีย์ได้ เมื่อรายการไม่ว่าง คำขอจาก IP อื่นจะถูกปฏิเสธด้วย IP_NOT_ALLOWED และ HTTP 403 ก่อนที่ business logic จะทำงาน credential ที่หลุดออกไปจึงใช้ไม่ได้นอกเซิร์ฟเวอร์ของคุณ การเปลี่ยนคีย์ทำแค่ deploy ไม่ต้อง migrate สร้างคีย์ใหม่ deploy ขึ้นไป แล้วลบคีย์เก่า
คีย์ผูกกับบัญชีตัวแทนจำหน่ายบัญชีเดียวและตัดเงินจากกระเป๋าเงินของบัญชีนั้น ห้ามใส่ secret ไว้ในเบราว์เซอร์ ใน bundle ของแอปมือถือ หรือใน repository สาธารณะ ให้เรียก API จาก backend แล้วให้ front end ของคุณคุยกับ backend ของคุณเอง ทุกอย่างในเอกสารนี้ถือว่าผู้เรียกเป็นแบบ server-to-server
Authorization: Bearer <keyId>.<secret>ตรวจคีย์ได้ในการเรียกครั้งเดียว
GET /account ใช้เช็กว่า credential ใช้งานได้ปกติหรือไม่ ได้คำตอบ 3 ข้อในครั้งเดียว คือ คีย์ยืนยันตัวตนผ่านไหม บัญชีเปิดใช้งานอยู่หรือเปล่า และยอดคงเหลือในกระเป๋าเงินพอสำหรับคำสั่งซื้อที่กำลังจะส่งหรือไม่ ควรเรียกตอนระบบเริ่มทำงานและก่อนรันงานแบบ batch ทุกครั้ง
อ่านเอกสารอ้างอิงเรื่องการยืนยันตัวตนและบัญชีการเชื่อมต่อครบทุกขั้นตอน ตั้งแต่ต้นจนจบ
การเชื่อมต่อที่ใช้งานได้จริงมี 4 ขั้นตอน ตัวอย่างด้านล่างรันครบทั้งหมดกับ API จริงด้วย cURL, Node และ PHP แค่ใส่ credential ของคุณ ก็รันได้ตามที่เขียนไว้
- 1
ดึงแคตตาล็อก
ไล่จากหมวดหมู่หลัก ไปหมวดหมู่ แล้วไปหมวดหมู่ย่อย จากนั้นดึงราคาปัจจุบันของสินค้าที่จะขาย แคชโครงสร้างหมวดหมู่ไว้ได้ แต่ราคาต้องดึงใหม่ทุกครั้ง
- 2
ตรวจสอบผู้เล่น
แปลง ID เป็นชื่อในเกมและโซนก่อนจะมีการตัดเงิน ขั้นนี้ช่วยจับ ID ที่พิมพ์ผิดและการเลือกโซนผิดได้
- 3
สร้างคำสั่งซื้อ
สร้าง UUID บันทึกเก็บไว้ แล้วส่งเป็น order_id ค่านี้คือ idempotency key ของคุณ และเป็นวิธีเดียวที่ปลอดภัยในการส่งคำขอสร้างคำสั่งซื้อซ้ำ
- 4
ยืนยันการจัดส่ง
รอ webhook แจ้งสถานะคำสั่งซื้อ หากคำสั่งซื้อใดยังรอดำเนินการหลังพ้นระยะ timeout ที่ตั้งไว้เอง ให้เรียกดูสถานะเป็นทางสำรอง
cURL - ครบทุกขั้นตอนแสดงโค้ดซ่อนโค้ด
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 แบบครบทุกขั้นตอนแสดงโค้ดซ่อนโค้ด
// 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 - ครบทุกขั้นตอนแสดงโค้ดซ่อนโค้ด
<?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'];มีสองเรื่องที่ควรทำตามตัวอย่างนี้ทุกประการ คือบันทึก UUID ของคำสั่งซื้อไว้ก่อนเรียกสร้างคำสั่งซื้อ ไม่ใช่หลังจากนั้น และดึงราคาใหม่ทุกครั้งแทนการเชื่อตัวเลขที่แคชไว้ สองข้อนี้ช่วยตัดปัญหาการตัดเงินซ้ำและการถูกปฏิเสธด้วย PRICE_INCREASED ได้แทบทั้งหมด
Webhook: แจ้งกลับเมื่อสถานะคำสั่งซื้อเปลี่ยน
ลงทะเบียน endpoint แบบ HTTPS ไว้หนึ่งจุด แล้วระบบจะส่งผลคำสั่งซื้อไปที่นั่นทันทีที่เกิดขึ้น หน้าร้านจึงไม่ต้องคอย poll สถานะ แค่รอรับแจ้งแล้วทำงานต่อ ทุก callback มีลายเซ็นกำกับ จึงตรวจได้ก่อนว่า payload มาจากเราจริงก่อนนำไปใช้
อีเวนต์
| อีเวนต์ | ส่งเมื่อ |
|---|---|
| order.completed | ส่งมอบสินค้าในคำสั่งซื้อแล้ว 1 หน่วย ดูสถานะของทั้งคำสั่งซื้อได้ที่ data.status |
| order.refunded | คืนเงิน 1 หน่วยของคำสั่งซื้อเข้ากระเป๋าเงินแล้ว data.status จะเป็น refunded เมื่อคืนเงินครบทุกหน่วย |
| webhook.test | คุณสั่งการจัดส่ง webhook ทดสอบพร้อมลายเซ็นจากแผงควบคุม เพื่อตรวจสอบการทำงานของ handler |
เฮดเดอร์ที่แนบมากับการจัดส่ง webhook
| เฮดเดอร์ | ค่า |
|---|---|
| Content-Type | application/json |
| X-Shop2Topup-Signature | sha256=<hex> คือค่า HMAC-SHA256 ที่คำนวณจากเนื้อหาดิบของคำขอ |
| X-Shop2Topup-Event | ชื่ออีเวนต์ เช่น order.completed ที่ส่งมาเมื่อคำสั่งซื้อสำเร็จ |
| User-Agent | shop2topup-webhook/1.0 |
ข้อมูลที่ส่งมาในการแจ้งกลับ
ทุก callback มีฟิลด์ระดับบนสุดสามตัวเหมือนกัน ได้แก่ event, timestamp และ data โดย data มีโครงสร้างเดียวกับออบเจกต์คำสั่งซื้อที่ status endpoint ส่งกลับมา โค้ดบัตรเติมเงินจะมีเฉพาะคำสั่งซื้อที่สำเร็จแล้ว player_name อาจเป็น null และจะมี sub_transaction_summary ทุกครั้งที่คำสั่งซื้อถูกแยกเป็นหน่วยย่อย
{
"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
}
}
}การตรวจสอบลายเซ็น
การจัดส่ง webhook ทุกครั้งจะแนบเฮดเดอร์ X-Shop2Topup-Signature มาด้วย ค่านี้อยู่ในรูป sha256= ตามด้วย HMAC-SHA256 แบบเลขฐานสิบหก ที่คำนวณจากบอดี้ดิบของคำขอด้วยซีเคร็ตของ webhook ของคุณ ให้ตรวจลายเซ็นกับไบต์ดิบก่อนพาร์ส JSON ทุกครั้ง และเปรียบเทียบค่าแบบใช้เวลาคงที่ หากโค้ดพาร์สก่อนแล้วค่อยตรวจ อาจถูกหลอกได้ด้วยบอดี้ที่ถูก serialize ใหม่
// 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);
},
);การส่งและการลองใหม่
ระบบส่ง callback เพียงครั้งเดียว และ endpoint ของคุณมีเวลาตอบกลับ 10 วินาที ไม่มีการส่งซ้ำอัตโนมัติและไม่มี exponential backoff ซึ่งเราออกแบบไว้แบบนี้โดยตั้งใจ เพราะ status endpoint คือข้อมูลอ้างอิงหลัก การกระทบยอดจึงควรทำผ่านการ poll ที่คุณควบคุมเอง ไม่ใช่พึ่งตารางส่งซ้ำที่มองไม่เห็น ให้ตอบ 200 ทันที แล้วค่อยประมวลผลต่อในคิวของคุณเอง
ตัวจัดการที่กันการประมวลผลซ้ำ
ใช้ order_id เป็นคีย์ใน handler และกำหนดให้ callback ที่ส่งมาซ้ำไม่มีผลต่อการทำงาน แม้จะมีการจัดส่งเพียงครั้งเดียว ระบบฝั่งคุณเองก็อาจส่งข้อความเดียวกันให้ worker สองตัวได้เป็นครั้งคราว บั๊กที่ทำให้คำสั่งซื้อเติมเงินให้ลูกค้าซ้ำสองครั้งร้ายแรงกว่าบั๊กที่ทำให้เติมเงินล่าช้า
การลงทะเบียนและ secret
การลงทะเบียน callback URL การส่ง callback ทดสอบที่มีลายเซ็น และการเปลี่ยน signing secret ทั้งหมดทำในแผงควบคุมตัวแทนจำหน่ายหลังเข้าสู่ระบบ ไม่ได้ทำผ่าน public API ต้องใช้ HTTPS และระบบจะปฏิเสธที่อยู่แบบ private
เปิดหน้า API Access ในแผงควบคุมตัวแทนจำหน่ายRate limit และการจัดการข้อผิดพลาด
ขีดจำกัดประกาศไว้ชัดเจน กำหนดแยกตามบัญชี และบังคับใช้แบบ rolling window งานดึงข้อมูลแค็ตตาล็อกที่เน้นการอ่านได้โควตามากที่สุด ส่วน status endpoint ตั้งไว้ต่ำโดยตั้งใจ เพราะมีไว้เป็นทางสำรองสำหรับกระทบยอด ไม่ได้มีไว้ให้วน poll
| เส้นทาง | จำนวนคำขอ | ช่วงเวลา | นับต่อ |
|---|---|---|---|
| GET /catalog/* (ทั้ง 5 รายการ) | 5000 | 60 วินาที | ต่อบัญชี |
| POST /orders/create | 5000 | 60 วินาที | ต่อบัญชี |
| GET /orders/:orderId | 3 | 60 วินาที | ต่อบัญชี + ID คำสั่งซื้อ |
| POST /orders/batch | 2 | 60 วินาที | ต่อบัญชี |
| GET /orders | 2 | 60 วินาที | ต่อบัญชี |
| GET /account | 60 | 60 วินาที | ต่อบัญชี |
| POST /player/validate | โควตาตรวจสอบผู้เล่นรายวัน | 24 ชั่วโมง | นับแยกตามบัญชี (ไม่มีเฮดเดอร์ X-RateLimit ในการตอบกลับ) |
ทุก response จาก endpoint แคตตาล็อก คำสั่งซื้อ และบัญชี จะมี X-RateLimit-Limit, X-RateLimit-Remaining และ X-RateLimit-Reset กำกับมา client ที่เขียนดีจึงรู้สถานะของตัวเองเสมอโดยไม่ต้องเดา คอยดูค่าคงเหลือแล้วชะลอการเรียกก่อนโดนจำกัด ไม่ใช่รอให้โดนก่อน ข้อยกเว้นคือการตรวจสอบผู้เล่น ซึ่งใช้โควตาตรวจผู้เล่นรายวันของคุณ ไม่ส่ง header X-RateLimit และแจ้งโควตานั้นไว้ใน body ของ 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
}
}
}ข้อผิดพลาดที่มักเจอก่อน และวิธีแก้
เมื่อเกิดข้อผิดพลาด ระบบจะส่งโค้ดที่กำหนดไว้แน่นอนกลับมาพร้อมสถานะ HTTP เสมอ รายการด้านล่างคือข้อผิดพลาดที่นักพัฒนามักพบในสัปดาห์แรกของการเชื่อมต่อ API พร้อมสิ่งที่ต้องปรับแก้ในแต่ละกรณี
| โค้ด | รหัสสถานะ HTTP | ความหมาย | วิธีแก้ |
|---|---|---|---|
| INVALID_API_KEY | 401 | คีย์ API ไม่ถูกต้องคีย์ API ที่ส่งมาไม่ตรงกับคีย์ใดที่ใช้งานอยู่ในระบบ | ตรวจสอบ key ID และ secret ให้ถูกต้อง หากจำเป็นให้สร้างคีย์ใหม่จากหน้า API Accessaction: FIX_INPUT · retryable: false · retry_after: null |
| IP_NOT_ALLOWED | 403 | ที่อยู่ IP ไม่อยู่ในรายการที่อนุญาตคำขอส่งมาจากที่อยู่ IP ที่ไม่อยู่ในรายการที่อนุญาตของคีย์ API นี้ | เพิ่ม IP ของเซิร์ฟเวอร์ลงในรายการที่อนุญาตที่หน้า API Access หรือยกเลิกการจำกัด IPaction: FIX_INPUT · retryable: false · retry_after: null |
| MISSING_REQUIRED_FIELD | 400 | ขาดฟิลด์ที่จำเป็นbody ของคำขอขาดฟิลด์ที่จำเป็น หากเป็นข้อมูลผู้เล่น details.field จะระบุชื่อฟิลด์ที่ขาดไป | อ่านเอกสารของเอนด์พอยต์นี้ และเรียก GET /catalog/category/:categoryId/requirements เพื่อดูว่าต้องกรอกข้อมูลผู้เล่นช่องใดบ้าง จากนั้นส่งทุกช่องที่จำเป็นให้ครบaction: FIX_INPUT · retryable: false · retry_after: null |
| INVALID_UUID_FORMAT | 400 | รูปแบบ UUID ไม่ถูกต้องorder_id ไม่อยู่ในรูปแบบ UUID ที่ถูกต้อง | ใช้ UUID ที่ถูกต้องได้ทุกเวอร์ชัน เช่น v1, v4, v7 เป็นต้น โดยต้องเขียนตามรูปแบบนี้: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx ซึ่งเป็นอักขระ 32 ตัว แบ่งเป็นชุด 8-4-4-4-12 คั่นด้วยขีดกลางaction: FIX_INPUT · retryable: false · retry_after: null |
| PLAYER_NOT_FOUND | 400 | ไม่พบผู้เล่นเกมยืนยันแล้วว่าไม่มี ID ผู้เล่นนี้อยู่จริง โค้ดนี้จะส่งกลับเฉพาะเมื่อได้รับคำยืนยันแน่ชัดเท่านั้น หากตรวจสอบ ID ไม่ได้ จะได้ PLAYER_CHECK_UNAVAILABLE แทน | ตรวจสอบว่า ID ผู้เล่นถูกต้อง และดูว่าเกมนี้ต้องใช้ zone_id ด้วยหรือไม่action: FIX_INPUT · retryable: false · retry_after: null |
| PLAYER_CHECK_UNAVAILABLE | 503 | ขณะนี้ยังตรวจสอบ ID ผู้เล่นของเกมนี้ไม่ได้ระบบยังตรวจสอบ ID ผู้เล่นของเกมนี้ไม่ได้ชั่วคราว โดย ID ผู้เล่นที่ส่งมาไม่ได้ถูกปฏิเสธ | ส่งคำขอเดิมอีกครั้งหลังผ่านไป retry_after วินาที และอย่าแจ้งลูกค้าว่า ID ไม่ถูกต้องaction: RETRY_LATER · retryable: true · retry_after: 60 |
| REGION_MISMATCH | 400 | โซนของผู้เล่นนี้รับสินค้านี้ไม่ได้ผู้เล่นอยู่ในโซนที่สินค้านี้ส่งให้ไม่ได้ ข้อผิดพลาดนี้จะแนบ player_region และ player_name มาด้วย | เลือกสินค้าที่ตรงกับโซนของผู้เล่นaction: CHOOSE_OTHER · retryable: false · retry_after: null |
| INSUFFICIENT_BALANCE | 400 | ยอดคงเหลือในกระเป๋าเงินไม่พอเงินในกระเป๋าเงินไม่พอสำหรับคำสั่งซื้อนี้ | เติมเงินเข้ากระเป๋าเงินที่หน้า Reload ก่อนสั่งซื้อaction: TOP_UP · retryable: false · retry_after: null |
| OUT_OF_STOCK | 400 | สินค้าหมดสินค้าที่ต้องการหมดในขณะนี้ | ลองใหม่ภายหลัง หรือเลือกสินค้าอื่นaction: RETRY_LATER · retryable: true · retry_after: null |
| PRODUCT_UNAVAILABLE | 409 | สินค้านี้ไม่พร้อมจำหน่ายชั่วคราวสินค้ายังมีอยู่ในระบบ แต่ขายไม่ได้ในขณะนี้ | ลองใหม่ภายหลัง หรือเลือกสินค้าอื่นแทนaction: RETRY_LATER · retryable: true · retry_after: null |
| DUPLICATE_ORDER | 409 | ID คำสั่งซื้อซ้ำมีคำสั่งซื้อที่ใช้ UUID นี้อยู่แล้ว ระบบป้องกันการสั่งซ้ำไว้ (idempotency) | สร้าง UUID ใหม่สำหรับคำสั่งซื้อใหม่ ถ้าได้รับข้อผิดพลาดนี้ตอนส่งซ้ำ แปลว่าคำขอแรกผ่านแล้ว ให้ดูคำสั่งซื้อนั้นด้วย GET /orders/{orderId} แทนการสร้างใหม่action: CHECK_ORDER · retryable: false · retry_after: null |
| PRICE_INCREASED | 400 | ราคาสูงกว่าที่คาดไว้ราคาต่อหน่วยปัจจุบันสูงกว่าราคาที่คุณระบุไว้ในพารามิเตอร์ expected_unit_price | ให้เรียกเอนด์พอยต์ GET /catalog/subcategory/:id/price เพื่อดึงราคาล่าสุดของสินค้านี้ จากนั้นอัปเดตค่าในพารามิเตอร์ expected_unit_price ของคำขอครั้งถัดไปให้ตรงกับราคาใหม่action: FIX_INPUT · retryable: false · retry_after: null |
| RATE_LIMIT_EXCEEDED | 429 | คำขอเกินขีดจำกัดคุณเรียกใช้เอนด์พอยต์นี้เกินจำนวนครั้งที่กำหนดไว้แล้ว กลุ่มโควตาที่นับจำนวนครั้งเป็นรายนาที (แคตตาล็อก คำสั่งซื้อ และบัญชี) จะแนบเฮดเดอร์ X-RateLimit-Limit, X-RateLimit-Remaining และ X-RateLimit-Reset มากับการตอบกลับทุกครั้ง ส่วนฟิลด์ details จะแสดงค่า limit, remaining (0), retry_after และ window_seconds ซ้ำอีกครั้ง สำหรับ POST /player/validate ระบบจะไม่ใช้โควตารายนาที แต่จะนับตามโควตาการตรวจสอบผู้เล่นรายวันของคุณแทน จึงไม่มีเฮดเดอร์ชุดข้างต้นส่งกลับมา และเมื่อระบบตอบกลับด้วยสถานะ 429 จะมีค่า limit, current, remaining, reset_at และ reset_in_seconds อยู่ภายในฟิลด์ error ทั้งสองกรณี จำนวนวินาทีที่ต้องรอจะอยู่ในค่า error.retry_after และเฮดเดอร์ Retry-After | รอให้ครบจำนวนวินาทีตามค่า retry_after หรือเฮดเดอร์ Retry-After ก่อนส่งคำขอใหม่ สำหรับเอนด์พอยต์ที่จำกัดจำนวนครั้งต่อนาที ให้คอยดูค่า X-RateLimit-Remaining เพื่อไม่ให้ใช้เกินขีดจำกัดตั้งแต่แรกaction: RETRY_LATER · retryable: true · retry_after: วินาทีก่อนรีเซ็ต |
| INTERNAL_ERROR | 500 | ข้อผิดพลาดภายในเซิร์ฟเวอร์เกิดข้อผิดพลาดที่ไม่คาดคิดบนเซิร์ฟเวอร์ | ส่งคำขอเดิมอีกครั้งหลังผ่านไป retry_after วินาที สำหรับ POST /orders/create ให้ใช้ order_id เดิม คำสั่งซื้อจะไม่ถูกสร้างซ้ำ หากยังพบปัญหา โปรดติดต่อทีมงานaction: RETRY_LATER · retryable: true · retry_after: 30 |
ถาม-ตอบเรื่องการเชื่อมต่อ API
รวมคำถามที่นักพัฒนาถามจริงระหว่างเชื่อมต่อระบบ ทั้งการยืนยันตัวตน การลองใหม่ idempotency การจำกัดอัตรา callback และการจัดการเมื่อเกิดข้อผิดพลาด
API ตัวแทนจำหน่ายยืนยันตัวตนของคำขออย่างไร?
ใช้ HTTP header เดียว คือ Authorization: Bearer <keyId>.<secret> คู่คีย์ออกให้จากหน้า API Access ในแผงตัวแทนจำหน่าย ส่วน secret จะแสดงเพียงครั้งเดียวตอนสร้าง และเซิร์ฟเวอร์ตรวจสอบด้วย HMAC ทุกครั้งที่เรียก ไม่มี OAuth redirect ไม่มี session cookie และไม่มี token ที่ต้อง refresh header เดียวกันจึงใช้ได้ตั้งแต่คำขอแรกและทุกคำขอหลังจากนั้น
ไคลเอนต์ควรเรียกใช้ base URL และ API เวอร์ชันใด?
มี base URL เดียว และเป็นระบบจริง ไม่มี sandbox แยกให้พัฒนาแล้วค่อยสลับทีหลัง ตั้ง client ให้ชี้ไปที่ base URL v1 ด้านบน ส่ง Authorization: Bearer <keyId>.<secret> ไปกับทุกคำขอ และเริ่มจาก GET /account เพื่อยืนยันว่าคีย์และรูปแบบ header ถูกต้องก่อนเรียกอย่างอื่น ส่วน POST /orders/create เป็นคำขอเดียวที่มีผลกับเงิน จึงควรเก็บไว้ทำเป็นขั้นสุดท้าย ทดสอบกับแพ็กมูลค่าต่ำที่สุด และส่ง expected_unit_price ไปด้วยระหว่างที่ client ยังอยู่ในช่วงทดสอบ
เมื่อเรียก POST /orders/create ต้องส่งคีย์ป้องกันการสร้างคำสั่งซื้อซ้ำในฟิลด์ใด?
ฟิลด์ order_id ซึ่งเป็น UUID ที่สร้างฝั่ง client แล้วส่งมาใน body ของคำขอสร้างคำสั่งซื้อ หากส่งคำขอสร้างซ้ำด้วย order_id ที่มีอยู่แล้ว API จะตอบ HTTP 409 พร้อม DUPLICATE_ORDER แทนการเปิดคำสั่งซื้อที่สอง การ retry จึงไม่มีทางกลายเป็นการหักเงินรอบที่สอง ดูคำสั่งซื้อเดิมได้ด้วย GET /orders/:orderId ควรสร้างและบันทึก UUID ไว้ก่อนส่งครั้งแรก เพราะ UUID ที่สร้างหลัง timeout จะนับเป็นคีย์ใหม่ ไม่ใช่คีย์สำหรับ retry และเซิร์ฟเวอร์จะไม่รวม order_id สองค่าที่ตั้งใจหมายถึงคำสั่งซื้อเดียวกันให้
คำขอ timeout ไป คำสั่งซื้อถูกสร้างแล้วหรือยัง?
ตรวจสถานะดูแทนการเดา เรียก GET /orders/:orderId ด้วย UUID เดิมที่ส่งไป ถ้าได้คำสั่งซื้อกลับมา แปลว่า timeout เกิดหลังระบบรับคำสั่งซื้อแล้ว และไม่ควรส่งซ้ำ ถ้าได้ ORDER_NOT_FOUND แปลว่ายังไม่มีการหักเงิน ส่งคำขอสร้างซ้ำด้วย UUID เดิมได้อย่างปลอดภัย
ตรวจสอบลายเซ็นของ webhook callback ได้อย่างไร?
คำนวณ HMAC ใหม่จากเนื้อหาคำขอดิบ แล้วเทียบกับเฮดเดอร์ X-Shop2Topup-Signature ซึ่งอยู่ในรูปแบบ sha256=<hex> โดยใช้ HMAC-SHA256 กับไบต์ที่ได้รับจริงและ webhook secret ของคุณ เติม sha256= ไว้ด้านหน้า แล้วเปรียบเทียบด้วยวิธีที่ป้องกัน timing attack ห้ามใช้ JSON ที่แปลงกลับเป็นข้อความใหม่มาเปรียบเทียบ เพราะการแยกวิเคราะห์แล้วแปลงกลับเป็นข้อความจะเปลี่ยนไบต์ ทำให้ผลเปรียบเทียบไม่ตรงกัน หากไม่ตรงให้ปฏิเสธด้วย 401 หากการจัดส่งผ่านการตรวจสอบ ให้ตอบ 200 ภายใน 10 วินาที แล้วจึงดำเนินการชำระบัญชีผ่านคิวของคุณเองภายหลัง
ถ้าเรียกเกิน rate limit จะเกิดอะไรขึ้น?
ระบบจะตอบ HTTP 429 พร้อมโค้ด RATE_LIMIT_EXCEEDED และ header Retry-After ใน body ของคำตอบจะมีตัวเลขเดียวกันอยู่ในฟิลด์ retry_after พร้อมฟิลด์ limit remaining และ window_seconds ให้รอตามจำนวนวินาทีนั้นพอดีแล้วลองใหม่หนึ่งครั้ง อย่าลองซ้ำทันที และอย่ากระจายคำขอเดียวกันไปยังคีย์หลายตัว เพราะตัวนับผูกกับบัญชี ไม่ใช่การเชื่อมต่อ
เมื่อเรียก POST /player/validate ระบบจะส่งข้อมูลกลับมาในฟิลด์ใดบ้าง
ได้ player_name และได้ zone_id กับ region ด้วยในเกมที่มีข้อมูลนี้ ทั้งหมดอยู่ในออบเจกต์ data ของ response ตอนสร้างคำสั่งซื้อ ระบบจะตรวจผู้เล่นและเช็กโซนเองอยู่แล้ว ถ้าโซนไม่ตรงจะถูกปฏิเสธด้วย REGION_MISMATCH (HTTP 400) ก่อนตัดเงินจากกระเป๋าเงิน การเรียก endpoint นี้จึงไม่ได้มีไว้ป้องกันยอดคงเหลือ เหตุผลที่ควรเรียกคือ player_name ที่ได้กลับมาเป็นวิธีเดียวที่ใช้ได้จริงในการแสดงบัญชีที่กำลังจะเติมให้ผู้ซื้อเห็น และช่วยจับ ID ที่พิมพ์ผิดก่อนผู้ซื้อกดยืนยัน ชื่อนี้มาจากเกมเสมอ ไม่ได้มาจากสิ่งที่ผู้ซื้อพิมพ์เข้ามา
จะนำค่าจากผลลัพธ์ requirements ไปใส่ในข้อมูลคำสั่งซื้อได้อย่างไร
ใช้ field_name เป็นคีย์ ทุกรายการที่ GET /catalog/category/:categoryId/requirements ส่งกลับมา จะกลายเป็น property หนึ่งตัวในออบเจกต์ requirements ที่ส่งไปกับ POST /orders/create แต่ละรายการมี data_type มาด้วย ฟอร์มจึงรู้ว่าต้องแสดงช่องแบบไหน และเซิร์ฟเวอร์รู้ว่าต้องแปลงค่าเป็นชนิดใด รายการแบบ single_select และ multi_select จะแนบค่าที่อนุญาตมาให้ ควรให้ผู้ใช้เลือกจากค่าเหล่านี้แทนช่องพิมพ์อิสระ แคชรายการไว้ตามหมวดหมู่ ไม่ต้องดึงใหม่ทุกคำสั่งซื้อ แต่ให้ดึงใหม่เมื่อคำขอสร้างคำสั่งซื้อตอบกลับมาด้วย MISSING_REQUIRED_FIELD
เมื่อได้รับ PRICE_INCREASED ซึ่งแจ้งว่าราคาสูงขึ้น ระบบฝั่งไคลเอนต์ควรจัดการอย่างไร
ดึงราคาใหม่ ตัดสินใจ แล้วค่อยส่งอีกครั้ง ห้ามส่ง body เดิมซ้ำ PRICE_INCREASED (HTTP 400) หมายความว่าราคาต่อหน่วยปัจจุบันสูงกว่า expected_unit_price ที่ส่งมา คำขอสร้างจึงถูกปฏิเสธและไม่มีการเรียกเก็บเงิน รายละเอียดของ error มีทั้ง expected_unit_price และ current_unit_price ถ้าส่งซ้ำด้วยตัวเลขเดิมโดยไม่ตรวจ ระบบจะปฏิเสธทุกครั้ง ให้เรียก GET /catalog/subcategory/:itemId/price ดูว่าราคาใหม่ยังรับได้หรือไม่ แล้วส่งคำขอสร้างใหม่พร้อม expected_unit_price ที่อัปเดตและ order_id เดิม คำขอที่ถูกปฏิเสธจะไม่ทำให้ order_id นั้นถูกใช้ไป ถ้าไม่ส่ง expected_unit_price มาเลย การป้องกันนี้จะปิดไปทั้งหมด ซึ่งแทบไม่เคยเป็นสิ่งที่ระบบอัตโนมัติต้องการ
คำสั่งซื้อที่ส่งได้บางส่วนจะแสดงใน response อย่างไร?
คำสั่งซื้อจะแสดงสถานะเป็น partial (สำเร็จบางส่วน) โดยแจกแจงรายละเอียดไว้ใน sub_transaction_summary (สรุปธุรกรรมย่อย) ออบเจกต์นี้นับจำนวนหน่วยแยกเป็น completed (สำเร็จ) refunded (คืนเงินแล้ว) และ pending (รอดำเนินการ) ทั้งสามค่านี้รวมกันได้เท่ากับ total เสมอ ส่วน sub_transactions จะแสดงทุกหน่วยพร้อมสถานะของแต่ละหน่วย หน่วยที่ยังไม่ได้ส่งและยังไม่ได้คืนเงินจะคงอยู่ในสถานะรอดำเนินการ ให้ถือว่า pending ยังไม่ใช่สถานะสุดท้ายและรอต่อไป สถานะ completed partial และ refunded ถือว่าสิ้นสุดแล้ว ตัวนับ failed (ไม่สำเร็จ) processing และ retrying มีไว้เพื่อความเข้ากันได้เท่านั้นและมีค่าเป็น 0 เสมอ คอลแบ็ก order.completed และ order.refunded ส่งสรุปชุดเดียวกันนี้มาด้วย เมื่อตรวจสอบข้อมูลที่ได้รับแล้วจึงปิดยอดได้ทันทีโดยไม่ต้องดึงข้อมูลซ้ำ
คำขอจาก IP ที่ไม่อยู่ใน allowlist จะได้ผลลัพธ์อะไร?
ได้ HTTP 403 พร้อมโค้ด IP_NOT_ALLOWED ซึ่งตอบกลับก่อนที่ business logic ใดๆ จะทำงาน การตรวจนี้มีผลทุกครั้งที่คีย์มี IP allowlist ที่ไม่ว่าง คีย์ที่หลุดออกไปจึงใช้ไม่ได้จากที่อื่นนอกจาก egress IP ของคุณเอง ให้แยกเงื่อนไขจาก error.code ไม่ใช่จากข้อความ ถ้าเจอ IP_NOT_ALLOWED บน production แบบกะทันหัน ให้สันนิษฐานก่อนว่า egress IP เปลี่ยน เช่น มี NAT gateway หรือ subnet ของ worker ใหม่ ไม่ใช่คีย์เสีย ปล่อย allowlist ว่างไว้เฉพาะกรณีที่ IP ขาออกของคุณไม่คงที่จริงๆ
คำสั่งซื้อใช้เวลานานแค่ไหนกว่าจะเสร็จ?
การดำเนินคำสั่งซื้อต้องใช้เวลาและไม่ได้เสร็จทันที POST /orders/create จะตอบกลับเมื่อระบบรับคำสั่งซื้อและหักเงินจากกระเป๋าเงินแล้ว โดยปกติสถานะจะเป็นรอดำเนินการ จากนั้นระบบจะจัดการคำสั่งซื้อผ่าน worker ที่ทำงานแยกกัน และแจ้งสถานะสุดท้ายผ่าน webhook หรือเมื่อเรียกดูสถานะครั้งถัดไป อย่าให้คำขอ HTTP จากฝั่งลูกค้าค้างรอจนได้สถานะสุดท้าย ให้รับคำสั่งซื้อและตอบกลับลูกค้าก่อน แล้วจัดการผลลัพธ์สุดท้ายแยกจากคำขอนั้น
สมัครบัญชี สร้างคีย์ แล้วใช้งานจริงได้เลย
ไม่ต้องรออนุมัติ และไม่มีช่วงรอระหว่างสมัครกับเริ่มเรียก API สมัครบัญชีตัวแทนจำหน่าย สร้างคีย์ที่หน้า API Access เติมเงินเข้ากระเป๋าเงิน แล้วส่งคำสั่งซื้อแรกได้ภายในวันเดียวกัน
หากคุณไม่ใช่นักพัฒนาของโปรเจกต์นี้ ดูรายละเอียดฝั่งธุรกิจได้ที่ หน้าโปรแกรมตัวแทนจำหน่าย SHOP2TOPUP.