Authentication & signing
Required headers, key formats, HMAC-SHA256 signature generation, the timestamp window, IP whitelisting and code samples in four languages.
Generated from public-api.mdVersion 1.2.0Last updated 2026-09-20
#Authentication
All API requests must include the following headers:
| Header | Required | Description |
|---|---|---|
X-API-Key | Yes | Your merchant API key |
X-Signature | Yes | Lowercase-hex HMAC-SHA256 signature of the request |
X-Timestamp | Yes | Unix timestamp in seconds since epoch |
Content-Type | On requests with a body | application/json (multipart/form-data for slip upload) |
X-Content-SHA256 | Slip upload only | Lowercase hex SHA-256 of the uploaded file bytes |
X-Sandbox-Scenario | No | Sandbox merchants only — forces a simulated outcome |
#Key format
| Key | Production form | Sandbox form | Length |
|---|---|---|---|
| API key | pk_prod_ + 32 hex chars | pk_demo_ + 32 hex chars | 40 chars |
| Secret key | sk_prod_ + 64 hex chars | sk_demo_ + 64 hex chars | 72 chars |
The secret key is only ever shown to you; it never travels in a request. Treat any key of a different shape as invalid.
Key rotation lag. Credentials are cached by the gateway for 5 minutes per API key. After regenerating keys in the dashboard, allow up to 5 minutes before the old key stops working and the new one starts.
#Signature Generation
The signature is computed using HMAC-SHA256 with your Secret Key:
Signature = lowercase_hex(HMAC-SHA256(SecretKey, SignatureData))
SignatureData = METHOD|PATH|TIMESTAMP (no request body)
SignatureData = METHOD|PATH|TIMESTAMP|BODY (with a request body)Rules that are enforced exactly as written:
METHODis the upper-case HTTP method (GET,POST).PATHis the URL path only. It includes the/v1prefix and it excludes the query string and the host. ForGET /v1/merchant-reports/transactions?from=2026-09-01,PATHis/v1/merchant-reports/transactions.TIMESTAMPis the same string you send inX-Timestamp.BODYis the exact raw bytes you transmit. Serialise once and send the same bytes you signed — re-serialising (different key order, whitespace, or number formatting) breaks the signature.- When the body is empty, the trailing
|is omitted entirely. AGETsigns three segments, not four.
Example (POST request):
SignatureData = "POST|/v1/payments/deposit|1702400000|{\"amount\":100.00,\"currency\":\"THB\",\"reference_id\":\"ORDER-001\"}"Example (GET request):
SignatureData = "GET|/v1/payments/550e8400-e29b-41d4-a716-446655440000|1702400000"Query strings are not covered by the signature. Only the path is signed. For the report endpoints, which take filters in the query string, the signature therefore protects the endpoint but not the parameters.
#Timestamp window
X-Timestamp must be within ±5 minutes of server time (configurable per
deployment via HMAC_TIMESTAMP_WINDOW; 5 minutes is the deployed default).
Outside that window the request is rejected with 401 TIMESTAMP_EXPIRED. Keep
your servers on NTP.
#IP whitelist
If your merchant account has a whitelist configured, every request must come
from a listed address; entries may be single IPs or CIDR ranges. A request from
any other address is rejected with 403 IP_NOT_WHITELISTED, even when the
signature is valid. With no entries configured, no IP check is applied.
The client IP is taken from CF-Connecting-IP (set by our edge and not
spoofable through it), falling back to the left-most X-Forwarded-For entry and
then to the socket address.
#Code Examples
#Python
import hmac
import hashlib
import time
import json
import requests
api_key = "your_api_key"
secret_key = "your_secret_key"
base_url = "https://api.paymenow.asia" # sandbox: https://sandbox-api.paymenow.asia
def create_signature(method, path, timestamp, body=""):
# path is the full URL path including /v1, WITHOUT the query string
data = f"{method}|{path}|{timestamp}"
if body:
data += f"|{body}"
return hmac.new(
secret_key.encode(),
data.encode(),
hashlib.sha256
).hexdigest()
def make_request(method, path, body=None):
timestamp = str(int(time.time()))
# Serialise ONCE and send exactly the bytes that were signed.
body_str = json.dumps(body) if body else ""
signature = create_signature(method, path, timestamp, body_str)
headers = {
"X-API-Key": api_key,
"X-Signature": signature,
"X-Timestamp": timestamp,
"Content-Type": "application/json"
}
url = base_url + path
if method == "POST":
return requests.post(url, headers=headers, data=body_str)
else:
return requests.get(url, headers=headers)
# Create deposit - note: use full path including /v1
response = make_request("POST", "/v1/payments/deposit", {
"amount": 1000.00,
"currency": "THB",
"reference_id": "ORD888888888",
"bank_code": "004",
"bank_account_no": "1234567890",
"bank_account_name": "test test",
"webhook_url": "https://example.com/path/to/your/webhook"
})
print(response.json())Note the
data=body_strabove rather thanjson=body:requestswould re-serialise the dict and could produce different bytes from the ones signed.
#Node.js
const crypto = require("crypto");
const axios = require("axios");
const apiKey = "your_api_key";
const secretKey = "your_secret_key";
const baseUrl = "https://api.paymenow.asia"; // sandbox: https://sandbox-api.paymenow.asia
function createSignature(method, path, timestamp, body = "") {
// path is the full URL path including /v1, WITHOUT the query string
let data = `${method}|${path}|${timestamp}`;
if (body) {
data += `|${body}`;
}
return crypto.createHmac("sha256", secretKey).update(data).digest("hex");
}
async function makeRequest(method, path, body = null) {
const timestamp = Math.floor(Date.now() / 1000).toString();
// Serialise ONCE and send exactly the bytes that were signed.
const bodyStr = body ? JSON.stringify(body) : "";
const signature = createSignature(method, path, timestamp, bodyStr);
const headers = {
"X-API-Key": apiKey,
"X-Signature": signature,
"X-Timestamp": timestamp,
"Content-Type": "application/json",
};
const url = baseUrl + path;
if (method === "POST") {
return axios.post(url, bodyStr, { headers });
} else {
return axios.get(url, { headers });
}
}
// Create deposit - note: use full path including /v1
makeRequest("POST", "/v1/payments/deposit", {
amount: 1000.0,
currency: "THB",
reference_id: "ORD888888888",
bank_code: "004",
bank_account_no: "1234567890",
bank_account_name: "test test",
webhook_url: "https://example.com/path/to/your/webhook",
}).then((res) => console.log(res.data));#PHP
<?php
$apiKey = 'your_api_key';
$secretKey = 'your_secret_key';
$baseUrl = 'https://api.paymenow.asia'; // sandbox: https://sandbox-api.paymenow.asia
function createSignature($method, $path, $timestamp, $body = '') {
global $secretKey;
// path is the full URL path including /v1, WITHOUT the query string
$data = "$method|$path|$timestamp";
if ($body) {
$data .= "|$body";
}
return hash_hmac('sha256', $data, $secretKey);
}
function makeRequest($method, $path, $body = null) {
global $apiKey, $baseUrl;
$timestamp = time();
// Serialise ONCE and send exactly the bytes that were signed.
$bodyStr = $body ? json_encode($body) : '';
$signature = createSignature($method, $path, $timestamp, $bodyStr);
$headers = [
'X-API-Key: ' . $apiKey,
'X-Signature: ' . $signature,
'X-Timestamp: ' . $timestamp,
'Content-Type: application/json'
];
$ch = curl_init($baseUrl . $path);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyStr);
}
$response = curl_exec($ch);
curl_close($ch);
return json_decode($response, true);
}
// Create deposit - note: use full path including /v1
$response = makeRequest('POST', '/v1/payments/deposit', [
'amount' => 1000.00,
'currency' => 'THB',
'reference_id' => 'ORD888888888',
'bank_code' => '004',
'bank_account_no' => '1234567890',
'bank_account_name' => 'test test',
'webhook_url' => 'https://example.com/path/to/your/webhook'
]);
print_r($response);#Go
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"time"
)
const (
apiKey = "your_api_key"
secretKey = "your_secret_key"
baseURL = "https://api.paymenow.asia" // sandbox: https://sandbox-api.paymenow.asia
)
func createSignature(method, path, timestamp, body string) string {
// path is the full URL path including /v1, WITHOUT the query string
data := fmt.Sprintf("%s|%s|%s", method, path, timestamp)
if body != "" {
data += "|" + body
}
h := hmac.New(sha256.New, []byte(secretKey))
h.Write([]byte(data))
return hex.EncodeToString(h.Sum(nil))
}
func makeRequest(method, path string, body interface{}) (map[string]interface{}, error) {
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
var bodyStr string
var reqBody io.Reader
if body != nil {
// Marshal ONCE and send exactly the bytes that were signed.
bodyBytes, _ := json.Marshal(body)
bodyStr = string(bodyBytes)
reqBody = bytes.NewBuffer(bodyBytes)
}
signature := createSignature(method, path, timestamp, bodyStr)
req, _ := http.NewRequest(method, baseURL+path, reqBody)
req.Header.Set("X-API-Key", apiKey)
req.Header.Set("X-Signature", signature)
req.Header.Set("X-Timestamp", timestamp)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
return result, nil
}
func main() {
// Create deposit - note: use full path including /v1
result, _ := makeRequest("POST", "/v1/payments/deposit", map[string]interface{}{
"amount": 1000.00,
"currency": "THB",
"reference_id": "ORD888888888",
"bank_code": "004",
"bank_account_no": "1234567890",
"bank_account_name": "test test",
"webhook_url": "https://example.com/path/to/your/webhook",
})
fmt.Printf("%+v\n", result)
}
