Skip to content

Request authentication ​

Normal merchant API requests need two things: an API key in Authorization: Bearer, and an HMAC-SHA256 signature in PG-Signature. The key alone is not enough. The sole exception is POST /v1/pay/deposits/{id}/slip, which uses an order-scoped capability token described below.

These are two unrelated mechanisms — never reuse the code of one for the other

your normal merchant requestinbound webhook from us
algorithmHMAC-SHA256 (shared secret)Ed25519 (key pair)
headerPG-Signature: v1=<hex>X-PG-Signature: v1,kid=...,t=...,s=...
what is signeda 9-line canonical string"v1." + t + "." + event_id + "." + RAW_BODY
who holds the secretyou and we share one secretwe hold the private key, you hold the public key

This page covers the left column only. The right column is in verifying webhook signatures.

Headers you must send ​

http
POST /v1/pay/deposits HTTP/1.1
Host: api.example-wl.com
Authorization: Bearer sk_live_xxx
Content-Type: application/json; charset=utf-8
PG-Timestamp: 1755749553
PG-Nonce: 01JQA5T8V0X2Z4B6D8F0H2J4L6
PG-Version: 2026-08-01
Idempotency-Key: order-9981-topup
PG-Signature: v1=63cfd5ec77f6df17241e5db6c1592999e0b2c97709175bd724b6cc2afeddfbc9
headervaluenotes
AuthorizationBearer sk_live_*, or your sandbox keykeep it in a secret manager; it can be rotated without a deploy
PG-Timestampunix secondsmore than ±300 s off → 401 timestamp_out_of_window
PG-Noncea string unique within 10 minutes🔴 new on every attempt — unlike Idempotency-Key, which must stay the same
PG-Versionthe YYYY-MM-DD version you pinnedpart of the canonical string
Idempotency-KeyPOST onlypart of the canonical string · see idempotency
PG-Signaturev1= + 64 lowercase hex charactersthe HMAC-SHA256 result

Issued keys use a 43-character unpadded Base64URL suffix (A-Z, a-z, 0-9, -, _). Preserve - and _ exactly when sending the Authorization header.

Exception: the slip-upload capability ​

The POST /v1/pay/deposits/{id}/slip ingress has no database, merchant secret, Durable Object, or service binding, so it cannot verify API-key/HMAC auth and does not accept PG- headers*. It uses an Ed25519 capability minted by the money service after merchant authentication and destination/reservation assignment:

http
POST {slip_upload.upload_url}
Authorization: Bearer pgsut_eyJraWQiOiJvcmRlc...
Idempotency-Key: slip-dep_...-1
Content-Type: multipart/form-data; boundary=...

The token carries aud=pg-slip-ingest, scope=slip:write, tenant_id, deposit_id, livemode, htu (full scheme + host + path), rate_subject/jti, nbf, exp=match_reservations.hold_until, and api_version. Ingress verifies signature, URL, and claims statelessly; the consumer then revalidates live database state before any transition.

  • A 201 awaiting_payment create response includes slip_upload: {token, expires_at, upload_url, max_bytes, content_types}.
  • A 202 awaiting_liquidity response cannot mint one yet. After deposit.matched, your backend calls POST /v1/pay/deposits/{id}/slip-token using the API key + HMAC rules on this page, then reveals payment instructions.
  • This is a bearer secret for one deposit. Never log it, put it in a URL, or send it to analytics.
  • Use the returned upload_url directly; never construct the upload host/path. Precheck decoded bytes/media type with max_bytes/content_types.
  • An issued token remains usable until expiry if the API key/WL is frozen later, so a user who may already have paid can still submit evidence. No new token can be minted after the freeze.

Canonical string — 9 lines ​

v1\n
{ts}\n
{nonce}\n
{METHOD}\n
{path}\n
{sorted_query}\n
{sha256_hex(raw_body)}\n
{idempotency_key}\n
{pg_version}\n

Every line ends with \n, including the last one

A correct string therefore always ends in \n. In languages where you build it with join("\n") you must append one more "\n" — this is the single most common cause of 401 signature_invalid.

#linewhat goes in itcommon mistake
1v1literal—
2{ts}same value as the PG-Timestamp headerusing milliseconds; it must be seconds
3{nonce}same value as the PG-Nonce headerreusing the value on retry
4{METHOD}uppercase GET POST DELETEsending lowercase post
5{path}the path only, no query string, no trailing slash · 🔴 do not URL-decode itincluding ?limit=50 · using the full https://api... URL
6{sorted_query}see the rules below · no query = empty string, but the line staysdropping the line when there is no query
7{sha256_hex(raw_body)}64 lowercase hex characters of the exact bytes you sendomitting it on GET · using a re-serialised body
8{idempotency_key}same value as the header · absent = empty string, line staysforgetting it on a POST
9{pg_version}same value as the PG-Version headerdropping the whole line

PG-Signature = "v1=" + hex(hmac_sha256(secret_key, canonical))

GET and DELETE need a body hash too

Line 7 of a request with no body is sha256(""), which is

e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

That value never changes, so you may hardcode it — but never leave the line blank.

Rules for sorted_query ​

  1. Percent-encode keys and values first, per RFC 3986 · uppercase hex · a space is %20, not +.
  2. Sort by the bytes of the encoded key. If two keys are equal, sort by value next.
  3. Join with & as k=v.
  4. Thai values are encoded as UTF-8 first, then percent-encoded.

Example

actual query   ?status=settled&updated_after=2026-08-21T00:00:00.000Z&limit=50
line 6         limit=50&status=settled&updated_after=2026-08-21T00%3A00%3A00.000Z

: must become %3A

The only characters that are not encoded are A-Z a-z 0-9 - . _ ~. Everything else — including : + / , — must be encoded. Many languages ship a "URL encode but leave : alone" helper that will break your signature: in JavaScript use encodeURIComponent, not encodeURI; in PHP use rawurlencode, not urlencode (urlencode turns spaces into +).


Signing code — Node.js ​

js
// pg-sign.mjs
import crypto from 'node:crypto';

const RFC3986 = (s) =>
  encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());

export function sortedQuery(params = {}) {
  return Object.entries(params)
    .filter(([, v]) => v !== undefined && v !== null)
    .map(([k, v]) => [RFC3986(String(k)), RFC3986(String(v))])
    .sort((a, b) =>
      Buffer.compare(Buffer.from(a[0]), Buffer.from(b[0])) ||
      Buffer.compare(Buffer.from(a[1]), Buffer.from(b[1])))
    .map(([k, v]) => `${k}=${v}`)
    .join('&');
}

export function canonicalString({
  ts, nonce, method, path, query = '', rawBody = '', idempotencyKey = '', apiVersion,
}) {
  const bodyHash = crypto.createHash('sha256').update(Buffer.from(rawBody, 'utf8')).digest('hex');
  return [
    'v1', String(ts), nonce, method.toUpperCase(), path, query, bodyHash, idempotencyKey, apiVersion,
  ].join('\n') + '\n';                       // 🔴 append \n after the last line
}

export function signRequest(opts) {
  const canonical = canonicalString(opts);
  const sig = crypto.createHmac('sha256', opts.secretKey).update(canonical, 'utf8').digest('hex');
  return { canonical, signature: 'v1=' + sig };
}

Using it with fetch — the important part is that you serialise the body once and pass that same string both to the signer and to the request.

js
import crypto from 'node:crypto';
import { signRequest } from './pg-sign.mjs';

export async function createDeposit(payload, idempotencyKey) {
  const rawBody = JSON.stringify(payload);          // 🔴 one variable, used for signing and for sending
  const ts = Math.floor(Date.now() / 1000);
  const nonce = crypto.randomUUID().replace(/-/g, '').toUpperCase();

  const { signature } = signRequest({
    secretKey: process.env.PG_SECRET_KEY,
    ts, nonce,
    method: 'POST',
    path: '/v1/pay/deposits',
    query: '',
    rawBody,
    idempotencyKey,
    apiVersion: process.env.PG_VERSION,
  });

  const res = await fetch(`${process.env.PG_API}/v1/pay/deposits`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.PG_API_KEY}`,
      'Content-Type': 'application/json; charset=utf-8',
      'PG-Timestamp': String(ts),
      'PG-Nonce': nonce,
      'PG-Version': process.env.PG_VERSION,
      'Idempotency-Key': idempotencyKey,
      'PG-Signature': signature,
    },
    body: rawBody,                                   // never call JSON.stringify(payload) again here
  });

  // 🔴 201 and 202 are both successes
  if (!res.ok) throw Object.assign(new Error('pg_error'), { status: res.status, body: await res.json() });
  return { status: res.status, body: await res.json() };
}

Signing code — PHP ​

php
<?php
// PgSigner.php — works on PHP 8.0+, no extra extensions needed
final class PgSigner
{
    public function __construct(
        private string $secretKey,
        private string $apiVersion = '2026-08-01',
    ) {}

    /** Sort by the bytes of the percent-encoded key; ties broken by value. */
    public static function sortedQuery(array $params): string
    {
        $pairs = [];
        foreach ($params as $k => $v) {
            if ($v === null) { continue; }
            $pairs[] = [rawurlencode((string) $k), rawurlencode((string) $v)];
        }
        usort($pairs, fn($a, $b) => strcmp($a[0], $b[0]) ?: strcmp($a[1], $b[1]));
        return implode('&', array_map(fn($p) => $p[0] . '=' . $p[1], $pairs));
    }

    public function canonicalString(
        int $ts, string $nonce, string $method, string $path,
        string $query = '', string $rawBody = '', string $idempotencyKey = ''
    ): string {
        $lines = [
            'v1', (string) $ts, $nonce, strtoupper($method), $path, $query,
            hash('sha256', $rawBody), $idempotencyKey, $this->apiVersion,
        ];
        return implode("\n", $lines) . "\n";   // 🔴 the last line gets a \n too
    }

    /** @return array{canonical:string, signature:string} */
    public function sign(
        int $ts, string $nonce, string $method, string $path,
        string $query = '', string $rawBody = '', string $idempotencyKey = ''
    ): array {
        $canonical = $this->canonicalString($ts, $nonce, $method, $path, $query, $rawBody, $idempotencyKey);
        return [
            'canonical' => $canonical,
            'signature' => 'v1=' . hash_hmac('sha256', $canonical, $this->secretKey),
        ];
    }

    public static function nonce(): string
    {
        return strtoupper(bin2hex(random_bytes(13)));
    }
}

Using it with cURL

php
<?php
require __DIR__ . '/PgSigner.php';

function pg_post(string $path, array $payload, string $idempotencyKey): array
{
    $rawBody = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    // 🔴 one $rawBody, used for signing and for sending — never json_encode twice

    $signer = new PgSigner(getenv('PG_SECRET_KEY'), getenv('PG_VERSION'));
    $ts     = time();
    $nonce  = PgSigner::nonce();
    $sig    = $signer->sign($ts, $nonce, 'POST', $path, '', $rawBody, $idempotencyKey)['signature'];

    $ch = curl_init(getenv('PG_API') . $path);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => $rawBody,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 15,
        CURLOPT_HTTPHEADER     => [
            'Authorization: Bearer ' . getenv('PG_API_KEY'),
            'Content-Type: application/json; charset=utf-8',
            'PG-Timestamp: ' . $ts,
            'PG-Nonce: ' . $nonce,
            'PG-Version: ' . getenv('PG_VERSION'),
            'Idempotency-Key: ' . $idempotencyKey,
            'PG-Signature: ' . $sig,
        ],
    ]);

    $body   = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    return ['status' => $status, 'body' => json_decode($body, true)];   // 201 and 202 are both successes
}

json_encode and Thai text

Without JSON_UNESCAPED_UNICODE, PHP escapes สมชาย into \uXXXX sequences — which is still correct, as long as you sign and send the same string. What breaks is signing one string and sending a different one.


Test vectors ​

Run your code against the inputs below. You must get exactly the same signatures before you call the real API.

secret_key = sk_test_01K2QF7H3X9M4TB0YR6WCZ8V2N
ts         = 1755749553
nonce      = 01JQA5T8V0X2Z4B6D8F0H2J4L6
pg_version = 2026-08-01

Vector 1 — GET with three query parameters ​

input

method            GET
path              /v1/pay/deposits
actual query      ?status=settled&updated_after=2026-08-21T00:00:00.000Z&limit=50
idempotency_key   (none)
raw_body          (empty)

the correct canonical string (every \n shown literally)

v1\n1755749553\n01JQA5T8V0X2Z4B6D8F0H2J4L6\nGET\n/v1/pay/deposits\nlimit=50&status=settled&updated_after=2026-08-21T00%3A00%3A00.000Z\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n\n2026-08-01\n

the correct signature

v1=62e8d2b9b1bf29ec9520984576fa2fbaa70d7290700e279c74cef3bf2f6f1b0f

Note the \n\n in the middle: that is the empty idempotency_key line. The line is still there.

Vector 2 — POST containing Thai text ​

raw body (205 bytes — no extra whitespace or newlines)

json
{"amount_minor":500000,"currency":"THB","merchant_reference":"ORD-9981","end_user_ref":"u_88213","sender":{"bank_code":"002","account_number":"1234567890","account_name_th":"สมชาย ใจดี"}}

input

method            POST
path              /v1/pay/deposits
query             (none)
idempotency_key   order-9981-topup
sha256(raw_body)  a0822715353f4df6c8dff936e54c2c9efbb6942663f977d996888989f413a295

the correct canonical string

v1\n1755749553\n01JQA5T8V0X2Z4B6D8F0H2J4L6\nPOST\n/v1/pay/deposits\n\na0822715353f4df6c8dff936e54c2c9efbb6942663f977d996888989f413a295\norder-9981-topup\n2026-08-01\n

the correct signature

v1=63cfd5ec77f6df17241e5db6c1592999e0b2c97709175bd724b6cc2afeddfbc9

Vector 3 — POST containing an emoji (surrogate pair) ​

raw body (211 bytes)

json
{"amount_minor":30000,"currency":"THB","merchant_reference":"ORD-9982","end_user_ref":"u_88213","sender_id":"snd_01K2QF7H3X9M4TB0YR6WCZ8V2N","metadata":{"note":"เติมเงินสำเร็จ 🎉"}}

input

method            POST
path              /v1/pay/deposits
query             (none)
idempotency_key   order-9982-topup
sha256(raw_body)  73d61ef322c0a9ee81decc0db75e3037e93d0ec0ef32683e70f9afe60315afe7

the correct signature

v1=4c5911983ce029a6d6da31066cfb123630520af7bd8a300c867083445cc4de6e

The authoritative set lives at an endpoint

GET /v1/webhooks/test-vectors returns all 3 vectors (a GET with ≥ 2 query parameters, a POST with Thai text, a POST with an emoji), each with the canonical string escaped so every \n is visible, plus the correct signature. Drop it straight into a unit test that runs in CI.

The negative test you also need — change the Idempotency-Key and the signature must fail. If it still passes, you forgot line 8.


Common traps ​

1. Forgetting the body hash on GET ​

js
// ❌ wrong — line 7 dropped because "there is no body"
const canonical = ['v1', ts, nonce, 'GET', path, query, idem, ver].join('\n') + '\n';

// ✅ right — sha256("") still has to be there
const canonical = ['v1', ts, nonce, 'GET', path, query,
  'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', idem, ver].join('\n') + '\n';

2. Signing a parsed body instead of the raw one ​

js
// ❌ wrong — serialised twice, producing different bytes (key order, whitespace, unicode escaping)
const sig  = sign(JSON.stringify(payload));
await fetch(url, { body: JSON.stringify(payload) });

// ❌ worse — middleware parsed it and you serialise it back
app.use(express.json());
const sig = sign(JSON.stringify(req.body));

// ✅ right — serialise once, hold it in one variable
const rawBody = JSON.stringify(payload);
const sig = sign(rawBody);
await fetch(url, { body: rawBody });

3. A drifting clock ​

A PG-Timestamp more than ±300 seconds out gives 401 timestamp_out_of_window every time. A container without NTP drifts several seconds a day, and you find out three weeks later when every request dies.

bash
# Compare your clock against the server's Date header — should be within 2–3 seconds
curl -sI "$PG_API/v1/pay/banks" | tr -d '\r' | awk 'BEGIN{IGNORECASE=1} /^date:/ {print "server: " $0}'
echo "local : Date: $(date -u '+%a, %d %b %Y %H:%M:%S GMT')"

This error is retryable: true, but retrying alone will not help

Sync the clock first, or the eleventh retry fails exactly like the first.

4. Missing or reused nonce ​

js
// ❌ wrong — generated once and reused on every retry ⇒ 401 nonce_replayed
const nonce = crypto.randomUUID();
for (const attempt of [1, 2, 3]) await send(nonce, idempotencyKey);

// ✅ right — new nonce per attempt, same Idempotency-Key throughout
for (const attempt of [1, 2, 3]) await send(crypto.randomUUID(), idempotencyKey);

Short version: PG-Nonce changes on every send · Idempotency-Key changes only when the intent changes.

5. Building the path wrong ​

❌ wrong✅ right
https://api.example-wl.com/v1/pay/deposits/v1/pay/deposits
/v1/pay/deposits//v1/pay/deposits
/v1/pay/deposits?limit=50line 5 = /v1/pay/deposits · line 6 = limit=50
decoding %20 in the path back to a space before signinguse the path exactly as sent, untouched

6. Forgetting PG-Version ​

A missing line 9 is an instant 401, and the value in the canonical string must match the header exactly.


401 codes and how to fix them ​

codewhat actually happenedhow to fixretryable
signature_invalidthe canonical string does not matchcompare byte by byte against a test vector · check lines 7, 8, 9 first❌
timestamp_out_of_windowclock more than 300 s offsync NTP and send again✅ after syncing
nonce_replayedPG-Nonce reused within 10 minutesgenerate a new nonce per attempt (keep Idempotency-Key)❌ change the nonce
api_key_invalid / api_key_revokedwrong or revoked keycontact your White Label provider❌

The error body has the same shape on every endpoint — branch on error.code, never on message.

json
{
  "error": {
    "code": "insufficient_balance",
    "type": "invalid_request_error",
    "message": "Available balance is lower than amount plus fee.",
    "message_th": "ยอดเงินที่ถอนได้ไม่พอสำหรับยอดถอนบวกค่าธรรมเนียม",
    "retryable": false,
    "retry_after_seconds": null,
    "request_id": "req_01JQA5T8V0X2Z4B6D8F0H2J4L6",
    "details": {
      "available_minor": 1002000,
      "required_minor": 1003000,
      "pending_clearing_minor": 500000,
      "pending_clearing_available_at": "2026-08-24T14:26:44.000Z"
    }
  }
}

message and message_th carry the same explanation in English and in Thai — we supply both, so you do not have to compose end-user wording yourself. details appears only on errors that have something extra to say. retryable tells you whether a retry could ever succeed — it does not mean an immediate retry will work (timestamp_out_of_window needs the clock fixed first).

🔴 request_id is the only thing support can trace for you — log it on every non-2xx.


Rate limits ​

Every response carries these headers, not just 429 responses.

http
RateLimit-Limit: 600
RateLimit-Remaining: 587
RateLimit-Reset: 41
RateLimit-Policy: 600;w=60
X-RateLimit-Scope: api_key
bucketscopelimitwindow
pay_write — creating deposits/withdrawalsapi_key60060 s
pay_create_per_userend_user_ref + device fingerprint + IP1060 s
pay_read — GET /v1/pay/*api_key1,20060 s
slip_uploadsigned capability rate_subject + IP1,20060 s
slip_upload_per_usersigned rate_subject + IP2060 s

429 and 503 mean different things

429 rate_limited = you went over your own quota. Respect Retry-After and add ±20% jitter. 🔴 Never retry in a tight loop; the penalty compounds. 503 route_unavailable / service_unavailable = the system is shedding load. Not your bug. Show the user "ระบบหนาแน่น ลองใหม่" ("the system is busy, please try again"). For 503 slip_intake_unavailable, retry the same logical evidence with the same Idempotency-Key; storage + queue admission is not fully confirmed yet.

429 does not consume your Idempotency-Key

After a 429 you can send again with the same key as soon as Retry-After elapses. There is no need to generate a new one.

Next ​

เอกสารชุดนี้อิงสัญญา API เวอร์ชัน 2026-08-01