Authentication and request signing
Every request is signed with HMAC-SHA256 using your secret. There is no way to skip signing: the server checks the signature, the timestamp and the nonce of every request.
Your credentials
Section titled “Your credentials”| Value | Purpose | Where to keep it |
|---|---|---|
| API key | Sent in X-Api-Key to identify your account | Server configuration (not secret, but do not publish it) |
| Secret | Signs requests and verifies webhook signatures | A secret manager or server environment variable only |
Required headers
Section titled “Required headers”| Header | Value |
|---|---|
X-Api-Key | Your API key |
X-Timestamp | Current Unix time in seconds (integer). Must be within ±300 seconds of the server clock |
X-Nonce | A new random string for every request: 16-128 characters of A-Z a-z 0-9 _ -. It cannot be reused within 10 minutes |
X-Signature | The base64 signature described below |
Building the signature
Section titled “Building the signature”string_to_sign = METHOD + "\n" + PATH_AND_QUERY + "\n" + X-Timestamp + "\n" + X-Nonce + "\n" + hex( SHA-256( raw_body ) )
X-Signature = base64( HMAC-SHA256( secret, string_to_sign ) )-
METHOD is upper case:
GET,POST,PATCH,DELETE. -
PATH_AND_QUERY is the request target exactly as sent, including
/v1and the query string, for example/v1/payins?status=pending&limit=10. No host. Do not re-order or re-encode the query after signing. -
raw_body is the exact bytes you send. Serialise your JSON once, sign that string, and send that same string. The hash is lowercase hex.
-
Requests without a body (
GET,DELETEand the cancel endpoints) hash the empty string:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855. -
Join the five parts with
\n(no trailing newline), compute HMAC-SHA256 with your secret and encode the result as base64 (not hex).
Multipart requests
Section titled “Multipart requests”Endpoints that take a file (POST /v1/payins/{id}/slip, and the image form of POST /v1/slips/verify) use multipart/form-data:
- The body hash is
hex(SHA-256(file bytes))of thefilepart, not of the whole multipart body. - Send only the
filepart. Any other part is rejected withparameter_unknown, because it would not be covered by the signature. - The file must be a JPG or PNG of at most 5 MB.
Other checks
Section titled “Other checks”Once the signature is valid, the server also checks that:
- Your partner account is active, otherwise
403 partner_inactive. - If your account uses an IP allow-list (see
ipAllowListEnabledinGET /v1/account), the request comes from an allowed address, otherwise403 ip_not_allowed. Contact support to add or change addresses. - The nonce has not been used before, otherwise
401 nonce_reused.
Code samples
Section titled “Code samples”The samples read the API key and secret from the PARTNER_API_KEY and PARTNER_API_SECRET environment variables. The signing functions accept a fixed timestamp and nonce so you can check them against the test vectors.
// partner-api.mjs (Node.js 18+, no dependencies)import crypto from 'node:crypto';import { readFile } from 'node:fs/promises';
const BASE_URL = 'https://api.example.com';const API_KEY = process.env.PARTNER_API_KEY;const API_SECRET = process.env.PARTNER_API_SECRET;
export function signRequest(method, pathAndQuery, body = '', opts = {}) { const timestamp = opts.timestamp ?? String(Math.floor(Date.now() / 1000)); const nonce = opts.nonce ?? crypto.randomBytes(16).toString('hex'); const bodyHash = crypto.createHash('sha256').update(body).digest('hex'); const stringToSign = [method.toUpperCase(), pathAndQuery, timestamp, nonce, bodyHash].join('\n'); const signature = crypto .createHmac('sha256', opts.secret ?? API_SECRET) .update(stringToSign) .digest('base64'); return { 'X-Api-Key': opts.apiKey ?? API_KEY, 'X-Timestamp': timestamp, 'X-Nonce': nonce, 'X-Signature': signature, };}
// JSON requests: serialise once, sign that string, send that string.export async function api(method, pathAndQuery, payload) { const body = payload === undefined ? '' : JSON.stringify(payload); const headers = signRequest(method, pathAndQuery, body); if (body) headers['Content-Type'] = 'application/json'; const res = await fetch(BASE_URL + pathAndQuery, { method, headers, body: body || undefined }); const json = await res.json(); if (!res.ok) { const err = new Error(`${json.error.code}: ${json.error.message}`); Object.assign(err, { status: res.status, ...json.error, requestId: json.requestId }); throw err; } return json;}
// Multipart: sign the SHA-256 of the file bytes, send only the `file` part.export async function uploadSlip(payinId, filePath, mimeType = 'image/jpeg') { const path = `/v1/payins/${payinId}/slip`; const bytes = await readFile(filePath); const form = new FormData(); form.append('file', new Blob([bytes], { type: mimeType }), 'slip.jpg'); const res = await fetch(BASE_URL + path, { method: 'POST', headers: signRequest('POST', path, bytes), // no Content-Type: fetch sets the boundary body: form, }); return res.json();}
// const { data: account } = await api('GET', '/v1/account');<?php// partner_api.php (PHP 7.4+, ext-curl)const BASE_URL = 'https://api.example.com';
function partner_sign(string $method, string $pathAndQuery, string $body, ?string $timestamp = null, ?string $nonce = null, ?string $secret = null): array{ $timestamp = $timestamp ?? (string) time(); $nonce = $nonce ?? bin2hex(random_bytes(16)); $stringToSign = implode("\n", [ strtoupper($method), $pathAndQuery, $timestamp, $nonce, hash('sha256', $body), ]); $secret = $secret ?? getenv('PARTNER_API_SECRET'); $signature = base64_encode(hash_hmac('sha256', $stringToSign, $secret, true)); return [ 'X-Api-Key: ' . getenv('PARTNER_API_KEY'), 'X-Timestamp: ' . $timestamp, 'X-Nonce: ' . $nonce, 'X-Signature: ' . $signature, ];}
function partner_api(string $method, string $pathAndQuery, ?array $payload = null): array{ // Serialise once and send exactly these bytes. $body = $payload === null ? '' : json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $headers = partner_sign($method, $pathAndQuery, $body); if ($body !== '') { $headers[] = 'Content-Type: application/json'; } $ch = curl_init(BASE_URL . $pathAndQuery); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, ]); if ($body !== '') { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } $json = json_decode(curl_exec($ch), true); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if ($status >= 400) { throw new RuntimeException($json['error']['code'] . ' (requestId ' . $json['requestId'] . ')', $status); } return $json;}
// Multipart: sign the SHA-256 of the file bytes, send only the `file` part.function partner_upload_slip(string $payinId, string $filePath, string $mimeType = 'image/jpeg'): array{ $path = "/v1/payins/$payinId/slip"; $headers = partner_sign('POST', $path, file_get_contents($filePath)); $ch = curl_init(BASE_URL . $path); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_POSTFIELDS => ['file' => new CURLFile($filePath, $mimeType)], CURLOPT_RETURNTRANSFER => true, ]); $json = json_decode(curl_exec($ch), true); curl_close($ch); return $json;}
// $account = partner_api('GET', '/v1/account')['data'];# partner_api.py (Python 3.8+, pip install requests)import base64, hashlib, hmac, json, os, secrets, timeimport requests
BASE_URL = "https://api.example.com"API_KEY = os.environ["PARTNER_API_KEY"]API_SECRET = os.environ["PARTNER_API_SECRET"]
def sign_request(method, path_and_query, body=b"", *, secret=None, timestamp=None, nonce=None): timestamp = timestamp or str(int(time.time())) nonce = nonce or secrets.token_hex(16) body_hash = hashlib.sha256(body).hexdigest() string_to_sign = "\n".join([method.upper(), path_and_query, timestamp, nonce, body_hash]) digest = hmac.new((secret or API_SECRET).encode(), string_to_sign.encode(), hashlib.sha256).digest() return { "X-Api-Key": API_KEY, "X-Timestamp": timestamp, "X-Nonce": nonce, "X-Signature": base64.b64encode(digest).decode(), }
def api(method, path_and_query, payload=None): # Serialise once and send exactly these bytes (do not use requests' json= argument). body = b"" if payload is None else json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode() headers = sign_request(method, path_and_query, body) if body: headers["Content-Type"] = "application/json" r = requests.request(method, BASE_URL + path_and_query, headers=headers, data=body or None, timeout=30) data = r.json() if r.status_code >= 400: raise RuntimeError(f"{data['error']['code']} (requestId {data['requestId']})") return data
def upload_slip(payin_id, file_path, mime_type="image/jpeg"): # Multipart: sign the SHA-256 of the file bytes, send only the `file` part. path = f"/v1/payins/{payin_id}/slip" with open(file_path, "rb") as f: content = f.read() r = requests.post(BASE_URL + path, headers=sign_request("POST", path, content), files={"file": ("slip.jpg", content, mime_type)}, timeout=60) return r.json()
# account = api("GET", "/v1/account")["data"]Test vectors
Section titled “Test vectors”Check your signer with these values before calling the API.
- secret:
3f9c1a7e5b2d4c6e8f0a1b3c5d7e9f11 X-Timestamp:1767225600
| Request | X-Nonce | Body | Expected X-Signature |
|---|---|---|---|
POST /v1/payins | n0nce-0123456789abcdef | see below | RbN+tURaQJG//uGx4R6FI4eBSwkHuPf1Ou/Vwy5MNKU= |
GET /v1/payins?status=pending&limit=10 | abcdefghijklmnop | (empty) | dK6H3MXkxAZurxacWPiWtFWkvEfPpGv9pT2HtwZpw5Y= |
Body of the first request (no spaces or newlines):
{"payerId":"payer_7c0a4b8e2f3d4a1b9c8d7e6f5a4b3c2d","amount":"150.00","currency":"THB"}Its SHA-256 is 6fc1ff4ee67aa8ec233a428b3f48ac43436dfcd59d252a3cff673161d828d5e4.
import { signRequest } from './partner-api.mjs';
const body = '{"payerId":"payer_7c0a4b8e2f3d4a1b9c8d7e6f5a4b3c2d","amount":"150.00","currency":"THB"}';const h = signRequest('POST', '/v1/payins', body, { secret: '3f9c1a7e5b2d4c6e8f0a1b3c5d7e9f11', timestamp: '1767225600', nonce: 'n0nce-0123456789abcdef',});console.log(h['X-Signature']); // RbN+tURaQJG//uGx4R6FI4eBSwkHuPf1Ou/Vwy5MNKU=$body = '{"payerId":"payer_7c0a4b8e2f3d4a1b9c8d7e6f5a4b3c2d","amount":"150.00","currency":"THB"}';$h = partner_sign('POST', '/v1/payins', $body, '1767225600', 'n0nce-0123456789abcdef', '3f9c1a7e5b2d4c6e8f0a1b3c5d7e9f11');echo $h[3]; // X-Signature: RbN+tURaQJG//uGx4R6FI4eBSwkHuPf1Ou/Vwy5MNKU=body = b'{"payerId":"payer_7c0a4b8e2f3d4a1b9c8d7e6f5a4b3c2d","amount":"150.00","currency":"THB"}'h = sign_request("POST", "/v1/payins", body, secret="3f9c1a7e5b2d4c6e8f0a1b3c5d7e9f11", timestamp="1767225600", nonce="n0nce-0123456789abcdef")print(h["X-Signature"]) # RbN+tURaQJG//uGx4R6FI4eBSwkHuPf1Ou/Vwy5MNKU=Common signing mistakes
Section titled “Common signing mistakes”| Symptom | Cause and fix |
|---|---|
invalid_signature on every request with a body | You signed one string and sent another, e.g. the HTTP library serialised the JSON again (axios given an object, requests with json=). Serialise once yourself and send the signed string |
invalid_signature only when there is a query | The query was left out of the signed path, or the library re-encoded it (, became %2C). Build the final URL first, then sign its path and query character for character |
invalid_signature on every request | Missing /v1, host included in the path, lowercase method, body hash in base64 instead of hex, signature in hex instead of base64, trailing \n, or an old secret after a new one was created |
invalid_signature on slip uploads | The whole multipart body was hashed. Hash only the file bytes |
invalid_timestamp | Milliseconds were sent (Date.now()), or the server clock is more than 5 minutes off. Use seconds and enable NTP |
invalid_nonce | Shorter than 16 characters, or characters outside A-Z a-z 0-9 _ - |
nonce_reused | A retry re-sent the same headers. Every attempt needs a new timestamp, nonce and signature |
invalid_api_key | Typo or stray whitespace in the key, or a test key while the account is not in test mode |
parameter_unknown on uploads | The multipart body contains parts other than file |
ip_not_allowed | Your server’s outgoing IP is not on the allow-list (watch out for NAT or IPs that change with deployments) |