Skip to content
UnknownPay
ไทย
Core Concepts

Authentication

Every S2S request is signed with HMAC-SHA256 using your API key id and secret — a bare API key is never accepted

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.

Every request must be HMAC-signed. There is no "API key only" mode. A request missing X-Signature / X-Timestamp, or carrying a signature that does not match, is rejected with 401 UNAUTHORIZED.
Money note: every money field on the wire is a baht string with 2 decimal places, e.g. "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):

ComponentExampleDescription
key_idunk_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:

PrefixModeMeaning
unk_live_liveReal transactions
unk_test_testsandbox / 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.
Diagram coming soon — Portal page showing the rotate-secret button and the show-once secret box with the warning 'copy it now, it will not be shown again'
Pending confirmation — the secret length/format to show merchants in the docs (the code generates a 64-character hex string)

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>
LineValueSource
1HTTP methodUppercase, e.g. POST, GET
2request target = path + query stringe.g. /v1/deposits or /v1/deposits?foo=1the query is part of the signature too
3timestampUnix epoch seconds (numeric string) — the same value sent in X-Timestamp
4SHA-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)
Line 2 must be the path with the query string actually sent. If you sign /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

HeaderRequiredValue
X-Api-KeyThe merchant key id, e.g. unk_live_...
X-SignatureThe HMAC-SHA256 signature as hex (from section 2)
X-TimestampUnix 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.

Pending confirmation — whether the merchant must also send Content-Type: application/json on POST (the verify code does not check Content-Type, but the create handler might)

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. If delta > 300 (too old) or delta < -300 (too far in the future) → rejected.
  • The resulting error is 401 with body code UNAUTHORIZED (the system does not return a distinct error code for an out-of-range timestamp — it is the same 401 UNAUTHORIZED as a bad signature, so as not to reveal the cause).

Best practices:

  • Keep your server clock synced with NTP.
  • Generate a fresh X-Timestamp for 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-Timestamp is 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
In bash you must send the body with --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.
Pending confirmation — confirm the real path of the first endpoint a merchant will call (the code has POST /v1/deposits, GET /v1/deposits/:id, POST /v1/deposits/:id/cancel) and the body fields of POST /v1/deposits