Skip to main content

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:

  1. Payload Encryption & HMAC-SHA256 Signing (DecryptBookingPayload)
  2. 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 the payload string using HMAC-SHA256 derived from the first 32 characters of the Partner Secret Key (partner.token).

Request Format​

Headers​

HeaderValueRequiredDescription
Partner-Access-TokenBase64 StringYesPartner authentication token.
X-Payload-SignatureHex StringConditionalHMAC-SHA256 hex digest of the payload string. Required when sending an encrypted payload (or when strict encryption mode is enabled).
X-Recaptcha-TokenStringConditionalGoogle 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​

  1. Add a _timestamp (Unix timestamp in seconds) to the original JSON payload object.
  2. Generate a random 16-byte Initialization Vector (IV).
  3. Encrypt the JSON string using AES-256-CBC (PKCS#7 padding) with the first 32 characters of your Partner Secret Key.
  4. Format the encrypted string as base64(iv):base64(ciphertext).
  5. Compute the HMAC-SHA256 signature of the base64(iv):base64(ciphertext) string using the same 32-character key.
  6. The backend verifies the HMAC signature in constant time, decrypts the JSON payload, validates that _timestamp is 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 tokenX-Recaptcha-TokenBehaviour
none, or webRequiredVerified with Google; score below RECAPTCHA_MIN_SCORE is rejected
mobile_appNot neededreCAPTCHA skipped
mini_appNot neededreCAPTCHA 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​

EndpointExpected reCAPTCHA ActionRate Limit
POST /api/v1/unified-booking/checkoutbooking_checkout30 req / 1 min
POST /api/v1/unified-booking/checkout/{checkout_session_id}booking_checkout30 req / 1 min
POST /api/v1/unified-booking/checkout/confirmbooking_confirm20 req / 2 min
POST /api/v1/airport-vip-assistance/checkoutbooking_checkout30 req / 1 min
POST /api/v1/airport-vip-assistance/checkout/confirmbooking_confirm20 req / 2 min

Frontend Implementation​

  1. 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>
  1. 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);
});
});
}
  1. Send the token in the X-Recaptcha-Token header (or as recaptcha_token in 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 StatusMessageCause
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.
403Encrypted payload and signature are required.Unencrypted request sent when BOOKING_PAYLOAD_ENCRYPTION_STRICT=true.
403Invalid payload signature.Payload was modified in transit, signed twice, or signed with Partner-Access-Token instead of Partner Secret Key.
403Request timestamp has expired._timestamp is older than 300 seconds (5 minutes).
400Unable to decrypt booking payload.Invalid AES key, IV, or corrupted ciphertext.
403reCAPTCHA verification token is required.Missing X-Recaptcha-Token header when RECAPTCHA_ENABLED=true and the token did not declare mobile_app or mini_app.
403Invalid reCAPTCHA action.Token action does not match the endpoint (booking_checkout or booking_confirm).
403Suspicious activity detected. Please try again.reCAPTCHA score is below RECAPTCHA_MIN_SCORE (default 0.5).