Skip to content

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.

ValuePurposeWhere to keep it
API keySent in X-Api-Key to identify your accountServer configuration (not secret, but do not publish it)
SecretSigns requests and verifies webhook signaturesA secret manager or server environment variable only
HeaderValue
X-Api-KeyYour API key
X-TimestampCurrent Unix time in seconds (integer). Must be within ±300 seconds of the server clock
X-NonceA new random string for every request: 16-128 characters of A-Z a-z 0-9 _ -. It cannot be reused within 10 minutes
X-SignatureThe base64 signature described below
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 ) )
  1. METHOD is upper case: GET, POST, PATCH, DELETE.

  2. PATH_AND_QUERY is the request target exactly as sent, including /v1 and the query string, for example /v1/payins?status=pending&limit=10. No host. Do not re-order or re-encode the query after signing.

  3. 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.

  4. Requests without a body (GET, DELETE and the cancel endpoints) hash the empty string: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.

  5. Join the five parts with \n (no trailing newline), compute HMAC-SHA256 with your secret and encode the result as base64 (not hex).

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 the file part, not of the whole multipart body.
  • Send only the file part. Any other part is rejected with parameter_unknown, because it would not be covered by the signature.
  • The file must be a JPG or PNG of at most 5 MB.

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 ipAllowListEnabled in GET /v1/account), the request comes from an allowed address, otherwise 403 ip_not_allowed. Contact support to add or change addresses.
  • The nonce has not been used before, otherwise 401 nonce_reused.

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');

Check your signer with these values before calling the API.

  • secret: 3f9c1a7e5b2d4c6e8f0a1b3c5d7e9f11
  • X-Timestamp: 1767225600
RequestX-NonceBodyExpected X-Signature
POST /v1/payinsn0nce-0123456789abcdefsee belowRbN+tURaQJG//uGx4R6FI4eBSwkHuPf1Ou/Vwy5MNKU=
GET /v1/payins?status=pending&limit=10abcdefghijklmnop(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=
SymptomCause and fix
invalid_signature on every request with a bodyYou 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 queryThe 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 requestMissing /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 uploadsThe whole multipart body was hashed. Hash only the file bytes
invalid_timestampMilliseconds were sent (Date.now()), or the server clock is more than 5 minutes off. Use seconds and enable NTP
invalid_nonceShorter than 16 characters, or characters outside A-Z a-z 0-9 _ -
nonce_reusedA retry re-sent the same headers. Every attempt needs a new timestamp, nonce and signature
invalid_api_keyTypo or stray whitespace in the key, or a test key while the account is not in test mode
parameter_unknown on uploadsThe multipart body contains parts other than file
ip_not_allowedYour server’s outgoing IP is not on the allow-list (watch out for NAT or IPs that change with deployments)