เอกสารมีรหัสผ่าน
คู่มือต่อระบบ LESS
ใส่รหัสผ่านเพื่อเปิดคู่มือ SDK
รหัสผ่านไม่ถูกต้อง
คู่มือต่อ Android EDC เข้ากับ LESS

LESS SDK — คู่มือสำหรับนักพัฒนา EDC

คู่มือทางเทคนิคสำหรับต่อเครื่อง EDC Android เข้ากับระบบสลิปดิจิทัล LESS

SDK v2.3.0 core-api 2026-08-22c Android API 26+ OkHttp 5.4+ ปรับปรุง 2026-08-25

สิ่งที่เปลี่ยนจากฉบับก่อน (260819)

ถ้าเคยอ่านฉบับ 260819 มาแล้ว อ่านหัวข้อนี้ก่อน — พฤติกรรมของ server เปลี่ยนจริงในรอบ D88 ที่ deploy ขึ้น UAT วันที่ 2026-08-22 (core-api:2026-08-22c) และ SDK ขึ้น v2.3.0 ซึ่งเป็น breaking change

อันตรายที่สุด: serviceType ไม่ใช่ประตูตรวจสิทธิ์อีกแล้ว

เดิม server ปฏิเสธสลิปที่ service_type ไม่ตรง binding ด้วย 403 ตอนนี้ ไม่ปฏิเสธแล้ว — มันกลายเป็นตัวเลือก template ของสลิป แทน (D88) เหตุผล: acquiring host อนุมัติรายการก่อนสลิปจะเกิด การปฏิเสธสลิปจึงเป็นการเอาปัญหา config ไปวางไว้หน้าลูกค้าที่เคาน์เตอร์

ผลกับฝั่ง EDC: ส่ง serviceType ผิด ไม่ได้ error แล้ว — ได้สลิปที่ผิดรูปแบบไปอยู่ในมือลูกค้า โดยไม่มีสัญญาณเตือนที่ไหนเลย จุดเดียวที่ยังจับความผิดนี้ได้คือ build() ของ SDK ซึ่ง throw ให้ตั้งแต่บนเครื่อง อย่า bypass builder แล้วประกอบ JSON เอง

เรื่องฉบับ 260819 บอกว่าความจริงตอนนี้
SDK version v2.2.0 v2.3.0 — pull ใหม่จาก Gitea แล้ว build ก่อนทดสอบรอบต่อไป
mainMid / mainTid ไม่บังคับ ส่งก็ได้ไม่ส่งก็ได้ บังคับ — build() throw ถ้าขาด และ server ตอบ 400 MISSING_MERCHANT_OWNER ดูข้อยกเว้นที่ § เจ้าของสลิป
403 TERMINAL_INACTIVE code เดียวครอบ 4 สาเหตุ แยกเป็น TERMINAL_INACTIVE · TERMINAL_NOT_FOUND · MERCHANT_REVOKED · MISSING_MERCHANT_OWNER — code เดียวสี่สาเหตุเคยทำให้ส่งช่างไปผิดทีมมา 2 ครั้ง
MERCHANT_MISMATCH ได้ 403 นี้เมื่อ main_mid ไม่ตรง server ไม่ส่ง code นี้อีกแล้ว — ยุบรวมเป็น MERCHANT_REVOKED (constant ยังอยู่ใน SDK เพื่อ server รุ่นเก่าเท่านั้น)
mid / tid ราย service ถูกตรวจเทียบ binding ทั้งชุด 4 ค่า ไม่ถูกตรวจแล้ว — ส่งอะไรมาก็ถูกเก็บและพิมพ์ตามนั้น ความเป็นเจ้าของสลิปตรวจที่ main_mid/main_tid จุดเดียว
paymentType ไม่ได้ระบุไว้ เพิ่ม adjustment (เมนู TIP) และ verify (เมนู CardVerify) ครบชุดคือ sale · cash_advance · preauth · preauth_completion · adjustment · verify
KTBPayment ยกเป็นตัวอย่างค่า bankCode เป็น customer ที่ถูกลบไปแล้วใน TMS (อ่านจาก TMS2 จริง 2026-08-21) เอกสารไหนที่ยังอ้างค่านี้ผิด
extensions ไม่มี ช่องใหม่ฝั่ง server สำหรับ field ที่ core-api ยังไม่รู้จัก — ดู § Extensions (SDK v2.3.0 ยังไม่เปิด method ให้)
เอกสาร EDC_UAT_Request_Contract_260818.md ที่ส่งไปทาง chat ก็เก่าไปแล้วบางส่วน — ที่ต้องอ่านใหม่คือ main_mid (เอกสารนั้นบอกว่าไม่บังคับ), MERCHANT_MISMATCH และ service_type ส่งค่าอื่น = 403 ทั้งสามข้อไม่จริงแล้ว ส่วน REPLAY_DETECTED เอกสารนั้นเขียนว่า 409 — ของจริงคือ 401

ใครทำอะไร

✅ LESS SDK จัดการให้แล้ว

  • HMAC v1.2 sign + UUID nonce + timestamp อัตโนมัติทุก request
  • Clock-skew auto-retry (เวลาเครื่อง EDC คลาดจาก server) — retry 1 ครั้ง
  • Connection pooling + retry ของ OkHttp
  • provision() — แลก bootstrap code เป็น secret ถาวร
  • postSlip() / syncBatch() / getSyncStatus()
  • Typed response models (trustedUrl, claimCode, token…)
  • Typed error parsing (ApiError.isReplayDetected() ฯลฯ)
  • SlipRequestBuilder + BatchRequestBuilder พร้อม validation บนเครื่อง ที่ดักความผิดที่ server ไม่ดักให้อีกแล้ว

🔨 ฝั่ง EDC ต้องทำเอง

  • อ่าน bootstrap code จาก TMS channel (ระยะ UAT: ขอจากทีม LESS)
  • เรียก provision() แล้วเก็บ secret ลง Android KeyStore
  • Map ข้อมูลจาก EDC hardware เข้า field ของ Builder — รวมถึง map เมนูที่พนักงานกด → serviceType
  • Render QR จาก trustedUrl + แสดง claimCode บนจอ
  • Offline queue บนเครื่อง + batch sync ตอนเน็ตกลับมา
  • Error handling (catch LESSException → parse ApiError)
  • PDPA consent UI (ถ้าจะส่งสลิปทาง sms)
การ map เมนู → serviceType ยังไม่มีใครจดไว้เป็นเอกสาร
ตาราง map นี้ hardcode อยู่ในแอป EDC ฝั่งเดียว ไม่มีใน TMS และไม่มีใน LESS ตั้งแต่ D88 ค่านี้เป็นตัวเลือก template ของสลิป — map ผิดคือสลิปผิดแบบเงียบ ๆ ถ้าจะส่งตาราง map มาให้ทีม LESS เก็บไว้ด้วย จะช่วยให้ตรวจสอบย้อนหลังได้

Environment & Network

Base URL

ค่านี้ส่งเข้าทุก SDK call (provision(), postSlip(), syncBatch(), getSyncStatus()):

val baseUrl = "https://less-api.loxbit.co.th"
อย่าใช้ less-customer.loxbit.co.th เป็น baseUrl
โดเมนนั้นคือหน้าเว็บที่ลูกค้าเปิดดูสลิป (ปลายทางของ trustedUrl) ไม่ใช่ API ถ้าตั้งผิด ทุก request จะได้ HTTP 200 พร้อม HTML แทน JSON แล้ว SDK จะ parse ไม่ผ่าน โดยไม่มี error code บอกสาเหตุ — เป็น failure mode ที่เสียเวลาหาที่สุด

ค่าที่ผูกไว้กับเครื่องทดสอบ UAT

ค่าชุดนี้อ่านจากตารางบน UAT จริง (less_devices / less_terminal_bindings / less_device_merchants) ไม่ใช่จากเอกสาร

fieldค่า
serial (= X-API-Key)P302246N10589
bankCodeKTB — เป็น TMS customerName ไม่ใช่เลขธนาคาร
midUAT-DUMMY-MID-01
tidUATDUM01
serviceTypePURCHASE — binding เดียวที่ผูกไว้กับเครื่องนี้
mainMid / mainTidUAT-DUMMY-MID-01 / UATDUM01

ชื่อร้านที่จะขึ้นบนสลิป (ร้านทดสอบ UAT (ไม่ใช่ร้านจริง)) ฝั่ง server ใส่ให้เอง จาก registration — ห้ามส่งมา field merchant_name เป็น json:"-" คือเครื่องตั้งชื่อร้านตัวเองไม่ได้โดยการออกแบบ

bankCode คือรหัส customer ของ TMS ไม่ใช่เลขธนาคาร
ชื่อ field ทำให้เข้าใจผิด — ค่าที่ใส่คือ TMS customerName เช่น KTB, KTC, TSB, KTBSPAY ลูกค้า LESS ไม่ได้เป็นธนาคารทุกราย (KTC เป็นบริษัทบัตรเครดิต TSB เป็นผู้ให้บริการขนส่ง) เลข BOT จึงแทนลูกค้าเหล่านี้ไม่ได้และไม่เคยใช้ที่นี่ — ส่ง 004 คือผิด ทั้งที่ 004 เป็นรหัส BOT ของ KTB จริง

รายชื่อ customer จริงใน TMS มี 9 ค่า (อ่านจาก TMS2 UAT 2026-08-21): KTBSPAY · TSBPaymentWelfare · DEV · TSB · SameBIN · KTC · KTB2DR · KTBPVT · KTB
KTBPayment ถูกลบไปแล้ว อย่าใช้

MID / TID บน UAT เป็น placeholder
UAT-DUMMY-MID-01 / UATDUM01 เป็นค่าทดสอบ ไม่ใช่ merchant จริง เปลี่ยนเป็นเลขจริงภายหลังได้โดยไม่ต้อง provision ใหม่ — เครื่องเก็บ secret เดิมไว้ ไม่ต้องขอ bootstrap code ใหม่ ไม่ต้องแก้โค้ด ไม่ต้องล้างแอป สิ่งที่เปลี่ยนคือชื่อร้านและเลข MID/TID ที่ขึ้นบนสลิปเท่านั้น

Network

รายการค่า
ProtocolHTTPS เท่านั้น — firewall เปิดเฉพาะ TCP 443, HTTP/80 ไม่ผ่าน
TLSTLS 1.2+ · cert public (Sectigo) — ไม่ต้องติดตั้ง CA เอง ไม่ต้องปิด cert validation ไม่ต้องทำ pinning
DNSresolve ได้เลย ไม่ต้องแก้ hosts file
Wi-Fi (ที่ออฟฟิศ)loxbit-edc — ขอรหัสจากทีม Infra
ทางไกลผ่าน VPN ได้ (ACL เปิดให้แล้ว 2026-08-18) ไม่จำเป็นต้องเข้าออฟฟิศ

เช็คก่อนเริ่มเขียนโค้ด

รันจากเครื่อง EDC หรือเครื่องที่อยู่วงเดียวกัน ถ้าไม่ได้ 200 คือติดที่ network ไม่ใช่ที่โค้ด:

curl -sS -o /dev/null -w '%{http_code}\n' https://less-api.loxbit.co.th/healthz
timeout ไม่ได้แปลว่า config ผิด — 172.29.220.200:443 ผ่าน VPN เคยไม่นิ่ง (2026-08-18 เจอ timeout แล้วกลับมาเองสองรอบในไม่กี่นาที ขณะที่ host อื่นใน subnet เดียวกันเปิดตลอด) ให้ retry ก่อน แล้วเทียบกับ /healthz ข้างบน: ได้ 200 = เครือข่ายปกติ ปัญหาอยู่ที่ payload/signature · ไม่ตอบเลย = network

ขอ Onboard เครื่องใหม่ (ทำก่อน provision ทุกครั้ง)

"Zero-touch" ไม่ได้แปลว่า self-service
ระยะนี้ยังไม่มี TMS integration — หัวข้อ Flow ข้างล่างคือ flow เป้าหมาย เมื่อต่อ TMS จริงแล้ว ตอนนี้ยังไม่มีใคร push bootstrap code ลงเครื่องให้ ต้องขอจากทีม LESS เป็นรายเครื่อง (D77)

เครื่องที่ LESS ยังไม่รู้จัก เรียก provision() จะได้ 404 DEVICE_NOT_FOUND ทันที เพราะ endpoint นั้นทำหน้าที่แลก bootstrap code เท่านั้น ไม่ได้สร้าง device ให้ DEVICE_NOT_FOUND ไม่ใช่ bug ฝั่งคุณ — มันแปลว่าเรายังไม่ได้ seed serial นั้น

ขั้นตอน

#ใครทำอะไร
1EDC Devแจ้ง serial number ของเครื่องมาที่ทีม LESS พร้อมบอกว่าจะเริ่มทดสอบเมื่อไหร่
2ทีม LESSonboard serial เข้าระบบ (POST /api/v1/tms/terminals ฝั่งเรา) แล้วส่ง bootstrap code กลับทาง chat
3EDC Devเรียก LESSClient.provision(baseUrl, serial, bootstrapCode) ครั้งเดียว แล้วเก็บ secret ลง Android KeyStore ก่อนทำอย่างอื่น
บอกเวลาที่จะทดสอบมาด้วย — bootstrap code มี TTL 48 ชั่วโมง ถ้าออกให้ล่วงหน้าหลายวันแล้วยังไม่ได้ใช้ จะได้ 401 INVALID_BOOTSTRAP_CODE ซึ่งหน้าตาเหมือน bug ใหม่ทั้งที่แค่หมดอายุ ขอใหม่ได้ทันทีไม่มีข้อจำกัด และไม่ต้องแก้อะไรบนเครื่อง

ยังไม่มีเลข MID/TID จริงก็เริ่มได้

ถ้าเลข merchant จริงยังไม่นิ่ง ทีม LESS ผูกเครื่องกับ MID/TID ทดสอบให้ก่อนได้ ทดสอบครบทั้ง flow (provision → postSlip → ลูกค้าเปิดสลิป) แล้วเปลี่ยนเป็นเลขจริงทีหลัง การเปลี่ยน binding ไม่ทำให้ secret ของเครื่องหาย (D78)

ถ้า secret หายบนเครื่อง

เช่น reinstall แอปแล้วไม่ได้ backup KeyStore — เรียก provision() ด้วย code เดิม จะได้ 409 DEVICE_ALREADY_PROVISIONED ไม่ใช่ secret ต้องแจ้งทีม LESS ให้ reset ฝั่ง server แล้วออก code ใหม่ (ฝั่งเรามี POST /api/v1/admin/devices/reissue-bootstrap ให้แล้ว ไม่ต้องแก้ DB มืออีก)

เครื่อง P302246N10589 — provision ไปแล้ว อย่าเรียกซ้ำ
เครื่องนี้ถูก provision สำเร็จเมื่อ 2026-08-19 07:27Z bootstrap code ถูก redeem แล้ว และเครื่องถือ LESS-SEC-* อยู่ — ใช้ secret ที่เก็บไว้ ไม่ต้อง provision ใหม่

ข้อควรระวัง: ฝั่ง TMS ยังเก็บค่า LESS_BC (bootstrap code ที่ถูกใช้ไปแล้ว) เป็น plaintext อยู่บนเครื่องนี้ ถ้า factory reset แล้วแอปไปอ่านค่านั้นมา provision จะได้ 401 INVALID_BOOTSTRAP_CODE — อย่าให้แอป trust ค่าใน TMS parameter ตัวนั้น

Flow ของระบบ

1. Device Provisioning — ทำครั้งเดียวตลอดอายุเครื่อง

2. Transaction — Online และ Offline Queue

ติดตั้ง SDK

1. Git Submodule

git submodule add http://172.29.220.3:3000/panuwat/terminal-sdk-android.git terminal-sdk-android
ถ้ามี submodule อยู่แล้ว — ต้อง pull ใหม่ เพื่อได้ v2.3.0:
cd terminal-sdk-android && git fetch && git checkout main && git pull
v2.3.0 rebase ทับ commit 3506d1c ของฝั่ง EDC ไว้แล้ว งานที่ push ไปไม่หาย

2. Gradle

ใน settings.gradle.kts:

include(":terminal-sdk-android")
project(":terminal-sdk-android").projectDir = File("./terminal-sdk-android")

ใน app/build.gradle.kts:

dependencies {
    implementation(project(":terminal-sdk-android"))
}

ใน AndroidManifest.xml:

<uses-permission android:name="android.permission.INTERNET" />

เวอร์ชันที่ต้องใช้

รายการขั้นต่ำหมายเหตุ
JDK17+จำเป็นสำหรับ AGP 8.2.2+
Android minSdkAPI 26 (Android 8.0)KeyStore รุ่นใหม่
Android compileSdkAPI 34
Kotlin plugin2.2.21+ต้องตรงกันทุก Gradle module
OkHttp5.4+
v2.3.0 ยังไม่เคยถูก compile
เครื่องที่เขียน v2.3.0 ไม่มี JDK ติดตั้งไว้ จึงรัน ./gradlew test ไม่ได้ — unit test ที่แก้ตามพฤติกรรมใหม่ (WireContractTest) ยังไม่เคยถูกรัน ขอให้รัน ./gradlew test รอบแรกฝั่งคุณ ถ้าเจอ compile error แจ้งกลับมาได้เลย — ถือเป็นข้อบกพร่องฝั่งเรา ไม่ใช่ฝั่งคุณ

ขั้นตอนทีละ Step

Step 01
Provision เครื่อง

แลก bootstrap code (ใช้ครั้งเดียว, TTL 48 ชม.) เป็น secret ถาวร

val result = LESSClient.provision(
    baseUrl = baseUrl,
    serial = deviceSerial,
    bootstrapCode = code
)
// result.serial -> echo serial กลับมา
// result.secret -> เก็บลง Android KeyStore ทันที ห้ามค้างใน memory
ถ้าเรียก raw API เอง: secret อยู่ที่ data.secret ไม่ใช่ secret ชั้นบนสุด ทุก endpoint ของ LESS ห่อด้วย envelope { status, code, message, data }
Step 02
สร้าง Client
val client = LESSClient(
    serial = deviceSerial,
    secret = loadFromKeyStore()
)
Step 03
ส่งสลิป (Online)
val payload = SlipRequestBuilder()
    .header("1.0", requestId = UUID.randomUUID().toString(), timestamp = Instant.now())
    .terminalSerialNo(deviceSerial)
    .transaction(rrn = "123456789012", refCode = "REF-1001", amount = 350.00, currency = "THB")
    .txnDateTime(Instant.now())
    .txnStatus("approved")
    .merchant(
        bankCode = "KTB",              // TMS customerName — ห้ามใส่ "004"
        mid = "UAT-DUMMY-MID-01",
        tid = "UATDUM01",
        serviceType = "PURCHASE",      // ตัวเลือก template — ผิดแล้วไม่มี error ให้เห็น
        mainMid = "UAT-DUMMY-MID-01",  // บังคับตั้งแต่ v2.3.0
        mainTid = "UATDUM01"           // บังคับตั้งแต่ v2.3.0
    )
    .deliveryQr()
    .buildJson()

val slip = client.postSlip(baseUrl, payload)
// ทำ QR จาก: slip.trustedUrl
// แสดงบนจอ:  slip.claimCode
rrn คือ idempotency key ของ LESS — ส่ง rrn เดิมซ้ำด้วย payload เหมือนเดิมจะได้ผลลัพธ์เดิมกลับมา (message เปลี่ยนเป็น Duplicate transaction accepted) แต่ถ้า rrn เดิม + payload ต่างจากเดิม จะได้ 409 DUPLICATE_CONFLICT
Step 04
Offline Queue → Batch Sync
val batch = BatchRequestBuilder()
    .header(requestId = UUID.randomUUID().toString(), timestamp = Instant.now())
    .merchant(
        bankCode = "KTB", mid = "UAT-DUMMY-MID-01", tid = "UATDUM01",
        serviceType = "PURCHASE",
        mainMid = "UAT-DUMMY-MID-01", mainTid = "UATDUM01"
    )
    .addItem { txn("RRN001", "REF001", 120.00, "THB").txnStatus("approved").deliveryQr() }
    .addItem { txn("RRN002", "REF002", 500.00, "THB").txnStatus("approved").deliveryQr() }
    .buildJson()

val syncResult = client.syncBatch(baseUrl, batch)
syncResult.results.forEach { item ->
    if (item.isSuccess) removeFromLocalQueue(item.rrn)
    else                logSyncFailure(item.rrn, item.errorCode)
}
block merchant อยู่บน envelope ไม่ใช่ราย item
ทั้ง batch ใช้ merchant ชุดเดียว — mainMid/mainTid ที่ผิดหรือขาด ทำให้ทุกรายการใน batch (ได้ถึง 50) พลาดทั้งชุด ไม่ใช่แค่รายการเดียว
item ใน batch เป็น flattened structure (txn fields อยู่ root ไม่ nested) — BatchRequestBuilder จัดการให้แล้ว
รองรับ partial success: ลบออกจาก queue เฉพาะตัวที่ isSuccess
Step 05
เก็บ secret ใน Android KeyStore
private fun getOrCreateSecretKey(): SecretKey {
    val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
    if (!ks.containsAlias("less_device_secret")) {
        val kg = KeyGenerator.getInstance("AES", "AndroidKeyStore")
        kg.init(
            KeyGenParameterSpec.Builder(
                "less_device_secret",
                KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
            )
            .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
            .setKeySize(256)
            .build()
        )
        kg.generateKey()
    }
    return (ks.getEntry("less_device_secret", null) as KeyStore.SecretKeyEntry).secretKey
}
Step 06
เช็ค Sync Status — ยังใช้ไม่ได้กับเครื่องนี้
val status = client.getSyncStatus(baseUrl, tid)   // -> 404 TERMINAL_NOT_FOUND
ข้อจำกัดที่ทราบแล้ว ไม่ใช่ bug
GET /api/v1/terminals/{tid}/sync-status จะได้ 404 TERMINAL_NOT_FOUND สำหรับเครื่องที่ provision ผ่าน bootstrap code — ตัวนับ sync อยู่ใน terminal registry แบบเก่า (less_terminals) ซึ่ง path device/binding ไม่เคยเขียน record ลงไป
อ่านว่า "รายงาน sync state ไม่ได้" ไม่ใช่ "provision ล้มเหลว" ให้นับ queue ที่ฝั่งเครื่องไปก่อน เช็คด้วย ApiError.isTerminalNotFound()

HMAC v1.2 — SDK เซ็นให้แล้ว แต่ต้องรู้ไว้ debug

LESSSecurityInterceptor ทำให้อัตโนมัติทุก signed request ไม่ต้องเขียนเอง หัวข้อนี้มีไว้เพื่อไล่หาสาเหตุตอนเจอ 401 เท่านั้น

string-to-sign =
  {METHOD}\n{PATH}\n{X-API-Key}\n{X-Timestamp}\n{X-Nonce}\n{HEX(SHA256(BODY))}
headerค่า
X-API-Keyserial ของเครื่อง เช่น P302246N10589 — ไม่ใช่ API key แยก นี่คือสิ่งที่ทำให้ zero-touch provisioning เป็นไปได้
X-TimestampRFC3339 เช่น 2026-08-25T14:46:00Z — ไม่ใช่ epoch ส่ง epoch มาจะได้ 401 REPLAY_DETECTED ซึ่งอ่านแล้วเข้าใจผิดว่านาฬิกาเพี้ยน
X-Nonceunique ทุก request (ซ้ำ = 401 REPLAY_DETECTED)
X-Signaturehex ตัวพิมพ์เล็ก
POST /api/v1/terminals/provision เป็น endpoint เดียวที่ไม่ต้องเซ็น — ตัว bootstrap code คือหลักฐานในตัวเอง

เจ้าของสลิป: mainMid / mainTid

สองค่านี้ตอบคำถาม "สลิปใบนี้เป็นของร้านไหน" และไม่มี field อื่นในทั้ง payload ที่ตอบคำถามนี้ได้ — main_mid คือ merchant_ref ตัวเดียวกับที่ merchant portal ใช้ scope ข้อมูล (WHERE mid = $1 OR merchant_ref = $1) สลิปที่ไม่มีสองค่านี้จึงไม่มีเจ้าของ

นี่คือช่องโหว่จริงที่ D88 ปิด
ก่อนหน้านี้ checkDeviceMerchantRegistration มี if binding.MainMID == "" { return nil } คือ ไม่ส่ง main_mid มา = ผ่านการตรวจ และการตรวจนั้นยังเรียกอยู่แค่ในสาขา fail ของ ValidateTerminal — เครื่องที่ registry แบบเก่ารู้จักจึงไม่ถูกตรวจเลย และ ยิงสลิปเข้าบัญชีร้านไหนก็ได้ที่มันพิมพ์ชื่อมา

กฎที่ใช้ตอนนี้

ส่งอะไรมาผลลัพธ์
ครบทั้งคู่ และตรงกับ registration ที่ active✅ ผ่าน — ชื่อร้าน + merchant_ref มาจาก registration นั้น
ครบทั้งคู่ แต่ไม่ตรง registration403 MERCHANT_REVOKED
ส่งมาแค่ตัวเดียว400 MISSING_MERCHANT_OWNER — key ของ registration คือ triple (serial, main_mid, main_tid) ครึ่ง key ค้นไม่ได้
ไม่ส่งทั้งคู่ + เครื่องมี registration active ใบเดียว⚠️ ผ่าน — server เติมให้จาก registration ใบนั้น (เป็นไปไม่ได้ที่จะเติมผิด) และเขียน log ทุกครั้ง
ไม่ส่งทั้งคู่ + เครื่องมี registration active ตั้งแต่ 2 ใบ400 MISSING_MERCHANT_OWNER — ไม่เดา เพราะเดาผิดคือรายการจริงไปอยู่บัญชีร้านจริงที่ไม่ใช่เจ้าของ
การเติมให้ในกรณี registration ใบเดียวเป็น ความอนุเคราะห์ชั่วคราว เพราะเอกสารที่ส่งให้ เมื่อ 2026-08-18 เขียนไว้ว่า field นี้ไม่บังคับ — เราไม่ปฏิเสธ request ที่ทำตามเอกสารของเราเอง กลางรอบทดสอบ แต่ให้ส่งมาตรง ๆ ทุกครั้ง: build() ของ v2.3.0 บังคับอยู่แล้ว และเครื่องที่รับหลาย merchant จะพังทันทีถ้าพึ่งการเติมให้

mainMid/mainTid ไม่ใช่ mid/tid — mid/tid คือเลขราย service ที่พิมพ์บนสลิป ส่วน mainMid/mainTid คือเลขหลักของร้านที่ TMS ลงทะเบียนไว้ บน UAT ค่าทั้งสองชุดเท่ากันเพราะเป็น placeholder ชุดเดียว บน production จะไม่เท่ากัน

Builder Reference

ตั้งแต่ D88 ฝั่ง server เลิกปฏิเสธความผิดหลายอย่างที่เคยปฏิเสธ — build() จึงไม่ใช่ทางลัดกันเสีย round-trip อีกแล้ว มันเป็นจุดสุดท้ายที่ยังจับความผิดได้ ทุก require() ที่ throw คือความผิดที่ถ้าปล่อยผ่านไป จะไม่มี error กลับมาเลย

SlipRequestBuilder

methodพารามิเตอร์บังคับหมายเหตุ
.header(...)schemaVersion, requestId, timestampRequiredschemaVersion = "1.0" · requestId unique ต่อ request
.terminalSerialNo(v)StringOptionalใส่ลง header เพื่อ trace ย้อนหลัง
.transaction(...)rrn, refCode, amount, currencyRequiredamount ต้อง > 0 · currency = "THB" · rrn เป็น idempotency key
.txnDateTime(v) / .txnStatus(v)Instant / StringOptionalไม่ส่ง build() เติม Instant.now() และ "approved" ให้
.merchant(...)bankCode, mid, tid, serviceType, mainMid, mainTidRequiredทั้ง 6 ค่าบังคับตั้งแต่ v2.3.0 — serviceType เลือก template, mainMid/mainTid บอกเจ้าของสลิป ดู § เจ้าของสลิป
.deliveryQr()-Required**เลือกอย่างใดอย่างหนึ่งจาก delivery
.deliverySms(...)mobileNumber, consentTimestampRequired*ต้องมี consent PDPA จริง — server บังคับ ดู § Delivery
.deliveryEmail(...)emailAddressRequired*ตรวจรูปแบบตาม RFC 5322 · ยังส่งไม่ออกจริง ดู § Delivery
.cardData(pan, scheme)String, StringOptionalPAN ต้อง mask แล้ว เช่น 524172XXXXXX5566 — PAN เต็มถูกปฏิเสธด้วย 400 FULL_PAN_REJECTED
.approvalCode(v) · .traceNo(v) · .batchNo(v)StringOptionalค่าจาก host / เครื่อง
.feeAmount(v) · .entryMode(v) · .txnType(v)Double / StringOptionaltxnType = SALE / VOID / REFUND — เป็นตัวเลือก template ลำดับสูงสุด
.paymentType(v)StringOptionalsale · cash_advance · preauth · preauth_completion · adjustment · verify — ค่าอื่น = 400
.parentRrn(v)StringOptionalบังคับ เมื่อ paymentType = "preauth_completion"
.additionalRefs(...)vararg StringOptionalไม่เกิน 3 ตัว เกินนั้น 400
.evidenceSignature(...) · .evidenceGps(...)base64 / lat,lonOptionalลายเซ็น / พิกัด
.taxInvoice(...)taxId, vatIncluded, traceNo, batchNoOptionalใบกำกับภาษี
.dcc(...)ccy, amount, rate, consent, feePctOptionalcustomerConsent ต้องเป็น true ไม่งั้น 400 DCC_CONSENT_REQUIRED
.wallet(...)provider, providerTxnId, …Optionale-wallet (Alipay ฯลฯ) — ใช้แทน cardData · provider ว่างไม่ได้
.installment(...)plan, termMonths, …OptionalKTC Flexi / Proud — plan และ termMonths > 0 บังคับถ้าใส่ block นี้
.buildJson()--ตรวจแล้ว serialize เป็น JSON String · throw IllegalStateException ถ้าขาด field บังคับ
server ใช้ DisallowUnknownFields
ส่ง field เกินมาแม้ตัวเดียว = 400 INVALID_PAYLOAD — ถ้าจะส่งข้อมูลที่ core-api ยังไม่รู้จัก ต้องใส่ผ่าน extensions เท่านั้น ดู § Extensions

BatchRequestBuilder

กฎเดียวกัน ต่างที่ merchant อยู่บน envelope ใบเดียวสำหรับทุก item และ item ใน batch เป็น flat structure · ไม่เกิน 50 item ต่อ call (เกิน = 413 BATCH_TOO_LARGE)

Service Type Codes

ความหมายของ field นี้เปลี่ยนไปแล้ว (D88)

เดิม: service_type คือกุญแจตรวจสิทธิ์ — ผิดหรือไม่ส่ง = 403

ตอนนี้: เป็น ตัวเลือก template ของสลิป บวกกับการตรวจความสอดคล้องของ payload — ส่งค่าที่ไม่ได้ผูกไว้ หรือค่าที่ยังไม่ได้ลงทะเบียน ก็ถูกรับ เก็บตามที่ส่งมา และรายงานลง log

ผิดแล้วเกิดอะไรขึ้นจริง ๆ

กรณีผลลัพธ์
ส่งค่าที่ลงทะเบียนไว้และผูกกับเครื่อง✅ สลิปได้ template ที่ถูกต้อง
ส่งค่าที่ลงทะเบียนไว้ แต่ไม่ได้ผูกกับเครื่องนี้✅ ถูกรับ (เดิมคือ 403) — template ถูกเลือกตามค่าที่ส่งมา
ส่งค่าที่ยังไม่ได้ลงทะเบียน (พิมพ์ผิด/service ใหม่)✅ ถูกรับ เก็บดิบไว้ บันทึกเป็น inactive ให้คนมาตรวจ (โหมด learn) → สลิปได้ template GENERIC_SALE
ไม่ส่งเลย จาก builder⛔ build() throw บนเครื่อง — จับได้ตรงนี้ที่เดียว
ไม่ส่งเลย โดยประกอบ JSON เอง (เครื่องนี้)⛔ 403 TERMINAL_NOT_FOUND — UATDUM01 ไม่มีใน terminal registry แบบเก่า และไม่มี service_type ก็เข้า path binding ไม่ได้
ไม่ส่งเลย บนเครื่องที่อยู่ใน registry แบบเก่า⚠️ ถูกรับเงียบ ๆ ได้ template GENERIC_SALE = ลูกค้าถือสลิปผิดรูปแบบ ไม่มีสัญญาณเตือนที่ไหน
รูปแบบของ code
ต้องตรง ^[A-Z][A-Z0-9_]{1,31}$ และเป็น prefix ของ TMS parameterName ทั้งก้อน — ห้ามตัดที่ _ (D81) เช่น WELFARE1_MID ตัด _MID ออก ได้ WELFARE1 ไม่ใช่ WELFARE
ไม่มี code ที่ชื่อ WELFARE — ของจริงคือ WELFARE1 / WELFARE2 / WELFARE3

16 code ที่ลงทะเบียนไว้ในระบบ LESS (migration 013_service_types.sql):

codecategorytemplate ที่ได้คำอธิบาย
PURCHASEpurchasePURCHASEรายการขายปกติ
ONUSpurchasePURCHASEOn-us
WAY4purchasePURCHASEWay4
WELFARE1welfareWELFAREสวัสดิการ 1
WELFARE2welfareWELFAREสวัสดิการ 2 — ไม่มีบน UAT
WELFARE3welfareWELFAREสวัสดิการ 3 — ไม่มีบน UAT
HCAREwelfareWELFAREHealth Care
QRqrQRQR Payment
ALIPAYwalletWALLETชำระผ่าน Alipay
ALIPAY_PLUSwalletWALLETAlipay+
FLEXIinstallmentINSTALLMENTผ่อนชำระโดยบัตร KTC
PROUDinstallmentINSTALLMENTKTC PROUD
CASHADVANCEcash_advanceCASH_ADVANCEเบิกเงินสด
FOREVERloyaltyGENERIC_SALEใช้คะแนน KTC FOREVER — ยังไม่มี block สำหรับ loyalty
BMTAtransitGENERIC_SALEBMTA — ยังไม่มี block สำหรับ transit
BMTASRTtransitGENERIC_SALEBMTA + SRT — ยังไม่มี block สำหรับ transit
NHSO มี rule ของ template อยู่แล้ว (→ WELFARE) แต่ยังไม่อยู่ใน registry 16 ตัวข้างบน ส่งได้ ถูกรับ และจะถูกบันทึกเป็น code ใหม่ให้เราตรวจ · ใน TMS ตัวนี้มี NHSO_TID แต่ไม่มี NHSO_MID ซึ่งหักล้างสมมติฐานว่า "ทุก service มีทั้ง MID และ TID"
FOREVER / BMTA / BMTASRT จงใจไม่มี rule เฉพาะ: template ของมันต้องมี block loyalty / transit ที่ยังไม่มี field ใน payload รองรับ จึงให้ตกไป GENERIC_SALE ซึ่งพิมพ์ออกมาถูกแต่เรียบ ดีกว่าอ้าง block ที่ renderer เติมไม่ได้

ป้ายภาษาไทยของ service (คำถามที่ปิดไปแล้ว)

10 จาก 16 code ยังไม่มี label_th — เลิกถามไปแล้ว ไม่ต้องหาคำตอบมาให้ (D90) เพราะไม่มีอะไรใน LESS ที่ render ค่านี้เลย สลิปแสดงชนิดรายการจาก template ไม่ใช่จากป้าย และตาราง EDC-Menu ของ TMS ก็จ่ายหนี้นี้ไม่ได้ (WELFARE1, HCARE, ONUS, NHSO, BMTA, BMTASRT, WAY4, PROUD, ALIPAY_PLUS ไม่มีแถวเมนูเลย) registry จะเติมตัวเองจาก traffic จริงในโหมด learn

Endpoints & Models

ทุก response ห่อด้วย envelope เดียวกัน — ข้อมูลจริงอยู่ใน data:

{ "status": "success", "code": 200, "message": "...", "data": { ... } }
POST /api/v1/terminals/provision

แลก bootstrap code (ใช้ครั้งเดียว) เป็น HMAC secret ถาวร · ไม่ต้องเซ็น HMAC

// request
{ "serial": "P302246N10589", "bootstrap_code": "<ส่งให้ทาง chat>" }

// data
{ "serial": "P302246N10589", "secret": "LESS-SEC-..." }

server คืน secret ให้ครั้งเดียว เรียกซ้ำได้ 409 DEVICE_ALREADY_PROVISIONED · serial ที่ยังไม่ได้ seed ได้ 404 DEVICE_NOT_FOUND (endpoint นี้ไม่สร้าง device)

POST /api/v1/slips

ส่งสลิปแบบ online · payload เป็น nested envelope (header / transaction / merchant / delivery) ไม่ใช่ flat fields

// request (ตัวเต็มที่ใช้ได้จริงกับเครื่อง UAT)
{
  "header": {
    "schema_version": "1.0",
    "request_id": "<unique ต่อ request>",
    "timestamp": "2026-08-25T14:46:00Z",
    "terminal_serial_no": "P302246N10589"
  },
  "transaction": {
    "rrn": "<unique — idempotency key>",
    "ref_code": "EDC-1001",
    "approval_code": "123456",
    "trace_no": "000123",
    "batch_no": "000003",
    "amount": 100.50,
    "currency": "THB",
    "txn_date_time": "2026-08-25T14:46:00Z",
    "txn_status": "approved",
    "txn_type": "SALE",
    "payment_type": "sale",
    "entry_mode": "CONTACTLESS_CHIP"
  },
  "card_data": { "masked_pan": "524172XXXXXX5566", "card_scheme": "MASTERCARD" },
  "merchant": {
    "bank_code": "KTB",
    "mid": "UAT-DUMMY-MID-01",
    "tid": "UATDUM01",
    "service_type": "PURCHASE",
    "main_mid": "UAT-DUMMY-MID-01",
    "main_tid": "UATDUM01"
  },
  "delivery": { "delivery_method": "qr" }
}

// data
{
  "transaction_id": "...",
  "token": "ZV726CJ6JB",
  "ref_code": "EDC-1001",
  "claim_code": "62VUNF",
  "trusted_url": "https://less-customer.loxbit.co.th/v/ZV726CJ6JB",
  "expires_at": "...",
  "notification_status": "queued"    // มีเฉพาะเมื่อ delivery = sms
}

trusted_url → ทำ QR ให้ลูกค้าสแกน · claim_code → แสดงบนจอ EDC · รูปแบบ URL คือ {public_base_url}/v/{token}

field ที่บังคับ (ขาดตัวใดตัวหนึ่ง = 400 VALIDATION_FAILED)

header.schema_version · header.request_id · header.timestamp · transaction.rrn · transaction.ref_code · transaction.currency · transaction.txn_status · transaction.amount (> 0) · transaction.txn_date_time · merchant.bank_code · merchant.mid · merchant.tid · delivery.delivery_method

merchant.service_type ไม่อยู่ในรายการบังคับของ server — ไม่ใช่เพราะไม่สำคัญ แต่เพราะ D88 เลิกปฏิเสธมัน ให้ถือว่าบังคับจากฝั่งคุณเสมอ ดู § Service Type Codes
merchant.main_mid / main_tid ตรวจแยกต่างหาก ดู § เจ้าของสลิป

Enum ที่ server รับ (ค่าอื่น = 400)

fieldค่าที่รับ
txn_statusapproved · voided · refunded · reversed · failed_sync
delivery_methodqr · sms · email · qr_and_sms · qr_and_email
payment_typeว่าง · sale · cash_advance · preauth · preauth_completion · adjustment · verify
POST /api/v1/sync/batch

ส่ง offline queue รวมทีเดียว ไม่เกิน 50 item · รองรับ partial success

// data
{
  "total": 2,
  "accepted": 2,
  "rejected": 0,
  "results": [
    { "rrn": "RRN001", "result": "accepted", "retryable": false,
      "transaction_id": "...", "token": "...", "ref_code": "REF001",
      "claim_code": "...", "trusted_url": "https://less-customer.loxbit.co.th/v/..." },
    { "rrn": "RRN002", "result": "rejected", "retryable": true,
      "error_code": "SERVICE_UNAVAILABLE", "message": "..." }
  ]
}

ลบออกจาก local queue เฉพาะรายการที่ result = "accepted" · retryable = true คือส่งซ้ำได้ · false คือ payload ผิด ส่งซ้ำก็ผิดเหมือนเดิม

Error Codes

ทุก code ในตารางนี้อ่านจาก source ของ core-api:2026-08-22c ตรง ๆ

codeHTTPแปลว่าอะไร และทำอะไรต่อ
MISSING_AUTH_HEADER401header 4 ตัว (X-API-Key / X-Timestamp / X-Nonce / X-Signature) ไม่ครบ
REPLAY_DETECTED401nonce ซ้ำ · นาฬิกาคลาด > 600 วินาที · หรือส่ง timestamp ผิดรูปแบบ (ต้องเป็น RFC3339 ไม่ใช่ epoch) SDK แก้ skew แล้ว retry ให้ 1 ครั้งเอง
INVALID_SIGNATURE401HMAC ไม่ตรง — เช็ค secret, body ที่เซ็นต้องเป็น byte เดียวกับที่ส่ง, PATH ห้ามรวม query string
INVALID_API_KEY401serial ไม่รู้จัก หรือ serial/secret ไม่เข้าคู่กัน
DEVICE_NOT_FOUND404ตอน provision: serial พิมพ์ผิด ยิงผิด environment หรือ ทีม LESS ยังไม่ได้ seed serial นี้ — ไม่ใช่ bug ฝั่งคุณ
INVALID_BOOTSTRAP_CODE401code หมดอายุ (48 ชม.) หรือถูกใช้ไปแล้ว — ขอใหม่ได้ ไม่ต้องแก้อะไรบนเครื่อง
DEVICE_ALREADY_PROVISIONED409เครื่องนี้ redeem ไปแล้ว → ใช้ secret ที่เก็บไว้ · ถ้า secret หายต้องแจ้งทีม LESS reset
แยกใหม่
MISSING_MERCHANT_OWNER
400ขาด main_mid/main_tid หรือส่งมาแค่ตัวเดียว หรือเครื่องรับหลาย merchant จนเดาไม่ได้ ดู § เจ้าของสลิป
MERCHANT_REVOKED403ส่ง main_mid/main_tid ที่ไม่มี registration ที่ active — ครอบทั้งกรณี "อ้างร้านที่ไม่ได้ลงทะเบียนไว้" และ "registration ถูก revoke" (เดิมกรณีแรกคือ MERCHANT_MISMATCH ซึ่งเลิกใช้แล้ว)
TERMINAL_NOT_FOUND403ไม่มี terminal ที่ผูกกับ tid นี้ในทั้งสอง registry และไม่ได้ส่ง service_type มา → เช็ค service_type ก่อนอย่างอื่น
TERMINAL_INACTIVE403ความหมายแคบลงแล้ว: tid นั้นมีอยู่ใน registry แบบเก่า แต่ไม่ใช่ของเครื่องคุณ หรือ status ไม่ใช่ active
TERMINAL_NOT_FOUND404บน GET /terminals/{tid}/sync-status — ข้อจำกัดที่ทราบแล้ว ไม่ใช่ provision ล้มเหลว ดู Step 06
VALIDATION_FAILED400field บังคับขาด หรือ enum ผิด — เทียบกับรายการใน § Endpoints
INVALID_PAYLOAD400JSON ผิด หรือ มี field เกิน (DisallowUnknownFields)
FULL_PAN_REJECTED400ส่ง PAN ที่ยังไม่ mask — mask ก่อนเข้า builder
DCC_CONSENT_REQUIRED400block dcc ขาด foreign_currency / exchange_rate > 0 / customer_consent: true
DUPLICATE_CONFLICT409rrn เดิมแต่ payload ต่างจากเดิม — ส่งซ้ำต้องเหมือนเดิมทุก byte
BATCH_TOO_LARGE413เกิน 50 item ต่อ batch
PAYLOAD_TOO_LARGE413body ใหญ่เกินลิมิต
ใหม่
INVALID_EXTENSION_NAMESPACE
400ชื่อ namespace ใน extensions ไม่ตรง ^[a-z][a-z0-9_]{1,31}$
ใหม่
EXTENSION_TOO_LARGE
413namespace เดียวเกิน 4KB หรือรวมกันเกิน 16KB
SERVICE_UNAVAILABLE503backend ล่มชั่วคราว — retry แบบ exponential backoff (retryable = true)
INTERNAL_ERROR500แจ้งทีม LESS พร้อม request_id
code ที่ SDK ยังมี constant ไว้ แต่ server ไม่ส่งอีกแล้ว
MERCHANT_MISMATCH — ยุบรวมเข้า MERCHANT_REVOKED constant ค้างไว้เพื่อ server รุ่นเก่าเท่านั้น อย่าเขียน logic ใหม่ที่พึ่ง code นี้

Delivery & PDPA Consent

methodfield ที่บังคับเงื่อนไข
qrไม่มีทำ QR จาก trusted_url ขึ้นจอเครื่อง — ใช้ตัวนี้สำหรับ UAT รอบนี้
smsmobile_number + consent_sms: true + consent_timestampครบ 3 ตัว ไม่งั้น 400 — server บังคับตาม PDPA ที่ระดับ code ก่อนส่ง SMS
emailemail_addressตรวจรูปแบบด้วย net/mail.ParseAddress (RFC 5322) — "@", "a@" ไม่ผ่าน
qr_and_smsเหมือน smsส่งทั้งสองทาง
qr_and_emailเหมือน emailส่งทั้งสองทาง
email ยังส่งออกจริงไม่ได้
schema รับค่า และ deliveryEmail() มีให้เรียก แต่ notification-service ยังไม่มี SMTP/SendGrid connector — request ผ่าน แต่อีเมลไม่ถูกส่ง รอบ UAT นี้ให้ใช้ qr
consent เป็นของจริง ไม่ใช่ field ประดับ — consent_sms / consent_timestamp ถูกตรวจใน server.go ก่อนส่ง SMS ทุกครั้ง ค่านี้ต้องมาจากการที่ลูกค้ากดยินยอมจริงบนจอ EDC ไม่ใช่ hardcode true

Extensions — ส่งข้อมูลที่ LESS ยังไม่รู้จัก

ปัญหาเดิม: DisallowUnknownFields ทำให้ field ที่ core-api ยังไม่ model ไว้กลายเป็น 400 INVALID_PAYLOAD — ฝั่ง EDC จึงส่งข้อมูลใหม่ไม่ได้เลยจนกว่าเราจะ deploy extensions เปิดทางให้ส่งมาก่อนได้

"extensions": {
  "welfare": { "entitlement_id": "...", "balance_before": 500.00 },
  "loyalty": { "points_earned": 25 }
}
กฎรายละเอียด
ชื่อ namespaceต้องตรง ^[a-z][a-z0-9_]{1,31}$ — ตัวพิมพ์เล็กเท่านั้น (Loyalty กับ loyalty จะกลายเป็นสองก้อนที่ไม่มีใคร reconcile)
ขนาดไม่เกิน 4KB ต่อ namespace และ 16KB รวม
เนื้อข้างในไม่ถูกตรวจ — จะตรวจได้ต้องมี schema ที่ลงทะเบียนไว้ก่อน ซึ่งเป็นจุดประสงค์ของกลไกนี้
ปลายทางเก็บใน quarantine แยกจากตัวสลิป · ไม่ถูก render บนสลิป · ไม่ออกใน export · TTL 30 วัน
การแจ้งเตือนnamespace ที่ไม่มี schema จะถูกเขียน log ให้คนมาตรวจ แล้วมาคุยกันว่าจะลงทะเบียนหรือให้หยุดส่ง
SDK v2.3.0 ยังไม่มี method ให้ใส่ extensions
SlipRequestBuilder ยังไม่เปิด .extensions() — ถ้าจำเป็นต้องใช้รอบนี้ แจ้งมา จะเพิ่มให้ อย่าประกอบ JSON เองเพื่อเลี่ยง builder เพราะจะเสีย validation ที่กันสลิปผิดอยู่

service ไหนควรส่ง block อะไร

ทุก service_type ประกาศไว้ว่าสลิปของมันควรมี block อะไร ค่านี้ถูกตรวจและรายงาน แต่ไม่เคยบังคับ — block ที่ขาดคือปัญหา payload การปฏิเสธสลิปเพราะเรื่องนี้คือการเอาปัญหาไปวางไว้หน้าลูกค้าที่เคาน์เตอร์ ซึ่งเป็นความผิดที่ D88 แก้

service_typeblock ที่ประกาศไว้
FLEXI · PROUDinstallment
WELFARE1 · WELFARE2 · WELFARE3 · HCAREwelfare
BMTA · BMTASRTtransit
FOREVERloyalty
QRqr_biller

SDK Source & AAR

Build release AAR

cd terminal-sdk-android
./gradlew assembleRelease
# ผลลัพธ์: ./build/outputs/aar/terminal-sdk-android-release.aar

โครงสร้าง package

com.loxbit.less.sdk/
├── LESSClient.kt                   — client หลัก (VERSION = "2.3.0")
├── LESSSecurityInterceptor.kt      — HMAC v1.2 + clock-skew retry
├── LESSException.kt                — exception ที่ห่อ ApiError
├── SlipRequestBuilder.kt           — builder ของ POST /api/v1/slips
├── BatchRequestBuilder.kt          — builder ของ POST /api/v1/sync/batch
├── SlipCreateResponse.kt           — response model ของสลิป
├── SyncBatchResponse.kt            — response model ของ batch
├── TerminalSyncStatus.kt           — response model ของ sync status
├── ApiError.kt                     — error code + predicate (isMerchantRevoked() ฯลฯ)
└── ProvisionResult.kt              — ผลลัพธ์ของ provision
ติดต่อ / แจ้งปัญหา
แจ้ง request_id ที่ส่งไปมาด้วยทุกครั้ง — ฝั่งเรา trace จากค่านี้ได้ตรง ๆ ถ้าเป็น 403 หรือ 400 ให้แนบ block merchant ที่ส่งไปมาด้วย (ไม่ต้องแนบ secret หรือ signature)
ฉบับ 2026-08-25 · ตรวจกับ source ของ core-api:2026-08-22c และ SDK v2.3.0 (commit 6ad28e2) · ฉบับก่อนหน้า 260819 (อังกฤษ) เลิกใช้ · ค่าที่ระบุในเอกสารนี้อ่านจากโค้ดและตาราง UAT จริง ไม่ใช่จากเอกสารอื่น