คู่มือ API SMSCode: เชื่อมต่อเบอร์เสมือนเข้ากับระบบของคุณ

คู่มือ API SMSCode: เชื่อมต่อเบอร์เสมือนเข้ากับระบบของคุณ

TL;DR: SMSCode มี REST API แบบ Bearer auth ราคาและ stock ปัจจุบันอ่านจาก catalog เป็นจำนวนเต็ม IDR รองรับ polling และ webhook และกำหนดให้ paid create ทุกครั้งใช้ Idempotency-Key ที่คงที่กับ request body เดิมจนกว่าจะทราบผลแน่ชัด


ถ้าคุณเป็นนักพัฒนาและต้องการรวมระบบเบอร์เสมือนเข้ากับแอปหรือระบบของคุณ SMSCode API คือเครื่องมือที่ถูกออกแบบมาเพื่อคุณโดยเฉพาะ บทความนี้จะพาคุณจากศูนย์จนถึงการรัน API call แรกที่ใช้งานได้จริงในเวลาไม่ถึงครึ่งชั่วโมง พร้อม code example ในหลายภาษาและแนวทางปฏิบัติที่ดีที่สุดสำหรับ production

ทำไมนักพัฒนาถึงต้องการ API เบอร์เสมือน

ก่อนลงรายละเอียด มาทำความเข้าใจก่อนว่าทำไม API เบอร์เสมือนจึงเป็นสิ่งจำเป็นสำหรับงานพัฒนาสมัยใหม่

กรณีการใช้งานที่พบบ่อย:

  • การทดสอบระบบ OTP อัตโนมัติ — ทีม QA ต้องการทดสอบ flow การยืนยัน SMS โดยไม่ต้องใช้เบอร์จริงของทีม ซึ่งจะรกและยุ่งยากมากถ้าต้องทดสอบหลายร้อยครั้ง
  • การสร้างบัญชีผู้ใช้จำนวนมากสำหรับทดสอบ — นักพัฒนาต้องการ seed data ที่มีผู้ใช้จริงๆ เพื่อทดสอบ feature ใหม่
  • โปรแกรม scraper หรือ automation ที่ถูกกฎหมาย — ระบบที่ต้องสร้างบัญชีในแพลตฟอร์มต่างๆ โดยอัตโนมัติสำหรับธุรกิจ
  • บริการ multi-account management — แอปที่ช่วยผู้ใช้จัดการหลายบัญชีในแพลตฟอร์มเดียวกัน
  • ระบบ SaaS ที่ต้องการเบอร์โทรศัพท์ — สร้างเบอร์เสมือนให้ผู้ใช้ปลายทางของคุณ

ทำไมต้องใช้ SMSCode API?

SMSCode API ช่วยให้ระบบของคุณอ่านรายการสินค้า ราคา และจำนวนคงเหลือปัจจุบันจาก catalog ก่อนสร้างออเดอร์:

  • Catalog เป็นแหล่งข้อมูลปัจจุบัน — ตรวจสอบประเทศ แพลตฟอร์ม ราคา IDR และจำนวนที่พร้อมใช้ก่อนทุกออเดอร์
  • Webhook real-time — รับเหตุการณ์ SMS โดยไม่ต้อง polling ตลอดเวลา
  • Idempotency สำหรับ paid create — ใช้ key และ request body เดิมเมื่อผลลัพธ์ไม่ชัดเจน
  • สถานะจากเซิร์ฟเวอร์ — ตัดสินใจยกเลิกจาก can_cancel และสถานะล่าสุด ไม่เดาจากเวลาใน client
  • ตัวอย่างหลายภาษา — ใช้ REST และ JSON กับ HTTP client ที่คุณเลือก

เปรียบเทียบกับคู่แข่งได้ในบทความ SMSCode vs SMS-Activate

ขั้นตอนเริ่มต้น: จากศูนย์ถึง API call แรก

ขั้นที่ 1: สมัครบัญชีและรับ API Key

เข้าไปที่ SMSCode.gg แล้วสมัครบัญชีฟรี ไม่มีค่าใช้จ่ายในการสมัคร

หลังจากยืนยันอีเมลแล้ว:

  1. เข้าสู่ระบบและไปที่ Account → API Settings
  2. กด “Generate API Key”
  3. คัดลอก API key ที่ได้และเก็บไว้อย่างปลอดภัย

สำคัญมาก: API key ของคุณเทียบเท่ารหัสผ่าน ห้ามทำสิ่งเหล่านี้:

  • ห้าม hardcode ใน source code ที่จะ push ขึ้น Git
  • ห้ามใส่ใน .env ที่ไม่ได้ gitignore ไว้
  • ห้าม share ใน Slack, Discord, หรือที่สาธารณะ
  • ห้ามใส่ใน URL parameters

วิธีที่ถูกต้อง: เก็บไว้ใน environment variable เช่น SMSCODE_API_KEY แล้วโหลดจาก .env ที่อยู่ใน .gitignore

ขั้นที่ 2: เติมเงินเข้าบัญชี

API ทำงานบนระบบ prepaid เติมเงินผ่านช่องทางที่แสดงในหน้า Deposit ของบัญชี ช่องทางและเงื่อนไขอาจเปลี่ยนได้ จึงควรตรวจสอบจากหน้านั้นก่อนชำระเงิน

ดูรายละเอียดช่องทางชำระเงินทั้งหมดในคู่มือการชำระเงิน SMSCode

ขั้นที่ 3: ทดสอบ API แรก

ทดสอบด้วย curl ก่อนเพื่อตรวจสอบว่า key ใช้งานได้และยอดเงินแสดงถูกต้อง:

curl -X GET "https://api.smscode.gg/v1/balance" \
  -H "Authorization: Bearer YOUR_API_KEY"

ผลลัพธ์ที่คาดหวัง:

{
  "success": true,
  "data": {
    "currency": "IDR",
    "balance": 100000
  }
}

ถ้าได้ success: true และยอดเงินถูกต้อง แสดงว่า API key ใช้งานได้แล้ว ยินดีด้วย!

API Endpoints หลักและตัวอย่างการใช้งาน

SMSCode API เป็น RESTful API มาตรฐาน ทุก request ต้องมี header Authorization: Bearer YOUR_API_KEY และ response เป็น JSON เสมอ

GET /v1/catalog/products — ดูบริการและราคา

ใช้ก่อนสั่งเบอร์เพื่อเช็คว่ามีเบอร์ว่างสำหรับ service และ country ที่ต้องการ

curl -X GET "https://api.smscode.gg/v1/catalog/products" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -G \
  --data-urlencode "country_id=7" \
  --data-urlencode "platform_id=1"

ผลลัพธ์จะแสดงรายการเบอร์ที่ว่างอยู่ ราคาปัจจุบัน และจำนวนที่เหลืออยู่ ทำให้คุณวางแผนงบประมาณได้ล่วงหน้า

Service codes ที่ใช้บ่อย:

  • line — LINE
  • wa — WhatsApp
  • tg — Telegram
  • google — Google Account
  • fb — Facebook
  • ig — Instagram
  • tt — TikTok
  • shopee — Shopee
  • chatgpt — ChatGPT

Country codes ที่ใช้บ่อยสำหรับนักพัฒนาไทย:

  • th — ไทย
  • id — อินโดนีเซีย
  • my — มาเลเซีย
  • us — สหรัฐอเมริกา
  • gb — สหราชอาณาจักร
  • ru — รัสเซีย

POST /v1/orders/create — สั่งเบอร์เสมือน

เมื่อเช็ค catalog แล้วพร้อมสั่ง:

curl --connect-timeout 5 --max-time 30 -X POST "https://api.smscode.gg/v1/orders/create" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-example-001" \
  -d '{
    "catalog_product_id": 88,
    "quantity": 1
  }'

ผลลัพธ์:

{
  "success": true,
  "data": {
    "orders": [{
      "id": 90210,
      "status": "ACTIVE",
      "phone_number": "+66812345678",
      "otp_code": null,
      "otp_received_at": null,
      "expires_at": "2026-03-16T10:30:00Z",
      "failed_reason": null,
      "product_id": 1024,
      "catalog_product_id": 88,
      "operator_id": null,
      "operator_name": null,
      "amount": 750000,
      "can_finish": false,
      "can_resend": false,
      "can_cancel": false,
      "can_replace": false,
      "can_reactivate": false,
      "resend_available_at": null,
      "cancel_available_at": "2026-03-16T10:02:00Z",
      "replace_available_at": "2026-03-16T10:02:00Z"
    }],
    "failed_count": 0
  }
}

เก็บ id ไว้เพื่อตรวจสอบสถานะภายหลัง phone_number เป็น field แบบ optional/nullable: หาก create response มี string ที่ไม่ว่าง ให้ใช้ได้ทันทีโดยไม่ต้อง GET เพิ่ม แต่หาก field หายไป เป็น null หรือว่าง ให้ทำ bounded GET ด้วย id เดิมได้หนึ่งครั้งเท่านั้น หากยังไม่มีเบอร์, identity ไม่ตรง หรือ GET/decode ล้มเหลว ให้คืน pending_assignment พร้อม order_id และสถานะที่ resolve แล้วก่อนนำเบอร์ไปลงทะเบียนกับปลายทาง, polling หรือ cancel ผล create ยังคงเป็น resolved; ห้ามจัดกรณีนี้เป็น needs_reconciliation, ห้าม replay paid POST และห้ามอนุมานข้อมูล refund หากผลลัพธ์ของ request แรกไม่ชัดเจน ให้ส่งซ้ำด้วย Idempotency-Key และ JSON body เดิมเท่านั้น

Parameter ที่รองรับ:

  • catalog_product_id (จำเป็น) — ID ที่ได้จาก catalog
  • quantity — จำนวนเบอร์ ในตัวอย่างนี้คือหนึ่ง

GET /v1/orders/{id} — ตรวจสอบสถานะและรับ SMS

curl --connect-timeout 5 --max-time 30 -X GET "https://api.smscode.gg/v1/orders/90210" \
  -H "Authorization: Bearer YOUR_API_KEY"

projection ของ V1OrderSummary เมื่อ SMS มาถึง (เฉพาะ field ที่เลือก ไม่ใช่ wire response ทั้งหมด):

{
  "success": true,
  "data": {
    "id": 90210,
    "phone_number": "+66812345678",
    "status": "OTP_RECEIVED",
    "otp_code": null,
    "otp_message": "Your LINE verification code is 123456",
    "sms_revision": 1,
    "can_cancel": false
  }
}

สถานะที่เป็นไปได้:

  • ACTIVE — รอรับ SMS
  • OTP_RECEIVED — เซิร์ฟเวอร์รายงานว่าได้รับข้อความแล้ว; otp_code อาจเป็น null
  • COMPLETED — ออเดอร์เสร็จสมบูรณ์ หลังตรวจหลักฐาน SMS revision ใหม่แล้วจึงหยุด polling
  • CANCELED — ถูกยกเลิก
  • EXPIRED — หมดเวลา (เงินถูกคืนอัตโนมัติ)

consumer ของ SMS ต้องเริ่ม last_seen_revision ที่ -1 และรับข้อความเฉพาะเมื่อ response มี sms_revision เป็น integer แบบ explicit ที่มากกว่าสถานะของ caller พร้อม otp_message string ที่ไม่ว่าง ให้ consume ข้อความและอัปเดต revision ก่อนตรวจ terminal status เสมอ โดยไม่ผูกกับ OTP_RECEIVED หรือ otp_code; boolean, revision ที่เก่าหรือเท่ากัน และข้อความว่างไม่ทำให้ revision เดินหน้า

POST /v1/orders/cancel — ยกเลิกออเดอร์

curl --connect-timeout 5 --max-time 30 -X POST "https://api.smscode.gg/v1/orders/cancel" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"id":90210}'

ยกเลิกเฉพาะเมื่อ snapshot ล่าสุดมี can_cancel: true หากได้รับ SMS แบบ durable แล้วจะยกเลิกไม่ได้

ตัวอย่างโค้ดแบบครบวงจร

CreateAttemptStore ด้านล่างไม่ใช่ key-value store ทั่วไป: caller ต้องบันทึก caller_scope ที่ไม่ใช่ secret, business_job_id, Idempotency-Key และ absolute review_threshold_at ตาม store clock ไว้ใน application-job record แบบ durable ก่อนเรียก helper Store ต้องทำ atomic UNIQUE insert-or-load บน (caller_scope, business_job_id) และผูก pair นี้กับ fingerprint ของ endpoint ที่เปลี่ยนไม่ได้และ canonical JSON body โดย prepare บันทึก started_at, sends=0 และ effective_replay_deadline_at = min(review_threshold_at, started_at + CLIENT_REPLAY_MAX) เพียงครั้งเดียว

CLIENT_REPLAY_MAX = 3600 คือ budget ความปลอดภัยของ client หนึ่งชั่วโมง ไม่ใช่คำรับรองเวลาเก็บข้อมูลหรือ takeover ภายในเซิร์ฟเวอร์ Caller ลด threshold ให้สั้นลงได้แต่ยืดเกิน budget นี้ไม่ได้ และ MAX_AUTOMATIC_SENDS = 32 จำกัดจำนวน paid POST อีกชั้น Store ต้องตรวจ deadline กับ send count แบบ atomic ทั้งก่อน claim และก่อน POST; เมื่อค่าเท่ากับหรือเกินขอบเขตให้ append operator_review ด้วย CAS และส่ง POST เป็นศูนย์ ครั้งที่ส่งจริงต้องเพิ่ม sends ใน sending CAS ก่อน I/O ทุก ambiguity เข้า needs_reconciliation โดย persist next_attempt_at จาก store clock ด้วยลำดับ 2, 4, 8, 16, 32 และสูงสุด 60 วินาที พร้อม replay endpoint, body และ key เดิมทุก byte ห้ามใช้ in-process timer ห้ามทิ้งงานหรือสร้าง paid request ใหม่

Python — รอรับ OTP แบบ polling

import os
import hashlib
import json
import math
import time
import requests
from urllib3.util import Timeout
from typing import Optional, Dict, Any
from myapp.attempts import durable_create_attempt_store

API_KEY = os.environ.get("SMSCODE_API_TOKEN", "your_api_key_here")
BASE_URL = "https://api.smscode.gg/v1"
HEADERS = {
    "Authorization": f"Bearer {API_KEY}",
    "Content-Type": "application/json",
}

DEFINITIVE_CREATE_ERRORS = {
    "NO_OFFER_AVAILABLE",
    "VALIDATION_ERROR",
    "PROVIDER_ERROR",
    "IDEMPOTENCY_KEY_REUSED",
}
ORDER_STATUSES = {
    "ACTIVE", "OTP_RECEIVED", "COMPLETED", "CANCELED", "EXPIRED"
}
V1_CREATE_ITEM_REQUIRED_FIELDS = {
    "id", "status", "product_id", "amount", "otp_code", "can_finish",
    "can_resend", "can_cancel", "can_replace", "can_reactivate",
    "resend_available_at", "cancel_available_at", "replace_available_at",
}
V1_CREATE_ITEM_FIELDS = {
    "id", "status", "product_id", "amount", "otp_code", "can_finish",
    "can_resend", "can_cancel", "can_replace", "can_reactivate",
    "resend_available_at", "cancel_available_at", "replace_available_at",
    "phone_number", "otp_received_at", "expires_at", "failed_reason",
    "catalog_product_id", "operator_id", "operator_name",
}
V1_CREATE_NULLABLE_STRING_FIELDS = {
    "phone_number", "otp_code", "otp_received_at", "expires_at",
    "failed_reason", "operator_name", "resend_available_at",
    "cancel_available_at", "replace_available_at",
}
V1_CREATE_BOOLEAN_FIELDS = {
    "can_finish", "can_resend", "can_cancel", "can_replace", "can_reactivate",
}
CANCEL_ERROR_CODES_BY_STATUS = {
    401: {"UNAUTHORIZED"},
    404: {"NOT_FOUND"},
    409: {"CONFLICT", "CANCEL_TOO_EARLY"},
    422: {"PROVIDER_ERROR", "VALIDATION_ERROR"},
    429: {"RATE_LIMIT_EXCEEDED"},
    500: {"INTERNAL_ERROR"},
    503: {"SERVICE_UNAVAILABLE"},
}
CREATE_CONNECT_TIMEOUT_SECONDS = 5
CREATE_TOTAL_TIMEOUT_SECONDS = 30
CREATE_LEASE_SECONDS = 45
RECONCILIATION_BASE_BACKOFF_SECONDS = 2
RECONCILIATION_MAX_BACKOFF_SECONDS = 60
CLIENT_REPLAY_MAX = 3600
MAX_AUTOMATIC_SENDS = 32


def _is_int32(value: Any) -> bool:
    return type(value) is int and -(2 ** 31) <= value <= (2 ** 31) - 1


def _is_int64(value: Any) -> bool:
    return type(value) is int and -(2 ** 63) <= value <= (2 ** 63) - 1


def is_v1_error_response(value: Any) -> bool:
    if (
        type(value) is not dict
        or value.get("success") is not False
        or set(value) != {"success", "error"}
        or type(value["error"]) is not dict
    ):
        return False
    error = value["error"]
    fields = set(error)
    return (
        {"code", "message"} <= fields <= {"code", "message", "details"}
        and type(error["code"]) is str
        and type(error["message"]) is str
        and ("details" not in error or type(error["details"]) is dict)
    )


def validate_v1_create_order_result(value: Any) -> Dict[str, Any]:
    """ตรวจสอบ closed OpenAPI V1CreateOrderResult ให้ครบทุกฟิลด์"""
    if type(value) is not dict or set(value) != {"orders", "failed_count"}:
        raise ValueError("ฟิลด์ของ V1CreateOrderResult ไม่ถูกต้อง")
    if type(value["orders"]) is not list:
        raise ValueError("orders ต้องเป็น array")
    if not _is_int32(value["failed_count"]) or value["failed_count"] < 0:
        raise ValueError("failed_count ต้องเป็น int32 ที่ไม่ติดลบ")

    for item in value["orders"]:
        if type(item) is not dict:
            raise ValueError("แต่ละ order ต้องเป็น object")
        fields = set(item)
        if not V1_CREATE_ITEM_REQUIRED_FIELDS <= fields:
            raise ValueError("V1CreateOrderItem ขาดฟิลด์ที่จำเป็น")
        if not fields <= V1_CREATE_ITEM_FIELDS:
            raise ValueError("V1CreateOrderItem มีฟิลด์ที่ไม่รู้จัก")
        if not _is_int32(item["id"]) or not _is_int32(item["product_id"]):
            raise ValueError("order ID และ product ID ต้องเป็น int32")
        for field in ("catalog_product_id", "operator_id"):
            if field in item and item[field] is not None and not _is_int32(item[field]):
                raise ValueError(f"{field} ต้องเป็น nullable int32")
        if type(item["status"]) is not str or item["status"] not in ORDER_STATUSES:
            raise ValueError("สถานะออเดอร์ไม่อยู่ในสัญญา")
        if not _is_int64(item["amount"]):
            raise ValueError("amount ต้องเป็น int64")
        for field in V1_CREATE_BOOLEAN_FIELDS:
            if type(item[field]) is not bool:
                raise ValueError(f"{field} ต้องเป็น boolean")
        for field in V1_CREATE_NULLABLE_STRING_FIELDS & fields:
            if item[field] is not None and type(item[field]) is not str:
                raise ValueError(f"{field} ต้องเป็น nullable string")

    return value


def _valid_order_snapshot(value: Any, order_id: int) -> bool:
    return (
        type(value) is dict
        and _is_int32(value.get("id"))
        and value["id"] == order_id
        and type(value.get("status")) is str
        and value["status"] in ORDER_STATUSES
        and "refund_amount" not in value
        and "new_balance" not in value
    )


def _validated_cancel_receipt(payload: Any, order_id: int) -> Optional[Dict]:
    if type(payload) is not dict or payload.get("success") is not True:
        return None
    if not set(payload) <= {"success", "data", "meta"} or "data" not in payload:
        return None
    data = payload["data"]
    if type(data) is not dict or set(data) != {
        "order_id", "status", "refund_amount", "new_balance"
    }:
        return None
    if not _is_int32(data["order_id"]) or data["order_id"] != order_id:
        return None
    if data["status"] != "CANCELED":
        return None
    if not _is_int64(data["refund_amount"]) or data["refund_amount"] < 0:
        return None
    if not _is_int64(data["new_balance"]):
        return None
    return data


def _documented_cancel_error(http_status: int, payload: Any) -> Optional[Dict]:
    if type(payload) is not dict or set(payload) != {"success", "error"}:
        return None
    error = payload.get("error")
    if payload["success"] is not False or type(error) is not dict:
        return None
    if not {"code", "message"} <= set(error) or not set(error) <= {
        "code", "message", "details"
    }:
        return None
    if type(error["code"]) is not str or type(error["message"]) is not str:
        return None
    if "details" in error and type(error["details"]) is not dict:
        return None
    if error["code"] not in CANCEL_ERROR_CODES_BY_STATUS.get(http_status, set()):
        return None
    return error

def _raw_request(method: str, endpoint: str, **kwargs):
    url = f"{BASE_URL}{endpoint}"
    request_headers = {**HEADERS, **kwargs.pop("headers", {})}
    kwargs.setdefault(
        "timeout",
        Timeout(
            connect=CREATE_CONNECT_TIMEOUT_SECONDS,
            total=CREATE_TOTAL_TIMEOUT_SECONDS,
        ),
    )
    return requests.request(method, url, headers=request_headers, **kwargs)

def _decode_json(response) -> Any:
    return json.loads(response.content.decode("utf-8", errors="strict"))

def _request(method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
    """ส่งคำขอ API ด้วย connect deadline 5 วินาทีและ total deadline 30 วินาที"""
    response = _raw_request(method, endpoint, **kwargs)
    data = _decode_json(response)
    if (
        response.status_code != 200
        or type(data) is not dict
        or data.get("success") is not True
        or "data" not in data
        or "error" in data
    ):
        error = data.get("error", {}) if type(data) is dict else {}
        error = error if type(error) is dict else {}
        raise Exception(f"API Error {error.get('code')}: {error.get('message')}")
    return data["data"]

def get_catalog(country_id: int, platform_id: int):
    """อ่านบริการและราคาที่พร้อมใช้"""
    return _request(
        "GET",
        "/catalog/products",
        params={"country_id": country_id, "platform_id": platform_id},
    )

def prepare_create_attempt(
    caller_scope: str,
    business_job_id: str,
    idempotency_key: str,
    review_threshold_at: int,
    catalog_product_id: int,
    quantity: int = 1,
) -> Dict:
    """สร้างหรือโหลด durable attempt เดิมจาก caller-owned identity"""
    if not caller_scope or not business_job_id or not idempotency_key:
        raise ValueError("ต้องมี caller identity และ persisted key")
    if type(review_threshold_at) is not int or review_threshold_at < 0:
        raise ValueError("review_threshold_at ต้องเป็น timestamp ของ store clock")
    if (
        not _is_int32(catalog_product_id)
        or not _is_int32(quantity)
        or quantity <= 0
    ):
        raise ValueError("catalog_product_id และ quantity ต้องเป็น int32 ที่ถูกต้อง")
    body = {"catalog_product_id": catalog_product_id, "quantity": quantity}
    endpoint = "/orders/create"
    body_json = json.dumps(body, separators=(",", ":"), sort_keys=True)
    request_fingerprint = hashlib.sha256(
        f"{endpoint}\0{body_json}".encode("utf-8")
    ).hexdigest()
    started_at = durable_create_attempt_store.now()
    candidate = {
        "caller_scope": caller_scope,
        "business_job_id": business_job_id,
        "endpoint": endpoint,
        "body_json": body_json,
        "request_fingerprint": request_fingerprint,
        "idempotency_key": idempotency_key,
        "review_threshold_at": review_threshold_at,
        "started_at": started_at,
        "effective_replay_deadline_at": min(
            review_threshold_at,
            started_at + CLIENT_REPLAY_MAX,
        ),
        "sends": 0,
        "attempt_history": [],
    }
    # หาก pair เดิมมี fingerprint, key หรือ review threshold ต่างกัน
    # store ต้อง conflict แบบ atomic ห้ามรับ candidate ใหม่ของ caller แบบเงียบ ๆ
    attempt = durable_create_attempt_store.insert_or_load(
        caller_scope,
        business_job_id,
        request_fingerprint,
        candidate,
    )
    _assert_attempt_binding(
        attempt,
        caller_scope,
        business_job_id,
        idempotency_key,
        review_threshold_at,
    )
    return attempt

def _assert_attempt_binding(
    attempt: Dict,
    caller_scope: str,
    business_job_id: str,
    idempotency_key: str,
    review_threshold_at: int,
) -> None:
    if type(attempt.get("started_at")) is not int or attempt["started_at"] < 0:
        raise ValueError("stored create attempt ไม่มี started_at ของ store clock")
    expected_deadline = min(
        review_threshold_at,
        attempt["started_at"] + CLIENT_REPLAY_MAX,
    )
    if attempt.get("effective_replay_deadline_at") != expected_deadline:
        raise ValueError("effective replay deadline ถูกเปลี่ยนหลัง prepare")
    if type(attempt.get("sends")) is not int or attempt["sends"] < 0:
        raise ValueError("stored sends ต้องเป็นจำนวนเต็มที่ไม่ติดลบ")
    try:
        persisted_body = json.loads(attempt["body_json"])
    except (KeyError, TypeError, ValueError):
        raise ValueError("stored create body ไม่ใช่ canonical JSON ที่ถูกต้อง")
    if (
        type(persisted_body) is not dict
        or set(persisted_body) != {"catalog_product_id", "quantity"}
        or not _is_int32(persisted_body["catalog_product_id"])
        or not _is_int32(persisted_body["quantity"])
        or persisted_body["quantity"] <= 0
    ):
        raise ValueError("stored create body ไม่ตรงกับ request contract")
    fingerprint = hashlib.sha256(
        f"{attempt['endpoint']}\0{attempt['body_json']}".encode("utf-8")
    ).hexdigest()
    expected = {
        "caller_scope": caller_scope,
        "business_job_id": business_job_id,
        "idempotency_key": idempotency_key,
        "review_threshold_at": review_threshold_at,
        "endpoint": "/orders/create",
        "request_fingerprint": fingerprint,
    }
    if any(attempt.get(field) != value for field, value in expected.items()):
        raise ValueError("stored create attempt ไม่ตรงกับ caller identity")

def _append_create_event(
    caller_scope: str,
    business_job_id: str,
    attempt: Dict,
    claim_token: str,
    event: Dict,
) -> Dict:
    # Store ต้อง append event และ project ฟิลด์ sends ใน event ไปยัง attempt
    # ภายใน CAS เดียวกัน ก่อนปล่อย lease หรือส่งงานเข้า due queue
    return durable_create_attempt_store.append_event(
        caller_scope,
        business_job_id,
        claim_token=claim_token,
        expected_version=attempt["version"],
        event=event,
    )

def _recovery_fields(attempt: Dict) -> Dict:
    return {
        "endpoint": attempt["endpoint"],
        "body_json": attempt["body_json"],
        "idempotency_key": attempt["idempotency_key"],
        "review_threshold_at": attempt["review_threshold_at"],
        "started_at": attempt["started_at"],
        "effective_replay_deadline_at": attempt["effective_replay_deadline_at"],
        "sends": attempt["sends"],
    }

def _backoff_seconds(attempt: Dict) -> int:
    send_count = attempt["sends"]
    exponent = min(max(send_count - 1, 0), 5)
    return min(
        RECONCILIATION_BASE_BACKOFF_SECONDS * (2 ** exponent),
        RECONCILIATION_MAX_BACKOFF_SECONDS,
    )

def _needs_reconciliation(
    caller_scope: str,
    business_job_id: str,
    attempt: Dict,
    claim_token: str,
    reason: str,
    **evidence,
) -> Dict:
    event = {
        "state": "needs_reconciliation",
        "reason": reason,
        **_recovery_fields(attempt),
        **evidence,
    }
    event["next_attempt_at"] = min(
        durable_create_attempt_store.now() + _backoff_seconds(attempt),
        attempt["effective_replay_deadline_at"],
    )
    attempt = _append_create_event(
        caller_scope, business_job_id, attempt, claim_token, event
    )
    return {"kind": "needs_reconciliation", "attempt": attempt}

def get_virtual_number(
    caller_scope: str,
    business_job_id: str,
    idempotency_key: str,
    review_threshold_at: int,
    attempt: Dict,
) -> Dict:
    """ส่งหนึ่งครั้งต่อหนึ่ง claim; durable worker จะทำ retry ภายหลัง"""
    _assert_attempt_binding(
        attempt, caller_scope, business_job_id,
        idempotency_key, review_threshold_at,
    )
    pre_now = durable_create_attempt_store.now()
    if (
        pre_now >= attempt["effective_replay_deadline_at"]
        or attempt["sends"] >= MAX_AUTOMATIC_SENDS
    ):
        # Store ต้องตรวจ version, persisted bounds และ store clock ซ้ำ แล้ว append
        # operator_review แบบ CAS เดียวโดยไม่ออก send claim
        attempt = durable_create_attempt_store.retain_operator_review(
            caller_scope,
            business_job_id,
            attempt,
            pre_now,
        )
        return {"kind": "operator_review", "attempt": attempt}
    claimed = durable_create_attempt_store.claim_for_send(
        caller_scope,
        business_job_id,
        expected_version=attempt["version"],
        lease_seconds=CREATE_LEASE_SECONDS,
    )
    attempt = claimed["attempt"]
    claim_token = claimed["claim_token"]
    _assert_attempt_binding(
        attempt, caller_scope, business_job_id,
        idempotency_key, review_threshold_at,
    )

    store_now = durable_create_attempt_store.now()
    if (
        store_now >= attempt["effective_replay_deadline_at"]
        or attempt["sends"] >= MAX_AUTOMATIC_SENDS
    ):
        attempt = _append_create_event(
            caller_scope,
            business_job_id,
            attempt,
            claim_token,
            {
                "state": "operator_review",
                "retained_at": store_now,
                **_recovery_fields(attempt),
            },
        )
        return {"kind": "operator_review", "attempt": attempt}

    send_number = attempt["sends"] + 1
    attempt = _append_create_event(
        caller_scope,
        business_job_id,
        attempt,
        claim_token,
        {
            **_recovery_fields(attempt),
            "number": send_number,
            "state": "sending",
            "last_send_at": store_now,
            "deadline_at": store_now + CREATE_TOTAL_TIMEOUT_SECONDS,
            "sends": send_number,
        },
    )
    if attempt.get("sends") != send_number:
        raise RuntimeError("store ไม่ได้ persist sends ใน sending CAS")
    try:
        response = _raw_request(
            "POST",
            attempt["endpoint"],
            data=attempt["body_json"],
            headers={"Idempotency-Key": attempt["idempotency_key"]},
        )
    except requests.RequestException:
        return _needs_reconciliation(
            caller_scope, business_job_id, attempt, claim_token, "transport"
        )

    if response.status_code >= 500:
        return _needs_reconciliation(
            caller_scope,
            business_job_id,
            attempt,
            claim_token,
            "http_5xx",
            http_status=response.status_code,
        )

    try:
        payload = _decode_json(response)
    except (UnicodeDecodeError, ValueError):
        return _needs_reconciliation(
            caller_scope,
            business_job_id,
            attempt,
            claim_token,
            "malformed_response",
            http_status=response.status_code,
        )

    if type(payload) is dict and payload.get("success") is False:
        error = payload.get("error")
        code = error.get("code") if type(error) is dict else None
        code = code if type(code) is str else None
        if not is_v1_error_response(payload):
            return _needs_reconciliation(
                caller_scope,
                business_job_id,
                attempt,
                claim_token,
                "malformed_response",
                http_status=response.status_code,
                error_code=code,
            )
        if response.status_code == 422 and code in DEFINITIVE_CREATE_ERRORS:
            attempt = _append_create_event(
                caller_scope,
                business_job_id,
                attempt,
                claim_token,
                {
                    "state": "definitive_rejection",
                    "http_status": response.status_code,
                    "error_code": code,
                },
            )
            return {"kind": "definitive_rejection", "attempt": attempt, "code": code}
        if response.status_code == 409 and code == "INSUFFICIENT_BALANCE":
            attempt = _append_create_event(
                caller_scope,
                business_job_id,
                attempt,
                claim_token,
                {
                    "state": "insufficient_balance",
                    "http_status": response.status_code,
                    "error_code": code,
                },
            )
            return {"kind": "insufficient_balance", "attempt": attempt}
        reason = (
            "request_in_progress"
            if response.status_code == 409 and code == "REQUEST_IN_PROGRESS"
            else "unknown_response"
        )
        return _needs_reconciliation(
            caller_scope,
            business_job_id,
            attempt,
            claim_token,
            reason,
            http_status=response.status_code,
            error_code=code,
        )

    if (
        response.status_code != 200
        or type(payload) is not dict
        or payload.get("success") is not True
        or "data" not in payload
        or "error" in payload
    ):
        return _needs_reconciliation(
            caller_scope,
            business_job_id,
            attempt,
            claim_token,
            "contradictory_response",
            http_status=response.status_code,
        )

    try:
        validated_result = validate_v1_create_order_result(payload["data"])
        persisted_quantity = json.loads(attempt["body_json"])["quantity"]
        if (
            len(validated_result["orders"])
            + validated_result["failed_count"]
            != persisted_quantity
        ):
            raise ValueError("create result ไม่อนุรักษ์ quantity ที่ persist ไว้")
    except (KeyError, TypeError, ValueError):
        return _needs_reconciliation(
            caller_scope,
            business_job_id,
            attempt,
            claim_token,
            "malformed_success",
            http_status=response.status_code,
        )

    attempt = _append_create_event(
        caller_scope,
        business_job_id,
        attempt,
        claim_token,
        {"state": "resolved", "validated_response": validated_result},
    )
    return {
        "kind": "resolved",
        "attempt": attempt,
        "result": validated_result,
    }

def reconcile_create_order(
    caller_scope: str,
    business_job_id: str,
    idempotency_key: str,
    review_threshold_at: int,
) -> Dict:
    """replay durable job ที่ถึงเวลาแล้วด้วย stored key/body เดิมทุก byte"""
    attempt = durable_create_attempt_store.load(caller_scope, business_job_id)
    _assert_attempt_binding(
        attempt, caller_scope, business_job_id,
        idempotency_key, review_threshold_at,
    )
    return get_virtual_number(
        caller_scope,
        business_job_id,
        idempotency_key,
        review_threshold_at,
        attempt,
    )

def get_order(order_id: int) -> Dict:
    """ตรวจสอบสถานะออเดอร์"""
    return _request("GET", f"/orders/{order_id}")

def _reconcile_cancellation(order_id: int) -> Dict:
    try:
        latest = get_order(order_id)
    except Exception:
        return {"kind": "ambiguous", "order_id": order_id, "latest_status": None}
    if _valid_order_snapshot(latest, order_id) and latest["status"] == "CANCELED":
        return {
            "kind": "confirmed_canceled",
            "snapshot": {"id": order_id, "status": "CANCELED"},
        }
    latest_status = latest.get("status") if _valid_order_snapshot(latest, order_id) else None
    return {
        "kind": "ambiguous",
        "order_id": order_id,
        "latest_status": latest_status,
    }

def cancel_order(order_id: int) -> Dict:
    """คืนค่าเฉพาะ skipped/receipt/rejected/confirmed_canceled/ambiguous"""
    try:
        current = get_order(order_id)
    except Exception:
        return {"kind": "ambiguous", "order_id": order_id, "latest_status": None}
    if not _valid_order_snapshot(current, order_id) or type(current.get("can_cancel")) is not bool:
        return {"kind": "ambiguous", "order_id": order_id, "latest_status": None}
    if not current["can_cancel"]:
        return {"kind": "skipped", "order_id": order_id}

    try:
        response = _raw_request("POST", "/orders/cancel", json={"id": order_id})
    except requests.RequestException:
        return _reconcile_cancellation(order_id)
    try:
        payload = _decode_json(response)
    except (UnicodeDecodeError, ValueError):
        return _reconcile_cancellation(order_id)

    if response.status_code == 200:
        receipt = _validated_cancel_receipt(payload, order_id)
        if receipt is not None:
            return {"kind": "receipt", "receipt": receipt}
        return _reconcile_cancellation(order_id)

    error = _documented_cancel_error(response.status_code, payload)
    if error is not None:
        return {
            "kind": "rejected",
            "http_status": response.status_code,
            "error_code": error["code"],
        }
    return _reconcile_cancellation(order_id)

def wait_for_otp(
    order_id: int,
    timeout_seconds: int = 240,
    poll_interval: int = 10
) -> Optional[str]:
    """poll หา OTP ภายในเวลาที่กำหนด"""
    if (
        isinstance(timeout_seconds, bool)
        or not isinstance(timeout_seconds, (int, float))
        or not math.isfinite(timeout_seconds)
    ):
        raise ValueError("timeout_seconds must be a finite number")
    # `timeout_seconds` จำกัดเวลาที่ loop นี้จะ "เริ่ม" poll ครั้งใหม่ ไม่ใช่การรับประกัน
    # เวลารวมแบบสัมบูรณ์ เพราะ GET ที่กำลังทำงานอยู่ยังใช้เวลาได้ตาม transport bound
    # ของตัวเอง และไม่ใช่จำนวนรอบ: แต่ละรอบใช้เวลา request ด้วย
    # interval ที่ไม่เป็นบวกจะทำให้ loop นี้ยิง GET รัวตลอด budget
    # จึงบังคับขั้นต่ำที่ประกาศไว้
    poll_interval = max(1, int(poll_interval or 0))
    deadline = time.time() + timeout_seconds
    last_seen_revision = -1
    poll_number = 0

    while True:
        poll_number += 1
        order = get_order(order_id)
        status = order["status"]
        sms_revision = order.get("sms_revision")
        otp_message = order.get("otp_message")

        if (
            type(sms_revision) is int
            and sms_revision > last_seen_revision
            and isinstance(otp_message, str)
            and otp_message.strip()
        ):
            last_seen_revision = sms_revision
            print(f"SMS revision {sms_revision}: {otp_message}")
            return otp_message
        if status in ("COMPLETED", "EXPIRED", "CANCELED"):
            return None

        print(f"ครั้งที่ {poll_number}: ยังรออยู่...")

        # poll แรกทำงานเสมอแม้ budget จะไม่เป็นบวก แต่ poll ถัดไปทุกครั้งถูกตรวจ
        # หลัง sleep จึงไม่มี GET ใหม่เริ่มขึ้นหลัง budget หมด
        time.sleep(min(poll_interval, max(0.0, deadline - time.time())))
        if time.time() >= deadline:
            break

    # Local timeout ไม่ได้ตัดสินผลทางการเงิน ให้ตรวจ cancelability จาก latest snapshot
    cancellation = cancel_order(order_id)
    if cancellation["kind"] == "receipt":
        receipt = cancellation["receipt"]
        print(
            f"ยกเลิกแล้ว: คืนเงิน Rp {receipt['refund_amount']}; "
            f"ยอดใหม่ Rp {receipt['new_balance']}"
        )
    elif cancellation["kind"] == "confirmed_canceled":
        print("Latest snapshot เป็น CANCELED แต่ไม่มี cancel receipt หรือหลักฐานการคืนเงิน")
    elif cancellation["kind"] == "rejected":
        print(f"คำขอยกเลิกถูกปฏิเสธ: {cancellation['error_code']}")
    elif cancellation["kind"] == "ambiguous":
        print("ผลการยกเลิกยังไม่ชัดเจน ให้เก็บไว้ใน durable reconciliation")
    else:
        print("Latest snapshot มี can_cancel=false จึงไม่ได้ส่ง cancel POST")
    return None


# ตัวอย่างการใช้งาน
def verify_whatsapp(
    caller_scope: str,
    business_job_id: str,
    idempotency_key: str,
    review_threshold_at: int,
):
    """ขั้นตอนยืนยัน WhatsApp แบบครบวงจร"""
    print("กำลังสร้างออเดอร์หมายเลข WhatsApp...")
    attempt = prepare_create_attempt(
        caller_scope,
        business_job_id,
        idempotency_key,
        review_threshold_at,
        88,
        1,
    )
    create_outcome = get_virtual_number(
        caller_scope,
        business_job_id,
        idempotency_key,
        review_threshold_at,
        attempt,
    )
    if create_outcome["kind"] != "resolved":
        print(f"Create durable state: {create_outcome['kind']}")
        return {"create_outcome": create_outcome["kind"], "attempt": create_outcome["attempt"]}
    create_result = create_outcome["result"]
    if len(create_result["orders"]) != 1 or create_result["failed_count"] != 0:
        return {"create_outcome": "resolved_without_single_order", "attempt": create_outcome["attempt"]}
    order = create_result["orders"][0]

    order_id = order["id"]
    phone = order.get("phone_number")
    assignment_status = order["status"]
    if not (isinstance(phone, str) and phone.strip()):
        try:
            current = get_order(order_id)
        except Exception:
            current = None
        if _valid_order_snapshot(current, order_id):
            assignment_status = current["status"]
            current_phone = current.get("phone_number")
            if isinstance(current_phone, str) and current_phone.strip():
                phone = current_phone

    if not (isinstance(phone, str) and phone.strip()):
        return {
            "create_outcome": "resolved",
            "assignment": {
                "kind": "pending_assignment",
                "order_id": order_id,
                "status": assignment_status,
            },
        }

    print(f"เบอร์เสมือน: {phone}")

    # TODO: นำหมายเลขนี้ไปใช้ในขั้นตอนสมัคร WhatsApp
    # [ใส่ระบบอัตโนมัติสำหรับสมัคร WhatsApp ที่นี่]

    print("กำลังรอ OTP...")
    otp = wait_for_otp(order_id)

    if otp:
        print(f"ได้รับ OTP: {otp}")
        # TODO: กรอก OTP ใน WhatsApp
        return {"phone": phone, "otp": otp}
    else:
        print("ไม่ได้รับ OTP ภายในเวลาที่กำหนด")
        return None


# จุดเริ่มต้นหลัก
result = verify_whatsapp(
    os.environ["SMSCODE_CALLER_SCOPE"],
    os.environ["SMSCODE_BUSINESS_JOB_ID"],
    os.environ["SMSCODE_IDEMPOTENCY_KEY"],
    int(os.environ["SMSCODE_REVIEW_THRESHOLD_AT"]),
)

Node.js / TypeScript — รอรับ OTP พร้อม retry

import crypto from 'crypto';
import { Agent, request } from 'undici';
import { durableCreateAttemptStore } from './durable-create-attempt-store';

const BASE_URL = 'https://api.smscode.gg/v1';
const HEADERS = {
    'Authorization': `Bearer ${process.env.SMSCODE_API_KEY}`,
    'Content-Type': 'application/json'
};

const DEFINITIVE_CREATE_ERRORS = new Set([
    'NO_OFFER_AVAILABLE',
    'VALIDATION_ERROR',
    'PROVIDER_ERROR',
    'IDEMPOTENCY_KEY_REUSED'
]);
const ORDER_STATUSES = new Set([
    'ACTIVE', 'OTP_RECEIVED', 'COMPLETED', 'CANCELED', 'EXPIRED'
]);
const V1_CREATE_REQUIRED_FIELDS = new Set([
    'id', 'status', 'product_id', 'amount', 'otp_code', 'can_finish',
    'can_resend', 'can_cancel', 'can_replace', 'can_reactivate',
    'resend_available_at', 'cancel_available_at', 'replace_available_at'
]);
const V1_CREATE_FIELDS = new Set([
    ...V1_CREATE_REQUIRED_FIELDS,
    'phone_number', 'otp_received_at', 'expires_at', 'failed_reason',
    'catalog_product_id', 'operator_id', 'operator_name'
]);
const V1_CREATE_NULLABLE_STRING_FIELDS = new Set([
    'phone_number', 'otp_code', 'otp_received_at', 'expires_at',
    'failed_reason', 'operator_name', 'resend_available_at',
    'cancel_available_at', 'replace_available_at'
]);
const V1_CREATE_BOOLEAN_FIELDS = new Set([
    'can_finish', 'can_resend', 'can_cancel', 'can_replace', 'can_reactivate'
]);
const CANCEL_ERROR_CODES_BY_STATUS = new Map([
    [401, new Set(['UNAUTHORIZED'])],
    [404, new Set(['NOT_FOUND'])],
    [409, new Set(['CONFLICT', 'CANCEL_TOO_EARLY'])],
    [422, new Set(['PROVIDER_ERROR', 'VALIDATION_ERROR'])],
    [429, new Set(['RATE_LIMIT_EXCEEDED'])],
    [500, new Set(['INTERNAL_ERROR'])],
    [503, new Set(['SERVICE_UNAVAILABLE'])]
]);
const CREATE_CONNECT_TIMEOUT_MS = 5000;
const CREATE_TOTAL_TIMEOUT_MS = 30000;
const CREATE_LEASE_MS = 45000;
const RECONCILIATION_BASE_BACKOFF_MS = 2000;
const RECONCILIATION_MAX_BACKOFF_MS = 60 * 1000;
const CLIENT_REPLAY_MAX = 3600;
const MAX_AUTOMATIC_SENDS = 32;
const createDispatcher = new Agent({ connectTimeout: CREATE_CONNECT_TIMEOUT_MS });

function canonicalJson(value) {
    if (Array.isArray(value)) {
        return `[${value.map(canonicalJson).join(',')}]`;
    }
    if (value !== null && typeof value === 'object') {
        return `{${Object.keys(value).sort().map((key) =>
            `${JSON.stringify(key)}:${canonicalJson(value[key])}`
        ).join(',')}}`;
    }
    return JSON.stringify(value);
}

function isObject(value) {
    return value !== null && typeof value === 'object' && !Array.isArray(value);
}

function isV1ErrorResponse(value) {
    if (!isObject(value) || value.success !== false ||
        Object.keys(value).sort().join(',') !== 'error,success' ||
        !isObject(value.error)) {
        return false;
    }
    const errorFields = Object.keys(value.error).sort().join(',');
    return (errorFields === 'code,message' || errorFields === 'code,details,message') &&
        typeof value.error.code === 'string' &&
        typeof value.error.message === 'string' &&
        (!('details' in value.error) || isObject(value.error.details));
}

function isInt32(value) {
    return Number.isInteger(value) && value >= -(2 ** 31) && value <= (2 ** 31) - 1;
}

function validateV1CreateOrderResult(value) {
    if (!isObject(value) || Object.keys(value).sort().join(',') !== 'failed_count,orders') {
        throw new Error('ฟิลด์ของ V1CreateOrderResult ไม่ถูกต้อง');
    }
    if (!Array.isArray(value.orders)) throw new Error('orders ต้องเป็น array');
    if (!isInt32(value.failed_count) || value.failed_count < 0) {
        throw new Error('failed_count ต้องเป็น int32 ที่ไม่ติดลบ');
    }
    for (const item of value.orders) {
        if (!isObject(item)) throw new Error('แต่ละ order ต้องเป็น object');
        const fields = new Set(Object.keys(item));
        if ([...V1_CREATE_REQUIRED_FIELDS].some((field) => !fields.has(field))) {
            throw new Error('V1CreateOrderItem ขาดฟิลด์ที่จำเป็น');
        }
        if ([...fields].some((field) => !V1_CREATE_FIELDS.has(field))) {
            throw new Error('V1CreateOrderItem มีฟิลด์ที่ไม่รู้จัก');
        }
        if (!isInt32(item.id) || !isInt32(item.product_id)) {
            throw new Error('order ID และ product ID ต้องเป็น int32');
        }
        for (const field of ['catalog_product_id', 'operator_id']) {
            if (fields.has(field) && item[field] !== null && !isInt32(item[field])) {
                throw new Error(`${field} ต้องเป็น nullable int32`);
            }
        }
        if (!ORDER_STATUSES.has(item.status)) throw new Error('สถานะออเดอร์ไม่อยู่ในสัญญา');
        if (!Number.isSafeInteger(item.amount)) throw new Error('amount ต้องเป็นจำนวนเต็ม');
        for (const field of V1_CREATE_BOOLEAN_FIELDS) {
            if (typeof item[field] !== 'boolean') throw new Error(`${field} ต้องเป็น boolean`);
        }
        for (const field of V1_CREATE_NULLABLE_STRING_FIELDS) {
            if (fields.has(field) && item[field] !== null && typeof item[field] !== 'string') {
                throw new Error(`${field} ต้องเป็น nullable string`);
            }
        }
    }
    return value;
}

function validOrderSnapshot(value, orderId) {
    return isObject(value) && isInt32(value.id) && value.id === orderId &&
        ORDER_STATUSES.has(value.status) && !('refund_amount' in value) &&
        !('new_balance' in value);
}

function validatedCancelReceipt(payload, orderId) {
    if (!isObject(payload) || payload.success !== true || !('data' in payload)) return null;
    if (Object.keys(payload).some((field) => !['success', 'data', 'meta'].includes(field))) {
        return null;
    }
    const data = payload.data;
    if (!isObject(data) || Object.keys(data).sort().join(',') !==
        'new_balance,order_id,refund_amount,status') return null;
    if (!isInt32(data.order_id) || data.order_id !== orderId || data.status !== 'CANCELED') {
        return null;
    }
    if (!Number.isSafeInteger(data.refund_amount) || data.refund_amount < 0 ||
        !Number.isSafeInteger(data.new_balance)) return null;
    return data;
}

function documentedCancelError(httpStatus, payload) {
    if (!isObject(payload) || Object.keys(payload).sort().join(',') !== 'error,success' ||
        payload.success !== false || !isObject(payload.error)) return null;
    const error = payload.error;
    const fields = Object.keys(error);
    if (!fields.includes('code') || !fields.includes('message') ||
        fields.some((field) => !['code', 'message', 'details'].includes(field))) return null;
    if (typeof error.code !== 'string' || typeof error.message !== 'string') return null;
    if ('details' in error && !isObject(error.details)) return null;
    return CANCEL_ERROR_CODES_BY_STATUS.get(httpStatus)?.has(error.code) ? error : null;
}

async function _rawRequest(method, endpoint, options = {}) {
    const query = options.params
        ? `?${new URLSearchParams(options.params)}`
        : '';
    return request(`${BASE_URL}${endpoint}${query}`, {
        method,
        headers: { ...HEADERS, ...(options.headers || {}) },
        body: options.body ??
            (options.data !== undefined ? JSON.stringify(options.data) : undefined),
        dispatcher: createDispatcher,
        signal: AbortSignal.timeout(CREATE_TOTAL_TIMEOUT_MS)
    });
}

async function _decodeJson(response) {
    const bytes = new Uint8Array(await response.body.arrayBuffer());
    return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
}

async function _request(method, endpoint, options = {}) {
    const response = await _rawRequest(method, endpoint, options);
    const data = await _decodeJson(response);
    if (response.statusCode !== 200 || !isObject(data) || data.success !== true ||
        !('data' in data) || 'error' in data) {
        const { code, message } = data.error || {};
        throw new Error(`API Error ${code}: ${message}`);
    }
    return data.data;
}

async function getCatalog(countryId, platformId) {
    return _request('GET', '/catalog/products', {
        params: { country_id: countryId, platform_id: platformId }
    });
}

async function prepareCreateAttempt(
    callerScope,
    businessJobId,
    idempotencyKey,
    reviewThresholdAt,
    catalogProductId,
    quantity = 1
) {
    if (!callerScope || !businessJobId || !idempotencyKey ||
        !Number.isSafeInteger(reviewThresholdAt) || reviewThresholdAt < 0 ||
        !isInt32(catalogProductId) ||
        !isInt32(quantity) || quantity <= 0) {
        throw new Error('ต้องมี caller identity, persisted key, threshold, product และ quantity ที่ถูกต้อง');
    }
    const body = { catalog_product_id: catalogProductId, quantity };
    const endpoint = '/orders/create';
    const bodyJson = canonicalJson(body);
    const requestFingerprint = crypto.createHash('sha256')
        .update(endpoint).update('\0').update(bodyJson).digest('hex');
    const startedAt = durableCreateAttemptStore.now();
    const candidate = {
        callerScope,
        businessJobId,
        endpoint,
        bodyJson,
        requestFingerprint,
        idempotencyKey,
        reviewThresholdAt,
        startedAt,
        effectiveReplayDeadlineAt: Math.min(
            reviewThresholdAt,
            startedAt + CLIENT_REPLAY_MAX * 1000
        ),
        sends: 0,
        attemptHistory: []
    };
    // หาก pair เดิมมี fingerprint, key หรือ threshold ต่างกัน ให้ conflict
    const attempt = await durableCreateAttemptStore.insertOrLoad(
        callerScope,
        businessJobId,
        requestFingerprint,
        candidate
    );
    assertAttemptBinding(
        attempt, callerScope, businessJobId, idempotencyKey, reviewThresholdAt
    );
    return attempt;
}

function assertAttemptBinding(
    attempt,
    callerScope,
    businessJobId,
    idempotencyKey,
    reviewThresholdAt
) {
    if (!Number.isSafeInteger(attempt.startedAt) || attempt.startedAt < 0 ||
        !Number.isSafeInteger(attempt.startedAt + CLIENT_REPLAY_MAX * 1000)) {
        throw new Error('stored create attempt ไม่มี startedAt ของ store clock');
    }
    const expectedDeadline = Math.min(
        reviewThresholdAt,
        attempt.startedAt + CLIENT_REPLAY_MAX * 1000
    );
    if (attempt.effectiveReplayDeadlineAt !== expectedDeadline) {
        throw new Error('effective replay deadline ถูกเปลี่ยนหลัง prepare');
    }
    if (!Number.isSafeInteger(attempt.sends) || attempt.sends < 0) {
        throw new Error('stored sends ต้องเป็นจำนวนเต็มที่ไม่ติดลบ');
    }
    let persistedBody;
    try {
        persistedBody = JSON.parse(attempt.bodyJson);
    } catch (error) {
        throw new Error('stored create body ไม่ใช่ canonical JSON ที่ถูกต้อง');
    }
    if (!isObject(persistedBody) ||
        Object.keys(persistedBody).sort().join(',') !== 'catalog_product_id,quantity' ||
        !isInt32(persistedBody.catalog_product_id) ||
        !isInt32(persistedBody.quantity) || persistedBody.quantity <= 0) {
        throw new Error('stored create body ไม่ตรงกับ request contract');
    }
    const fingerprint = crypto.createHash('sha256')
        .update(attempt.endpoint).update('\0').update(attempt.bodyJson).digest('hex');
    if (attempt.callerScope !== callerScope ||
        attempt.businessJobId !== businessJobId ||
        attempt.idempotencyKey !== idempotencyKey ||
        attempt.reviewThresholdAt !== reviewThresholdAt ||
        attempt.endpoint !== '/orders/create' ||
        attempt.requestFingerprint !== fingerprint) {
        throw new Error('stored create attempt ไม่ตรงกับ caller identity');
    }
}

async function appendCreateEvent(
    callerScope,
    businessJobId,
    attempt,
    claimToken,
    event
) {
    // Store ต้อง append event และ project event.sends ไปยัง attempt ใน CAS เดียวกัน
    return durableCreateAttemptStore.appendEvent(callerScope, businessJobId, {
        claimToken,
        expectedVersion: attempt.version,
        event
    });
}

function recoveryFields(attempt) {
    return {
        endpoint: attempt.endpoint,
        bodyJson: attempt.bodyJson,
        idempotencyKey: attempt.idempotencyKey,
        reviewThresholdAt: attempt.reviewThresholdAt,
        startedAt: attempt.startedAt,
        effectiveReplayDeadlineAt: attempt.effectiveReplayDeadlineAt,
        sends: attempt.sends
    };
}

function backoffMs(attempt) {
    const sendCount = attempt.sends;
    const exponent = Math.min(Math.max(sendCount - 1, 0), 5);
    return Math.min(
        RECONCILIATION_BASE_BACKOFF_MS * (2 ** exponent),
        RECONCILIATION_MAX_BACKOFF_MS
    );
}

async function needsReconciliation(
    callerScope,
    businessJobId,
    attempt,
    claimToken,
    reason,
    evidence = {}
) {
    const event = {
        state: 'needs_reconciliation',
        reason,
        ...recoveryFields(attempt),
        ...evidence
    };
    event.nextAttemptAt = Math.min(
        durableCreateAttemptStore.now() + backoffMs(attempt),
        attempt.effectiveReplayDeadlineAt
    );
    attempt = await appendCreateEvent(
        callerScope, businessJobId, attempt, claimToken, event
    );
    return { kind: 'needs_reconciliation', attempt };
}

async function getVirtualNumber(
    callerScope,
    businessJobId,
    idempotencyKey,
    reviewThresholdAt,
    attempt
) {
    assertAttemptBinding(
        attempt, callerScope, businessJobId, idempotencyKey, reviewThresholdAt
    );
    const preNow = durableCreateAttemptStore.now();
    if (preNow >= attempt.effectiveReplayDeadlineAt ||
        attempt.sends >= MAX_AUTOMATIC_SENDS) {
        // Store ตรวจ version, persisted bounds และ clock ซ้ำ แล้ว retain แบบ CAS
        // โดยไม่ออก send claim
        attempt = await durableCreateAttemptStore.retainOperatorReview(
            callerScope,
            businessJobId,
            attempt,
            preNow
        );
        return { kind: 'operator_review', attempt };
    }
    const claimed = await durableCreateAttemptStore.claimForSend(
        callerScope,
        businessJobId,
        { expectedVersion: attempt.version, leaseMs: CREATE_LEASE_MS }
    );
    attempt = claimed.attempt;
    const claimToken = claimed.claimToken;
    assertAttemptBinding(
        attempt, callerScope, businessJobId, idempotencyKey, reviewThresholdAt
    );

    const storeNow = durableCreateAttemptStore.now();
    if (storeNow >= attempt.effectiveReplayDeadlineAt ||
        attempt.sends >= MAX_AUTOMATIC_SENDS) {
        attempt = await appendCreateEvent(
            callerScope,
            businessJobId,
            attempt,
            claimToken,
            {
                state: 'operator_review',
                retainedAt: storeNow,
                ...recoveryFields(attempt)
            }
        );
        return { kind: 'operator_review', attempt };
    }

    const sendNumber = attempt.sends + 1;
    attempt = await appendCreateEvent(
        callerScope,
        businessJobId,
        attempt,
        claimToken,
        {
            ...recoveryFields(attempt),
            number: sendNumber,
            state: 'sending',
            lastSendAt: storeNow,
            deadlineAt: storeNow + CREATE_TOTAL_TIMEOUT_MS,
            sends: sendNumber
        }
    );
    if (attempt.sends !== sendNumber) {
        throw new Error('store ไม่ได้ persist sends ใน sending CAS');
    }

    let response;
    try {
        response = await _rawRequest('POST', attempt.endpoint, {
            headers: { 'Idempotency-Key': attempt.idempotencyKey },
            body: attempt.bodyJson
        });
    } catch (error) {
        return needsReconciliation(
            callerScope, businessJobId, attempt, claimToken, 'transport'
        );
    }

    if (response.statusCode >= 500) {
        const outcome = await needsReconciliation(
            callerScope,
            businessJobId,
            attempt,
            claimToken,
            'http_5xx',
            { httpStatus: response.statusCode }
        );
        response.body.destroy?.();
        return outcome;
    }

    let payload;
    try {
        payload = await _decodeJson(response);
    } catch (error) {
        return needsReconciliation(
            callerScope,
            businessJobId,
            attempt,
            claimToken,
            'malformed_response',
            { httpStatus: response.statusCode }
        );
    }

    if (isObject(payload) && payload.success === false) {
        const candidateCode = isObject(payload.error) ? payload.error.code : null;
        const code = typeof candidateCode === 'string' ? candidateCode : null;
        if (!isV1ErrorResponse(payload)) {
            return needsReconciliation(
                callerScope,
                businessJobId,
                attempt,
                claimToken,
                'malformed_response',
                { httpStatus: response.statusCode, errorCode: code }
            );
        }
        if (response.statusCode === 422 && DEFINITIVE_CREATE_ERRORS.has(code)) {
            attempt = await appendCreateEvent(
                callerScope,
                businessJobId,
                attempt,
                claimToken,
                {
                    state: 'definitive_rejection',
                    httpStatus: response.statusCode,
                    errorCode: code
                }
            );
            return { kind: 'definitive_rejection', attempt, code };
        }
        if (response.statusCode === 409 && code === 'INSUFFICIENT_BALANCE') {
            attempt = await appendCreateEvent(
                callerScope,
                businessJobId,
                attempt,
                claimToken,
                {
                    state: 'insufficient_balance',
                    httpStatus: response.statusCode,
                    errorCode: code
                }
            );
            return { kind: 'insufficient_balance', attempt };
        }
        return needsReconciliation(
            callerScope,
            businessJobId,
            attempt,
            claimToken,
            response.statusCode === 409 && code === 'REQUEST_IN_PROGRESS'
                ? 'request_in_progress'
                : 'unknown_response',
            { httpStatus: response.statusCode, errorCode: code }
        );
    }

    if (response.statusCode !== 200 || !isObject(payload) ||
        payload.success !== true || !('data' in payload) || 'error' in payload) {
        return needsReconciliation(
            callerScope,
            businessJobId,
            attempt,
            claimToken,
            'contradictory_response',
            { httpStatus: response.statusCode }
        );
    }

    let validatedResult;
    try {
        validatedResult = validateV1CreateOrderResult(payload.data);
        const persistedQuantity = JSON.parse(attempt.bodyJson).quantity;
        if (validatedResult.orders.length + validatedResult.failed_count !==
            persistedQuantity) {
            throw new Error('create result ไม่อนุรักษ์ quantity ที่ persist ไว้');
        }
    } catch (error) {
        return needsReconciliation(
            callerScope,
            businessJobId,
            attempt,
            claimToken,
            'malformed_success',
            { httpStatus: response.statusCode }
        );
    }

    attempt = await appendCreateEvent(
        callerScope,
        businessJobId,
        attempt,
        claimToken,
        { state: 'resolved', validatedResponse: validatedResult }
    );
    return { kind: 'resolved', attempt, result: validatedResult };
}

async function reconcileCreateOrder(
    callerScope,
    businessJobId,
    idempotencyKey,
    reviewThresholdAt
) {
    const attempt = await durableCreateAttemptStore.load(callerScope, businessJobId);
    assertAttemptBinding(
        attempt, callerScope, businessJobId, idempotencyKey, reviewThresholdAt
    );
    return getVirtualNumber(
        callerScope,
        businessJobId,
        idempotencyKey,
        reviewThresholdAt,
        attempt
    );
}

async function getOrder(orderId) {
    return _request('GET', `/orders/${orderId}`);
}

async function reconcileCancellation(orderId) {
    try {
        const latest = await getOrder(orderId);
        if (validOrderSnapshot(latest, orderId) && latest.status === 'CANCELED') {
            return {
                kind: 'confirmed_canceled',
                snapshot: { id: orderId, status: 'CANCELED' }
            };
        }
        return {
            kind: 'ambiguous',
            orderId,
            latestStatus: validOrderSnapshot(latest, orderId) ? latest.status : null
        };
    } catch (error) {
        return { kind: 'ambiguous', orderId, latestStatus: null };
    }
}

async function cancelOrder(orderId) {
    let current;
    try {
        current = await getOrder(orderId);
    } catch (error) {
        return { kind: 'ambiguous', orderId, latestStatus: null };
    }
    if (!validOrderSnapshot(current, orderId) || typeof current.can_cancel !== 'boolean') {
        return { kind: 'ambiguous', orderId, latestStatus: null };
    }
    if (!current.can_cancel) return { kind: 'skipped', orderId };

    let response;
    try {
        response = await _rawRequest('POST', '/orders/cancel', {
            data: { id: orderId }
        });
    } catch (error) {
        return reconcileCancellation(orderId);
    }

    let payload;
    try {
        payload = await _decodeJson(response);
    } catch (error) {
        return reconcileCancellation(orderId);
    }

    if (response.statusCode === 200) {
        const receipt = validatedCancelReceipt(payload, orderId);
        return receipt
            ? { kind: 'receipt', receipt }
            : reconcileCancellation(orderId);
    }
    const apiError = documentedCancelError(response.statusCode, payload);
    if (apiError) {
        return {
            kind: 'rejected',
            httpStatus: response.statusCode,
            errorCode: apiError.code
        };
    }
    return reconcileCancellation(orderId);
}

async function waitForOtp(orderId, { timeoutMs = 240000, pollIntervalMs = 10000 } = {}) {
    if (!Number.isFinite(timeoutMs)) {
        throw new TypeError('timeoutMs must be finite');
    }
    // interval ที่ไม่เป็นบวก/ไม่ finite จะยิง GET รัวตลอด budget จึงบังคับขั้นต่ำ
    const interval = Number.isFinite(pollIntervalMs)
        ? Math.max(1000, pollIntervalMs)
        : 10000;
    const deadline = Date.now() + timeoutMs;
    let lastSeenRevision = -1;

    // The first poll always runs, even for a non-positive budget. `timeoutMs`
    // bounds when this loop stops STARTING later polls; a request already in
    // flight still runs to its own transport bound.
    while (true) {
        const order = await getOrder(orderId);
        const smsRevision = order.sms_revision;
        const otpMessage = order.otp_message;

        if (Number.isInteger(smsRevision) &&
            smsRevision > lastSeenRevision &&
            typeof otpMessage === 'string' && otpMessage.trim() !== '') {
            lastSeenRevision = smsRevision;
            console.log(`SMS revision ${smsRevision}`);
            return otpMessage;
        }
        if (['COMPLETED', 'EXPIRED', 'CANCELED'].includes(order.status)) return null;

        // หน่วงเวลาตามงบที่เหลือ แล้วตรวจ: จะไม่มี poll ใหม่เริ่มขึ้นหลัง budget หมด
        // และเส้นทางยกเลิก/มัดจำยังเข้าถึงได้
        await new Promise(resolve =>
            setTimeout(resolve, Math.min(interval, Math.max(0, deadline - Date.now())))
        );
        if (Date.now() >= deadline) break;
    }

    const cancellation = await cancelOrder(orderId);
    if (cancellation?.kind === 'receipt') {
        console.log(`คืนเงิน Rp ${cancellation.receipt.refund_amount}; ยอดใหม่ Rp ${cancellation.receipt.new_balance}`);
    } else if (cancellation.kind === 'confirmed_canceled') {
        console.log('Latest snapshot เป็น CANCELED แต่ไม่มี cancel receipt หรือหลักฐานการคืนเงิน');
    } else if (cancellation.kind === 'rejected') {
        console.log(`คำขอยกเลิกถูกปฏิเสธ: ${cancellation.errorCode}`);
    } else if (cancellation.kind === 'ambiguous') {
        console.log('ผลการยกเลิกยังไม่ชัดเจน ให้เก็บไว้ใน durable reconciliation');
    } else {
        console.log('Latest snapshot มี can_cancel=false จึงไม่ได้ส่ง cancel POST');
    }
    return null;
}

// ใช้งาน
async function main() {
    const callerScope = process.env.SMSCODE_CALLER_SCOPE;
    const businessJobId = process.env.SMSCODE_BUSINESS_JOB_ID;
    const idempotencyKey = process.env.SMSCODE_IDEMPOTENCY_KEY;
    const reviewThresholdAt = Number(process.env.SMSCODE_REVIEW_THRESHOLD_AT);
    const attempt = await prepareCreateAttempt(
        callerScope, businessJobId, idempotencyKey, reviewThresholdAt, 88, 1
    );
    const createOutcome = await getVirtualNumber(
        callerScope, businessJobId, idempotencyKey, reviewThresholdAt, attempt
    );
    if (createOutcome.kind !== 'resolved') {
        console.log(`Create durable state: ${createOutcome.kind}`);
        return;
    }
    if (createOutcome.result.orders.length !== 1 ||
        createOutcome.result.failed_count !== 0) {
        throw new Error('Create resolved แต่ไม่ได้รับ order เพียงหนึ่งรายการ');
    }
    const order = createOutcome.result.orders[0];
    let phone = order.phone_number;
    let assignmentStatus = order.status;
    if (!(typeof phone === 'string' && phone.trim() !== '')) {
        try {
            const current = await getOrder(order.id);
            if (validOrderSnapshot(current, order.id)) {
                assignmentStatus = current.status;
                const currentPhone = current.phone_number;
                if (typeof currentPhone === 'string' && currentPhone.trim() !== '') {
                    phone = currentPhone;
                }
            }
        } catch {
            // Lookup failure resolves to pending_assignment below.
        }
    }
    if (!(typeof phone === 'string' && phone.trim() !== '')) {
        return {
            createOutcome: 'resolved',
            assignment: {
                kind: 'pending_assignment',
                orderId: order.id,
                status: assignmentStatus
            }
        };
    }

    console.log(`เบอร์เสมือน: ${phone}`);
    const otp = await waitForOtp(order.id);
    console.log(otp ? `OTP: ${otp}` : 'ไม่ได้รับ OTP');
}

main()
    .then((outcome) => {
        if (outcome?.assignment?.kind === 'pending_assignment') {
            console.log(
                `pending_assignment: order ${outcome.assignment.orderId} ` +
                `ยังไม่มีหมายเลข; status ล่าสุด ${outcome.assignment.status}`
            );
        }
    })
    .catch(console.error);

PHP — สำหรับนักพัฒนาเว็บ

<?php
interface CreateAttemptStore {
    public function insertOrLoad(
        string $callerScope,
        string $businessJobId,
        string $requestFingerprint,
        array $candidate
    ): array;
    public function load(string $callerScope, string $businessJobId): array;
    public function claimForSend(
        string $callerScope,
        string $businessJobId,
        int $expectedVersion,
        int $leaseSeconds
    ): array;
    public function appendEvent(
        string $callerScope,
        string $businessJobId,
        string $claimToken,
        int $expectedVersion,
        array $event
    ): array;
    public function retainOperatorReview(
        string $callerScope,
        string $businessJobId,
        array $attempt,
        int $storeNow
    ): array;
    public function now(): int;
}

class SMSCodeClient {
    private const DEFINITIVE_CREATE_ERRORS = [
        'NO_OFFER_AVAILABLE',
        'VALIDATION_ERROR',
        'PROVIDER_ERROR',
        'IDEMPOTENCY_KEY_REUSED',
    ];
    private const ORDER_STATUSES = [
        'ACTIVE', 'OTP_RECEIVED', 'COMPLETED', 'CANCELED', 'EXPIRED',
    ];
    private const V1_CREATE_REQUIRED_FIELDS = [
        'id', 'status', 'product_id', 'amount', 'otp_code', 'can_finish',
        'can_resend', 'can_cancel', 'can_replace', 'can_reactivate',
        'resend_available_at', 'cancel_available_at', 'replace_available_at',
    ];
    private const V1_CREATE_FIELDS = [
        'id', 'status', 'product_id', 'amount', 'otp_code', 'can_finish',
        'can_resend', 'can_cancel', 'can_replace', 'can_reactivate',
        'resend_available_at', 'cancel_available_at', 'replace_available_at',
        'phone_number', 'otp_received_at', 'expires_at', 'failed_reason',
        'catalog_product_id', 'operator_id', 'operator_name',
    ];
    private const V1_CREATE_NULLABLE_STRING_FIELDS = [
        'phone_number', 'otp_code', 'otp_received_at', 'expires_at',
        'failed_reason', 'operator_name', 'resend_available_at',
        'cancel_available_at', 'replace_available_at',
    ];
    private const V1_CREATE_BOOLEAN_FIELDS = [
        'can_finish', 'can_resend', 'can_cancel', 'can_replace', 'can_reactivate',
    ];
    private const CANCEL_ERROR_CODES_BY_STATUS = [
        401 => ['UNAUTHORIZED'],
        404 => ['NOT_FOUND'],
        409 => ['CONFLICT', 'CANCEL_TOO_EARLY'],
        422 => ['PROVIDER_ERROR', 'VALIDATION_ERROR'],
        429 => ['RATE_LIMIT_EXCEEDED'],
        500 => ['INTERNAL_ERROR'],
        503 => ['SERVICE_UNAVAILABLE'],
    ];
    private const CREATE_CONNECT_TIMEOUT_SECONDS = 5;
    private const CREATE_TOTAL_TIMEOUT_SECONDS = 30;
    private const CREATE_LEASE_SECONDS = 45;
    private const RECONCILIATION_BASE_BACKOFF_SECONDS = 2;
    private const RECONCILIATION_MAX_BACKOFF_SECONDS = 60;
    private const CLIENT_REPLAY_MAX = 3600;
    private const MAX_AUTOMATIC_SENDS = 32;

    private string $apiToken;
    private string $baseUrl = 'https://api.smscode.gg/v1';
    private CreateAttemptStore $attemptStore;

    public function __construct(string $apiToken, CreateAttemptStore $attemptStore) {
        $this->apiToken = $apiToken;
        $this->attemptStore = $attemptStore;
    }

    private function sendRequest(
        string $method,
        string $endpoint,
        array|string $data = [],
        array $extraHeaders = []
    ): array {
        $ch = curl_init();
        $url = $this->baseUrl . $endpoint;

        curl_setopt_array($ch, [
            CURLOPT_URL => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => self::CREATE_CONNECT_TIMEOUT_SECONDS,
            CURLOPT_TIMEOUT => self::CREATE_TOTAL_TIMEOUT_SECONDS,
            CURLOPT_HTTPHEADER => array_merge([
                'Authorization: Bearer ' . $this->apiToken,
                'Content-Type: application/json'
            ], $extraHeaders)
        ]);

        if ($method === 'POST') {
            curl_setopt($ch, CURLOPT_POST, true);
            if ($data !== [] && $data !== '') {
                $payload = is_string($data)
                    ? $data
                    : json_encode($data, JSON_THROW_ON_ERROR);
                curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
            }
        }

        $rawResponse = curl_exec($ch);
        if ($rawResponse === false) {
            $message = curl_error($ch);
            curl_close($ch);
            throw new RuntimeException($message);
        }
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        return [$httpCode, $rawResponse];
    }

    private function request(
        string $method,
        string $endpoint,
        array|string $data = [],
        array $extraHeaders = []
    ): array {
        [$httpStatus, $rawResponse] = $this->sendRequest(
            $method,
            $endpoint,
            $data,
            $extraHeaders
        );
        $response = json_decode($rawResponse, true, 512, JSON_THROW_ON_ERROR);
        if ($httpStatus !== 200 || !is_array($response) ||
            ($response['success'] ?? null) !== true ||
            !array_key_exists('data', $response) || array_key_exists('error', $response)) {
            $error = is_array($response) && is_array($response['error'] ?? null)
                ? $response['error']
                : [];
            throw new RuntimeException(
                'API Error ' . ($error['code'] ?? 'unknown') . ': ' .
                ($error['message'] ?? 'unknown')
            );
        }
        return $response['data'];
    }

    private static function isInt32(mixed $value): bool {
        return is_int($value) && $value >= -(2 ** 31) && $value <= (2 ** 31) - 1;
    }

    private static function exactFields(array $value, array $expected): bool {
        $actual = array_keys($value);
        sort($actual);
        sort($expected);
        return $actual === $expected;
    }

    private static function isV1ErrorResponse(mixed $value, mixed $shape): bool {
        if (!is_array($value) || !self::exactFields($value, ['success', 'error']) ||
            ($value['success'] ?? null) !== false || !is_array($value['error']) ||
            !($shape instanceof \stdClass) ||
            !(($shape->error ?? null) instanceof \stdClass)) {
            return false;
        }
        $error = $value['error'];
        $fields = array_keys($error);
        sort($fields);
        if ($fields !== ['code', 'message'] && $fields !== ['code', 'details', 'message']) {
            return false;
        }
        if (!is_string($error['code']) || !is_string($error['message'])) {
            return false;
        }
        if (!array_key_exists('details', $error)) {
            return true;
        }
        return property_exists($shape->error, 'details') &&
            $shape->error->details instanceof \stdClass;
    }

    private static function validateV1CreateOrderResult(mixed $value, mixed $shape): array {
        if (!is_array($value) || !self::exactFields($value, ['orders', 'failed_count'])) {
            throw new UnexpectedValueException('ฟิลด์ของ V1CreateOrderResult ไม่ถูกต้อง');
        }
        if (!($shape instanceof \stdClass) || !is_array($shape->orders ?? null)) {
            throw new UnexpectedValueException('orders ต้องมาจาก JSON array');
        }
        if (!is_array($value['orders']) || !array_is_list($value['orders'])) {
            throw new UnexpectedValueException('orders ต้องเป็น array');
        }
        if (!self::isInt32($value['failed_count']) || $value['failed_count'] < 0) {
            throw new UnexpectedValueException('failed_count ต้องเป็น int32 ที่ไม่ติดลบ');
        }
        foreach ($value['orders'] as $item) {
            if (!is_array($item) || array_is_list($item)) {
                throw new UnexpectedValueException('แต่ละ order ต้องเป็น object');
            }
            $fields = array_keys($item);
            if (array_diff(self::V1_CREATE_REQUIRED_FIELDS, $fields) !== [] ||
                array_diff($fields, self::V1_CREATE_FIELDS) !== []) {
                throw new UnexpectedValueException('ฟิลด์ของ V1CreateOrderItem ไม่ถูกต้อง');
            }
            if (!self::isInt32($item['id']) || !self::isInt32($item['product_id'])) {
                throw new UnexpectedValueException('order ID และ product ID ต้องเป็น int32');
            }
            foreach (['catalog_product_id', 'operator_id'] as $field) {
                if (array_key_exists($field, $item) && $item[$field] !== null &&
                    !self::isInt32($item[$field])) {
                    throw new UnexpectedValueException("{$field} ต้องเป็น nullable int32");
                }
            }
            if (!in_array($item['status'], self::ORDER_STATUSES, true)) {
                throw new UnexpectedValueException('สถานะออเดอร์ไม่อยู่ในสัญญา');
            }
            if (!is_int($item['amount'])) {
                throw new UnexpectedValueException('amount ต้องเป็น int64');
            }
            foreach (self::V1_CREATE_BOOLEAN_FIELDS as $field) {
                if (!is_bool($item[$field])) {
                    throw new UnexpectedValueException("{$field} ต้องเป็น boolean");
                }
            }
            foreach (self::V1_CREATE_NULLABLE_STRING_FIELDS as $field) {
                if (array_key_exists($field, $item) && $item[$field] !== null &&
                    !is_string($item[$field])) {
                    throw new UnexpectedValueException("{$field} ต้องเป็น nullable string");
                }
            }
        }
        return $value;
    }

    private static function validOrderSnapshot(mixed $value, int $orderId): bool {
        return is_array($value) && self::isInt32($value['id'] ?? null) &&
            $value['id'] === $orderId &&
            in_array($value['status'] ?? null, self::ORDER_STATUSES, true) &&
            !array_key_exists('refund_amount', $value) &&
            !array_key_exists('new_balance', $value);
    }

    private static function validatedCancelReceipt(mixed $payload, int $orderId): ?array {
        if (!is_array($payload) || ($payload['success'] ?? null) !== true ||
            array_diff(array_keys($payload), ['success', 'data', 'meta']) !== []) {
            return null;
        }
        $data = $payload['data'] ?? null;
        if (!is_array($data) || !self::exactFields(
            $data,
            ['order_id', 'status', 'refund_amount', 'new_balance']
        )) return null;
        if (!self::isInt32($data['order_id']) || $data['order_id'] !== $orderId ||
            $data['status'] !== 'CANCELED') return null;
        if (!is_int($data['refund_amount']) || $data['refund_amount'] < 0 ||
            !is_int($data['new_balance'])) return null;
        return $data;
    }

    private static function documentedCancelError(int $httpStatus, mixed $payload): ?array {
        if (!is_array($payload) || !self::exactFields($payload, ['success', 'error']) ||
            $payload['success'] !== false || !is_array($payload['error'])) return null;
        $error = $payload['error'];
        $fields = array_keys($error);
        if (array_diff(['code', 'message'], $fields) !== [] ||
            array_diff($fields, ['code', 'message', 'details']) !== [] ||
            !is_string($error['code']) || !is_string($error['message']) ||
            (array_key_exists('details', $error) && !is_array($error['details']))) return null;
        return in_array(
            $error['code'],
            self::CANCEL_ERROR_CODES_BY_STATUS[$httpStatus] ?? [],
            true
        ) ? $error : null;
    }

    public function prepareCreateAttempt(
        string $callerScope,
        string $businessJobId,
        string $idempotencyKey,
        int $reviewThresholdAt,
        int $catalogProductId,
        int $quantity = 1
    ): array {
        if ($callerScope === '' || $businessJobId === '' || $idempotencyKey === '') {
            throw new InvalidArgumentException(
                'ต้องมี caller identity และ persisted idempotency key'
            );
        }
        if ($reviewThresholdAt < 0) {
            throw new InvalidArgumentException(
                'review_threshold_at ต้องเป็น timestamp ของ store clock'
            );
        }
        if (!self::isInt32($catalogProductId) || !self::isInt32($quantity) ||
            $quantity <= 0) {
            throw new InvalidArgumentException(
                'catalog_product_id และ quantity ต้องเป็น int32 ที่ถูกต้อง'
            );
        }
        $body = ['catalog_product_id' => $catalogProductId, 'quantity' => $quantity];
        ksort($body);
        $endpoint = '/orders/create';
        $bodyJson = json_encode($body, JSON_THROW_ON_ERROR);
        $requestFingerprint = hash('sha256', $endpoint . "\0" . $bodyJson);
        $startedAt = $this->attemptStore->now();
        $candidate = [
            'caller_scope' => $callerScope,
            'business_job_id' => $businessJobId,
            'endpoint' => $endpoint,
            'body_json' => $bodyJson,
            'request_fingerprint' => $requestFingerprint,
            'idempotency_key' => $idempotencyKey,
            'review_threshold_at' => $reviewThresholdAt,
            'started_at' => $startedAt,
            'effective_replay_deadline_at' => min(
                $reviewThresholdAt,
                $startedAt + self::CLIENT_REPLAY_MAX
            ),
            'sends' => 0,
            'attempt_history' => [],
        ];
        // หาก pair เดิมมี fingerprint, key หรือ threshold ต่างกัน ให้ conflict
        $attempt = $this->attemptStore->insertOrLoad(
            $callerScope,
            $businessJobId,
            $requestFingerprint,
            $candidate
        );
        $this->assertAttemptBinding(
            $attempt,
            $callerScope,
            $businessJobId,
            $idempotencyKey,
            $reviewThresholdAt
        );
        return $attempt;
    }

    private function assertAttemptBinding(
        array $attempt,
        string $callerScope,
        string $businessJobId,
        string $idempotencyKey,
        int $reviewThresholdAt
    ): void {
        if (!is_int($attempt['started_at'] ?? null) || $attempt['started_at'] < 0) {
            throw new InvalidArgumentException(
                'stored create attempt ไม่มี started_at ของ store clock'
            );
        }
        $expectedDeadline = min(
            $reviewThresholdAt,
            $attempt['started_at'] + self::CLIENT_REPLAY_MAX
        );
        if (($attempt['effective_replay_deadline_at'] ?? null) !== $expectedDeadline) {
            throw new InvalidArgumentException(
                'effective replay deadline ถูกเปลี่ยนหลัง prepare'
            );
        }
        if (!is_int($attempt['sends'] ?? null) || $attempt['sends'] < 0) {
            throw new InvalidArgumentException(
                'stored sends ต้องเป็นจำนวนเต็มที่ไม่ติดลบ'
            );
        }
        try {
            $persistedBody = json_decode(
                $attempt['body_json'] ?? '',
                true,
                512,
                JSON_THROW_ON_ERROR
            );
        } catch (JsonException $error) {
            throw new InvalidArgumentException(
                'stored create body ไม่ใช่ canonical JSON ที่ถูกต้อง'
            );
        }
        if (!is_array($persistedBody) || !self::exactFields(
            $persistedBody,
            ['catalog_product_id', 'quantity']
        ) || !self::isInt32($persistedBody['catalog_product_id']) ||
            !self::isInt32($persistedBody['quantity']) ||
            $persistedBody['quantity'] <= 0) {
            throw new InvalidArgumentException(
                'stored create body ไม่ตรงกับ request contract'
            );
        }
        $fingerprint = hash(
            'sha256',
            $attempt['endpoint'] . "\0" . $attempt['body_json']
        );
        if (($attempt['caller_scope'] ?? null) !== $callerScope ||
            ($attempt['business_job_id'] ?? null) !== $businessJobId ||
            ($attempt['idempotency_key'] ?? null) !== $idempotencyKey ||
            ($attempt['review_threshold_at'] ?? null) !== $reviewThresholdAt ||
            ($attempt['endpoint'] ?? null) !== '/orders/create' ||
            ($attempt['request_fingerprint'] ?? null) !== $fingerprint) {
            throw new InvalidArgumentException(
                'stored create attempt ไม่ตรงกับ caller identity'
            );
        }
    }

    private function appendCreateEvent(
        string $callerScope,
        string $businessJobId,
        array $attempt,
        string $claimToken,
        array $event
    ): array {
        // Store ต้อง append event และ project event.sends ไปยัง attempt ใน CAS เดียวกัน
        return $this->attemptStore->appendEvent(
            $callerScope,
            $businessJobId,
            $claimToken,
            $attempt['version'],
            $event
        );
    }

    protected function sendCreateRequest(array $attempt): array {
        return $this->sendRequest(
            'POST',
            $attempt['endpoint'],
            $attempt['body_json'],
            ['Idempotency-Key: ' . $attempt['idempotency_key']]
        );
    }

    private function recoveryFields(array $attempt): array {
        return [
            'endpoint' => $attempt['endpoint'],
            'body_json' => $attempt['body_json'],
            'idempotency_key' => $attempt['idempotency_key'],
            'review_threshold_at' => $attempt['review_threshold_at'],
            'started_at' => $attempt['started_at'],
            'effective_replay_deadline_at' => $attempt['effective_replay_deadline_at'],
            'sends' => $attempt['sends'],
        ];
    }

    private function backoffSeconds(array $attempt): int {
        $sendCount = $attempt['sends'];
        $exponent = min(max($sendCount - 1, 0), 5);
        return min(
            self::RECONCILIATION_BASE_BACKOFF_SECONDS * (2 ** $exponent),
            self::RECONCILIATION_MAX_BACKOFF_SECONDS
        );
    }

    private function needsReconciliation(
        string $callerScope,
        string $businessJobId,
        array $attempt,
        string $claimToken,
        string $reason,
        array $evidence = []
    ): array {
        $event = array_merge([
            'state' => 'needs_reconciliation',
            'reason' => $reason,
        ], $this->recoveryFields($attempt), $evidence);
        $event['next_attempt_at'] = min(
            $this->attemptStore->now() + $this->backoffSeconds($attempt),
            $attempt['effective_replay_deadline_at']
        );
        $attempt = $this->appendCreateEvent(
            $callerScope,
            $businessJobId,
            $attempt,
            $claimToken,
            $event
        );
        return ['kind' => 'needs_reconciliation', 'attempt' => $attempt];
    }

    public function orderNumber(
        string $callerScope,
        string $businessJobId,
        string $idempotencyKey,
        int $reviewThresholdAt,
        array $attempt
    ): array {
        $this->assertAttemptBinding(
            $attempt,
            $callerScope,
            $businessJobId,
            $idempotencyKey,
            $reviewThresholdAt
        );
        $preNow = $this->attemptStore->now();
        if ($preNow >= $attempt['effective_replay_deadline_at'] ||
            $attempt['sends'] >= self::MAX_AUTOMATIC_SENDS) {
            // Store ตรวจ version, persisted bounds และ clock ซ้ำ แล้ว retain แบบ CAS
            // โดยไม่ออก send claim
            $attempt = $this->attemptStore->retainOperatorReview(
                $callerScope,
                $businessJobId,
                $attempt,
                $preNow
            );
            return ['kind' => 'operator_review', 'attempt' => $attempt];
        }
        $claimed = $this->attemptStore->claimForSend(
            $callerScope,
            $businessJobId,
            $attempt['version'],
            self::CREATE_LEASE_SECONDS
        );
        $attempt = $claimed['attempt'];
        $claimToken = $claimed['claim_token'];
        $this->assertAttemptBinding(
            $attempt,
            $callerScope,
            $businessJobId,
            $idempotencyKey,
            $reviewThresholdAt
        );

        $storeNow = $this->attemptStore->now();
        if ($storeNow >= $attempt['effective_replay_deadline_at'] ||
            $attempt['sends'] >= self::MAX_AUTOMATIC_SENDS) {
            $attempt = $this->appendCreateEvent(
                $callerScope,
                $businessJobId,
                $attempt,
                $claimToken,
                array_merge([
                    'state' => 'operator_review',
                    'retained_at' => $storeNow,
                ], $this->recoveryFields($attempt))
            );
            return ['kind' => 'operator_review', 'attempt' => $attempt];
        }

        $sendNumber = $attempt['sends'] + 1;
        $attempt = $this->appendCreateEvent(
            $callerScope,
            $businessJobId,
            $attempt,
            $claimToken,
            array_merge($this->recoveryFields($attempt), [
                'number' => $sendNumber,
                'state' => 'sending',
                'last_send_at' => $storeNow,
                'deadline_at' => $storeNow + self::CREATE_TOTAL_TIMEOUT_SECONDS,
                'sends' => $sendNumber,
            ])
        );
        if (($attempt['sends'] ?? null) !== $sendNumber) {
            throw new RuntimeException('store ไม่ได้ persist sends ใน sending CAS');
        }

        try {
            [$httpStatus, $rawBody] = $this->sendCreateRequest($attempt);
        } catch (\Throwable $error) {
            return $this->needsReconciliation(
                $callerScope, $businessJobId, $attempt, $claimToken, 'transport'
            );
        }

        if ($httpStatus >= 500) {
            return $this->needsReconciliation(
                $callerScope,
                $businessJobId,
                $attempt,
                $claimToken,
                'http_5xx',
                ['http_status' => $httpStatus]
            );
        }

        try {
            $payload = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
            $payloadShape = json_decode($rawBody, false, 512, JSON_THROW_ON_ERROR);
        } catch (JsonException $error) {
            return $this->needsReconciliation(
                $callerScope,
                $businessJobId,
                $attempt,
                $claimToken,
                'malformed_response',
                ['http_status' => $httpStatus]
            );
        }

        if (is_array($payload) && ($payload['success'] ?? null) === false) {
            $error = $payload['error'] ?? null;
            $code = is_array($error) ? ($error['code'] ?? null) : null;
            $code = is_string($code) ? $code : null;
            if (!self::isV1ErrorResponse($payload, $payloadShape)) {
                return $this->needsReconciliation(
                    $callerScope,
                    $businessJobId,
                    $attempt,
                    $claimToken,
                    'malformed_response',
                    ['http_status' => $httpStatus, 'error_code' => $code]
                );
            }
            if ($httpStatus === 422 && in_array($code, self::DEFINITIVE_CREATE_ERRORS, true)) {
                $attempt = $this->appendCreateEvent(
                    $callerScope,
                    $businessJobId,
                    $attempt,
                    $claimToken,
                    [
                        'state' => 'definitive_rejection',
                        'http_status' => $httpStatus,
                        'error_code' => $code,
                    ]
                );
                return ['kind' => 'definitive_rejection', 'attempt' => $attempt, 'code' => $code];
            }
            if ($httpStatus === 409 && $code === 'INSUFFICIENT_BALANCE') {
                $attempt = $this->appendCreateEvent(
                    $callerScope,
                    $businessJobId,
                    $attempt,
                    $claimToken,
                    [
                        'state' => 'insufficient_balance',
                        'http_status' => $httpStatus,
                        'error_code' => $code,
                    ]
                );
                return ['kind' => 'insufficient_balance', 'attempt' => $attempt];
            }
            return $this->needsReconciliation(
                $callerScope,
                $businessJobId,
                $attempt,
                $claimToken,
                $httpStatus === 409 && $code === 'REQUEST_IN_PROGRESS'
                    ? 'request_in_progress'
                    : 'unknown_response',
                ['http_status' => $httpStatus, 'error_code' => $code]
            );
        }

        if ($httpStatus !== 200 || !is_array($payload) ||
            ($payload['success'] ?? null) !== true || !array_key_exists('data', $payload) ||
            array_key_exists('error', $payload)) {
            return $this->needsReconciliation(
                $callerScope,
                $businessJobId,
                $attempt,
                $claimToken,
                'contradictory_response',
                ['http_status' => $httpStatus]
            );
        }

        try {
            $validatedResult = self::validateV1CreateOrderResult(
                $payload['data'],
                $payloadShape instanceof \stdClass ? ($payloadShape->data ?? null) : null
            );
            $persistedBody = json_decode(
                $attempt['body_json'],
                true,
                512,
                JSON_THROW_ON_ERROR
            );
            if (count($validatedResult['orders']) + $validatedResult['failed_count'] !==
                $persistedBody['quantity']) {
                throw new UnexpectedValueException(
                    'create result ไม่อนุรักษ์ quantity ที่ persist ไว้'
                );
            }
        } catch (JsonException|UnexpectedValueException $error) {
            return $this->needsReconciliation(
                $callerScope,
                $businessJobId,
                $attempt,
                $claimToken,
                'malformed_success',
                ['http_status' => $httpStatus]
            );
        }

        $attempt = $this->appendCreateEvent(
            $callerScope,
            $businessJobId,
            $attempt,
            $claimToken,
            ['state' => 'resolved', 'validated_response' => $validatedResult]
        );
        return [
            'kind' => 'resolved',
            'attempt' => $attempt,
            'result' => $validatedResult,
        ];
    }

    public function reconcileCreateOrder(
        string $callerScope,
        string $businessJobId,
        string $idempotencyKey,
        int $reviewThresholdAt
    ): array {
        $attempt = $this->attemptStore->load($callerScope, $businessJobId);
        $this->assertAttemptBinding(
            $attempt,
            $callerScope,
            $businessJobId,
            $idempotencyKey,
            $reviewThresholdAt
        );
        return $this->orderNumber(
            $callerScope,
            $businessJobId,
            $idempotencyKey,
            $reviewThresholdAt,
            $attempt
        );
    }

    public function getOrder(int $orderId): array {
        return $this->request('GET', "/orders/{$orderId}");
    }

    private function reconcileCancellation(int $orderId): array {
        try {
            $latest = $this->getOrder($orderId);
        } catch (Throwable $error) {
            return ['kind' => 'ambiguous', 'order_id' => $orderId, 'latest_status' => null];
        }
        if (self::validOrderSnapshot($latest, $orderId) &&
            $latest['status'] === 'CANCELED') {
            return [
                'kind' => 'confirmed_canceled',
                'snapshot' => ['id' => $orderId, 'status' => 'CANCELED'],
            ];
        }
        return [
            'kind' => 'ambiguous',
            'order_id' => $orderId,
            'latest_status' => self::validOrderSnapshot($latest, $orderId)
                ? $latest['status']
                : null,
        ];
    }

    public function cancelOrder(int $orderId): array {
        try {
            $current = $this->getOrder($orderId);
        } catch (Throwable $error) {
            return ['kind' => 'ambiguous', 'order_id' => $orderId, 'latest_status' => null];
        }
        if (!self::validOrderSnapshot($current, $orderId) ||
            !is_bool($current['can_cancel'] ?? null)) {
            return ['kind' => 'ambiguous', 'order_id' => $orderId, 'latest_status' => null];
        }
        if (!$current['can_cancel']) return ['kind' => 'skipped', 'order_id' => $orderId];

        try {
            [$httpStatus, $rawBody] = $this->sendRequest(
                'POST',
                '/orders/cancel',
                ['id' => $orderId]
            );
        } catch (Throwable $error) {
            return $this->reconcileCancellation($orderId);
        }
        try {
            $payload = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
        } catch (JsonException $error) {
            return $this->reconcileCancellation($orderId);
        }

        if ($httpStatus === 200) {
            $receipt = self::validatedCancelReceipt($payload, $orderId);
            return $receipt !== null
                ? ['kind' => 'receipt', 'receipt' => $receipt]
                : $this->reconcileCancellation($orderId);
        }
        $apiError = self::documentedCancelError($httpStatus, $payload);
        if ($apiError !== null) {
            return [
                'kind' => 'rejected',
                'http_status' => $httpStatus,
                'error_code' => $apiError['code'],
            ];
        }
        return $this->reconcileCancellation($orderId);
    }

    public function waitForOtp(int $orderId, int $timeoutSeconds = 240): ?string {
        $deadline = time() + $timeoutSeconds;
        $lastSeenRevision = -1;

        // The first poll always runs, even for a non-positive budget.
        // `$timeoutSeconds` bounds when this loop stops STARTING later polls.
        while (true) {
            $order = $this->getOrder($orderId);
            $smsRevision = $order['sms_revision'] ?? null;
            $otpMessage = $order['otp_message'] ?? null;

            if (is_int($smsRevision) && $smsRevision > $lastSeenRevision &&
                is_string($otpMessage) && trim($otpMessage) !== '') {
                $lastSeenRevision = $smsRevision;
                return $otpMessage;
            }
            if (in_array($order['status'], ['COMPLETED', 'EXPIRED', 'CANCELED'])) return null;

            // Clamp to the remaining budget, then gate: no later poll starts once
            // it is spent, and the cancellation path below stays reachable.
            sleep((int) max(0, min(10, $deadline - time())));
            if (time() >= $deadline) {
                break;
            }
        }

        $cancellation = $this->cancelOrder($orderId);
        if ($cancellation['kind'] === 'receipt') {
            $receipt = $cancellation['receipt'];
            echo "คืนเงิน Rp {$receipt['refund_amount']}; ยอดใหม่ Rp {$receipt['new_balance']}\n";
        } elseif ($cancellation['kind'] === 'confirmed_canceled') {
            echo "Latest snapshot เป็น CANCELED แต่ไม่มี cancel receipt หรือหลักฐานการคืนเงิน\n";
        } elseif ($cancellation['kind'] === 'rejected') {
            echo "คำขอยกเลิกถูกปฏิเสธ: {$cancellation['error_code']}\n";
        } elseif ($cancellation['kind'] === 'ambiguous') {
            echo "ผลการยกเลิกยังไม่ชัดเจน ให้เก็บไว้ใน durable reconciliation\n";
        } else {
            echo "Latest snapshot มี can_cancel=false จึงไม่ได้ส่ง cancel POST\n";
        }
        return null;
    }
}

// ตัวอย่างการใช้งาน
$attemptStore = require __DIR__ . '/durable-create-attempt-store.php';
$client = new SMSCodeClient(getenv('SMSCODE_API_TOKEN'), $attemptStore);
$callerScope = getenv('SMSCODE_CALLER_SCOPE');
$businessJobId = getenv('SMSCODE_BUSINESS_JOB_ID');
$idempotencyKey = getenv('SMSCODE_IDEMPOTENCY_KEY');
$reviewThresholdAt = (int) getenv('SMSCODE_REVIEW_THRESHOLD_AT');
$attempt = $client->prepareCreateAttempt(
    $callerScope,
    $businessJobId,
    $idempotencyKey,
    $reviewThresholdAt,
    88,
    1
);
$createOutcome = $client->orderNumber(
    $callerScope,
    $businessJobId,
    $idempotencyKey,
    $reviewThresholdAt,
    $attempt
);
if ($createOutcome['kind'] !== 'resolved') {
    echo "Create durable state: {$createOutcome['kind']}\n";
    return;
}
if (count($createOutcome['result']['orders']) !== 1 ||
    $createOutcome['result']['failed_count'] !== 0) {
    throw new RuntimeException('Create resolved แต่ไม่ได้รับ order เพียงหนึ่งรายการ');
}
$order = $createOutcome['result']['orders'][0];
$orderId = $order['id'];
$phone = $order['phone_number'] ?? null;
$assignmentStatus = $order['status'];
if (!(is_string($phone) && trim($phone) !== '')) {
    try {
        $current = $client->getOrder($orderId);
        if (is_array($current) && is_int($current['id'] ?? null) &&
            $current['id'] === $orderId && is_string($current['status'] ?? null) &&
            in_array(
                $current['status'],
                ['ACTIVE', 'OTP_RECEIVED', 'COMPLETED', 'CANCELED', 'EXPIRED'],
                true
            ) && !array_key_exists('refund_amount', $current) &&
            !array_key_exists('new_balance', $current)) {
            $assignmentStatus = $current['status'];
            $currentPhone = $current['phone_number'] ?? null;
            if (is_string($currentPhone) && trim($currentPhone) !== '') {
                $phone = $currentPhone;
            }
        }
    } catch (Throwable $error) {
        // Lookup failure resolves to pending_assignment below.
    }
}
if (!(is_string($phone) && trim($phone) !== '')) {
    return [
        'create_outcome' => 'resolved',
        'assignment' => [
            'kind' => 'pending_assignment',
            'order_id' => $orderId,
            'status' => $assignmentStatus,
        ],
    ];
}

echo "เบอร์เสมือน: {$phone}\n";

$otp = $client->waitForOtp($orderId);
echo $otp ? "OTP: {$otp}\n" : "ไม่ได้รับ OTP\n";

ตั้งค่า Webhook สำหรับ Production (แนะนำอย่างยิ่ง)

Webhook ลดการ polling ได้ แต่ event อาจมาช้าหรือถูก retry จึงต้องมี atomic deduplication และใช้ polling สำหรับ reconciliation ต่อไป ตอบ 2xx หลัง persist หรือ enqueue สำเร็จเท่านั้น

วิธีตั้งค่า Webhook

  1. ไปที่หน้า Account → Webhook Notifications
  2. กรอก URL ของ server ที่จะรับ webhook เช่น https://yourapp.com/webhooks/sms
  3. ระบบจะสร้าง Webhook Secret ให้โดยอัตโนมัติ เก็บไว้ใช้ตรวจสอบ signature
  4. กด Save และใช้ Send Test เพื่อตรวจ endpoint ก่อนเปิดใช้งานจริง

Webhook Payload ตัวอย่าง

{
  "event": "order.otp_received",
  "timestamp": "2026-03-16T10:15:30Z",
  "data": {
    "order_id": 90210,
    "phone_number": "+66812345678",
    "otp_code": null,
    "otp_message": "Your LINE verification code is 123456. Do not share this code.",
    "sms_revision": 1,
    "product_id": 1024,
    "catalog_product_id": 88,
    "country": "Thailand",
    "platform": "LINE",
    "operator_id": null,
    "operator_name": null,
    "can_finish": true,
    "can_resend": false,
    "can_cancel": false,
    "can_replace": false,
    "can_reactivate": false,
    "resend_available_at": null,
    "cancel_available_at": null,
    "replace_available_at": null
  }
}

Webhook Handler Node.js / Express

import express from "express";
import crypto from "crypto";
import { durableInbox } from "./durable-inbox";

const app = express();
const webhookSecret = process.env.WEBHOOK_SECRET!;

// ฟังก์ชันตรวจสอบ webhook signature
function verifySignature(rawBody: Buffer, signature: string, secret: string): boolean {
  const expected = `sha256=${crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex")}`;
  const actualBuffer = Buffer.from(signature);
  const expectedBuffer = Buffer.from(expected);
  return actualBuffer.length === expectedBuffer.length &&
    crypto.timingSafeEqual(actualBuffer, expectedBuffer);
}

function normalizeWebhookEvent(event: string, data: Record<string, unknown>) {
  if (event === "webhook.test") {
    return { event, message: data.message };
  }
  return {
    event,
    orderId: data.order_id,
    message: data.otp_message,
    revision: data.sms_revision,
  };
}

function webhookDedupeKey(
  event: string,
  data: Record<string, unknown>,
  rawBody: Buffer,
): string {
  if (event === "webhook.test") {
    return `${event}:${crypto.createHash("sha256").update(rawBody).digest("hex")}`;
  }
  if (event === "order.otp_received") {
    return `${event}:${data.order_id}:${data.sms_revision}`;
  }
  return `${event}:${data.order_id}`;
}

async function persistOrEnqueueOnce(
  dedupeKey: string,
  eventRecord: Record<string, unknown>,
) {
  await durableInbox.insertIfAbsent(dedupeKey, eventRecord);
}

app.post(
  "/webhooks/sms",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const signature = req.get("X-Webhook-Signature") || "";
    if (!verifySignature(req.body, signature, webhookSecret)) {
      console.error("Webhook signature ไม่ถูกต้อง");
      return res.status(401).json({ error: "Invalid signature" });
    }

    const { event, data } = JSON.parse(req.body.toString("utf8"));
    const dedupeKey = webhookDedupeKey(event, data, req.body);
    const eventRecord = normalizeWebhookEvent(event, data);
    await persistOrEnqueueOnce(dedupeKey, eventRecord);
    return res.status(200).json({ received: true });
  }
);

Webhook Handler Python / FastAPI

from fastapi import FastAPI, Request, HTTPException
from myapp.inbox import durable_inbox
import hmac
import hashlib
import json
import os

app = FastAPI()

def verify_signature(body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode(),
        body,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, f"sha256={expected}")

def normalize_webhook_event(event: str, data: dict) -> dict:
    if event == "webhook.test":
        return {"event": event, "message": data["message"]}
    return {
        "event": event,
        "order_id": data["order_id"],
        "otp_message": data["otp_message"],
        "sms_revision": data["sms_revision"],
    }

def webhook_dedupe_key(event: str, data: dict, body: bytes) -> str:
    if event == "webhook.test":
        return f"{event}:{hashlib.sha256(body).hexdigest()}"
    if event == "order.otp_received":
        return f"{event}:{data['order_id']}:{data['sms_revision']}"
    return f"{event}:{data['order_id']}"

async def persist_or_enqueue_once(dedupe_key: str, event_record: dict) -> None:
    await durable_inbox.insert_if_absent(dedupe_key, event_record)

@app.post("/webhooks/sms")
async def receive_webhook(request: Request):
    body = await request.body()
    signature = request.headers.get("X-Webhook-Signature", "")
    secret = os.environ["WEBHOOK_SECRET"]

    if not verify_signature(body, signature, secret):
        raise HTTPException(status_code=401, detail="Invalid signature")

    payload = json.loads(body)
    event = payload.get("event")
    data = payload.get("data", {})

    dedupe_key = webhook_dedupe_key(event, data, body)
    event_record = normalize_webhook_event(event, data)
    await persist_or_enqueue_once(dedupe_key, event_record)
    return {"received": True}

insertIfAbsent หรือ insert_if_absent ต้องเป็น atomic unique insert: event OTP ใช้ event + order_id + sms_revision, terminal event ใช้ event + order_id, และ webhook.test ใช้ SHA-256 ของ raw body ตัว request handler ทำเพียง normalize event แล้วบันทึกลง durable inbox; ให้ worker แยกต่างหากทำ business side effects จาก inbox หากบันทึกแบบ durable ไม่สำเร็จ ห้ามตอบ 2xx

Rate Limits และแนวทางปฏิบัติที่ดี

Rate Limits

เมื่อเกิน rate limit API จะตอบกลับด้วย HTTP 429 Too Many Requests ควรอ่าน header Retry-After; ถ้า header หายหรือไม่ใช่จำนวนบวก ให้ใช้ fallback delay ที่จำกัดไว้แทนการเดาค่า quota คงที่

แนวทางปฏิบัติที่ดีสำหรับ Production

1. ใช้ webhook ร่วมกับ polling สำหรับ reconciliation Webhook ลดจำนวน request ได้ แต่ต้องรองรับ retry แบบ idempotent และไม่ใช่หลักฐานว่า quota คงที่

2. ยกเลิกตามสถานะจากเซิร์ฟเวอร์ อ่านออเดอร์ล่าสุดก่อน และเรียก POST /v1/orders/cancel ด้วย body {"id": order_id} เฉพาะเมื่อ can_cancel: true; ผลลัพธ์จากเซิร์ฟเวอร์เป็นข้อยุติ

3. ตั้ง timeout ที่เหมาะสม ตั้ง local timeout แบบจำกัด อ่าน snapshot ล่าสุดเมื่อหมดเวลา และยกเลิกเฉพาะเมื่อ can_cancel: true

4. Handle errors อย่างครบถ้วน branch ตาม error.code ที่ประกาศใน OpenAPI เช่น INSUFFICIENT_BALANCE (409), NOT_FOUND (404), VALIDATION_ERROR (422), RATE_LIMIT_EXCEEDED (429) และ SERVICE_UNAVAILABLE (503)

5. เก็บ API key ใน environment variables ไม่ hardcode ใน code ไม่เช็คอิน Git ใช้ secrets manager เช่น AWS Secrets Manager หรือ HashiCorp Vault ใน production

6. Log ทุก API call สำหรับ debugging เก็บ order_id, timestamp, service, country และ status ไว้ใน database เพื่อ audit trail

7. ทดสอบ paid flow อย่างจำกัด ไม่มี sandbox credit แยกต่างหาก เลือกสินค้าที่มีต้นทุนต่ำจาก catalog ใช้ key เดิมเมื่อผลลัพธ์ create ไม่ชัดเจน และบันทึกหลักฐานสำหรับ reconciliation

Use Cases ที่นักพัฒนาไทยใช้บ่อย

สร้าง Test Accounts อัตโนมัติ

ทีม QA ที่ทดสอบ LINE Bot หรือ Shopee integration บ่อยๆ มักต้องสร้างบัญชีใหม่ทุกครั้ง SMSCode API ช่วยให้ทำ automated test ที่สร้างบัญชีใหม่ ทดสอบ feature แล้ว cleanup ได้ทั้งหมดโดยอัตโนมัติ

ระบบ Signup ที่ต้องการ Unique Phone

SaaS ที่ต้องการให้ผู้ใช้ยืนยันเบอร์โทรแต่ไม่ต้องการ expose เบอร์จริง สามารถออกเบอร์เสมือนให้ผู้ใช้แต่ละคนผ่าน API ได้

Monitoring ระบบ OTP

ติดตามผลจากออเดอร์ที่ workflow อนุมัติไว้แล้ว หลีกเลี่ยงการสร้าง paid order ตามเวลาโดยไม่มี budget, idempotency และ reconciliation ที่ชัดเจน


FAQ

ต้องมีความรู้ programming ระดับไหนจึงใช้ API ได้?

ต้องการความรู้พื้นฐาน HTTP REST API สามารถใช้ภาษาใดก็ได้ที่รองรับ HTTP requests เช่น Python, JavaScript/TypeScript, PHP, Go, Ruby, Java, Dart ฯลฯ ถ้ารู้จัก curl พื้นฐาน เริ่มต้นได้เลย

Rate limit ถูก exceeded จะเกิดอะไรขึ้น?

API จะตอบกลับด้วย HTTP 429 Too Many Requests ถ้ามี header Retry-After ที่เป็นจำนวนบวกให้รอตามค่านั้น ถ้าไม่มีหรือ malformed ให้ใช้ fallback delay แบบมีขอบเขต และ retry เฉพาะ operation ที่อนุญาต

API มี uptime guarantee ไหม?

คู่มือนี้ไม่ได้ประกาศ SLA คงที่ Client ควรตั้ง connection/total timeout จัดการ network หรือ 5xx แบบ fail-closed และเก็บหลักฐาน request สำหรับ reconciliation

สามารถทดสอบใน sandbox ก่อนใช้งานจริงได้ไหม?

ไม่มีโหมด sandbox credit แยกต่างหาก การทดสอบ paid create ใช้ API จริง เลือกสินค้าที่มีต้นทุนต่ำจาก catalog และจำกัดจำนวนออเดอร์

ถ้า OTP ไม่มาจะเสียเงินไหม?

ถ้าออเดอร์หมดอายุโดยไม่มี SMS ระบบจะเปลี่ยนสถานะเป็น EXPIRED และคืนยอดที่ตัดไว้แบบ atomic; ถ้ามี SMS แล้ว ระบบอาจปิดออเดอร์โดยไม่คืนเงิน สำหรับการยกเลิกด้วยตัวเองให้อ่าน snapshot ล่าสุดและส่งคำขอเฉพาะเมื่อ can_cancel=true เท่านั้น ใช้ refund_amount กับ new_balance ได้เฉพาะจาก cancel receipt แบบ HTTP 200 ที่มี data.status=CANCELED; snapshot CANCELED ยืนยันได้เพียงสถานะและต้องไม่มีฟิลด์ receipt

รองรับภาษา Go, Rust หรือ Dart ไหม?

รองรับทุกภาษาที่ส่ง HTTP request ได้ เนื่องจาก API เป็น standard REST คุณสามารถสร้าง wrapper จาก endpoint และ schema ในเอกสาร API ได้โดยตรง

ความแตกต่างระหว่าง polling กับ webhook คืออะไร?

Polling คือการที่โค้ดของคุณถาม SMSCode ซ้ำตาม interval ที่กำหนดเองและมีขอบเขต ส่วน webhook คือการแจ้ง event แบบ asynchronous ซึ่งอาจถูก retry ได้ ตรวจ signature เก็บ event แบบ durable และ deduplicate ก่อนตอบ 2xx แล้วใช้ polling สำหรับ reconciliation

ถ้าต้องการสั่งเบอร์หลายร้อยเบอร์พร้อมกัน ทำได้ไหม?

ทำได้เมื่อ catalog มี stock และยอดเงินเพียงพอ จำกัด concurrency เคารพ Retry-After และอย่าสร้างออเดอร์ใหม่เมื่อผลลัพธ์ paid create เดิมยังไม่ชัดเจน

พร้อมลอง SMSCode แล้วหรือยัง?

สร้างบัญชีแล้วรับเบอร์เสมือนแรกของคุณภายในไม่ถึงสองนาที

เริ่มต้นใช้งาน →