PayMeNow

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:

HeaderRequiredDescription
X-API-KeyYesYour merchant API key
X-SignatureYesLowercase-hex HMAC-SHA256 signature of the request
X-TimestampYesUnix timestamp in seconds since epoch
Content-TypeOn requests with a bodyapplication/json (multipart/form-data for slip upload)
X-Content-SHA256Slip upload onlyLowercase hex SHA-256 of the uploaded file bytes
X-Sandbox-ScenarioNoSandbox merchants only — forces a simulated outcome

#Key format

KeyProduction formSandbox formLength
API keypk_prod_ + 32 hex charspk_demo_ + 32 hex chars40 chars
Secret keysk_prod_ + 64 hex charssk_demo_ + 64 hex chars72 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:

text
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:

  • METHOD is the upper-case HTTP method (GET, POST).
  • PATH is the URL path only. It includes the /v1 prefix and it excludes the query string and the host. For GET /v1/merchant-reports/transactions?from=2026-09-01, PATH is /v1/merchant-reports/transactions.
  • TIMESTAMP is the same string you send in X-Timestamp.
  • BODY is 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. A GET signs three segments, not four.

Example (POST request):

text
SignatureData = "POST|/v1/payments/deposit|1702400000|{\"amount\":100.00,\"currency\":\"THB\",\"reference_id\":\"ORDER-001\"}"

Example (GET request):

text
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

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_str above rather than json=body: requests would re-serialise the dict and could produce different bytes from the ones signed.

#Node.js

javascript
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
<?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

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)
}