Core conceptsAuthentication

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.

HeaderValue
X-MallPlus-Partner-IdYour app's client ID (mp_… or mp_live_…)
X-MallPlus-TimestampUnix epoch in seconds, e.g. 1700000000
X-MallPlus-Signature-VersionMust be 3
X-MallPlus-NonceFresh 32–64 lowercase-hex value, unique per request
X-MallPlus-Signature64-character lowercase hex HMAC-SHA256 digest

Seller-scoped calls add two more

HeaderValue
X-MallPlus-Access-TokenThe seller's access token from the OAuth flow
X-MallPlus-Seller-IdThe 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

ModeHeadersBase string
Public modeThe five HMAC-v3 headers only.Uses the identical base string.
Shop modeThe 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}
FieldRule
timestampSame value as the header, as a Unix epoch integer in seconds, not milliseconds
clientIdSame value as X-MallPlus-Partner-Id
METHODUppercase HTTP verb
requestPathPath only, leading slash, no query string — e.g. /open/v1/products
queryCanonicalCanonicalized 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 ""
nonceSame 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:

  1. If the query is empty or just ?, the canonical form is the empty string.
  2. Strip a leading ?.
  3. Split on &; for each pair, split on the first =. A key with no = has an empty value.
  4. Decode each key and value once (treating + as a space), then re-encode both with encodeURIComponent.
  5. Sort keys in ascending ASCII order. Where a key repeats, sort its values lexicographically and emit one key=value pair per value.
  6. 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

FormatExampleAccepted
Lowercase hex, 64 charse8864c9a…5b855yes
Uppercase hexE8864C9A…5B855Rejected
Base646IZMmq…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

CodeHTTPMeaningResolution
BAD_REQUEST400A required auth header is missingSend all five HMAC v3 headers
MISSING_NONCE400X-MallPlus-Nonce absent under version 3Generate a fresh nonce per request
INVALID_NONCE400Nonce is not 32–64 lowercase hexUse hex encoding of 16–32 random bytes
NONCE_REUSED401Nonce already used inside the windowNever reuse; generate per request, not per session
INVALID_SIGNATURE401Computed signature does not match, or is not lowercase hexCompare your base string field-by-field; check for a trailing newline in the secret
TIMESTAMP_EXPIRED401Outside the 90-second windowSend seconds; sync the clock via NTP
INVALID_CREDENTIALS401Credential kind does not match the hostPair mp_… with the sandbox host, mp_live_… with production
HMAC_VERSION_DEPRECATED401Signature version 1 is rejected on production; version 2 is deprecated and being phased outSign with version 3
SELLER_TOKEN_REQUIRED401Endpoint needs seller headersAdd X-MallPlus-Access-Token and X-MallPlus-Seller-Id
FORBIDDEN403App is not active, or lacks the endpoint's scopeCheck 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.

InputValue
Client secret (example)docs_secret
Method & pathGET /open/v1/usage
Querynone → empty string (keep the colon)
Bodyempty → sha256 of ""
X-MallPlus-Timestamp1700000000
X-MallPlus-Partner-Idmp_test_docs
X-MallPlus-Nonce0123456789abcdef0123456789abcdef
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.

InputValue
Client secret / timestamp / noncesame as example 1
Method & pathGET /open/v1/products
Query (as sent)?q=size 10 shoes&tag=Nike's!&tag=(new)~*
Bodyempty → 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.