Contract — Request · Response · กติกา

หน้านี้คือสัญญาการ implement wallet API ให้ถูก: รูปแบบ request/response และกติกาบังคับ (atomic · idempotency · retry) ที่ทำให้เงินไม่เพี้ยน

Request envelope

ทุก endpoint ใช้ envelope เดียวกัน: ข้อมูลผู้เล่นระดับบนสุด + ธุรกรรมเป็น batch ใน txns[] — ต่างกันแค่ฟิลด์ปลีกย่อยใน txn ของแต่ละ endpoint · ตัวอย่าง POST {base}/api/bet เดิมพัน 200:

json
{
  "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_idstring✓id ของ request นี้ — echo กลับใน response เพื่อ correlate
player_usernamestring✓username ผู้เล่นในระบบของคุณ
currencystring✓สกุลเงิน เช่น THB
product_codestring–รหัสค่ายเกมตามที่แพลตฟอร์มตั้ง (display code เช่น PGSOFT — อาจต่างจากรหัสของ vendor ต้นทาง) — เอาไปจัดหมวด report ฝั่งคุณได้ ค่าคงที่เว้นแต่ผู้ดูแลระบบ rebrand ค่าย
timestampnumber✓เวลาส่งเป็น unix milliseconds
txnstxn[]✓*ธุรกรรมทั้งชุด — *ทุก endpoint ยกเว้น /api/balance ซึ่งไม่มี txns

ฟิลด์ใน txns[]

ฟิลด์ชนิดจำเป็นคำอธิบาย
txn_idstring✓★ idempotency key — ยิงซ้ำห้ามทำซ้ำ · รายการหลัง rollback ใช้ id เดิมแต่ต้อง apply (re-settle หลัง rollback)
round_idstring✓รอบเกมที่ txn นี้สังกัด
amountnumber✓จำนวนเงิน บวกเสมอ — ทิศมาจากชนิด endpoint หรือ direction เท่านั้น
game_codestring–รหัสเกม
is_featureboolean–free/feature spin (default false)
ref_txn_idstring–reversal/ปรับยอด (/rollback, /void, /cancel-tip, /refund, /adjust): txn_id ของรายการต้นทาง (nullable)
directionstring–net op (/void, /adjust, /settle single-state): "DEBIT" หรือ "CREDIT"
statusstring–สถานะบิลจากค่าย (มีทุก callback) — /bet "OPEN" ปกติ, "WAITING" เฉพาะกีฬาก่อนค่ายยืนยัน (แล้ว relay "OPEN" + skip_balance_update ซ้ำ) · /settle "SETTLED" · /refund "REFUND" (ยกเลิก) หรือ "REJECT" (กีฬาถูกปฏิเสธ) · /rollback "ROLLBACK" · /void "VOID" · /tip "TIPS"
transaction_typestring–ขอบเขต (/settle, /refund, /rollback, /void) — "BY_TRANSACTION" = จัดการเฉพาะ txn_id นี้ · "BY_ROUND" = จัดการบิลทุกใบใน round_id พร้อมกัน (amount เป็นก้อนรวมของทั้งรอบ)
skip_balance_updateboolean–true = call นี้ห้ามขยับ balance (OPEN confirm ของกีฬา หรือ settle แบบ record-only) — ตอบ code:0 + balance ปัจจุบัน
is_feature_buyboolean–/bet, /settle: ซื้อฟีเจอร์ (คู่กับ is_feature)
play_infostring–ตัวเลือกที่เดิมพัน (เช่น banker/player) หรือชื่อเกม — มีทุก callback ที่เกี่ยวกับบิล
turn_overnumber–/settle: valid stake (turnover) ของบิล — ใช้ทำ report turnover/commission
is_end_roundboolean–/settle: true = ปิดรอบ · false = ยังมี settle ส่วนอื่นตามมา · ไม่มี = settle จบครั้งเดียว

/api/balance มีแค่ top-level (อ่านอย่างเดียว ไม่มี txns[])

/settle พิเศษ — single-state round

บางเกม (เช่น slot single-state) จบทั้งเดิมพันและผลใน call เดียว ไม่มี /bet มาก่อน txn ใน /settle จะมีฟิลด์เพิ่ม:

json
{ "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 → apply amount (ยอดสุทธิ = payout − bet, บวกเสมอ) ในทิศ direction และเช็ค balance >= bet_amount ก่อนเสมอ (เสมือนผู้เล่นวาง stake ก่อนรับ payout) — ไม่พอตอบ 10002
  • single_state: false (settle ปกติ มี /bet มาก่อน) → credit amount (= 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 ตัวเดิม:

json
{
  "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" }
  ]
}
json
{
  "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):

json
{
  "code": 0,
  "request_id": "r1",
  "username": "foobar",
  "currency": "THB",
  "balance_before": 1000,
  "balance_after": 800,
  "timestamp": 1712760660100
}
ฟิลด์ชนิดคำอธิบาย
codenumber0 = สำเร็จ · อื่นๆ ดูตารางด้านล่าง
request_idstringecho จาก request (correlate)
usernamestringecho player_username (กันสลับคน)
currencystringecho จาก request
balance_beforenumberยอดก่อนประมวล txns[] ทั้งชุด
balance_afternumberยอดหลังประมวล ทั้งชุด (ไม่ใช่ราย txn)
timestampnumberเวลาที่คุณประมวลผล (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
30002signature ไม่ถูกต้อง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 ทุกใบเหมือนกับที่เราได้รับ

วิธีทำ

  1. เก็บตัวนับ rollback ต่อบิล เริ่มที่ 0
  2. ได้รับ /rollback → +1 (ถ้า transaction_type เป็น BY_ROUND ให้ +1 กับทุกบิลใน round_id นั้น)
  3. ใช้ (endpoint, txn_id, ตัวนับปัจจุบัน) เป็น idempotency key
  4. รายการที่ตามมาหลัง 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