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 request | inbound webhook from us | |
|---|---|---|
| algorithm | HMAC-SHA256 (shared secret) | Ed25519 (key pair) |
| header | PG-Signature: v1=<hex> | X-PG-Signature: v1,kid=...,t=...,s=... |
| what is signed | a 9-line canonical string | "v1." + t + "." + event_id + "." + RAW_BODY |
| who holds the secret | you and we share one secret | we 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
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| header | value | notes |
|---|---|---|
Authorization | Bearer sk_live_*, or your sandbox key | keep it in a secret manager; it can be rotated without a deploy |
PG-Timestamp | unix seconds | more than ±300 s off → 401 timestamp_out_of_window |
PG-Nonce | a string unique within 10 minutes | 🔴 new on every attempt — unlike Idempotency-Key, which must stay the same |
PG-Version | the YYYY-MM-DD version you pinned | part of the canonical string |
Idempotency-Key | POST only | part of the canonical string · see idempotency |
PG-Signature | v1= + 64 lowercase hex characters | the 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:
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_paymentcreate response includesslip_upload: {token, expires_at, upload_url, max_bytes, content_types}. - A
202 awaiting_liquidityresponse cannot mint one yet. Afterdeposit.matched, your backend callsPOST /v1/pay/deposits/{id}/slip-tokenusing 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_urldirectly; never construct the upload host/path. Precheck decoded bytes/media type withmax_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}\nEvery 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.
| # | line | what goes in it | common mistake |
|---|---|---|---|
| 1 | v1 | literal | — |
| 2 | {ts} | same value as the PG-Timestamp header | using milliseconds; it must be seconds |
| 3 | {nonce} | same value as the PG-Nonce header | reusing the value on retry |
| 4 | {METHOD} | uppercase GET POST DELETE | sending lowercase post |
| 5 | {path} | the path only, no query string, no trailing slash · 🔴 do not URL-decode it | including ?limit=50 · using the full https://api... URL |
| 6 | {sorted_query} | see the rules below · no query = empty string, but the line stays | dropping the line when there is no query |
| 7 | {sha256_hex(raw_body)} | 64 lowercase hex characters of the exact bytes you send | omitting it on GET · using a re-serialised body |
| 8 | {idempotency_key} | same value as the header · absent = empty string, line stays | forgetting it on a POST |
| 9 | {pg_version} | same value as the PG-Version header | dropping 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
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855That value never changes, so you may hardcode it — but never leave the line blank.
Rules for sorted_query
- Percent-encode keys and values first, per RFC 3986 · uppercase hex · a space is
%20, not+. - Sort by the bytes of the encoded key. If two keys are equal, sort by value next.
- Join with
&ask=v. - 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
// 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.
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
// 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
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-01Vector 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\nthe correct signature
v1=62e8d2b9b1bf29ec9520984576fa2fbaa70d7290700e279c74cef3bf2f6f1b0fNote 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)
{"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) a0822715353f4df6c8dff936e54c2c9efbb6942663f977d996888989f413a295the correct canonical string
v1\n1755749553\n01JQA5T8V0X2Z4B6D8F0H2J4L6\nPOST\n/v1/pay/deposits\n\na0822715353f4df6c8dff936e54c2c9efbb6942663f977d996888989f413a295\norder-9981-topup\n2026-08-01\nthe correct signature
v1=63cfd5ec77f6df17241e5db6c1592999e0b2c97709175bd724b6cc2afeddfbc9Vector 3 — POST containing an emoji (surrogate pair)
raw body (211 bytes)
{"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) 73d61ef322c0a9ee81decc0db75e3037e93d0ec0ef32683e70f9afe60315afe7the correct signature
v1=4c5911983ce029a6d6da31066cfb123630520af7bd8a300c867083445cc4de6eThe 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
// ❌ 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
// ❌ 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.
# 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
// ❌ 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=50 | line 5 = /v1/pay/deposits · line 6 = limit=50 |
decoding %20 in the path back to a space before signing | use 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
code | what actually happened | how to fix | retryable |
|---|---|---|---|
signature_invalid | the canonical string does not match | compare byte by byte against a test vector · check lines 7, 8, 9 first | ❌ |
timestamp_out_of_window | clock more than 300 s off | sync NTP and send again | ✅ after syncing |
nonce_replayed | PG-Nonce reused within 10 minutes | generate a new nonce per attempt (keep Idempotency-Key) | ❌ change the nonce |
api_key_invalid / api_key_revoked | wrong or revoked key | contact your White Label provider | ❌ |
The error body has the same shape on every endpoint — branch on error.code, never on message.
{
"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.
RateLimit-Limit: 600
RateLimit-Remaining: 587
RateLimit-Reset: 41
RateLimit-Policy: 600;w=60
X-RateLimit-Scope: api_key| bucket | scope | limit | window |
|---|---|---|---|
pay_write — creating deposits/withdrawals | api_key | 600 | 60 s |
pay_create_per_user | end_user_ref + device fingerprint + IP | 10 | 60 s |
pay_read — GET /v1/pay/* | api_key | 1,200 | 60 s |
slip_upload | signed capability rate_subject + IP | 1,200 | 60 s |
slip_upload_per_user | signed rate_subject + IP | 20 | 60 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
- Test the whole set against sandbox → Sandbox
- Make retries stop creating duplicate orders → Idempotency
- The inbound side, an entirely separate mechanism → Verifying Ed25519 webhook signatures
- Get your first request through → Quickstart
