Writing HMAC/SHA256 Signers from Scratch for Shopee and Lazada
What the marketplace docs leave out. Practical guide with code snippets that actually work in production.
Every time I integrate a new marketplace’s Open API, the first real engineering task is the request signer. Shopee and Lazada both use HMAC/SHA256 — but the exact sequence of “what to sign” differs, and the docs leave out the parts that actually break you.
Here’s what I learned writing both from scratch in JavaScript.
Why HMAC/SHA256 Signing Exists
Public marketplace APIs need to prove three things about every incoming request:
- It came from an authorized partner (authentication)
- It hasn’t been tampered with in transit (integrity)
- It’s fresh, not a replay (freshness via timestamp)
HMAC (Hash-based Message Authentication Code) with SHA256 solves all three. You combine your partner secret with a deterministic string of request components, hash the result, and send the hash as a signparameter. The server does the same math and rejects anything that doesn’t match.
Shopee’s Signing Algorithm
Shopee signs: partner_id + path + timestamp + access_token + shop_id
import crypto from "node:crypto";
function signShopeeRequest({
partnerId, path, accessToken, shopId, partnerKey
}) {
const timestamp = Math.floor(Date.now() / 1000);
const baseString = `${partnerId}${path}${timestamp}${accessToken}${shopId}`;
const sign = crypto
.createHmac("sha256", partnerKey)
.update(baseString)
.digest("hex");
return { timestamp, sign };
}Gotchas:
timestampis in seconds, not milliseconds (JavaScript’s Date.now() returns ms)pathis the API path only — not the full URL, not the query string- The partner key is the HMAC key, not part of the base string
Lazada’s Signing Algorithm
Lazada is trickier. It signs all request parameters sorted alphabetically, joined as key-value pairs.
function signLazadaRequest({ params, path, appSecret }) {
const timestamp = Date.now(); // ms here, not seconds
const signed = { ...params, timestamp, sign_method: "sha256" };
const sortedKeys = Object.keys(signed).sort();
const baseString = path + sortedKeys
.map((k) => `${k}${signed[k]}`)
.join("");
const sign = crypto
.createHmac("sha256", appSecret)
.update(baseString)
.digest("hex")
.toUpperCase(); // Lazada wants uppercase hex
return { ...signed, sign };
}Gotchas:
- Timestamp is in milliseconds (unlike Shopee)
- Parameters must be sorted alphabetically before concatenation
- Hash output must be UPPERCASE hex (Shopee is lowercase)
- Don’t include
signitself when building the base string
Common Pitfalls Both APIs Share
Timestamp drift.If your server clock is off by more than ~5 minutes from the marketplace’s, every signed request fails with a vague “invalid sign” error. Use NTP. Never trust a Docker container’s time without syncing.
URL encoding. Don’t double-encode parameters. If a value has a space, URL-encode it once in the HTTP request, but use the raw value in the signing base string. Most “invalid sign” errors I’ve debugged were encoding mismatches.
Character encoding.Use UTF-8 everywhere. If a seller name has Thai or Chinese characters, Node’s default handles it — but a system with a different default will give you silent hash mismatches.
Key rotation. Partner keys can rotate. Keep them in environment variables, never hard-coded. Store the rotation date so you can audit when signing started failing.
The Debug Loop
When signs fail, compare byte-for-byte:
- Log the exact base string your code generates
- Log the resulting hex hash
- Compare with the marketplace’s docs example for identical inputs
- If no docs example, compare against their official SDK’s output
Usually the bug is one of: wrong sort order, wrong timestamp unit, wrong case on the hex output, or an extra whitespace character sneaking into a param.
The Payoff
Once both signers work, integrating any endpoint becomes a 10-minute task. Order sync, product catalog, revenue aggregation — they all reuse the same signer. That’s what lets one person ship production integrations across three markets simultaneously.
Working on something like this?
Start a conversation →