LESS SDK Developer Reference
Technical guide for integrating Android EDC terminals with the LESS digital receipt platform.
Overview & Scope
The LESS SDK encapsulates all cryptographic signing, clock drift mitigation, response model parsing, and network retry routines required for Android EDC integration.
Managed by LESS SDK
- HMAC-SHA256 signature generation (String-to-Sign v1.2)
- Cryptographic nonce (UUID) and Unix timestamp generation
- Automatic clock-skew retry on HTTP 401 drift
- Connection lifecycle and OkHttp client pooling
- Provisioning exchange (
provision()) - Type-safe payload builders (
SlipRequestBuilder,BatchRequestBuilder) - Response deserialization and structured error classification
EDC Application Responsibilities
- Retrieve bootstrap code via TMS or provisioning channel
- Store provisioned
secretin Android KeyStore (AES/GCM) - Map hardware transaction data to Builder parameters
- Render QR code from
trustedUrland displayclaimCode - Maintain local offline SQLite queue when offline
- Handle customer PDPA consent for SMS and Email delivery
Environment & Setup
API Base URL
val baseUrl = "https://less-api.loxbit.co.th"
baseUrl to customer-facing portal domains (e.g. less-customer.loxbit.co.th). The SDK requires direct access to the core API host on port 443.
Network Requirements
| Parameter | Specification |
|---|---|
| Protocol | HTTPS (TLS 1.2+ mandatory, Port 443) |
| TLS Certificate | Public Trusted Root (Sectigo) — No custom CA pinning required |
| Connectivity Pre-flight | curl -I https://less-api.loxbit.co.th/healthz |
System Flows
1. Device Provisioning Flow
Executed once per device lifecycle during initial setup before processing any transactions.
-
TMS → LESS core-apiTerminal registered via
POST /api/v1/tms/terminalswith hardware serial number and merchant binding. -
LESS core-api → TMSLESS issues single-use Bootstrap Code (48-hour TTL bound to device serial).
-
EDC App → LESS core-apiApp executes
LESSClient.provision(baseUrl, serial, bootstrapCode)to exchange bootstrap code for persistent device secret. -
EDC AppApp persists
secretinto Android KeyStore. Device is now authenticated.
2. Transaction Flow (Online & Offline Queue)
-
Customer → EDC AppPayment authorization completed on payment host.
-
EDC App → LESS core-apiPayload constructed via
SlipRequestBuilderand dispatched viaPOST /api/v1/slipswith HMAC authentication. -
LESS core-api → EDC AppCore API validates signature and responds with
trusted_url,claim_code, andtoken. -
Offline FailoverIf network connectivity fails, payload is written to local SQLite queue. Upon connection recovery, queued records are dispatched in bulk via
client.syncBatch()(POST /api/v1/sync/batch).
Installation
1. Git Submodule Configuration
git submodule add http://172.29.220.3:3000/panuwat/terminal-sdk-android.git terminal-sdk-android
2. Gradle Project Setup
In settings.gradle.kts:
include(":terminal-sdk-android")
project(":terminal-sdk-android").projectDir = File("./terminal-sdk-android")
In app/build.gradle.kts:
dependencies {
implementation(project(":terminal-sdk-android"))
}
In AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
Build Prerequisites
| Requirement | Minimum Version | Notes |
|---|---|---|
| JDK | 17+ | Required for AGP 8.2.2+ |
| Android minSdk | API 26 (Android 8.0) | Modern KeyStore & Instant support |
| Android compileSdk | API 34 | Current platform target |
| Kotlin Plugin | 2.2.21+ | Unified across all Gradle modules |
Step-by-Step Guide
Exchange the 48-hour single-use bootstrap code for the permanent device secret key.
val result = LESSClient.provision(
baseUrl = baseUrl,
serial = deviceSerial,
bootstrapCode = code
)
// result.serial -> echoes terminal serial
// result.secret -> save immediately to Android KeyStore
Instantiate LESSClient using the retrieved hardware serial and encrypted secret.
val client = LESSClient(
serial = deviceSerial,
secret = loadFromKeyStore()
)
Use SlipRequestBuilder to generate and sign the transaction payload.
val payload = SlipRequestBuilder()
.header("1.0", requestId = UUID.randomUUID().toString(), timestamp = Instant.now())
.transaction(rrn = "123456789012", refCode = "REF-1001", amount = 350.00, currency = "THB")
.txnDateTime(Instant.now())
.txnStatus("approved")
.merchant(
bankCode = "004",
mid = "MID12345678",
tid = "TID12345",
serviceType = "PURCHASE",
mainMid = mainMidFromTms,
mainTid = mainTidFromTms
)
.deliveryQr()
.buildJson()
val slip = client.postSlip(baseUrl, payload)
// Render QR from: slip.trustedUrl
// Display text code: slip.claimCode
If network connection fails during transaction processing, batch sync offline items using BatchRequestBuilder.
val batch = BatchRequestBuilder()
.header(requestId = UUID.randomUUID().toString(), timestamp = Instant.now())
.merchant(
bankCode = "004", mid = "MID12345678", tid = "TID12345", serviceType = "PURCHASE",
mainMid = mainMidFromTms, mainTid = mainTidFromTms
)
.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)
}
}
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
}
Builders Reference
SlipRequestBuilder Methods
| Method | Parameters | Requirement | Description |
|---|---|---|---|
.header(...) |
version, reqId, time | Required | Sets schema version ("1.0"), UUID request ID, and timestamp. |
.transaction(...) |
rrn, ref, amount, ccy | Required | Core transaction details (currency must be "THB"). |
.merchant(...) |
bank, mid, tid, svc | Required | Merchant scope. serviceType validated by server (e.g. PURCHASE, WELFARE). |
.deliveryQr() |
- | Required* | QR code delivery on screen (*or deliverySms / deliveryEmail). |
.deliverySms(...) |
mobile, consentTime | Required* | SMS delivery with explicit PDPA consent timestamp. |
.deliveryEmail(...) |
emailAddress | Required* | Email delivery (RFC 5322 compliant format). |
.cardData(pan, scheme) |
String, String | Optional | Masked PAN (e.g. "411111******1111"). Full PAN rejected. |
.approvalCode(v) |
String | Optional | Host authorization approval code. |
.traceNo(v) / .batchNo(v) |
String, String | Optional | Terminal trace and batch numbers. |
.dcc(...) |
ccy, amt, rate, consent, fee | Optional | Dynamic Currency Conversion parameters (consent must be true). |
.buildJson() |
- | - | Serializes validated payload to JSON String. |
Endpoints & Models
Exchanges single-use bootstrap code for permanent HMAC secret.
// Response Data
{
"serial": "POS-987654321",
"secret": "sec_live_9f823bc842918471"
}
Registers an online electronic slip with cryptographic validation.
// Response Data
{
"transaction_id": "txn_884210941",
"token": "tok_live_c48b291a",
"ref_code": "REF-1001",
"claim_code": "481920",
"trusted_url": "https://less-customer.loxbit.co.th/v1/s/tok_live_c48b291a",
"expires_at": "2026-09-18T10:00:00Z"
}
Synchronizes queued offline slips in a single bulk request.
// Response Data
{
"total": 2,
"accepted": 2,
"rejected": 0,
"results": [
{
"rrn": "RRN001",
"result": "accepted",
"retryable": false,
"trusted_url": "https://less-customer.loxbit.co.th/v1/s/tok_01"
}
]
}
Error Codes
| Error Code | HTTP | Condition | Resolution |
|---|---|---|---|
REPLAY_DETECTED |
401 | Timestamp skew > 10 min | Automatically retried by SDK with server clock sync. |
INVALID_SIGNATURE |
401 | HMAC-SHA256 mismatch | Check device secret integrity and raw body structure. |
INVALID_BOOTSTRAP_CODE |
401 | Code expired or invalid | Request new bootstrap code from TMS channel. |
TERMINAL_INACTIVE |
403 | Service type or TID mismatch | Verify serviceType matches registration in TMS. |
FULL_PAN_REJECTED |
400 | Unmasked PAN submitted | Mask PAN (first 6, last 4 digits) before calling builder. |
DUPLICATE_CONFLICT |
409 | RRN payload mismatch | Ensure idempotent resubmission has identical payload. |
SERVICE_UNAVAILABLE |
503 | Temporary backend outage | Retry with exponential backoff (retryable=true). |
Delivery & PDPA Consent
| Method | Mandatory Fields | Validation Rules |
|---|---|---|
qr |
None | Screen QR code generated on terminal display. |
sms |
mobile_number, consent_timestamp |
Requires explicit user PDPA consent opt-in. |
email |
email_address |
Requires RFC 5322 valid format. |
qr_and_sms |
mobile_number, consent_timestamp |
Dual delivery mode with consent verification. |
SDK Source & AAR
Building Release AAR
cd terminal-sdk-android
./gradlew assembleRelease
# Output artifact: ./build/outputs/aar/terminal-sdk-android-release.aar
Package Structure
com.loxbit.less.sdk/
├── LESSClient.kt — Primary SDK client interface
├── LESSSecurityInterceptor.kt — HMAC auth & clock-skew retry interceptor
├── SlipRequestBuilder.kt — Type-safe builder for POST /api/v1/slips
├── BatchRequestBuilder.kt — Type-safe builder for POST /api/v1/sync/batch
├── SlipCreateResponse.kt — Transaction response model
├── SyncBatchResponse.kt — Batch sync response models
├── ApiError.kt — Error codes and domain error classification
└── ProvisionResult.kt — Device provisioning model