การยืนยันตัวตนและการเซ็น request
ทุก request ต้องเซ็นด้วย HMAC-SHA256 โดยใช้ secret ของคุณ ไม่มีวิธีข้ามการเซ็น เซิร์ฟเวอร์จะตรวจลายเซ็น เวลา และ nonce ทุกครั้ง
คีย์ที่ใช้
หัวข้อที่มีชื่อว่า “คีย์ที่ใช้”| ค่า | ใช้ทำอะไร | เก็บที่ไหน |
|---|---|---|
| API key | ส่งใน header X-Api-Key เพื่อบอกว่าเป็นบัญชีไหน | config ของเซิร์ฟเวอร์ (ไม่ใช่ความลับ แต่ไม่ควรเผยแพร่) |
| Secret | ใช้เซ็น request และตรวจลายเซ็นของ webhook | secret manager หรือ environment variable บนเซิร์ฟเวอร์เท่านั้น |
Header ที่ต้องส่ง
หัวข้อที่มีชื่อว่า “Header ที่ต้องส่ง”| Header | ค่า |
|---|---|
X-Api-Key | API key ของคุณ |
X-Timestamp | เวลาปัจจุบันเป็น Unix time หน่วยวินาที (จำนวนเต็ม) ต้องต่างจากนาฬิกาเซิร์ฟเวอร์ไม่เกิน ±300 วินาที |
X-Nonce | สตริงสุ่มใหม่ทุก request ยาว 16-128 ตัว ใช้ได้เฉพาะ A-Z a-z 0-9 _ - ใช้ซ้ำภายใน 10 นาทีไม่ได้ |
X-Signature | ลายเซ็น base64 ตามหัวข้อถัดไป |
วิธีสร้างลายเซ็น
หัวข้อที่มีชื่อว่า “วิธีสร้างลายเซ็น”string_to_sign = METHOD + "\n" + PATH_AND_QUERY + "\n" + X-Timestamp + "\n" + X-Nonce + "\n" + hex( SHA-256( raw_body ) )
X-Signature = base64( HMAC-SHA256( secret, string_to_sign ) )-
METHOD เป็นตัวพิมพ์ใหญ่ เช่น
GET,POST,PATCH,DELETE -
PATH_AND_QUERY คือ path ที่ส่งจริงรวม
/v1และ query string เช่น/v1/payins?status=pending&limit=10ไม่รวมโดเมน ห้ามเรียงหรือ encode query ใหม่หลังจากเซ็นแล้ว -
raw_body คือไบต์ของ body ที่ส่งจริง ให้แปลง JSON เป็นสตริงครั้งเดียว เซ็นสตริงนั้น แล้วส่งสตริงเดียวกันนั้น hash เป็น hex ตัวเล็ก
-
request ที่ไม่มี body (
GET,DELETEและ endpoint ยกเลิกรายการ) ให้ hash สตริงว่าง ได้ค่าe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 -
ต่อทั้ง 5 ส่วนด้วยตัวขึ้นบรรทัด
\n(ไม่มี\nต่อท้าย) แล้วคำนวณ HMAC-SHA256 ด้วย secret และเข้ารหัสผลลัพธ์เป็น base64 (ไม่ใช่ hex)
Request แบบ multipart
หัวข้อที่มีชื่อว่า “Request แบบ multipart”endpoint ที่รับไฟล์ (POST /v1/payins/{id}/slip และการส่งรูปไปที่ POST /v1/slips/verify) ใช้ multipart/form-data กรณีนี้
- ค่า hash ของ body คือ
hex(SHA-256(ไบต์ของไฟล์))ของ part ชื่อfileไม่ใช่ ของ body multipart ทั้งก้อน - ส่งได้เพียง part
fileเท่านั้น part อื่นจะถูกปฏิเสธด้วยparameter_unknownเพราะไม่ได้อยู่ในลายเซ็น - ไฟล์ต้องเป็น JPG หรือ PNG ขนาดไม่เกิน 5 MB
การตรวจอื่นๆ
หัวข้อที่มีชื่อว่า “การตรวจอื่นๆ”หลังลายเซ็นถูกต้อง เซิร์ฟเวอร์ยังตรวจว่า
- บัญชีพาร์ทเนอร์ต้องมีสถานะ active ไม่เช่นนั้นได้
403 partner_inactive - ถ้าบัญชีเปิดใช้ IP allow-list (ดูได้จาก
ipAllowListEnabledในGET /v1/account) request ต้องมาจาก IP ที่อนุญาต ไม่เช่นนั้นได้403 ip_not_allowedติดต่อซัพพอร์ตเพื่อเพิ่มหรือแก้ IP - nonce ต้องไม่เคยใช้มาก่อน ไม่เช่นนั้นได้
401 nonce_reused
โค้ดตัวอย่าง
หัวข้อที่มีชื่อว่า “โค้ดตัวอย่าง”ตัวอย่างอ่าน API key และ secret จาก environment variable PARTNER_API_KEY และ PARTNER_API_SECRET ฟังก์ชันเซ็นรับ timestamp และ nonce แบบกำหนดเองได้ เพื่อใช้ตรวจกับ test vector
// partner-api.mjs (Node.js 18+, no dependencies)import crypto from 'node:crypto';import { readFile } from 'node:fs/promises';
const BASE_URL = 'https://api.example.com';const API_KEY = process.env.PARTNER_API_KEY;const API_SECRET = process.env.PARTNER_API_SECRET;
export function signRequest(method, pathAndQuery, body = '', opts = {}) { const timestamp = opts.timestamp ?? String(Math.floor(Date.now() / 1000)); const nonce = opts.nonce ?? crypto.randomBytes(16).toString('hex'); const bodyHash = crypto.createHash('sha256').update(body).digest('hex'); const stringToSign = [method.toUpperCase(), pathAndQuery, timestamp, nonce, bodyHash].join('\n'); const signature = crypto .createHmac('sha256', opts.secret ?? API_SECRET) .update(stringToSign) .digest('base64'); return { 'X-Api-Key': opts.apiKey ?? API_KEY, 'X-Timestamp': timestamp, 'X-Nonce': nonce, 'X-Signature': signature, };}
// JSON requests: serialise once, sign that string, send that string.export async function api(method, pathAndQuery, payload) { const body = payload === undefined ? '' : JSON.stringify(payload); const headers = signRequest(method, pathAndQuery, body); if (body) headers['Content-Type'] = 'application/json'; const res = await fetch(BASE_URL + pathAndQuery, { method, headers, body: body || undefined }); const json = await res.json(); if (!res.ok) { const err = new Error(`${json.error.code}: ${json.error.message}`); Object.assign(err, { status: res.status, ...json.error, requestId: json.requestId }); throw err; } return json;}
// Multipart: sign the SHA-256 of the file bytes, send only the `file` part.export async function uploadSlip(payinId, filePath, mimeType = 'image/jpeg') { const path = `/v1/payins/${payinId}/slip`; const bytes = await readFile(filePath); const form = new FormData(); form.append('file', new Blob([bytes], { type: mimeType }), 'slip.jpg'); const res = await fetch(BASE_URL + path, { method: 'POST', headers: signRequest('POST', path, bytes), // no Content-Type: fetch sets the boundary body: form, }); return res.json();}
// const { data: account } = await api('GET', '/v1/account');<?php// partner_api.php (PHP 7.4+, ext-curl)const BASE_URL = 'https://api.example.com';
function partner_sign(string $method, string $pathAndQuery, string $body, ?string $timestamp = null, ?string $nonce = null, ?string $secret = null): array{ $timestamp = $timestamp ?? (string) time(); $nonce = $nonce ?? bin2hex(random_bytes(16)); $stringToSign = implode("\n", [ strtoupper($method), $pathAndQuery, $timestamp, $nonce, hash('sha256', $body), ]); $secret = $secret ?? getenv('PARTNER_API_SECRET'); $signature = base64_encode(hash_hmac('sha256', $stringToSign, $secret, true)); return [ 'X-Api-Key: ' . getenv('PARTNER_API_KEY'), 'X-Timestamp: ' . $timestamp, 'X-Nonce: ' . $nonce, 'X-Signature: ' . $signature, ];}
function partner_api(string $method, string $pathAndQuery, ?array $payload = null): array{ // Serialise once and send exactly these bytes. $body = $payload === null ? '' : json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $headers = partner_sign($method, $pathAndQuery, $body); if ($body !== '') { $headers[] = 'Content-Type: application/json'; } $ch = curl_init(BASE_URL . $pathAndQuery); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, ]); if ($body !== '') { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } $json = json_decode(curl_exec($ch), true); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if ($status >= 400) { throw new RuntimeException($json['error']['code'] . ' (requestId ' . $json['requestId'] . ')', $status); } return $json;}
// Multipart: sign the SHA-256 of the file bytes, send only the `file` part.function partner_upload_slip(string $payinId, string $filePath, string $mimeType = 'image/jpeg'): array{ $path = "/v1/payins/$payinId/slip"; $headers = partner_sign('POST', $path, file_get_contents($filePath)); $ch = curl_init(BASE_URL . $path); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_POSTFIELDS => ['file' => new CURLFile($filePath, $mimeType)], CURLOPT_RETURNTRANSFER => true, ]); $json = json_decode(curl_exec($ch), true); curl_close($ch); return $json;}
// $account = partner_api('GET', '/v1/account')['data'];# partner_api.py (Python 3.8+, pip install requests)import base64, hashlib, hmac, json, os, secrets, timeimport requests
BASE_URL = "https://api.example.com"API_KEY = os.environ["PARTNER_API_KEY"]API_SECRET = os.environ["PARTNER_API_SECRET"]
def sign_request(method, path_and_query, body=b"", *, secret=None, timestamp=None, nonce=None): timestamp = timestamp or str(int(time.time())) nonce = nonce or secrets.token_hex(16) body_hash = hashlib.sha256(body).hexdigest() string_to_sign = "\n".join([method.upper(), path_and_query, timestamp, nonce, body_hash]) digest = hmac.new((secret or API_SECRET).encode(), string_to_sign.encode(), hashlib.sha256).digest() return { "X-Api-Key": API_KEY, "X-Timestamp": timestamp, "X-Nonce": nonce, "X-Signature": base64.b64encode(digest).decode(), }
def api(method, path_and_query, payload=None): # Serialise once and send exactly these bytes (do not use requests' json= argument). body = b"" if payload is None else json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode() headers = sign_request(method, path_and_query, body) if body: headers["Content-Type"] = "application/json" r = requests.request(method, BASE_URL + path_and_query, headers=headers, data=body or None, timeout=30) data = r.json() if r.status_code >= 400: raise RuntimeError(f"{data['error']['code']} (requestId {data['requestId']})") return data
def upload_slip(payin_id, file_path, mime_type="image/jpeg"): # Multipart: sign the SHA-256 of the file bytes, send only the `file` part. path = f"/v1/payins/{payin_id}/slip" with open(file_path, "rb") as f: content = f.read() r = requests.post(BASE_URL + path, headers=sign_request("POST", path, content), files={"file": ("slip.jpg", content, mime_type)}, timeout=60) return r.json()
# account = api("GET", "/v1/account")["data"]Test vectors
หัวข้อที่มีชื่อว่า “Test vectors”ใช้ค่าด้านล่างตรวจว่าฟังก์ชันเซ็นของคุณถูกต้อง ก่อนเรียก API จริง
- secret:
3f9c1a7e5b2d4c6e8f0a1b3c5d7e9f11 X-Timestamp:1767225600
| Request | X-Nonce | Body | X-Signature ที่ถูกต้อง |
|---|---|---|---|
POST /v1/payins | n0nce-0123456789abcdef | ดูด้านล่าง | RbN+tURaQJG//uGx4R6FI4eBSwkHuPf1Ou/Vwy5MNKU= |
GET /v1/payins?status=pending&limit=10 | abcdefghijklmnop | (ว่าง) | dK6H3MXkxAZurxacWPiWtFWkvEfPpGv9pT2HtwZpw5Y= |
body ของ request แรก (ไม่มีช่องว่างหรือขึ้นบรรทัด):
{"payerId":"payer_7c0a4b8e2f3d4a1b9c8d7e6f5a4b3c2d","amount":"150.00","currency":"THB"}SHA-256 ของ body นี้คือ 6fc1ff4ee67aa8ec233a428b3f48ac43436dfcd59d252a3cff673161d828d5e4
import { signRequest } from './partner-api.mjs';
const body = '{"payerId":"payer_7c0a4b8e2f3d4a1b9c8d7e6f5a4b3c2d","amount":"150.00","currency":"THB"}';const h = signRequest('POST', '/v1/payins', body, { secret: '3f9c1a7e5b2d4c6e8f0a1b3c5d7e9f11', timestamp: '1767225600', nonce: 'n0nce-0123456789abcdef',});console.log(h['X-Signature']); // RbN+tURaQJG//uGx4R6FI4eBSwkHuPf1Ou/Vwy5MNKU=$body = '{"payerId":"payer_7c0a4b8e2f3d4a1b9c8d7e6f5a4b3c2d","amount":"150.00","currency":"THB"}';$h = partner_sign('POST', '/v1/payins', $body, '1767225600', 'n0nce-0123456789abcdef', '3f9c1a7e5b2d4c6e8f0a1b3c5d7e9f11');echo $h[3]; // X-Signature: RbN+tURaQJG//uGx4R6FI4eBSwkHuPf1Ou/Vwy5MNKU=body = b'{"payerId":"payer_7c0a4b8e2f3d4a1b9c8d7e6f5a4b3c2d","amount":"150.00","currency":"THB"}'h = sign_request("POST", "/v1/payins", body, secret="3f9c1a7e5b2d4c6e8f0a1b3c5d7e9f11", timestamp="1767225600", nonce="n0nce-0123456789abcdef")print(h["X-Signature"]) # RbN+tURaQJG//uGx4R6FI4eBSwkHuPf1Ou/Vwy5MNKU=ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”| อาการ | สาเหตุและวิธีแก้ |
|---|---|
invalid_signature ทุก request ที่มี body | เซ็นสตริงหนึ่งแต่ส่งอีกสตริง เช่น ไลบรารี HTTP แปลง JSON ให้ใหม่ (axios ที่ส่ง object, requests ที่ใช้ json=) ให้แปลง JSON เองครั้งเดียว แล้วส่งสตริงที่เซ็น |
invalid_signature เฉพาะ request ที่มี query | เซ็น path โดยไม่มี query หรือไลบรารี encode query ใหม่ เช่น , กลายเป็น %2C ให้สร้าง URL เต็มก่อน แล้วเซ็น path และ query ตามที่ส่งจริงทุกตัวอักษร |
invalid_signature ทุก request | ลืม /v1 ใน path, ใส่โดเมนใน path, METHOD เป็นตัวเล็ก, hash body เป็น base64 แทน hex, ลายเซ็นเป็น hex แทน base64, มี \n ต่อท้าย หรือใช้ secret เก่าหลังสร้าง secret ใหม่ |
invalid_signature ตอนอัปโหลดสลิป | hash body multipart ทั้งก้อน ต้อง hash เฉพาะไบต์ของไฟล์ |
invalid_timestamp | ส่งเป็นมิลลิวินาที (Date.now()) หรือนาฬิกาเซิร์ฟเวอร์คลาดเกิน 5 นาที ให้ใช้วินาทีและเปิด NTP |
invalid_nonce | nonce สั้นกว่า 16 ตัว หรือมีตัวอักษรนอก A-Z a-z 0-9 _ - |
nonce_reused | ลองส่งซ้ำ (retry) ด้วย header ชุดเดิม ทุกครั้งที่ส่งต้องสร้าง timestamp, nonce และลายเซ็นใหม่ |
invalid_api_key | คีย์พิมพ์ผิดหรือมีช่องว่าง หรือใช้ test key ตอนที่บัญชีไม่ได้อยู่ในโหมดทดสอบ |
parameter_unknown ตอนอัปโหลด | ส่ง part อื่นนอกจาก file ใน multipart |
ip_not_allowed | IP ขาออกของเซิร์ฟเวอร์ไม่อยู่ใน allow-list (ระวัง NAT หรือ IP ที่เปลี่ยนตาม deploy) |