Merchant Integration Guide

เอกสารสำหรับ merchant ที่ต้องการเชื่อมต่อกับ GameZero แบบ Seamless Wallet — เงินของผู้เล่นอยู่ในระบบของคุณตลอด ไม่มีการโอนเข้า/ออก ทุกครั้งที่ผู้เล่นเดิมพันหรือรับรางวัล ระบบเราจะเรียก endpoint กระเป๋าเงินของคุณแบบเรียลไทม์

มีสองขาที่ต้องเข้าใจ: (1) API ที่คุณเรียกเรา ผ่าน gateway (สร้างผู้เล่น, เปิดเกม) และ (2) Wallet callback ที่เราเรียกคุณ (ตัดเงิน/จ่ายเงิน/คืนเงิน) คุณต้อง implement เพียง endpoint เดียวคือ wallet callback

ภาพรวมการทำงาน

ผู้เล่นกดเล่นเกม
   │
   ├─ 1) คุณเรียก  POST /v1/launch          → ได้ game_url ส่งผู้เล่นเข้าเกม
   │
เกมเริ่มเดิน (ผู้เล่นแทง / ได้รางวัล / โดนยกเลิก)
   │
   └─ 2) เราเรียก  POST {your_wallet_url}    → action = bet | settle | cancel | balance
             คุณขยับยอดเงินแล้วตอบ balance กลับ

การยืนยันตัวตน (HMAC Signature)

ทั้งสองขาใช้สูตร signature เดียวกัน ต่างกันแค่ key ที่ใช้:

signature = hex( HMAC_SHA256( secret, timestamp + "." + rawBody ) )
ขาsecret ที่ใช้ใครตรวจ
คุณ → เราAPI Secret (ออกให้ในหน้า Admin)gateway ของเรา
เรา → คุณWallet Secret (ตั้งร่วมกันตอนเปิดบัญชี)ระบบของคุณ

ทุก request แนบ 3 header:

Headerค่า
X-API-KeyAPI key ของคุณ (เฉพาะขาที่คุณเรียกเรา)
X-TimestampUnix timestamp (วินาที) — คลาดเคลื่อนได้ไม่เกิน 5 นาที ไม่งั้นถือว่า replay
X-Signatureผลลัพธ์จากสูตรด้านบน
rawBody คือ bytes ดิบของ body ที่ส่งจริง — ต้อง sign กับ string เดียวกับที่ส่งเป๊ะ (อย่า re-marshal / อย่าจัดเรียง key ใหม่หลัง sign) สำหรับ GET ที่ไม่มี body ให้ใช้ body ว่าง (`""`)

Environment & Base URL

EnvironmentBase URL (gateway)
Staginghttps://api.gamezero-tech.com (API key แบบ staging)
Productionhttps://api.gamezero-tech.com (API key แบบ production)

environment ถูกกำหนดโดย API key ที่ใช้ ไม่ใช่ URL — key คนละชุดคนละ environment

สร้างผู้เล่น

POST/v1/players

{ "username": "member_test" }

Idempotent — เรียกซ้ำด้วย username เดิมได้ ระบบจะผูก player_prefix ของคุณให้เอง (เช่น m001_member_test) ก่อน launch ครั้งแรกต้องมีผู้เล่นก่อน

รายการเกม

GET/v1/games — คืนเฉพาะค่าย/เกมที่เปิดให้ merchant คุณ

{ "status":"ok", "games":[
  {"provider":"allbet","game_code":"0","name":"AllBet Live Casino","category":"live"},
  {"provider":"m8","game_code":"SPORTS","name":"M8 Sportsbook","category":"sport"}
]}

เปิดเกม (Launch)

POST/v1/launch

{ "username":"member_test", "provider":"allbet",
  "game_code":"0", "lang":"th", "return_url":"https://your-site.com/lobby" }
{ "status":"ok",
  "game_url":"https://...",         // redirect ผู้เล่นไปที่นี่
  "session_token":"..." }           // เราผูก token นี้กับผู้เล่น ใช้ตอน callback

ดูยอดเงิน

GET/v1/players/{username}/balance — เราจะไปถามกระเป๋าเงินของคุณให้

รายการเดินเงิน

GET/v1/transactions?from=&to=&provider=&type=&limit=

GET/v1/players/{username}/statement

โปรโตคอล Wallet Callback (เราเรียกคุณ)

คุณเปิด endpoint 1 ตัว (เช่น POST https://your-site.com/wallet) แล้วแจ้ง URL นี้ให้เราตอนเปิดบัญชี ทุก request เป็น JSON โครงเดียวกัน:

{
  "action":    "bet",              // balance | bet | settle | cancel
  "tx_id":     "6f1c...",          // ★ unique ต่อ 1 การขยับเงิน ใช้กันซ้ำ
  "round_id":  "512345678",        // รหัสรอบเกม (อ้างอิงกลุ่ม bet/settle/cancel เดียวกัน)
  "provider":  "allbet",
  "username":  "member_test",
  "game_code": "0",
  "amount":    "10.50",            // เป็นบวกเสมอ (ทิศทางดูจาก action)
  "timestamp": 1720000000
}

ตอบกลับ (HTTP 200 เสมอ แม้ error ทางธุรกิจ):

// สำเร็จ
{ "status":"ok", "balance_before":"1000.00", "balance":"989.50" }
// ซ้ำ (tx_id เดิม) — ต้องคืน balance เดิม ห้ามขยับเงิน
{ "status":"ok", "balance":"989.50", "duplicate":true }
// ผิดพลาด
{ "status":"error", "code":"insufficient_funds", "message":"not enough balance" }

action = balance

ถามยอดปัจจุบันของผู้เล่น ไม่ขยับเงิน ตอบ {"status":"ok","balance":"..."}

action = bet

ตัดเงิน เท่ากับ amount ถ้าเงินไม่พอ ตอบ insufficient_funds ห้ามตัดติดลบ

action = settle

จ่ายรางวัล โดยบวก amount เข้ากระเป๋า (amount = ยอดที่ต้องจ่าย ไม่ใช่กำไรสุทธิ) ค่ายที่แยก bet/settle จะยิง 2 ครั้งใน round เดียวกัน

action = cancel — อ่านให้ละเอียด

ยกเลิก/คืนเงินของ bet เดิม โดยบวก amount (เท่ายอด bet ที่เคยตัด) กลับเข้ากระเป๋า

ข้อผิดพลาดที่พบบ่อยกับการ cancel:
  • ต้องอ้างอิงให้ตรง bet เดิม — เราส่ง round_id ชุดเดียวกับ bet ที่จะยกเลิกมาให้ ใช้จับคู่ในระบบคุณ ห้ามคืนมั่ว
  • cancel เป็น tx_id ใหม่ (คนละตัวกับ bet) แต่ผูกกับ round เดิม — ถ้าคุณ dedup ผิดชั้น อาจคืนซ้ำหรือไม่คืนเลย
  • cancel ต้อง idempotent เช่นกัน — ถ้าเรายิง cancel ซ้ำ (เพราะ timeout แล้ว retry) ต้องคืน balance เดิม ไม่บวกเงินซ้ำ
  • ถ้าไม่เคยเห็น bet นั้น (bet มาไม่ถึง/หาย) ให้ตอบสำเร็จแบบ no-op หรือ round_not_found ตามนโยบายคุณ — อย่าบวกเงินลอย

ฝั่งเราเช็คก่อนอยู่แล้วว่า bet เดิมต้องมีจริง (ผ่าน dedup key provider_id + provider_tx_id + type) และถ้า cancel ซ้ำเราจะส่ง duplicate กลับให้ค่าย แต่ฝั่งคุณก็ต้องกันซ้ำด้วยเพราะเป็นด่านสุดท้ายของเงินจริง

Idempotency (กันเงินซ้ำ)

กุญแจคือ tx_id: ทุก action ที่ขยับเงินมี tx_id ไม่ซ้ำ ให้คุณเก็บ tx_id ที่เคยประมวลผลไว้ แล้ว:

ได้รับ wallet request:
  ถ้า tx_id เคยเห็นแล้ว  → คืน balance ที่บันทึกไว้ตอนนั้น + duplicate:true  (ห้ามขยับเงิน)
  ถ้ายังไม่เคยเห็น        → ขยับเงิน, บันทึก (tx_id → balance หลังทำ) ในทรานแซกชันเดียวกัน
การบันทึก tx_id กับการขยับ balance ต้องอยู่ใน DB transaction เดียวกัน ไม่งั้นเครื่องดับกลางคันจะเงินเพี้ยน

Race condition & Queue — หัวใจของความถูกต้อง

บางค่ายยิง callback ของผู้เล่นคนเดียวกัน พร้อมกันหลายเส้น หรือส่ง bet กับ settle ไล่กันเร็วมากจนมาถึงคุณเกือบพร้อมกัน ถ้าคุณอ่าน balance → คำนวณ → เขียนกลับ แบบไม่ล็อก จะเกิด lost update (เงินหาย/เกิน)

กติกาบังคับฝั่ง merchant

เลือกวิธีใดวิธีหนึ่ง:

วิธีทำยังไงเหมาะกับ
Row lock ใน DBSELECT ... FOR UPDATE ที่แถว wallet ของ user ก่อนคำนวณ แล้ว commitง่ายสุด ทำได้เลยด้วย SQL
Atomic updateUPDATE wallet SET bal = bal - :amt WHERE user=:u AND bal >= :amt เช็คแถวที่กระทบbet เดี่ยวๆ เร็ว
Per-user queueเข้าคิวตาม hash(username) → worker เดียวต่อ userค่ายที่ยิงถี่/เป็นชุด
ค่ายกลุ่มที่ต้องใช้ queue เรียงลำดับ (เช่น SA Gaming): จะส่งผล เป็นชุด/เบิ้ลพร้อมกัน และบางครั้ง settle มาถึงก่อน bet ที่คู่กัน ถ้าประมวลผลสลับลำดับจะได้ยอดผิด ให้:
  • เข้าคิว ต่อผู้เล่น (partition by username) ประมวลผลเรียงตามลำดับที่รับเข้ามา — worker 1 ตัวต่อ 1 user ห้ามขนานภายใน user เดียว
  • ถ้า settle อ้าง round ที่ยังไม่มี bet ให้ พักไว้แล้ว retry (หรือ hold ในคิวสั้นๆ) อย่าเพิ่งปฏิเสธทันที เพราะ bet อาจตามมาในเสี้ยววินาที
  • ยังต้อง idempotent ด้วย tx_id เสมอ — queue ช่วยเรื่องลำดับ แต่ retry ยังทำให้ของซ้ำได้
ฝั่งเรากันซ้ำระดับ platform ให้ชั้นหนึ่งด้วย dedup key (provider_id, provider_tx_id, type) และ settle/cancel ที่ยิงไม่ถึงคุณจะถูกเรา retry แบบ exponential backoff อัตโนมัติ — แต่ด่านเงินจริงคือกระเป๋าคุณ จึงต้องกันซ้ำ+เรียงลำดับที่ฝั่งคุณด้วยเสมอ

กติกา Cancel แยกตามลักษณะค่าย

รูปแบบค่ายพฤติกรรม cancel ที่คุณต้องรองรับ
bet/settle แยกจังหวะ (เช่น AllBet)cancel อ้าง bet ด้วย round_id เดิม → คืนยอด bet. ถ้า settle ไปแล้วอาจมี cancel ตามมาเพื่อ reverse ทั้งรอบ
bet+settle รวมจังหวะเดียวcancel = reverse ทั้งก้อน (คืน bet, หัก payout ที่จ่ายไป) — จับคู่ด้วย round_id
กีฬา/โพลผลทีหลัง (เช่น M8)ไม่มี settle แบบ push — เราดึงผลเองแล้วส่ง settle/cancel มาให้ทีหลัง อาจห่างจาก bet เป็นชั่วโมง คุณต้องหา bet เดิมด้วย round_id ให้เจอแม้เวลาผ่านไปนาน

สรุปหลักการเดียวที่ใช้ได้ทุกค่าย: จับคู่ด้วย round_id, กันซ้ำด้วย tx_id, ขยับเงินใต้ล็อกต่อ user

Error codes ที่คุณควรตอบกลับ

codeเมื่อไหร่
insufficient_fundsยอดไม่พอตัด bet
invalid_parameteramount/field ผิดรูปแบบ
round_not_foundcancel/settle อ้าง round ที่ไม่มี
bad_signaturesignature ไม่ผ่าน

Checklist ก่อนขึ้นจริง

[ ] ตรวจ signature ทุก wallet request (secret + skew 5 นาที)
[ ] balance / bet / settle / cancel ครบ และตอบ HTTP 200 เสมอ
[ ] tx_id idempotent: ซ้ำ → คืน balance เดิม + duplicate:true (บันทึก tx_id ใน tx เดียวกับการขยับเงิน)
[ ] cancel: จับคู่ round_id เดิม, idempotent, ไม่คืนเงินลอยเมื่อไม่พบ bet
[ ] serialize ต่อ username (row lock / atomic update / per-user queue)
[ ] ค่ายกลุ่ม batch (SA ฯลฯ): queue เรียงลำดับต่อ user + hold settle ที่มาก่อน bet
[ ] ทดสอบครบ: bet → settle, bet → cancel, ยิงซ้ำทุก action, ยิงพร้อมกัน 2 เส้น

มีคำถามเรื่อง integration ติดต่อทีม GameZero — เอกสารนี้อ้างอิงโปรโตคอลจริงของระบบ v1