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
| Stage | String to sign | Key or secret | Header |
|---|---|---|---|
Access token | clientId|timestamp | Partner private key | X-SIGNATURE |
API request | METHOD:path:accessToken:sha256(body):timestamp | Client secret | X-SIGNATURE |
What you need
client_idfrom the partner dashboard.client_secretfrom 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-IDfrom the partner dashboard. The current value isWEB.
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.pemUpload 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 + "|" + timestampExample values:
| Part | Example |
|---|---|
clientId | 01a1b2c3-d4e5-f6a7-b8c9-d0e1f2a3b4c5 |
timestamp | 2026-11-04T14:30:00+07:00 |
String to sign | 01a1b2c3-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:
| Value | Rule | Example |
|---|---|---|
METHOD | Uppercase | GET |
path | Path and query, without scheme and host | /openapi/v1.0/products |
accessToken | Token only, without the Bearer prefix | <accessToken> |
bodyHash | SHA-256 of the body, lowercase hex | e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
timestamp | Request timestamp | 2026-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:timestampFull example string for GET:
GET:/openapi/v1.0/products:<accessToken>:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855:2026-11-04T14:30:00+07:00Compute 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.
| Value | Example |
|---|---|
client_secret | contoh-client-secret |
accessToken | token-contoh |
method | GET |
path | /openapi/v1.0/products |
bodyHash | e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
timestamp | 2026-11-04T14:30:00+07:00 |
String to sign:
GET:/openapi/v1.0/products:token-contoh:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855:2026-11-04T14:30:00+07:00Expected HMAC-SHA512 result:
1e343cec039c43ece0058a04db425f7ae411b81635e1418cf6052f7d3fd58f26210a9798166f6d2d62e0431986f6a2eb1373e292d6a6f6b69454491879303f78If 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
| Header | Value | Note |
|---|---|---|
Authorization | Bearer <accessToken> | The same token used in the signature |
X-PARTNER-ID | <client_id> | Must match the client in the access token |
X-EXTERNAL-ID | Unique string, max 36 characters | Random, unique per request. Allowed: letters, digits, dot, underscore, hyphen |
CHANNEL-ID | WEB | Must 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 code | Meaning | What to check |
|---|---|---|
4000100 | Access token body is not valid JSON | The body must be valid JSON |
4000101 | Invalid access token field format | Timestamp +07:00, 5-minute tolerance, or grantType |
4000102 | Missing access token header | X-CLIENT-KEY, X-TIMESTAMP, X-SIGNATURE |
4010100 | Access token signature failed | Private key, client_id, and signature encoding |
4030100 | IP is not allowed | Partner IP allowlist |
4000201 | Invalid request field format | X-EXTERNAL-ID, X-TIMESTAMP, CHANNEL-ID |
4000202 | Missing request header | All required headers were sent |
4010200 | Request signature failed | Client secret, path, body hash, and timestamp |
4010201 | Invalid access token | Token expired or belongs to another partner |
4010203 | Token not found | Authorization Bearer header |
4030200 | Catalog access denied | IP 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.