Skip to content

การยืนยันตัวตนของ 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 (คู่กุญแจ)
headerPG-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 ที่ต้องส่ง ​

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
headerค่าหมายเหตุ
AuthorizationBearer sk_live_* / คีย์ sandbox ของคุณเก็บใน secret manager หมุนได้โดยไม่ต้อง deploy
PG-Timestampunix วินาทีเพี้ยนเกิน ±300 วิ → 401 timestamp_out_of_window
PG-Nonceสตริงที่ไม่ซ้ำภายใน 10 นาที🔴 ใหม่ทุก attempt ต่างจาก Idempotency-Key ที่ต้องคงเดิม
PG-VersionYYYY-MM-DD ที่คุณ pin ไว้อยู่ใน canonical string
Idempotency-Keyเฉพาะ POSTอยู่ใน canonical string · ดู idempotency
PG-Signaturev1= + 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 แล้วแทน:

http
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

#บรรทัดประกอบจากอะไรค่าที่พลาดบ่อย
1v1ตัวอักษรคงที่—
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 ​

  1. percent-encode key และ value ก่อน ตาม RFC 3986 · hex ตัวพิมพ์ใหญ่ · ช่องว่าง = %20 (ไม่ใช่ +)
  2. เรียงตามไบต์ของ key ที่ encode แล้ว — ถ้า key ชนกัน เรียงต่อด้วย value
  3. join ด้วย & ในรูป k=v
  4. ค่าภาษาไทยเข้ารหัส 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 ​

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 ครั้งเดียว แล้วส่งสตริงตัวเดิมทั้งตอนเซ็นและตอนยิง

js
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
<?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
<?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\n

signature ที่ถูก

v1=62e8d2b9b1bf29ec9520984576fa2fbaa70d7290700e279c74cef3bf2f6f1b0f

สังเกต: \n\n ติดกันตรงกลางคือบรรทัด idempotency_key ที่ว่าง บรรทัดยังอยู่

เวกเตอร์ที่ 2 — POST ที่มี unicode ไทย ​

raw body (205 ไบต์ ห้ามมีเว้นวรรคหรือขึ้นบรรทัดใหม่เพิ่ม)

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             (ไม่มี)
idempotency_key   order-9981-topup
sha256(raw_body)  a0822715353f4df6c8dff936e54c2c9efbb6942663f977d996888989f413a295

canonical string ที่ถูก

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

signature ที่ถูก

v1=63cfd5ec77f6df17241e5db6c1592999e0b2c97709175bd724b6cc2afeddfbc9

เวกเตอร์ที่ 3 — POST ที่มี emoji (surrogate pair) ​

raw body (211 ไบต์)

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             (ไม่มี)
idempotency_key   order-9982-topup
sha256(raw_body)  73d61ef322c0a9ee81decc0db75e3037e93d0ec0ef32683e70f9afe60315afe7

signature ที่ถูก

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 ​

js
// ❌ ผิด — ตัดบรรทัดที่ 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 ​

js
// ❌ ผิด — 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 ตาย

bash
# เทียบนาฬิกาเครื่องคุณกับ 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 ซ้ำ ​

js
// ❌ ผิด — 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_invalidcanonical string ไม่ตรงเทียบกับ test vector ทีละไบต์ · ตรวจบรรทัดที่ 7, 8, 9 ก่อน❌
timestamp_out_of_windowนาฬิกาเพี้ยน > 300 วิsync NTP แล้วยิงใหม่✅ หลัง sync
nonce_replayedPG-Nonce ซ้ำใน 10 นาทีgen nonce ใหม่ทุก attempt (Idempotency-Key คงเดิม)❌ ต้องเปลี่ยน nonce
api_key_invalid / api_key_revokedคีย์ผิด/ถูกเพิกถอนติดต่อผู้ให้บริการ White Label❌

error body มีหน้าตาเดียวกันทุก endpoint — อ่าน error.code เป็นหลัก ไม่ใช่ 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"
    }
  }
}

details มีเฉพาะ error ที่มีอะไรให้บอกเพิ่ม · retryable บอกว่าลองใหม่แล้วมีโอกาสสำเร็จไหม แต่ ไม่ได้แปลว่าลองใหม่ทันทีแล้วจะผ่าน (เช่น timestamp_out_of_window ต้อง sync นาฬิกาก่อน)

🔴 request_id คือสิ่งเดียวที่ทีม support ใช้ตามเรื่องให้คุณได้ — log ไว้ทุกครั้งที่ได้ non-2xx


Rate limit ​

ทุก response คืน header ชุดนี้ ไม่ใช่เฉพาะตอน 429

http
RateLimit-Limit: 600
RateLimit-Remaining: 587
RateLimit-Reset: 41
RateLimit-Policy: 600;w=60
X-RateLimit-Scope: api_key
bucketขอบเขตเพดานหน้าต่าง
pay_write — สร้างรายการฝาก/ถอนapi_key60060 วิ
pay_create_per_userend_user_ref + device fingerprint + IP1060 วิ
pay_read — GET /v1/pay/*api_key1,20060 วิ
slip_uploadsigned rate_subject + IP จาก capability1,20060 วิ
slip_upload_per_usersigned rate_subject + IP2060 วิ

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

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