Signature Generation

Every partner request uses two different signatures. The first signature requests an access token and uses the partner private key. The second signature calls a product endpoint and uses the client secret. Both use hex encoding, and all strings are signed as UTF-8.

Summary

StageString to signKey or secretHeader
Access tokenclientId|timestampPartner private keyX-SIGNATURE
API requestMETHOD:path:accessToken:sha256(body):timestampClient secretX-SIGNATURE

What you need

  • client_id from the partner dashboard.
  • client_secret from the partner dashboard. Use the exact value, do not decode it.
  • The partner private key you generated yourself. Never send it to the platform.
  • The partner public key uploaded to the dashboard.
  • CHANNEL-ID from the partner dashboard. The current value is WEB.

Generate a partner key pair

Generate an RSA 2048-bit key pair on your side. Keep the private key, upload the public key to the partner dashboard.

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out partner-private-key.pem
openssl rsa -pubout -in partner-private-key.pem -out partner-public-key.pem

Upload the contents of partner-public-key.pem. The private key file must not be uploaded. The platform uses the public key to verify the access token signature.

1. Create a timestamp

Create the timestamp at request time, not when the application starts. Useyyyy-MM-ddTHH:mm:ss+07:00, without milliseconds. The value must be within 5 minutes of server time.

function timestampNow() {
  return new Date(Date.now() + 7 * 60 * 60 * 1000)
    .toISOString()
    .replace(/\.\d{3}Z$/, "+07:00");
}

// Example: 2026-11-04T14:30:00+07:00
const timestamp = timestampNow();

The access token timestamp and the request timestamp may differ. As long as each one is within the 5-minute tolerance, both are valid.

Make sure the server clock uses time synchronization (NTP). A clock difference of more than 5 minutes causes the timestamp to be rejected.

2. Create the access token signature

Build the following string with no extra spaces:

clientId + "|" + timestamp

Example values:

PartExample
clientId01a1b2c3-d4e5-f6a7-b8c9-d0e1f2a3b4c5
timestamp2026-11-04T14:30:00+07:00
String to sign01a1b2c3-d4e5-f6a7-b8c9-d0e1f2a3b4c5|2026-11-04T14:30:00+07:00

Sign that string with the partner private key using RSA-SHA256 with PKCS#1 v1.5 padding. The result is lowercase hex. For RSA 2048, the signature is 512 hex characters long.

import { createSign } from "node:crypto";

const stringToSign = `${clientId}|${timestamp}`;
const tokenSignature = createSign("RSA-SHA256")
  .update(stringToSign, "utf8")
  .sign(privateKeyPem, "hex");

The access token body is not part of this signature. It only uses clientId and timestamp.

Send the following request. The body must be valid JSON.

POST /openapi/v1.0/access-token/b2b
Content-Type: application/json
X-CLIENT-KEY: <client_id>
X-TIMESTAMP: <timestamp>
X-SIGNATURE: <tokenSignature>

{ "grantType": "client_credentials" }

A successful response contains the access token and its lifetime:

{
  "responseCode": "2000100",
  "responseMessage": "Successful",
  "accessToken": "<accessToken>",
  "tokenType": "Bearer",
  "expiresIn": "900",
  "additionalInfo": {}
}

Store accessToken. It is valid for at most 15 minutes. Do not reuse an expired token and never send the private key to the platform.

3. Prepare the request signature inputs

The request signature uses five values:

ValueRuleExample
METHODUppercaseGET
pathPath and query, without scheme and host/openapi/v1.0/products
accessTokenToken only, without the Bearer prefix<accessToken>
bodyHashSHA-256 of the body, lowercase hexe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
timestampRequest timestamp2026-11-04T14:30:00+07:00

For GET, the body is an empty string. The SHA-256 of an empty string is alwayse3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.

For POST, use the raw body that is actually sent. Do not change key order, spaces, or formatting after the signature is created. When in doubt, hash the same buffer that is sent to the HTTP client.

import { createHash } from "node:crypto";

const method = "GET";
const path = "/openapi/v1.0/products";
const body = "";
const bodyHash = createHash("sha256").update(body, "utf8").digest("hex");

4. Create the request signature

Join the values with colons, without spaces:

METHOD:path:accessToken:bodyHash:timestamp

Full example string for GET:

GET:/openapi/v1.0/products:<accessToken>:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855:2026-11-04T14:30:00+07:00

Compute HMAC-SHA512 with client_secret exactly as shown in the dashboard. Do not base64-decode the client secret. The result is lowercase hex.

import { createHmac } from "node:crypto";

const stringToSign = `${method}:${path}:${accessToken}:${bodyHash}:${timestamp}`;
const requestSignature = createHmac("sha512", clientSecret)
  .update(stringToSign, "utf8")
  .digest("hex");

If the path has a query string, include it. Example:/openapi/v1.0/products?example=1. Do not use the full URL.

HMAC test vector

Use the following values to verify your HMAC implementation. This client secret is only an example and does not work on the platform.

ValueExample
client_secretcontoh-client-secret
accessTokentoken-contoh
methodGET
path/openapi/v1.0/products
bodyHashe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
timestamp2026-11-04T14:30:00+07:00

String to sign:

GET:/openapi/v1.0/products:token-contoh:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855:2026-11-04T14:30:00+07:00

Expected HMAC-SHA512 result:

1e343cec039c43ece0058a04db425f7ae411b81635e1418cf6052f7d3fd58f26210a9798166f6d2d62e0431986f6a2eb1373e292d6a6f6b69454491879303f78

If your result differs, check the order of the parts, the number of colons, the secret encoding, and make sure there are no extra spaces.

5. Send the request

HeaderValueNote
AuthorizationBearer <accessToken>The same token used in the signature
X-PARTNER-ID<client_id>Must match the client in the access token
X-EXTERNAL-IDUnique string, max 36 charactersRandom, unique per request. Allowed: letters, digits, dot, underscore, hyphen
CHANNEL-IDWEBMust match the partner channel
X-TIMESTAMP<timestamp>The timestamp used in the signature
X-SIGNATURE<requestSignature>Lowercase hex
GET /openapi/v1.0/products
Authorization: Bearer <accessToken>
X-PARTNER-ID: <client_id>
X-EXTERNAL-ID: 7f3c2a1b-9d4e-4c6a-8b2f-1e5d7c9a0b3f
CHANNEL-ID: WEB
X-TIMESTAMP: 2026-11-04T14:30:00+07:00
X-SIGNATURE: <requestSignature>

6. Full example

The following Node.js example requests an access token, creates the request signature, and fetches the product catalog.

import { createHash, createHmac, createSign, randomUUID } from "node:crypto";

function timestampNow() {
  return new Date(Date.now() + 7 * 60 * 60 * 1000)
    .toISOString()
    .replace(/\.\d{3}Z$/, "+07:00");
}

const baseUrl = "https://api.example.com";
const clientId = "<client_id>";
const clientSecret = "<client_secret>";
const channelId = "WEB";
const privateKeyPem = process.env.PARTNER_PRIVATE_KEY;
if (!privateKeyPem) {
  throw new Error("PARTNER_PRIVATE_KEY is not set");
}

async function fetchProducts() {
  const tokenTimestamp = timestampNow();
  const tokenString = `${clientId}|${tokenTimestamp}`;
  const tokenSignature = createSign("RSA-SHA256")
    .update(tokenString, "utf8")
    .sign(privateKeyPem, "hex");

  const tokenResponse = await fetch(`${baseUrl}/openapi/v1.0/access-token/b2b`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-CLIENT-KEY": clientId,
      "X-TIMESTAMP": tokenTimestamp,
      "X-SIGNATURE": tokenSignature,
    },
    body: JSON.stringify({ grantType: "client_credentials" }),
  });
  const tokenBody = await tokenResponse.json();
  if (!tokenResponse.ok) {
    throw new Error(
      "Token request failed: " + tokenResponse.status + " " + JSON.stringify(tokenBody),
    );
  }
  const accessToken = tokenBody.accessToken;

  const method = "GET";
  const path = "/openapi/v1.0/products";
  const body = "";
  const requestTimestamp = timestampNow();
  const bodyHash = createHash("sha256").update(body, "utf8").digest("hex");
  const requestString = `${method}:${path}:${accessToken}:${bodyHash}:${requestTimestamp}`;
  const requestSignature = createHmac("sha512", clientSecret)
    .update(requestString, "utf8")
    .digest("hex");

  const response = await fetch(`${baseUrl}${path}`, {
    headers: {
      Authorization: `Bearer ${accessToken}`,
      "X-PARTNER-ID": clientId,
      "X-EXTERNAL-ID": randomUUID(),
      "CHANNEL-ID": channelId,
      "X-TIMESTAMP": requestTimestamp,
      "X-SIGNATURE": requestSignature,
    },
  });
  const responseBody = await response.json();
  if (!response.ok) {
    throw new Error(
      "Product request failed: " + response.status + " " + JSON.stringify(responseBody),
    );
  }
  return responseBody;
}

7. Token expiry

The access token is valid for at most 15 minutes. If an endpoint returns4010201, request a new access token, then create a new request signature with the new token and timestamp. Do not reuse the old signature.

A simple approach: store expiresIn, refresh the token before it expires, and add one retry when you receive 4010201.

Do not request a new access token for every request. Store the token and reuse it until it is close to expiry.

How the signature is verified

The platform rebuilds the same string from the values it receives: client ID and timestamp for the access token, then method, path, access token, body hash, and timestamp for the request. The platform compares its result with X-SIGNATURE. Because of this, one extra space, a different part order, or a body that changes after signing makes the signature invalid.

Common response codes

Response codeMeaningWhat to check
4000100Access token body is not valid JSONThe body must be valid JSON
4000101Invalid access token field formatTimestamp +07:00, 5-minute tolerance, or grantType
4000102Missing access token headerX-CLIENT-KEY, X-TIMESTAMP, X-SIGNATURE
4010100Access token signature failedPrivate key, client_id, and signature encoding
4030100IP is not allowedPartner IP allowlist
4000201Invalid request field formatX-EXTERNAL-ID, X-TIMESTAMP, CHANNEL-ID
4000202Missing request headerAll required headers were sent
4010200Request signature failedClient secret, path, body hash, and timestamp
4010201Invalid access tokenToken expired or belongs to another partner
4010203Token not foundAuthorization Bearer header
4030200Catalog access deniedIP allowlist and partner account status

Error response example

{
  "responseCode": "4010200",
  "responseMessage": "Unauthorized. Verify signature failed"
}

Checklist before contacting support

  • The timestamp uses the correct format and is within 5 minutes.
  • The string to sign has no extra spaces.
  • The access token signature uses the private key, not the client secret.
  • The request signature uses the client secret, not the private key.
  • The client secret is used exactly as shown, without base64 decoding.
  • The path has no host and includes the query string when present.
  • The body hash is computed from the body that is actually sent.
  • The token in the signature matches the token in the Authorization header.
  • CHANNEL-ID matches the partner channel.
  • X-EXTERNAL-ID is unique and at most 36 characters.