Authentication
Every request your merchant server makes to the UnknownPay S2S API (e.g. POST /v1/deposits) must be signed with HMAC-SHA256 using your merchant API key + secret. The system never accepts a bare API key — a valid signature (X-Signature) and timestamp (X-Timestamp) are always required, otherwise the request is rejected with 401.
X-Signature / X-Timestamp, or carrying a signature that does not match, is rejected with 401 UNAUTHORIZED."100.50" (not a number, not satang). This is unrelated to signing, but worth repeating because the body you sign contains these fields (for the full money rules see Money Format).1. API key and secret format
A merchant receives one credential set per mode, consisting of a key id (sent in the header) and a secret (used to sign, never sent on the wire). Issuing/rotating credentials happens in the Portal (see Portal Preparation → rotate secret):
| Component | Example | Description |
|---|---|---|
key_id | unk_live_1a2b3c4d5e6f... | Public identifier, placed in the X-Api-Key header |
secret | (a 64-character hex string) | Secret key for HMAC — shown only once, at rotate time |
Distinguishing live / test from the key id prefix:
| Prefix | Mode | Meaning |
|---|---|---|
unk_live_ | live | Real transactions |
unk_test_ | test | sandbox / testing |
The mode (live/test) is derived from the API key on the server side (server-derived), not from the URL or host — the key id is the only source of truth for the mode. So you switch live/test by changing the key only, never by changing the endpoint.
- A single merchant may hold at most 1 live + 1 test active key at the same time (separate sets, no overlap).
- The secret is stored encrypted (envelope encryption) on the UnknownPay side — the system decrypts it only at verify time, never logs it, and never returns it, except at rotate (show-once).
- If you lose the secret, you must issue/rotate a new secret in the Portal, which revokes the old key for that mode immediately and returns the new secret once.
2. Canonical signing string (must be signed exactly like this)
The signature is computed from a string assembled from 4 lines, joined by a plain \n (newline), in exactly this order:
<HTTP_METHOD>\n<REQUEST_TARGET>\n<TIMESTAMP>\n<BODY_SHA256_HEX>
| Line | Value | Source |
|---|---|---|
| 1 | HTTP method | Uppercase, e.g. POST, GET |
| 2 | request target = path + query string | e.g. /v1/deposits or /v1/deposits?foo=1 — the query is part of the signature too |
| 3 | timestamp | Unix epoch seconds (numeric string) — the same value sent in X-Timestamp |
| 4 | SHA-256 of the raw request body, hex-encoded (lowercase) | See section 5 |
Then:
signature = HEX( HMAC_SHA256( key = secret, message = canonical_string ) )
- Algorithm: HMAC-SHA256
- Key:
secret(used as the raw bytes of the secret string) - Output encoding: hex (lowercase) — not base64
- The server-side signature comparison is a constant-time compare (to prevent timing attacks)
/v1/deposits but call /v1/deposits?evil=1, you get a 401 (this case is tested) — the query string cannot be altered/appended after signing.3. HTTP headers required on every request
| Header | Required | Value |
|---|---|---|
X-Api-Key | ✅ | The merchant key id, e.g. unk_live_... |
X-Signature | ✅ | The HMAC-SHA256 signature as hex (from section 2) |
X-Timestamp | ✅ | Unix epoch seconds (numeric string) — must match line 3 of the canonical string |
If any one of these 3 headers is missing (empty value) → 401 UNAUTHORIZED immediately.
4. Replay window and the error when the timestamp is out of range
- The replay window is ±300 seconds (±5 minutes) from server time.
- The system computes
delta = server_now - X-Timestamp. Ifdelta > 300(too old) ordelta < -300(too far in the future) → rejected. - The resulting error is
401with body codeUNAUTHORIZED(the system does not return a distinct error code for an out-of-range timestamp — it is the same401 UNAUTHORIZEDas a bad signature, so as not to reveal the cause).
Best practices:
- Keep your server clock synced with NTP.
- Generate a fresh
X-Timestampfor every request (do not cache it) and re-sign every time, because the timestamp is part of the canonical string.
Example error envelope (the real shape from the system):
{
"error": {
"code": "UNAUTHORIZED",
"message": "unauthorized",
"request_id": "..."
}
}
The cases/causes that lead to 401 UNAUTHORIZED are as follows (the system answers identically to all of them so as not to give hints):
- Missing
X-Api-Key/X-Signature/X-Timestamp - An unknown key id, or one that has been revoked (e.g. superseded by a rotate)
X-Timestampis not numeric, or is outside the ±300s window- The signature does not match (body altered, query altered, wrong secret, etc.)
5. Computing the body hash (including the GET / no-body case)
- The body hash is computed from the raw bytes of the request body exactly as sent on the wire (raw bytes, not decoded/decompressed) — you must sign from the same set of bytes you send (do not re-serialize the JSON after signing, because a different key ordering/whitespace will change the hash).
- Formula:
sha256( raw_body_bytes )then hex-encode (lowercase) → place as line 4. - For GET or any request with no body: the body is an empty byte array (
"", length 0), so the body hash is the SHA-256 of the empty string, which is the constant:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
You can use this value directly as line 4 for any request with no body.
6. Signing examples
ts = unix_seconds_now() // e.g. "1718800000"
body = raw bytes to send (POST: JSON; GET: empty)
body_hash = hex( sha256(body) )
target = path + ("?" + query if any) // e.g. "/v1/deposits"
canonical = METHOD + "\n" + target + "\n" + ts + "\n" + body_hash
signature = hex( hmac_sha256(secret, canonical) )
headers:
X-Api-Key = key_id
X-Signature = signature
X-Timestamp = ts
const crypto = require("crypto");
function signRequest({ secret, method, target, body }) {
// body is a Buffer/string exactly as sent; for GET use "" (empty)
const raw = Buffer.from(body || "");
const ts = Math.floor(Date.now() / 1000).toString();
const bodyHash = crypto.createHash("sha256").update(raw).digest("hex");
const canonical = `${method}\n${target}\n${ts}\n${bodyHash}`;
const signature = crypto
.createHmac("sha256", secret)
.update(canonical)
.digest("hex");
return { ts, signature };
}
// Example usage
const secret = "<<YOUR_SECRET>>";
const body = JSON.stringify({ amount: "100.50" }); // money = baht string!
const { ts, signature } = signRequest({
secret,
method: "POST",
target: "/v1/deposits",
body,
});
// Send: X-Api-Key, X-Signature=signature, X-Timestamp=ts, body=body (same bytes)
import hashlib, hmac, time
def sign_request(secret: str, method: str, target: str, body: bytes):
ts = str(int(time.time()))
body_hash = hashlib.sha256(body).hexdigest() # GET: body = b""
canonical = f"{method}\n{target}\n{ts}\n{body_hash}"
signature = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()
return ts, signature
secret = "<<YOUR_SECRET>>"
body = b'{"amount":"100.50"}' # the same bytes you send
ts, sig = sign_request(secret, "POST", "/v1/deposits", body)
SECRET="<<YOUR_SECRET>>"
KEY_ID="unk_live_xxxxxxxxxxxx"
METHOD="POST"
TARGET="/v1/deposits"
BODY='{"amount":"100.50"}' # money = baht string with 2 decimals
TS=$(date +%s)
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')
CANONICAL=$(printf '%s\n%s\n%s\n%s' "$METHOD" "$TARGET" "$TS" "$BODY_HASH")
SIG=$(printf '%s' "$CANONICAL" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
curl -X "$METHOD" "https://api.unkpay.co${TARGET}" \
-H "X-Api-Key: ${KEY_ID}" \
-H "X-Signature: ${SIG}" \
-H "X-Timestamp: ${TS}" \
-H "Content-Type: application/json" \
--data-raw "$BODY"
--data-raw (not -d, which may strip newlines) and you must hash the same bytes curl actually sends — if the body has a different newline or whitespace, the hash will not match.