Authentication
Every request is independently signed with HMAC-SHA256 using your app's client secret. There is no bearer token for app authentication — signatures cannot be replayed or tampered with in transit.
Never expose your client secret in frontend code, mobile apps, or public repositories. All API calls must originate from your backend.
Required headers
Send all five HMAC-v3 headers. The following five HMAC-v3 headers are required on every request.
| Header | Value |
|---|---|
| X-MallPlus-Partner-Id | Your app's client ID (mp_… or mp_live_…) |
| X-MallPlus-Timestamp | Unix epoch in seconds, e.g. 1700000000 |
| X-MallPlus-Signature-Version | Must be 3 |
| X-MallPlus-Nonce | Fresh 32–64 lowercase-hex value, unique per request |
| X-MallPlus-Signature | 64-character lowercase hex HMAC-SHA256 digest |
Seller-scoped calls add two more
| Header | Value |
|---|---|
| X-MallPlus-Access-Token | The seller's access token from the OAuth flow |
| X-MallPlus-Seller-Id | The seller's shop ID |
These two headers are not part of the signature base string. A seller-scoped signature is computed exactly like an app-only one for the same method, path, query and body.
Signing modes
| Mode | Headers | Base string |
|---|---|---|
| Public mode | The five HMAC-v3 headers only. | Uses the identical base string. |
| Shop mode | The five HMAC-v3 headers plus X-MallPlus-Access-Token and X-MallPlus-Seller-Id. | Uses the identical base string. Seller headers are not part of the base string. |
The signature base string
Seven fields joined with colons, in this order:
{timestamp}:{clientId}:{METHOD}:{requestPath}:{queryCanonical}:{sha256(body)}:{nonce}
| Field | Rule |
|---|---|
| timestamp | Same value as the header, as a Unix epoch integer in seconds, not milliseconds |
| clientId | Same value as X-MallPlus-Partner-Id |
| METHOD | Uppercase HTTP verb |
| requestPath | Path only, leading slash, no query string — e.g. /open/v1/products |
| queryCanonical | Canonicalized query string. Empty string when there is no query — the two adjacent colons must remain |
| sha256(body) | Lowercase hex SHA-256 of the raw request body. For an empty body this is the SHA-256 of "" |
| nonce | Same value as X-MallPlus-Nonce |
Query canonicalization
Query strings must be canonicalized before signing into a sorted percent-encoded query string. Getting this wrong is the most common cause of
401 INVALID_SIGNATURE, so the rules are exact:
- If the query is empty or just
?, the canonical form is the empty string. - Strip a leading
?. - Split on
&; for each pair, split on the first=. A key with no=has an empty value. - Decode each key and value once (treating
+as a space), then re-encode both withencodeURIComponent. - Sort keys in ascending ASCII order. Where a key repeats, sort its values lexicographically and emit one
key=valuepair per value. - Join the pairs with
&.
Worked example — GET with a query
// Request
GET /open/v1/products?status=live&limit=10&page=1
// queryCanonical — keys sorted ASCII ascending: limit, page, status
limit=10&page=1&status=live
// body is empty, so sha256(body) is the SHA-256 of ""
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
// baseString
1700000000:mp_live_a1b2c3d4e5f6g7h8:GET:/open/v1/products:limit=10&page=1&status=live:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855:9f86d081884c7d659a2feaa0c55ad015
Worked example — POST with a body
// Send the body bytes EXACTLY as hashed. Re-serialising JSON later
// can reorder keys and invalidate the signature.
const body = JSON.stringify({ status: 'live' }); // {"status":"live"}
const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
// → 8dd3e0f2c1f2f7f0e9bfb9e0a1c2d3e4... (hash of that exact string)
const baseString = [
timestamp, clientId, 'POST', '/open/v1/products/prod_abc123/status',
'', // no query
bodyHash, nonce
].join(':');
// then send the SAME `body` string as the request payload
await fetch(url, { method: 'POST', headers, body });
Signature format
| Format | Example | Accepted |
|---|---|---|
| Lowercase hex, 64 chars | e8864c9a…5b855 | yes |
| Uppercase hex | E8864C9A…5B855 | Rejected |
| Base64 | 6IZMmq…W4VQ== | Rejected |
Most crypto libraries emit lowercase by default — Node .digest('hex'), Python .hexdigest(),
PHP hash_hmac(), Java HexFormat.of().formatHex(). If you build the hex yourself, do not upper-case it.
Timestamp window
Your timestamp must be within 90 seconds of server time (the operator-configurable default; the
permitted range is 30–300 seconds). Outside it, you get 401 TIMESTAMP_EXPIRED. Keep your clock on NTP — drift
is the cause of the majority of sudden platform-wide signing failures.
Nonces and replay
Each nonce may be used once per app inside the freshness window to prevent replay attack attempts; reuse returns 401 NONCE_REUSED.
Generate it from a cryptographically secure RNG — crypto.randomBytes(16).toString('hex') or equivalent.
A sequential counter satisfies the format check but weakens your replay posture.
Reference implementations
Python
import hmac, hashlib, secrets, time, requests
from urllib.parse import quote, parse_qsl
def enc(s: str) -> str:
# encodeURIComponent parity — keep ! * ' ( ) raw. quote(safe="") percent-encodes
# them, producing a signature the platform rejects for any query containing them.
return quote(s, safe="!*'()")
def canonical_query(qs: str) -> str:
if not qs or qs == '?': return ''
pairs = parse_qsl(qs.lstrip('?'), keep_blank_values=True)
pairs.sort(key=lambda kv: (kv[0], kv[1]))
return '&'.join(f'{enc(k)}={enc(v)}' for k, v in pairs)
client_id, client_secret = 'mp_…', '…'
method, path, query, body = 'GET', '/open/v1/products', '?limit=10&page=1', ''
timestamp = str(int(time.time()))
nonce = secrets.token_hex(16)
body_hash = hashlib.sha256(body.encode()).hexdigest()
base = ':'.join([timestamp, client_id, method, path,
canonical_query(query), body_hash, nonce])
signature = hmac.new(client_secret.encode(), base.encode(), hashlib.sha256).hexdigest()
requests.get('https://sandbox.open.mallplus.ph' + path + query, headers={
'X-MallPlus-Partner-Id': client_id,
'X-MallPlus-Timestamp': timestamp,
'X-MallPlus-Signature-Version': '3',
'X-MallPlus-Nonce': nonce,
'X-MallPlus-Signature': signature,
})
PHP
<?php
function canonicalQuery(string $qs): string {
$qs = ltrim($qs, '?');
if ($qs === '') return '';
$pairs = [];
foreach (explode('&', $qs) as $part) {
[$k, $v] = array_pad(explode('=', $part, 2), 2, '');
$pairs[] = [urldecode($k), urldecode($v)];
}
usort($pairs, fn($a, $b) => [$a[0], $a[1]] <=> [$b[0], $b[1]]);
// encodeURIComponent parity — rawurlencode() percent-encodes ! * ' ( );
// restore them or the signature breaks for any query containing them.
$enc = fn($s) => strtr(rawurlencode($s),
['%21' => '!', '%2A' => '*', '%27' => "'", '%28' => '(', '%29' => ')']);
return implode('&', array_map(
fn($p) => $enc($p[0]) . '=' . $enc($p[1]), $pairs));
}
$clientId = 'mp_…'; $clientSecret = '…';
$method = 'GET'; $path = '/open/v1/products'; $query = '?limit=10'; $body = '';
$timestamp = (string) time();
$nonce = bin2hex(random_bytes(16));
$baseString = implode(':', [$timestamp, $clientId, $method, $path,
canonicalQuery($query), hash('sha256', $body), $nonce]);
$signature = hash_hmac('sha256', $baseString, $clientSecret);
Java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
byte[] nb = new byte[16];
new java.security.SecureRandom().nextBytes(nb);
String nonce = HexFormat.of().formatHex(nb);
String bodyHash = HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(body.getBytes(StandardCharsets.UTF_8)));
String baseString = String.join(":",
timestamp, clientId, method, requestPath, queryCanonical, bodyHash, nonce);
Mac mac = Mac.getInstance("HmacSHA256");
// pin the charset on the key too — platform default will bite you
mac.init(new SecretKeySpec(clientSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String signature = HexFormat.of().formatHex(
mac.doFinal(baseString.getBytes(StandardCharsets.UTF_8)));
Authentication errors
| Code | HTTP | Meaning | Resolution |
|---|---|---|---|
| BAD_REQUEST | 400 | A required auth header is missing | Send all five HMAC v3 headers |
| MISSING_NONCE | 400 | X-MallPlus-Nonce absent under version 3 | Generate a fresh nonce per request |
| INVALID_NONCE | 400 | Nonce is not 32–64 lowercase hex | Use hex encoding of 16–32 random bytes |
| NONCE_REUSED | 401 | Nonce already used inside the window | Never reuse; generate per request, not per session |
| INVALID_SIGNATURE | 401 | Computed signature does not match, or is not lowercase hex | Compare your base string field-by-field; check for a trailing newline in the secret |
| TIMESTAMP_EXPIRED | 401 | Outside the 90-second window | Send seconds; sync the clock via NTP |
| INVALID_CREDENTIALS | 401 | Credential kind does not match the host | Pair mp_… with the sandbox host, mp_live_… with production |
| HMAC_VERSION_DEPRECATED | 401 | Signature version 1 is rejected on production; version 2 is deprecated and being phased out | Sign with version 3 |
| SELLER_TOKEN_REQUIRED | 401 | Endpoint needs seller headers | Add X-MallPlus-Access-Token and X-MallPlus-Seller-Id |
| FORBIDDEN | 403 | App is not active, or lacks the endpoint's scope | Check app status and granted scopes in the console |
Worked example — validate your signer
Reproduce this exactly before you touch a real credential. The secret is illustrative; substitute your own.
| Input | Value |
|---|---|
| Client secret (example) | docs_secret |
| Method & path | GET /open/v1/usage |
| Query | none → empty string (keep the colon) |
| Body | empty → sha256 of "" |
| X-MallPlus-Timestamp | 1700000000 |
| X-MallPlus-Partner-Id | mp_test_docs |
| X-MallPlus-Nonce | 0123456789abcdef0123456789abcdef |
| sha256(body) | e3b0c442…7852b855 |
Base string — note the empty query leaves two adjacent colons:
const requestPath = '/open/v1/usage'
1700000000:mp_test_docs:GET:/open/v1/usage::e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855:0123456789abcdef0123456789abcdef
X-MallPlus-Signature = HMAC-SHA256(base string, client secret), lowercase hex:
a8e6ad2df9b0ca1f9c5cb9c237580c47fdfe85b416d56207129e26a551ec55d7
If your code produces this digest for these inputs, your signing is correct. The usual mistakes: milliseconds instead of seconds, dropping the empty-query colon, hashing a pretty-printed body instead of the exact bytes sent, or uppercase hex.
Worked example 2 — reserved characters in the query
This vector exercises exactly where signer implementations diverge. The platform canonicalizes with
encodeURIComponent semantics: ! * ' ( ) ~ stay raw and a space becomes %20.
If your encoder percent-encodes any of those characters (Python quote(safe="") and PHP
rawurlencode both do), this vector fails while example 1 still passes.
| Input | Value |
|---|---|
| Client secret / timestamp / nonce | same as example 1 |
| Method & path | GET /open/v1/products |
| Query (as sent) | ?q=size 10 shoes&tag=Nike's!&tag=(new)~* |
| Body | empty → sha256 of "" |
Canonical query — keys sorted, repeated-key values sorted ((new)~* precedes
Nike's! because ( sorts before N), spaces as %20, all six reserved
characters untouched:
q=size%2010%20shoes&tag=(new)~*&tag=Nike's!
Base string:
1700000000:mp_test_docs:GET:/open/v1/products:q=size%2010%20shoes&tag=(new)~*&tag=Nike's!:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855:0123456789abcdef0123456789abcdef
X-MallPlus-Signature = HMAC-SHA256(base string, client secret), lowercase hex:
a18e107a763f5d784f8b08a9f36160c7930493accf4ae7a60552e42d42c25a23
Then verify once live. Sign a real GET /open/v1/usage to your sandbox host with your own credential — a 200 confirms the whole chain end to end.