// Developers

เชื่อมต่อระบบชำระเงินของ Dev365TH เรียนออนไลน์

ให้เกมหรือเว็บเซอร์วิสของคุณขายไอเทม โมดูล หรือฟีเจอร์ได้ทันที โดยใช้เครดิตของ Dev365TH เรียนออนไลน์ เป็นสกุลเงิน — คุณไม่ต้องทำระบบเติมเงิน ตัดบัตร ใบเสร็จ หรือบัญชีเงินเอง

ผู้ใช้เติมเครดิตที่เดียวใช้ซื้อของได้ทุกผลิตภัณฑ์ที่เชื่อมต่อ
เงินอยู่ฝั่งเราคุณเรียก API ไม่ต้องเก็บข้อมูลการเงิน
ใบเสร็จ/ภาษีจัดการให้ออกตอนผู้ใช้เติมเครดิตแล้ว
ได้ส่วนแบ่งรายได้โอนเข้าบัญชีเครดิตของคุณทันทีที่ขายได้

1 เริ่มต้นใน 5 นาที

  1. ขอ โทเคน และลงทะเบียน ผลิตภัณฑ์ + สินค้า (SKU) จากผู้ดูแลเว็บ (ดู หัวข้อ 3)
  2. เก็บโทเคนไว้ใน environment variable ของเซิร์ฟเวอร์คุณ DEV365TH_BILLING_TOKEN
  3. ลองรันคำสั่งด้านล่างจากเครื่องเซิร์ฟเวอร์ของคุณ
bash
# 1) ดูสินค้าที่คุณลงทะเบียนไว้ (ยืนยันว่าโทเคนใช้ได้)
curl -s -H "Authorization: Bearer $TOKEN" \
  https://dev365th.com/api/v1/billing/skus

# 2) เช็คยอดเครดิตของผู้ใช้ ก่อนแสดงปุ่มซื้อ
curl -s -H "Authorization: Bearer $TOKEN" \
  "https://dev365th.com/api/v1/billing/balance?memberId=$MEMBER_ID"

# 3) ตัดเครดิต (Idempotency-Key กันหักซ้ำเวลาเน็ตหลุด)
curl -s -X POST https://dev365th.com/api/v1/billing/charge \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1001" \
  -d '{"memberId":"'$MEMBER_ID'","sku":"sword_legendary","qty":1}'

# 4) ตรวจสิทธิ์ทุกครั้งที่ผู้ใช้จะใช้ของ (ห้ามเชื่อฝั่งเบราว์เซอร์)
curl -s -H "Authorization: Bearer $TOKEN" \
  "https://dev365th.com/api/v1/billing/entitlement?memberId=$MEMBER_ID&sku=sword_legendary"

$MEMBER_ID คือรหัสสมาชิกของ Dev365TH เรียนออนไลน์ — ผลิตภัณฑ์ของคุณจะได้รับค่านี้ตอนผู้ใช้เข้าใช้งานผ่านเว็บ (ไม่ใช่ id ในระบบคุณเอง)

2 แนวคิดที่ต้องเข้าใจ

เครดิต
สกุลเงินกลางของเว็บ เป็นจำนวนเต็มเสมอ ผู้ใช้เติมด้วยเงินจริงที่ /credits แล้วใช้ซื้อของได้ทุกผลิตภัณฑ์
ผลิตภัณฑ์ (merchant)
ตัวเกม/เซอร์วิสของคุณ · โทเคน 1 ตัวผูกกับ 1 ผลิตภัณฑ์ จึงขายในนามผลิตภัณฑ์อื่นไม่ได้
สินค้า (SKU)
ของที่ขาย เช่น sword_legendary · ตั้งราคาและโมเดลการขายไว้ล่วงหน้า คุณส่งแค่ชื่อ SKU ไม่ต้องส่งราคา (กันฝั่งผู้เรียกแก้ราคาเอง)
สิทธิ์ (entitlement)
สิ่งที่ผู้ใช้ได้รับหลังจ่าย — ระบบบันทึกให้ คุณถามผ่าน API ทุกครั้งก่อนปล่อยของ
Idempotency-Key
รหัสที่คุณสร้างต่อ 1 เจตนาซื้อ · ยิงซ้ำด้วยคีย์เดิมจะไม่หักเงินซ้ำ แต่ได้ผลลัพธ์เดิมกลับมาพร้อม replayed: true

3 ขอโทเคน

ติดต่อผู้ดูแลเว็บเพื่อขอ พร้อมแจ้ง:

  • ชื่อผลิตภัณฑ์ และรหัสสั้นที่ต้องการ (เช่น dungeon-game)
  • ประเภท — เกม หรือเว็บเซอร์วิส
  • รายการสินค้า ที่จะขาย: ชื่อ SKU, ชื่อที่แสดง, ราคาเครดิต, โมเดลการขาย (ดูหัวข้อ 4)
  • บัญชีสมาชิก ของคุณบนเว็บ (สำหรับรับส่วนแบ่งรายได้)

โทเคนใช้ตัดเงินได้ — เก็บไว้ฝั่งเซิร์ฟเวอร์เท่านั้น ห้าม commit ลง git ห้ามฝังในแอปมือถือหรือ JavaScript ที่เบราว์เซอร์เห็น ถ้าหลุดให้แจ้งเพิกถอนทันที

4 โมเดลการขาย 4 แบบ

ชนิดเหมาะกับพฤติกรรมตัวอย่าง
onceซื้อครั้งเดียวไอเทม สกิน ปลดล็อกด่าน — ซื้อซ้ำได้ จำนวนสะสมขึ้นดาบ, ตัวละคร, ปลดล็อกบท
timedมีอายุสิทธิ์หมดอายุตามชั่วโมงที่ตั้ง · ซื้อซ้ำก่อนหมด = ต่อเวลาจากเดิม ไม่ริบบัฟ 24 ชม., ตั๋วเข้าเล่น 7 วัน
periodสมาชิกรายรอบเหมือน timed แต่สื่อความเป็นแพ็กเกจรายเดือน/รายปีBattle Pass, VIP รายเดือน
meteredตามการใช้งานคิดตามจำนวนหน่วยที่ใช้ · รายงานผ่าน POST /usageคำใบ้ AI, เรนเดอร์, ข้อความ

5 ตัวช่วยเรียก API (คัดลอกไปใช้ได้เลย)

ไฟล์เดียวครอบทุก endpoint พร้อมจัดการกรณีเว็บตอบ HTML แทน JSON:

billing.js
// billing.js — เรียกจาก "เซิร์ฟเวอร์" ของคุณเท่านั้น ห้ามให้เบราว์เซอร์เห็นโทเคน
import { randomUUID } from 'node:crypto';

const BASE = 'https://dev365th.com/api/v1/billing';
const TOKEN = process.env.DEV365TH_BILLING_TOKEN;

async function call(path, { method = 'GET', body, idemKey } = {}) {
  const headers = { Authorization: `Bearer ${TOKEN}` };
  if (body) headers['Content-Type'] = 'application/json';
  if (idemKey) headers['Idempotency-Key'] = idemKey;

  const res = await fetch(BASE + path, {
    method,
    headers,
    body: body ? JSON.stringify(body) : undefined,
  });

  // เว็บอาจตอบ HTML ได้ (เช่น 503 ตอนปิดปรับปรุง) → กัน JSON.parse พังทั้งระบบ
  const text = await res.text();
  let data = null;
  try { data = JSON.parse(text); } catch { /* ไม่ใช่ JSON */ }

  if (!res.ok) {
    const err = new Error(data?.error?.message || text.slice(0, 200));
    err.code = data?.error?.code || `http_${res.status}`;
    err.status = res.status;
    throw err;
  }
  return data;
}

export const listSkus    = ()                   => call('/skus');
export const getBalance  = (memberId)           => call(`/balance?memberId=${memberId}`);
export const getEntitlement = (memberId, sku)   => call(`/entitlement?memberId=${memberId}&sku=${sku}`);
export const refund      = (chargeId)           => call('/refund', { method: 'POST', body: { chargeId } });

export const charge = (memberId, sku, qty = 1, idemKey = randomUUID()) =>
  call('/charge', { method: 'POST', body: { memberId, sku, qty }, idemKey });

export const reportUsage = (memberId, sku, qty, idemKey) =>
  call('/usage', { method: 'POST', body: { memberId, sku, qty }, idemKey });

6 ตัวอย่างจริง: ผู้เล่นซื้อไอเทม

server.js
// ตัวอย่างจริง: ผู้เล่นกดซื้อดาบในเกม
import { charge, getEntitlement } from './billing.js';

app.post('/shop/buy', async (req, res) => {
  const { memberId } = req.session;          // ผู้ใช้ที่ล็อกอินในเกมของคุณ
  const { sku, orderId } = req.body;

  try {
    // ★ orderId ของคุณเป็น Idempotency-Key ที่ดีที่สุด — ผู้เล่นกดปุ่มรัว
    //   หรือเน็ตหลุดแล้ว retry ก็จะหักเงินครั้งเดียว
    const r = await charge(memberId, sku, 1, `order-${orderId}`);

    if (r.replayed) {
      // คำขอนี้เคยสำเร็จแล้ว (ไม่ได้หักเงินใหม่) — ปล่อยของได้เลยถ้ายังไม่ปล่อย
      console.log('คำขอซ้ำ ใช้ผลเดิม', r.chargeId);
    }
    await giveItemToPlayer(memberId, sku);    // ระบบของคุณ
    return res.json({ ok: true, balance: r.balance });

  } catch (e) {
    if (e.status === 402) {
      // เครดิตไม่พอ — พาไปเติมที่เว็บ แล้วกลับมาซื้อต่อ
      return res.status(402).json({
        error: 'เครดิตไม่พอ',
        topupUrl: 'https://dev365th.com/credits',
      });
    }
    if (e.code === 'sku_inactive') return res.status(409).json({ error: 'สินค้านี้ปิดขายชั่วคราว' });
    throw e;
  }
});

// ★ ตรวจสิทธิ์ "ทุกครั้ง" ที่ผู้เล่นจะใช้ของ ไม่ใช่เชื่อ state ในเบราว์เซอร์
app.get('/play/use-sword', async (req, res) => {
  const ent = await getEntitlement(req.session.memberId, 'sword_legendary');
  if (!ent.has) {
    return res.status(403).json({
      error: ent.expired ? 'สิทธิ์หมดอายุแล้ว' : 'ยังไม่ได้ซื้อไอเทมนี้',
    });
  }
  // ปล่อยให้ใช้ได้
});

7 รายการ API

ทุก endpoint อยู่ใต้ https://dev365th.com/api/v1/billing และต้องส่ง header Authorization: Bearer <โทเคน>

MethodPathใช้ทำอะไรพารามิเตอร์
POST/chargeตัดเครดิต + มอบสิทธิ์memberId, sku, qty?, idemKey?, note?
GET/entitlementถามสิทธิ์ (ไม่ส่ง sku = ดูทั้งหมด)?memberId=&sku=
GET/balanceยอดเครดิตของผู้ใช้?memberId=
GET/skusแคตตาล็อก + ราคาของคุณ?all=1 รวมที่ปิดขาย
POST/usageรายงานการใช้งาน (สินค้าแบบ metered)memberId, sku, qty
POST/refundคืนเครดิต + ถอนสิทธิ์chargeId

8 จัดการข้อผิดพลาด

ทุก error ตอบรูปแบบเดียวกัน — อ่าน error.code ไม่ใช่ข้อความ (ข้อความอาจเปลี่ยน):

json
{ "error": { "code": "insufficient", "message": "เครดิตไม่พอ …" } }
codeHTTPเกิดเมื่อทำอย่างไร
unauthorized401ไม่ได้ส่ง header Authorizationส่ง `Authorization: Bearer <โทเคน>`
invalid_token401โทเคนผิด/ถูกเพิกถอนขอโทเคนใหม่จากผู้ดูแล
token_expired403โทเคนหมดอายุขอต่ออายุ
insufficient_scope403โทเคนไม่มีสิทธิ์ billingขอโทเคน scope `billing`
merchant_suspended403ผลิตภัณฑ์ถูกระงับติดต่อผู้ดูแลเว็บ
insufficient402ผู้ใช้เครดิตไม่พอพาผู้ใช้ไปเติมที่ /credits แล้วลองใหม่
sku_not_found404ไม่มี SKU นี้ในผลิตภัณฑ์ของคุณตรวจชื่อ SKU ด้วย GET /skus
member_not_found404ไม่มีสมาชิกรหัสนี้ตรวจว่าใช้ memberId ของ dev365th ไม่ใช่ id ในระบบคุณ
charge_not_found404ไม่พบรายการ (หรือไม่ใช่ของผลิตภัณฑ์คุณ)ตรวจ chargeId
sku_inactive409สินค้าถูกปิดขายชั่วคราวซ่อนปุ่มซื้อ หรือแจ้งผู้ใช้
already_refunded409คืนเงินรายการนี้ไปแล้วถือว่าสำเร็จ ไม่ต้องคืนซ้ำ
invalid400ข้อมูลที่ส่งไม่ถูกรูปแบบตรวจ qty (1–1000) และชนิดข้อมูล
rate_limited429เรียกถี่เกินโควตาอ่าน header `Retry-After` แล้วรอ

9 กฎที่ต้องทำ

ทำ
  • ส่ง Idempotency-Key ทุกครั้งที่ตัดเงิน (ใช้ order id ของคุณ)
  • ตรวจสิทธิ์ผ่าน API ทุกครั้งที่ผู้ใช้จะใช้ของ
  • เก็บ chargeId ไว้อ้างอิงตอนคืนเงิน
  • จัดการ 402 ด้วยการพาผู้ใช้ไปเติมเครดิต
  • เรียก API จากเซิร์ฟเวอร์ของคุณเท่านั้น
ห้าม
  • เชื่อสถานะสิทธิ์ที่ส่งมาจากเบราว์เซอร์/แอปผู้ใช้
  • ส่งราคาเองในคำขอ (ราคามาจาก SKU ที่ลงทะเบียนไว้)
  • ฝังโทเคนในโค้ดฝั่งผู้ใช้ หรือ commit ลง git
  • สร้าง Idempotency-Key ใหม่ทุกครั้งที่ retry คำขอเดิม
  • ถือว่า response เป็น JSON เสมอโดยไม่ try/catch

10 เช็กลิสต์ก่อนเปิดใช้จริง

  • โทเคนอยู่ใน env ของเซิร์ฟเวอร์ ไม่อยู่ใน git และไม่หลุดถึงเบราว์เซอร์
  • ทุกจุดที่ตัดเงินส่ง Idempotency-Key ที่ผูกกับคำสั่งซื้อจริง
  • ทดลองยิงคำขอเดิมซ้ำ 2 ครั้ง → เงินถูกหักครั้งเดียว
  • ทดลองกับผู้ใช้ที่เครดิตไม่พอ → ได้ 402 และ UI พาไปเติมเงินถูกทาง
  • ทุกจุดที่ปล่อยของเรียกตรวจสิทธิ์ก่อน ไม่มีทางลัด
  • รองรับ 429 ด้วยการรอตาม Retry-After
  • บันทึก chargeId ทุกรายการลงฐานข้อมูลของคุณ
  • ทดลองคืนเงิน 1 รายการ → สิทธิ์ถูกถอนและเครดิตกลับถึงผู้ใช้

11 คำถามที่พบบ่อย

ผู้ใช้เครดิตไม่พอ ต้องทำอย่างไร

API ตอบ 402 พร้อม code insufficient — พาผู้ใช้ไปหน้า /credits เพื่อเติมเครดิต แล้วให้กดซื้อใหม่ (ใช้ Idempotency-Key ตัวเดิมได้ ปลอดภัย)

ยิงคำขอไปแล้วเน็ตหลุด ไม่รู้ว่าตัดเงินสำเร็จไหม

ยิงซ้ำด้วย Idempotency-Key เดิม ถ้าครั้งแรกสำเร็จไปแล้ว จะได้ผลลัพธ์เดิมกลับมาพร้อม replayed: true โดยไม่หักเงินซ้ำ ถ้าครั้งแรกไม่สำเร็จ ระบบจะดำเนินการให้ปกติ

ผมได้ส่วนแบ่งรายได้เท่าไร และได้เมื่อไร

ตามอัตราที่ตกลงกับผู้ดูแลเว็บ (ตั้งได้ทั้งค่ากลางและรายผลิตภัณฑ์) ส่วนแบ่งจะโอนเข้าบัญชีเครดิตของคุณทันทีในทรานแซกชันเดียวกับการขาย — ตรวจได้ที่ประวัติเครดิตในบัญชีของคุณ

เปลี่ยนราคาสินค้าได้ไหม

ได้ — แจ้งผู้ดูแลเว็บแก้ที่ SKU ราคาใหม่มีผลทันทีกับการซื้อครั้งถัดไป · ให้ผลิตภัณฑ์ของคุณดึงราคาจาก GET /skus แทนการ hardcode เพื่อให้แสดงราคาตรงกับที่ตัดจริงเสมอ

ทดสอบได้ไหม ไม่อยากใช้เงินจริง

ยังไม่มีสภาพแวดล้อม sandbox แยก — วิธีที่ใช้ตอนนี้คือขอผลิตภัณฑ์ทดสอบแยกหนึ่งตัวพร้อม SKU ราคาต่ำ (เช่น 1 เครดิต) และให้ผู้ดูแลเติมเครดิตทดสอบให้บัญชีที่ใช้ทดสอบ

สิทธิ์แบบมีอายุ ถ้าซื้อซ้ำก่อนหมดอายุจะเสียเวลาที่เหลือไหม

ไม่เสีย — ระบบต่อเวลาจากวันหมดอายุเดิม (เช่นเหลือ 5 ชม. ซื้อเพิ่ม 24 ชม. → เหลือ 29 ชม.)

สิ่งที่ยังไม่รองรับ
  • ถอนส่วนแบ่งเป็นเงินจริง — ปัจจุบันได้เป็นเครดิตในเว็บ
  • ตัดบัตรเครดิตตรงจากผลิตภัณฑ์ — ผู้ใช้ต้องเติมเครดิตที่เว็บก่อน
  • webhook แจ้งผลกลับ — ยังไม่ส่ง callback ให้ผลิตภัณฑ์ (ให้เชื่อผลจาก response ของ API)
  • sandbox แยก — ใช้ผลิตภัณฑ์ทดสอบแทน

มีคำถามหรือต้องการขอโทเคน — ติดต่อเรา