Contract — Request · Response · กติกา
หน้านี้คือสัญญาการ implement wallet API ให้ถูก: รูปแบบ request/response และกติกาบังคับ (atomic · idempotency · retry) ที่ทำให้เงินไม่เพี้ยน
Request envelope
ทุก endpoint ใช้ envelope เดียวกัน: ข้อมูลผู้เล่นระดับบนสุด + ธุรกรรมเป็น batch ใน txns[] — ต่างกันแค่ฟิลด์ปลีกย่อยใน txn ของแต่ละ endpoint · ตัวอย่าง POST {base}/api/bet เดิมพัน 200:
{
"request_id": "r1",
"player_username": "foobar",
"currency": "THB",
"product_code": "PGSOFT",
"timestamp": 1712760660000,
"txns": [
{ "txn_id": "T-001", "round_id": "R-1", "amount": 200, "game_code": "10300" }
]
}| ฟิลด์ | ชนิด | จำเป็น | คำอธิบาย |
|---|---|---|---|
request_id | string | ✓ | id ของ request นี้ — echo กลับใน response เพื่อ correlate |
player_username | string | ✓ | username ผู้เล่นในระบบของคุณ |
currency | string | ✓ | สกุลเงิน เช่น THB |
product_code | string | – | รหัสค่ายเกมตามที่แพลตฟอร์มตั้ง (display code เช่น PGSOFT — อาจต่างจากรหัสของ vendor ต้นทาง) — เอาไปจัดหมวด report ฝั่งคุณได้ ค่าคงที่เว้นแต่ผู้ดูแลระบบ rebrand ค่าย |
timestamp | number | ✓ | เวลาส่งเป็น unix milliseconds |
txns | txn[] | ✓* | ธุรกรรมทั้งชุด — *ทุก endpoint ยกเว้น /api/balance ซึ่งไม่มี txns |
ฟิลด์ใน txns[]
| ฟิลด์ | ชนิด | จำเป็น | คำอธิบาย |
|---|---|---|---|
txn_id | string | ✓ | ★ idempotency key — ยิงซ้ำห้ามทำซ้ำ · รายการหลัง rollback ใช้ id เดิมแต่ต้อง apply (re-settle หลัง rollback) |
round_id | string | ✓ | รอบเกมที่ txn นี้สังกัด |
amount | number | ✓ | จำนวนเงิน บวกเสมอ — ทิศมาจากชนิด endpoint หรือ direction เท่านั้น |
game_code | string | – | รหัสเกม |
is_feature | boolean | – | free/feature spin (default false) |
ref_txn_id | string | – | reversal/ปรับยอด (/rollback, /void, /cancel-tip, /refund, /adjust): txn_id ของรายการต้นทาง (nullable) |
direction | string | – | net op (/void, /adjust, /settle single-state): "DEBIT" หรือ "CREDIT" |
status | string | – | สถานะบิลจากค่าย (มีทุก callback) — /bet "OPEN" ปกติ, "WAITING" เฉพาะกีฬาก่อนค่ายยืนยัน (แล้ว relay "OPEN" + skip_balance_update ซ้ำ) · /settle "SETTLED" · /refund "REFUND" (ยกเลิก) หรือ "REJECT" (กีฬาถูกปฏิเสธ) · /rollback "ROLLBACK" · /void "VOID" · /tip "TIPS" |
transaction_type | string | – | ขอบเขต (/settle, /refund, /rollback, /void) — "BY_TRANSACTION" = จัดการเฉพาะ txn_id นี้ · "BY_ROUND" = จัดการบิลทุกใบใน round_id พร้อมกัน (amount เป็นก้อนรวมของทั้งรอบ) |
skip_balance_update | boolean | – | true = call นี้ห้ามขยับ balance (OPEN confirm ของกีฬา หรือ settle แบบ record-only) — ตอบ code:0 + balance ปัจจุบัน |
is_feature_buy | boolean | – | /bet, /settle: ซื้อฟีเจอร์ (คู่กับ is_feature) |
play_info | string | – | ตัวเลือกที่เดิมพัน (เช่น banker/player) หรือชื่อเกม — มีทุก callback ที่เกี่ยวกับบิล |
turn_over | number | – | /settle: valid stake (turnover) ของบิล — ใช้ทำ report turnover/commission |
is_end_round | boolean | – | /settle: true = ปิดรอบ · false = ยังมี settle ส่วนอื่นตามมา · ไม่มี = settle จบครั้งเดียว |
/api/balance มีแค่ top-level (อ่านอย่างเดียว ไม่มี txns[])
/settle พิเศษ — single-state round
บางเกม (เช่น slot single-state) จบทั้งเดิมพันและผลใน call เดียว ไม่มี /bet มาก่อน txn ใน /settle จะมีฟิลด์เพิ่ม:
{ "txn_id": "T-001", "round_id": "R-1", "single_state": true, "bet_amount": 200, "payout_amount": 500, "direction": "CREDIT", "amount": 300, "is_feature": false }single_state: true→ applyamount(ยอดสุทธิ = payout − bet, บวกเสมอ) ในทิศdirectionและเช็คbalance >= bet_amountก่อนเสมอ (เสมือนผู้เล่นวาง stake ก่อนรับ payout) — ไม่พอตอบ10002single_state: false(settle ปกติ มี/betมาก่อน) → creditamount(= payout) ตรงๆ ไม่มีbet_amount/direction
single-state settle ก็ต้องเช็ค stake
แม้ direction: "CREDIT" (ยอดสุทธิเป็นการเติมเงิน) ก็ยังต้องเช็ค balance >= bet_amount — ผู้เล่นต้องมีเงินพอวางเดิมพันของรอบนั้นก่อน
กีฬา (sportsbook) — bet เดิมมาซ้ำสองครั้ง
บิลกีฬาวิ่งมาที่ /api/bet สองครั้ง ตาม flow ปกติ: ครั้งแรกตอนวางบิล (best-system ถือเป็น WAITING → txn มี status: "WAITING") และอีกครั้งเมื่อค่ายยืนยันบิล (OPEN) — best-system จะ relay /api/bet อีกรอบด้วย txn_id เดิม พร้อม status: "OPEN" + skip_balance_update: true ระบบ dedupe ต่อ txn_id ของคุณต้องรับ call ที่สองนี้ให้ได้: ตอบ code: 0 พร้อม balance ปัจจุบัน และห้ามหักเงินซ้ำเด็ดขาด (จะใช้ status/skip_balance_update แยกให้ชัด หรือ dedupe txn_id เฉยๆ ก็ได้)
ตัวอย่าง payload /api/bet สองครั้งติดของบิลเดียวกัน — request_id/timestamp ใหม่ แต่ txn_id ตัวเดิม:
{
"request_id": "r10",
"player_username": "foobar",
"currency": "THB",
"product_code": "SBOBET",
"timestamp": 1712760660000,
"txns": [
{ "txn_id": "S-88001", "round_id": "SB-1", "amount": 100, "game_code": "SBO-FOOTBALL", "status": "WAITING" }
]
}{
"request_id": "r11",
"player_username": "foobar",
"currency": "THB",
"product_code": "SBOBET",
"timestamp": 1712760790000,
"txns": [
{ "txn_id": "S-88001", "round_id": "SB-1", "amount": 100, "game_code": "SBO-FOOTBALL", "status": "OPEN", "skip_balance_update": true }
]
}ครั้งแรกหัก 100 · ครั้งที่สองตอบ code: 0 + balance ปัจจุบันโดยไม่เขียนเงิน — ตรงตามกติกา idempotency ด้านล่างเป๊ะ ไม่ต้องเขียน logic พิเศษสำหรับกีฬา
Response
ทุก endpoint ตอบ JSON โครงแบน (flat) ชุดเดียวกัน ไม่มี envelope ซ้อน · ตัวอย่างคำตอบ /api/bet เดิมพัน 200 (ยอดเดิม 1000):
{
"code": 0,
"request_id": "r1",
"username": "foobar",
"currency": "THB",
"balance_before": 1000,
"balance_after": 800,
"timestamp": 1712760660100
}| ฟิลด์ | ชนิด | คำอธิบาย |
|---|---|---|
code | number | 0 = สำเร็จ · อื่นๆ ดูตารางด้านล่าง |
request_id | string | echo จาก request (correlate) |
username | string | echo player_username (กันสลับคน) |
currency | string | echo จาก request |
balance_before | number | ยอดก่อนประมวล txns[] ทั้งชุด |
balance_after | number | ยอดหลังประมวล ทั้งชุด (ไม่ใช่ราย txn) |
timestamp | number | เวลาที่คุณประมวลผล (unix ms) |
/api/balance คืน balance เดี่ยวแทนคู่ before/after
ตอบ HTTP 200 เสมอ — แม้เป็น business error
business error (เงินไม่พอ, txn ซ้ำ, ไม่พบผู้เล่น) ตอบ HTTP 200 แล้วใส่รหัสใน code พร้อม balance_before == balance_after (เงินไม่ขยับ) · best-system retry เฉพาะ network error/timeout — ทันทีที่ได้ HTTP response ใดๆ ถือว่าจบและไม่ยิงซ้ำ ตอบ 5xx จะทำให้รายการถูกนับ fail ทิ้งเลย · ข้อยกเว้นเดียว: signature ไม่ผ่านตอบ 401 ได้
Error codes
รหัสชุดนี้เป็นของ best-system เอง — คงที่ ไม่เปลี่ยนตามเกมหรือเวอร์ชัน
code | ความหมาย | HTTP |
|---|---|---|
0 | สำเร็จ | 200 |
10001 | ไม่พบผู้เล่นในระบบของคุณ | 200 |
10002 | ยอดเงินไม่พอสำหรับรายการหัก (debit) | 200 |
20001 | ไม่พบรายการต้นทาง (ref_txn_id ไม่เจอ) | 200 |
20002 | รายการซ้ำ (txn_id เคยประมวลแล้ว) | 200 |
30002 | signature ไม่ถูกต้อง | 200 หรือ 401 |
40003 | ปฏิเสธการเข้าถึง (api-key / IP) | 200 |
50001 | ข้อผิดพลาดภายในระบบของคุณ | 200 |
รายละเอียดเต็ม: Seamless codes
กติกาบังคับ
Atomic batch
- ประมวล
txns[]ทั้งชุดใน DB transaction เดียว — ตัวใดพัง (เงินไม่พอ,ref_txn_idไม่เจอ) → roll back ทั้งชุด ห้ามมีสภาพ "ผ่านครึ่งชุด" - ตอบ error ด้วย
codeของตัวที่พัง พร้อมbalance_before == balance_after balance_before/balance_after= ยอดของทั้ง batch ไม่ใช่ราย txn
Idempotency ต่อ txn_id
txn_id คือ identity ของธุรกรรม — เจอ txn_id ที่เคยประมวลสำเร็จแล้ว ห้าม apply ซ้ำ แล้วตอบแบบใดแบบหนึ่ง: code: 0 + ยอดปัจจุบัน (แนะนำ) หรือ code: 20002
dedupe ด้วย txn_id ไม่ใช่ request_id — การส่งรายการเดิมซ้ำอาจมาพร้อม request_id ใหม่ แต่ txn_id เป็นตัวเดิมเสมอ · ทางปฏิบัติ: unique index บน txn_id แล้วจับ conflict ใน transaction เดียวกับการเขียนเงิน
ตอบช้า = ค่ายยิงซ้ำ
ประมวลผลสำเร็จแต่ตอบไม่ทัน 10 วินาที best-system ถือว่า call นั้น fail (ตอบ error กลับ upstream) — แล้วค่ายเกมต้นทาง retransmit มาใหม่ด้วย txn_id เดิม — idempotency จึงเป็นภาคบังคับ
Retry / timeout ฝั่ง best-system
- timeout ต่อ call: 10 วินาที — เป็นค่าคงที่ระดับระบบ (env
SEAMLESS_RELAY_TIMEOUT_MS, ops ปรับ) ไม่ได้ตั้งต่อ agent · relay อยู่ใน callback window แบบ synchronous ของ vendor → budget bound ด้วย SLA ของ vendor ไม่ใช่ค่ารายลูกค้า - single attempt — ไม่มี inline retry: network error / timeout best-system ตอบ error กลับ upstream ทันที ไม่เอารายการเข้าคิวส่งซ้ำเองภายหลัง (ไม่มี outbox, ไม่มี async re-delivery)
- การกู้คืนอาศัย retransmit จากค่ายเกมต้นทางเท่านั้น — มาเป็น call ใหม่ด้วย
txn_idเดิม (จึงต้อง dedupe ต่อtxn_id) - ได้ HTTP response ใดๆ — แม้ business error — ถือเป็น final (ไม่มีอะไรให้ retry) · ทำ endpoint ให้ available สูง + ตอบเร็ว
re-settle หลัง rollback
/rollback เปิดรอบนั้นกลับเป็นค้าง แล้วผลใหม่ถูกส่งมาอีกครั้ง ด้วย txn_id เดิม — เป็นได้ทั้ง /settle (ผลใหม่) และ /refund (ยกเลิกแทน) ห้ามตอบ 20002 เคสนี้ ไม่งั้นผลรอบนั้นหายถาวร
เราไม่ส่ง field ใดมาช่วยแยก เพราะฝั่งค่ายเกมก็ไม่ได้ส่งมาให้เรา คุณต้องนับเอง — ซึ่งทำได้เพราะคุณได้รับ /rollback ทุกใบเหมือนกับที่เราได้รับ
วิธีทำ
- เก็บตัวนับ rollback ต่อบิล เริ่มที่ 0
- ได้รับ
/rollback→+1(ถ้าtransaction_typeเป็นBY_ROUNDให้+1กับทุกบิลในround_idนั้น) - ใช้
(endpoint, txn_id, ตัวนับปัจจุบัน)เป็น idempotency key - รายการที่ตามมาหลัง rollback จะได้ key ใหม่โดยอัตโนมัติ จึงถูก apply ส่วนการยิงซ้ำจริงยังชนคีย์เดิมและถูกข้ามตามปกติ
อย่าใช้กติกา "ยอมรับซ้ำได้ 1 ครั้ง" รอบเดียวกันถูก rollback ได้มากกว่า 1 ครั้ง (ในทราฟฟิกจริงพบบิลที่ถูก rollback 2 ครั้งแล้วจบด้วย settle ใบที่ 3) กติกานับครั้งเดียวจะทิ้งผลใบสุดท้ายไป ซึ่งเป็นผลที่ถูกต้อง
ทำไมต้องมี endpoint ในคีย์ /bet กับ /settle ของบิลเดียวกันใช้ txn_id เดียวกัน ถ้าคีย์ไม่มี endpoint settle จะถูกมองเป็นของซ้ำกับ bet
Checklist — จุดพลาดบ่อย
- verify
X-Signatureจาก raw bytes ไม่ใช่ object ที่ parse แล้ว stringify กลับ - business error ตอบ HTTP 200 +
code— ตอบ 5xx จะถูกตีความเป็น internal error และไม่มี retry - dedupe ต่อ
txn_idก่อนเขียนเงินเสมอ amountเป็นบวกเสมอ — ทิศดูจากชนิด endpoint หรือdirection- Single-state settle: เช็ค
balance >= bet_amountก่อน apply ยอดสุทธิ balance_before/balance_after= ยอดของทั้ง batch- ตอบให้จบภายใน 10 วินาที — งานหนักทำ async หลัง commit