การยืนยันตัวตนของ request
Merchant API ปกติต้องมีสองอย่าง: API key ใน Authorization: Bearer และ ลายเซ็น HMAC-SHA256 ใน PG-Signature — key อย่างเดียวไม่พอ ข้อยกเว้นเดียวคือ POST /v1/pay/deposits/{id}/slip ซึ่งใช้ order-scoped capability token ตามหัวข้อด้านล่าง
สองกลไกนี้เป็นคนละเรื่องกัน อย่าเอาโค้ดข้ามกัน
| merchant request ปกติ ของคุณ | webhook ขาเข้า จากเรา | |
|---|---|---|
| อัลกอริทึม | HMAC-SHA256 (shared secret) | Ed25519 (คู่กุญแจ) |
| header | PG-Signature: v1=<hex> | X-PG-Signature: v1,kid=...,t=...,s=... |
| สิ่งที่เซ็น | canonical string 9 บรรทัด | "v1." + t + "." + event_id + "." + RAW_BODY |
| ใครถือความลับ | คุณและเราถือ secret เดียวกัน | เราถือ private key คุณถือ public key |
หน้านี้พูดถึงคอลัมน์ซ้ายเท่านั้น · คอลัมน์ขวาอยู่ที่ ตรวจลายเซ็น webhook
Header ที่ต้องส่ง
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 | ค่า | หมายเหตุ |
|---|---|---|
Authorization | Bearer sk_live_* / คีย์ sandbox ของคุณ | เก็บใน secret manager หมุนได้โดยไม่ต้อง deploy |
PG-Timestamp | unix วินาที | เพี้ยนเกิน ±300 วิ → 401 timestamp_out_of_window |
PG-Nonce | สตริงที่ไม่ซ้ำภายใน 10 นาที | 🔴 ใหม่ทุก attempt ต่างจาก Idempotency-Key ที่ต้องคงเดิม |
PG-Version | YYYY-MM-DD ที่คุณ pin ไว้ | อยู่ใน canonical string |
Idempotency-Key | เฉพาะ POST | อยู่ใน canonical string · ดู idempotency |
PG-Signature | v1= + hex ตัวพิมพ์เล็ก 64 ตัว | ผลของ HMAC-SHA256 |
คีย์ที่ระบบออกให้ใช้ suffix แบบ Base64URL ไม่ใส่ padding ความยาว 43 ตัวอักษร (A-Z, a-z, 0-9, -, _) ดังนั้นห้ามตัดหรือแปลงอักขระ - และ _ ก่อนส่งใน Authorization header
ข้อยกเว้น: capability token สำหรับอัปสลิป
Ingress ของ POST /v1/pay/deposits/{id}/slip ไม่มีฐานข้อมูล, merchant secret, Durable Object หรือ service binding จึง ตรวจ API key/HMAC ไม่ได้และไม่รับ PG- headers* ใช้ Ed25519 capability ที่ money service ออกหลัง merchant auth ผ่านและมี destination/reservation แล้วแทน:
POST {slip_upload.upload_url}
Authorization: Bearer pgsut_eyJraWQiOiJvcmRlc...
Idempotency-Key: slip-dep_...-1
Content-Type: multipart/form-data; boundary=...token มี aud=pg-slip-ingest, scope=slip:write, tenant_id, deposit_id, livemode, htu (scheme+host+path เต็ม), rate_subject/jti, nbf, exp=match_reservations.hold_until และ api_version ingress ตรวจ signature/URL/claims แบบ stateless แล้ว consumer ตรวจสถานะจริงใน DB ซ้ำก่อน transition
- create ที่ตอบ
201 awaiting_paymentคืนslip_upload: {token, expires_at, upload_url, max_bytes, content_types}มาด้วย - create ที่ตอบ
202 awaiting_liquidityยังออก token ไม่ได้ หลังdeposit.matchedให้ backend เรียกPOST /v1/pay/deposits/{id}/slip-tokenด้วย API key + HMAC ตามกติกาในหน้านี้ แล้วจึงแสดง payment instructions - token เป็น bearer secret ของ deposit เดียว ห้าม log, ใส่ URL หรือ analytics
- ใช้
upload_urlที่คืนมาโดยตรง ห้ามประกอบ upload host/path เอง และ precheck decoded bytes/media type ด้วยmax_bytes/content_types - token ที่ออกแล้วใช้ต่อจนหมดอายุได้แม้ API key/WL ถูก freeze ภายหลัง เพื่อไม่ปิดทางส่งหลักฐานของผู้ใช้ที่อาจโอนไปแล้ว แต่หลัง freeze จะ mint token ใหม่ไม่ได้
Canonical string — 9 บรรทัด
v1\n
{ts}\n
{nonce}\n
{METHOD}\n
{path}\n
{sorted_query}\n
{sha256_hex(raw_body)}\n
{idempotency_key}\n
{pg_version}\nทุกบรรทัดปิดท้ายด้วย \n รวมบรรทัดสุดท้าย
สตริงที่ถูกต้องจึงจบด้วย \n เสมอ · ภาษาที่ใช้ join("\n") ต้องบวก "\n" ต่อท้ายอีกหนึ่งตัว — นี่คือสาเหตุอันดับหนึ่งของ 401 signature_invalid
| # | บรรทัด | ประกอบจากอะไร | ค่าที่พลาดบ่อย |
|---|---|---|---|
| 1 | v1 | ตัวอักษรคงที่ | — |
| 2 | {ts} | ค่าเดียวกับ header PG-Timestamp | ใช้มิลลิวินาที ต้องเป็น วินาที |
| 3 | {nonce} | ค่าเดียวกับ header PG-Nonce | ใช้ค่าซ้ำตอน retry |
| 4 | {METHOD} | ตัวพิมพ์ใหญ่ GET POST DELETE | ส่ง post ตัวเล็ก |
| 5 | {path} | path อย่างเดียว ไม่รวม query · ไม่มี trailing slash · 🔴 ไม่ URL-decode | ใส่ ?limit=50 มาด้วย · ใส่ https://api... เต็ม URL |
| 6 | {sorted_query} | ดูกฎด้านล่าง · ไม่มี query = สตริงว่าง แต่บรรทัดยังอยู่ | ตัดบรรทัดทิ้งเมื่อไม่มี query |
| 7 | {sha256_hex(raw_body)} | hex ตัวพิมพ์เล็ก 64 ตัว ของ ไบต์ดิบที่ส่งจริง | GET ไม่ใส่ · ใช้ body ที่ re-serialize แล้ว |
| 8 | {idempotency_key} | ค่าเดียวกับ header · ไม่มี = สตริงว่าง บรรทัดยังอยู่ | ลืมใส่ทั้งที่เป็น POST |
| 9 | {pg_version} | ค่าเดียวกับ header PG-Version | ลืมทั้งบรรทัด |
PG-Signature = "v1=" + hex(hmac_sha256(secret_key, canonical))
GET และ DELETE ก็ต้องมี body hash
บรรทัดที่ 7 ของ request ที่ไม่มี body คือ sha256("") ซึ่งเท่ากับ
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855ค่านี้คงที่ตลอดกาล จะ hardcode ไว้เลยก็ได้ แต่ ห้ามเว้นบรรทัดนี้ว่าง
กฎของ sorted_query
- percent-encode key และ value ก่อน ตาม RFC 3986 · hex ตัวพิมพ์ใหญ่ · ช่องว่าง =
%20(ไม่ใช่+) - เรียงตามไบต์ของ key ที่ encode แล้ว — ถ้า key ชนกัน เรียงต่อด้วย value
- join ด้วย
&ในรูปk=v - ค่าภาษาไทยเข้ารหัส UTF-8 ก่อนแล้วค่อย percent-encode
ตัวอย่าง
query จริง ?status=settled&updated_after=2026-08-21T00:00:00.000Z&limit=50
บรรทัดที่ 6 limit=50&status=settled&updated_after=2026-08-21T00%3A00%3A00.000Z: ต้องกลายเป็น %3A
ตัวอักษรที่ ไม่ ถูก encode มีแค่ A-Z a-z 0-9 - . _ ~ · ทุกตัวที่เหลือรวมทั้ง : + / , ต้อง encode · หลายภาษามีฟังก์ชัน "encode แบบไม่แตะ :" ให้ใช้ ซึ่งจะทำให้ลายเซ็นพัง — ใน JavaScript ใช้ encodeURIComponent ไม่ใช่ encodeURI · ใน PHP ใช้ rawurlencode ไม่ใช่ urlencode (urlencode แปลงช่องว่างเป็น +)
โค้ดเซ็น — 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'; // 🔴 บวก \n ต่อท้ายบรรทัดสุดท้าย
}
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 };
}ใช้จริงกับ fetch — จุดสำคัญคือ serialize body ครั้งเดียว แล้วส่งสตริงตัวเดิมทั้งตอนเซ็นและตอนยิง
import crypto from 'node:crypto';
import { signRequest } from './pg-sign.mjs';
export async function createDeposit(payload, idempotencyKey) {
const rawBody = JSON.stringify(payload); // 🔴 ตัวแปรเดียว ใช้ทั้งเซ็นและส่ง
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, // ห้าม JSON.stringify(payload) ซ้ำตรงนี้
});
// 🔴 201 และ 202 คือสำเร็จทั้งคู่
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() };
}โค้ดเซ็น — PHP
<?php
// PgSigner.php — ใช้ได้กับ PHP 8.0 ขึ้นไป ไม่ต้องลง extension เพิ่ม
final class PgSigner
{
public function __construct(
private string $secretKey,
private string $apiVersion = '2026-08-01',
) {}
/** เรียงตาม byte ของ key ที่ percent-encode แล้ว ชนกันเรียงต่อด้วย 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"; // 🔴 \n ปิดท้ายบรรทัดสุดท้ายด้วย
}
/** @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)));
}
}ใช้จริงกับ 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);
// 🔴 $rawBody ตัวเดียว ใช้ทั้งเซ็นและส่ง ห้าม json_encode ซ้ำ
$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 และ 202 = สำเร็จทั้งคู่
}json_encode กับภาษาไทย
ถ้าไม่ใส่ JSON_UNESCAPED_UNICODE PHP จะแปลง สมชาย เป็น สม... ซึ่ง ยังถูกต้อง ตราบใดที่คุณเซ็นและส่งสตริงเดียวกัน · ที่พังคือกรณีที่เซ็นจากสตริงหนึ่งแล้วส่งอีกสตริงหนึ่ง
Test vector — ตรวจงานตัวเอง
รันโค้ดของคุณด้วย input ข้างล่างนี้ ต้องได้ signature ตรงกันเป๊ะ ก่อน จะยิง API จริง
secret_key = sk_test_01K2QF7H3X9M4TB0YR6WCZ8V2N
ts = 1755749553
nonce = 01JQA5T8V0X2Z4B6D8F0H2J4L6
pg_version = 2026-08-01เวกเตอร์ที่ 1 — GET ที่มี query 3 ตัว
input
method GET
path /v1/pay/deposits
query จริง ?status=settled&updated_after=2026-08-21T00:00:00.000Z&limit=50
idempotency_key (ไม่มี)
raw_body (ว่าง)canonical string ที่ถูก (แสดง \n ให้เห็นทุกตัว)
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\nsignature ที่ถูก
v1=62e8d2b9b1bf29ec9520984576fa2fbaa70d7290700e279c74cef3bf2f6f1b0fสังเกต: \n\n ติดกันตรงกลางคือบรรทัด idempotency_key ที่ว่าง บรรทัดยังอยู่
เวกเตอร์ที่ 2 — POST ที่มี unicode ไทย
raw body (205 ไบต์ ห้ามมีเว้นวรรคหรือขึ้นบรรทัดใหม่เพิ่ม)
{"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 (ไม่มี)
idempotency_key order-9981-topup
sha256(raw_body) a0822715353f4df6c8dff936e54c2c9efbb6942663f977d996888989f413a295canonical string ที่ถูก
v1\n1755749553\n01JQA5T8V0X2Z4B6D8F0H2J4L6\nPOST\n/v1/pay/deposits\n\na0822715353f4df6c8dff936e54c2c9efbb6942663f977d996888989f413a295\norder-9981-topup\n2026-08-01\nsignature ที่ถูก
v1=63cfd5ec77f6df17241e5db6c1592999e0b2c97709175bd724b6cc2afeddfbc9เวกเตอร์ที่ 3 — POST ที่มี emoji (surrogate pair)
raw body (211 ไบต์)
{"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 (ไม่มี)
idempotency_key order-9982-topup
sha256(raw_body) 73d61ef322c0a9ee81decc0db75e3037e93d0ec0ef32683e70f9afe60315afe7signature ที่ถูก
v1=4c5911983ce029a6d6da31066cfb123630520af7bd8a300c867083445cc4de6eชุดที่เป็นทางการอยู่ที่ endpoint
GET /v1/webhooks/test-vectors คืนเวกเตอร์ครบ 3 ชุด (GET ที่มี query ≥ 2 ตัว · POST ที่มี unicode ไทย · POST ที่มี emoji) พร้อม canonical string ที่ escape ให้เห็นทุก \n และ signature ที่ถูกต้อง — เอาไปทำ unit test ที่รันใน CI ได้เลย
ทดสอบเชิงลบที่ต้องมีด้วย — เปลี่ยน Idempotency-Key แล้ว signature ต้อง fail ถ้ายังผ่านแปลว่าคุณลืมบรรทัดที่ 8
กับดักที่พลาดบ่อย
1. ลืมใส่ body hash ตอน GET
// ❌ ผิด — ตัดบรรทัดที่ 7 ทิ้งเพราะ "ไม่มี body"
const canonical = ['v1', ts, nonce, 'GET', path, query, idem, ver].join('\n') + '\n';
// ✅ ถูก — sha256("") ยังต้องอยู่
const canonical = ['v1', ts, nonce, 'GET', path, query,
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', idem, ver].join('\n') + '\n';2. ใช้ body ที่ parse แล้วแทน raw
// ❌ ผิด — serialize สองรอบ ได้คนละไบต์ (key order, ช่องว่าง, escape ของ unicode)
const sig = sign(JSON.stringify(payload));
await fetch(url, { body: JSON.stringify(payload) });
// ❌ ผิดหนักกว่า — middleware parse แล้ว serialize กลับ
app.use(express.json());
const sig = sign(JSON.stringify(req.body));
// ✅ ถูก — serialize ครั้งเดียว ถือไว้ในตัวแปรเดียว
const rawBody = JSON.stringify(payload);
const sig = sign(rawBody);
await fetch(url, { body: rawBody });3. นาฬิกาเพี้ยน
PG-Timestamp เกิน ±300 วินาที = 401 timestamp_out_of_window ทุกครั้ง · container ที่ไม่ได้ sync NTP จะ drift วันละหลายวินาที รู้ตัวอีกทีคือ 3 สัปดาห์ต่อมาตอนที่ทุก request ตาย
# เทียบนาฬิกาเครื่องคุณกับ header Date ของเซิร์ฟเวอร์ — ควรต่างกันไม่เกิน 2–3 วินาที
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')"error นี้ retryable: true แต่ retry เฉย ๆ ไม่ช่วย
ต้อง sync นาฬิกาก่อน ไม่งั้น retry ครั้งที่ 11 ก็ยังพัง
4. ลืม nonce หรือใช้ nonce ซ้ำ
// ❌ ผิด — gen ครั้งเดียวแล้ววนใช้ทุก retry ⇒ 401 nonce_replayed
const nonce = crypto.randomUUID();
for (const attempt of [1, 2, 3]) await send(nonce, idempotencyKey);
// ✅ ถูก — nonce ใหม่ทุก attempt · Idempotency-Key คงเดิม
for (const attempt of [1, 2, 3]) await send(crypto.randomUUID(), idempotencyKey);จำสั้น ๆ: PG-Nonce เปลี่ยนทุกครั้งที่ยิง · Idempotency-Key เปลี่ยนทุกครั้งที่เจตนาเปลี่ยน
5. ประกอบ path ผิด
| ❌ ผิด | ✅ ถูก |
|---|---|
https://api.example-wl.com/v1/pay/deposits | /v1/pay/deposits |
/v1/pay/deposits/ | /v1/pay/deposits |
/v1/pay/deposits?limit=50 | บรรทัดที่ 5 = /v1/pay/deposits · บรรทัดที่ 6 = limit=50 |
decode %20 ใน path กลับเป็นช่องว่างก่อนเซ็น | ใช้ path ตามที่ส่งจริง ไม่แตะ |
6. ลืม PG-Version
บรรทัดที่ 9 หายทั้งบรรทัด = 401 ทันที · และค่าใน canonical ต้องตรงกับ header เป๊ะ
ตาราง 401 และวิธีแก้
code | สาเหตุจริง | แก้อย่างไร | retry ได้ไหม |
|---|---|---|---|
signature_invalid | canonical string ไม่ตรง | เทียบกับ test vector ทีละไบต์ · ตรวจบรรทัดที่ 7, 8, 9 ก่อน | ❌ |
timestamp_out_of_window | นาฬิกาเพี้ยน > 300 วิ | sync NTP แล้วยิงใหม่ | ✅ หลัง sync |
nonce_replayed | PG-Nonce ซ้ำใน 10 นาที | gen nonce ใหม่ทุก attempt (Idempotency-Key คงเดิม) | ❌ ต้องเปลี่ยน nonce |
api_key_invalid / api_key_revoked | คีย์ผิด/ถูกเพิกถอน | ติดต่อผู้ให้บริการ White Label | ❌ |
error body มีหน้าตาเดียวกันทุก endpoint — อ่าน error.code เป็นหลัก ไม่ใช่ 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"
}
}
}details มีเฉพาะ error ที่มีอะไรให้บอกเพิ่ม · retryable บอกว่าลองใหม่แล้วมีโอกาสสำเร็จไหม แต่ ไม่ได้แปลว่าลองใหม่ทันทีแล้วจะผ่าน (เช่น timestamp_out_of_window ต้อง sync นาฬิกาก่อน)
🔴 request_id คือสิ่งเดียวที่ทีม support ใช้ตามเรื่องให้คุณได้ — log ไว้ทุกครั้งที่ได้ non-2xx
Rate limit
ทุก response คืน header ชุดนี้ ไม่ใช่เฉพาะตอน 429
RateLimit-Limit: 600
RateLimit-Remaining: 587
RateLimit-Reset: 41
RateLimit-Policy: 600;w=60
X-RateLimit-Scope: api_key| bucket | ขอบเขต | เพดาน | หน้าต่าง |
|---|---|---|---|
pay_write — สร้างรายการฝาก/ถอน | api_key | 600 | 60 วิ |
pay_create_per_user | end_user_ref + device fingerprint + IP | 10 | 60 วิ |
pay_read — GET /v1/pay/* | api_key | 1,200 | 60 วิ |
slip_upload | signed rate_subject + IP จาก capability | 1,200 | 60 วิ |
slip_upload_per_user | signed rate_subject + IP | 20 | 60 วิ |
429 กับ 503 คนละเรื่อง
429 rate_limited = คุณ ยิงเกินโควตาตัวเอง เคารพ Retry-After แล้วเติม jitter ±20% 🔴 ห้าม retry เป็นลูป มี penalty ทวีคูณ 503 route_unavailable / service_unavailable = ระบบ กำลัง shed โหลด ไม่ใช่บั๊กของคุณ แสดง "ระบบหนาแน่น ลองใหม่" ส่วน 503 slip_intake_unavailable ให้ retry logical evidence เดิมด้วย Idempotency-Key เดิม เพราะ durable intake ยังยืนยัน storage+queue ไม่ครบ
429 ไม่ยึด Idempotency-Key
โดน 429 แล้วยิงซ้ำด้วย key เดิมได้ทันทีหลังพ้น Retry-After ไม่ต้อง gen key ใหม่
ขั้นต่อไป
- ทดสอบทั้งชุดกับ sandbox → Sandbox
- ทำ retry ให้ไม่สร้างออร์เดอร์ซ้ำ → Idempotency
- ฝั่งขาเข้า (คนละกลไกกันโดยสิ้นเชิง) → ตรวจลายเซ็น webhook Ed25519
- ยิง request ใบแรกให้ผ่าน → Quickstart
