Checkout Security & Bot Protection
To protect /api/v1/unified-booking/* and /api/v1/airport-vip-assistance/checkout* endpoints against payload tampering, replay attacks, and automated bot abuse, the platform supports two complementary security layers:
- Payload Encryption & HMAC-SHA256 Signing (
DecryptBookingPayload) - Google reCAPTCHA v3 Verification (
VerifyRecaptchaV3)
1. Payload Encryption & HMAC Signature
Clients can protect the request body in either of two modes:
- Mode A — Signed Raw JSON Body: Send the normal JSON request body along with
X-Payload-Signature: HMAC_SHA256(JSON.stringify(body), secretKey). - Mode B — AES-256-CBC Encrypted Payload + HMAC Signature: Encrypt the booking payload with AES-256-CBC (
{"payload": "<base64_iv>:<base64_ciphertext>"}) and sign thepayloadstring using HMAC-SHA256 derived from the first 32 characters of the Partner Secret Key (partner.token).
Request Format
Headers
| Header | Value | Required | Description |
|---|---|---|---|
Partner-Access-Token | Base64 String | Yes | Partner authentication token. |
X-Payload-Signature | Hex String | Conditional | HMAC-SHA256 hex digest of the payload string. Required when sending an encrypted payload (or when strict encryption mode is enabled). |
X-Recaptcha-Token | String | Conditional | Google reCAPTCHA v3 token. Required for browser clients when RECAPTCHA_ENABLED=true. Not needed when the Partner Access Token declares mobile_app or mini_app. |
Body
{
"payload": "<base64_iv>:<base64_ciphertext>"
}
(Note: signature can be sent either in the X-Payload-Signature HTTP header or as "signature" in the JSON body).
:::danger Use Partner Secret Key — Not Partner-Access-Token
Always pass your 32-character Partner Secret Key (NUXT_PARTNER_API_SECRET / partner.token) to encryptBookingPayload() and HMAC signing — never pass the base64 Partner-Access-Token generated by generateToken().
:::
How It Works
- Add a
_timestamp(Unix timestamp in seconds) to the original JSON payload object. - Generate a random 16-byte Initialization Vector (
IV). - Encrypt the JSON string using AES-256-CBC (PKCS#7 padding) with the first 32 characters of your Partner Secret Key.
- Format the encrypted string as
base64(iv):base64(ciphertext). - Compute the HMAC-SHA256 signature of the
base64(iv):base64(ciphertext)string using the same 32-character key. - The backend verifies the HMAC signature in constant time, decrypts the JSON payload, validates that
_timestampis within 300 seconds (5 minutes) of server time, and merges the decrypted data into the request for downstream controllers and DTOs.
JavaScript / TypeScript Example (crypto-js)
import CryptoJS from 'crypto-js';
/**
* Encrypts and signs a booking payload for /unified-booking/* endpoints.
*
* @param {Record<string, any>} payloadData - Original request body object
* @param {string} partnerSecret - Partner Secret Key
*/
export function encryptBookingPayload(payloadData, partnerSecret) {
const secretKeyStr = partnerSecret.substring(0, 32);
const key = CryptoJS.enc.Utf8.parse(secretKeyStr);
const iv = CryptoJS.lib.WordArray.random(16);
const dataWithTimestamp = {
...payloadData,
_timestamp: Math.floor(Date.now() / 1000),
};
const encrypted = CryptoJS.AES.encrypt(
JSON.stringify(dataWithTimestamp),
key,
{
iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
}
);
const ivBase64 = CryptoJS.enc.Base64.stringify(iv);
const cipherTextBase64 = encrypted.ciphertext.toString(CryptoJS.enc.Base64);
const payload = `${ivBase64}:${cipherTextBase64}`;
const signature = CryptoJS.HmacSHA256(payload, secretKeyStr).toString(CryptoJS.enc.Hex);
return { payload, signature };
}
Node.js Native crypto Example
const crypto = require('crypto');
function encryptBookingPayloadNode(payloadData, partnerSecret) {
const key = Buffer.from(partnerSecret.substring(0, 32), 'utf8');
const iv = crypto.randomBytes(16);
const json = JSON.stringify({
...payloadData,
_timestamp: Math.floor(Date.now() / 1000),
});
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
let encrypted = cipher.update(json, 'utf8', 'base64');
encrypted += cipher.final('base64');
const payload = `${iv.toString('base64')}:${encrypted}`;
const signature = crypto
.createHmac('sha256', key)
.update(payload)
.digest('hex');
return { payload, signature };
}
Multipart / File Uploads (e.g. Airport VIP Assistance)
When uploading files (air_ticket, passport_bio, last_entry_stamp) via multipart/form-data, append the encrypted payload and signature fields alongside the binary file fields. The middleware preserves uploaded files while replacing the JSON fields.
Nuxt Server Proxy Integration (onlynx_fe_core)
If your frontend encrypts the body into { payload: "<base64_iv>:<base64_ciphertext>" } before calling your Nuxt server proxy (/api/unified-booking/checkout*), do not call encryptBookingPayload() a second time inside buildApiHeaders(). Instead, sign the existing body.payload string using NUXT_PARTNER_API_SECRET:
// utils/apiHeaders.ts
if (isNeedSignature) {
const secretKey = (app.public?.private_key || app.private_key || '').substring(0, 32);
const body = await readBody(event);
const payloadToSign = body?.payload || JSON.stringify(body);
headers['X-Payload-Signature'] = CryptoJS.HmacSHA256(
payloadToSign,
secretKey
).toString(CryptoJS.enc.Hex);
}
2. Google reCAPTCHA v3 Integration
Checkout and booking confirmation endpoints verify client behavior using Google reCAPTCHA v3 (https://www.recaptcha.net/recaptcha/api/siteverify for global and China accessibility).
Native Clients Are Exempt
reCAPTCHA v3 needs a browser to run its script, so a mobile app or the JDB Mini App has no token to send. Those clients are exempt — but only when they prove what they are.
The proof is the platform declared inside the AES payload of the Partner Access Token
(<timestamp>|mobile_app or <timestamp>|mini_app, see
Partner Access Token). Because building that payload requires the
Partner Secret Key, a bot cannot claim the exemption. A plain header such as X-Client-Type
would be forgeable and is not accepted for this.
| Platform declared in the token | X-Recaptcha-Token | Behaviour |
|---|---|---|
none, or web | Required | Verified with Google; score below RECAPTCHA_MIN_SCORE is rejected |
mobile_app | Not needed | reCAPTCHA skipped |
mini_app | Not needed | reCAPTCHA skipped |
Two things are unchanged for exempt clients:
- Rate limits still apply — see the table below.
- Anything we cannot identify is treated as
web. A missing or misspelled platform means reCAPTCHA is enforced, so a typo fails closed rather than silently disabling the check.
The JDB Mini App does not need to declare itself on endpoints where JDB already signs the
request (X-Request-Signature): a valid RSA signature resolves mini_app on its own.
Expected Actions by Endpoint
| Endpoint | Expected reCAPTCHA Action | Rate Limit |
|---|---|---|
POST /api/v1/unified-booking/checkout | booking_checkout | 30 req / 1 min |
POST /api/v1/unified-booking/checkout/{checkout_session_id} | booking_checkout | 30 req / 1 min |
POST /api/v1/unified-booking/checkout/confirm | booking_confirm | 20 req / 2 min |
POST /api/v1/airport-vip-assistance/checkout | booking_checkout | 30 req / 1 min |
POST /api/v1/airport-vip-assistance/checkout/confirm | booking_confirm | 20 req / 2 min |
Frontend Implementation
- Load the reCAPTCHA v3 script using
www.recaptcha.net:
<script src="https://www.recaptcha.net/recaptcha/api.js?render=YOUR_RECAPTCHA_SITE_KEY" async defer></script>
- Execute reCAPTCHA immediately before calling checkout or confirm:
export function getRecaptchaToken(action) {
return new Promise((resolve, reject) => {
if (typeof window === 'undefined' || !window.grecaptcha) {
return reject(new Error('reCAPTCHA not loaded'));
}
window.grecaptcha.ready(() => {
window.grecaptcha
.execute(process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY, { action })
.then(resolve)
.catch(reject);
});
});
}
- Send the token in the
X-Recaptcha-Tokenheader (or asrecaptcha_tokenin the body):
const recaptchaToken = await getRecaptchaToken('booking_checkout');
const encryptedBody = encryptBookingPayload(checkoutData, PARTNER_SECRET);
await fetch('https://api.dev.onlynx.co.th/api/v1/unified-booking/checkout', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Partner-Access-Token': partnerAccessToken,
'X-Payload-Signature': encryptedBody.signature,
'X-Recaptcha-Token': recaptchaToken,
},
body: JSON.stringify(encryptedBody),
});
3. Security Error Responses
Failures during payload decryption, signature verification, or reCAPTCHA verification return HTTP 400 or 403:
| HTTP Status | Message | Cause |
|---|---|---|
200 (result: 0) | Invalid partner or missing partner secret key. | Partner could not be resolved from requested_partner_id() or has no secret token configured. |
403 | Encrypted payload and signature are required. | Unencrypted request sent when BOOKING_PAYLOAD_ENCRYPTION_STRICT=true. |
403 | Invalid payload signature. | Payload was modified in transit, signed twice, or signed with Partner-Access-Token instead of Partner Secret Key. |
403 | Request timestamp has expired. | _timestamp is older than 300 seconds (5 minutes). |
400 | Unable to decrypt booking payload. | Invalid AES key, IV, or corrupted ciphertext. |
403 | reCAPTCHA verification token is required. | Missing X-Recaptcha-Token header when RECAPTCHA_ENABLED=true and the token did not declare mobile_app or mini_app. |
403 | Invalid reCAPTCHA action. | Token action does not match the endpoint (booking_checkout or booking_confirm). |
403 | Suspicious activity detected. Please try again. | reCAPTCHA score is below RECAPTCHA_MIN_SCORE (default 0.5). |