ภาพรวม
AUTO P2P จับคู่คนฝากกับคนถอนให้โอนเงินหากันตรงผ่านบัญชีธนาคาร AUTO P2P ไม่ถือเงิน ไม่มีกระเป๋าเงิน และไม่คิดค่าธรรมเนียมแทนคุณ การกันเงิน ค่าธรรมเนียม และการลงบัญชีอยู่ฝั่งระบบของคุณทั้งหมด
| เซิร์ฟเวอร์ | https://autop2p.tech (ตอนนี้เป็นระบบทดสอบ ห้ามใช้เงินจริง) |
| Base path | /api/p2p/provider |
| รูปแบบ | JSON ทั้งขาเข้าและขาออก, THB เท่านั้น |
| สัญญา | contractVersion: 1 |
- ถอน (payout): กันเงินของร้านก่อน แล้วส่งคำสั่งถอน → AUTO P2P จับคู่กับคนฝาก → ผู้รับยืนยันว่าได้เงินครบ → callback COMPLETED ให้ตัดเงินที่กันไว้
- ฝาก (payin): ส่งคำสั่งฝาก → ได้ยอดที่ต้องโอนและหน้า payUrl ให้คนฝาก → คนฝากโอน → callback COMPLETED ให้เครดิตร้าน
เริ่มใช้งานเร็ว
- ขอบัญชีร้านจากทีม AUTO P2P แล้วรับคำเชิญ ตั้งรหัสผ่านและแอปยืนยันตัวตนที่ /partner
- เจ้าของร้านกด "สร้างคีย์" ได้ apiSecret และ callbackSecret (แสดงครั้งเดียว) เก็บเป็นตัวแปรลับบนเซิร์ฟเวอร์
- จดค่า Account ID (header x-account-id) และ gatewayAccountRef จากกล่อง "ข้อมูลเชื่อมต่อ"
- เรียก GET /api/p2p/provider/health ให้ได้ 200 (ดูการเซ็นด้านล่าง)
- ทำตัวรับ callback แล้วตั้ง Callback URL ในพอร์ทัล — ก่อนตั้ง API จะไม่รับคำสั่งจริง (409 CALLBACK_URL_REQUIRED)
- ทดสอบตามลำดับทดสอบ
การเซ็นคำขอ
| Header | ค่า |
|---|---|
x-account-id | Account ID ของร้าน |
x-timestamp | เวลาปัจจุบันเป็นมิลลิวินาที 13 หลัก (ต่างจากเซิร์ฟเวอร์ได้ไม่เกิน ±5 นาที) |
x-signature | HMAC-SHA256 เป็น hex ตัวเล็ก 64 ตัว |
Content-Type | application/json |
timestamp + METHOD + path + body
METHOD = POST | GET (uppercase)
path = full path incl. /api and any query, e.g. /api/p2p/provider/payout
body = "" for GET or an empty body, otherwise JSON.stringify(body)
signature = hex( HMAC_SHA256(apiSecret, string to sign) )ลายเซ็นผิดส่วนใหญ่มาจาก 3 ข้อนี้
- สร้างสตริง JSON ครั้งเดียว แล้วใช้สตริงเดียวกันทั้งเซ็นและส่ง
- ส่งยอดเงินเป็น string เช่น "1000.50" — เซิร์ฟเวอร์ตรวจกับ JSON.stringify ของ body ที่ parse แล้ว ตัวเลข 1000.50 จะกลายเป็น 1000.5 ลายเซ็นไม่ตรง; ห้ามมีช่องว่าง; PHP ต้องใช้ JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
- อย่าส่ง field ที่ไม่มีในตาราง (400 Unknown request field)
const crypto = require('node:crypto');
const BASE = 'https://autop2p.tech';
const ACCOUNT_ID = process.env.P2P_ACCOUNT_ID; // x-account-id
const API_SECRET = process.env.P2P_API_SECRET; // from the partner portal
async function p2p(method, path, body) {
const raw = body === undefined ? '' : JSON.stringify(body); // build once, sign and send the same string
const ts = String(Date.now());
const signature = crypto.createHmac('sha256', API_SECRET)
.update(ts + method.toUpperCase() + path + raw).digest('hex');
const res = await fetch(BASE + path, {
method, body: raw || undefined, redirect: 'error', signal: AbortSignal.timeout(15000),
headers: { 'content-type': 'application/json', 'x-account-id': ACCOUNT_ID, 'x-timestamp': ts, 'x-signature': signature },
});
const text = await res.text();
let json = null; try { json = text ? JSON.parse(text) : null; } catch {}
return { status: res.status, body: json };
}
// await p2p('GET', '/api/p2p/provider/health'); // 200 = connectedลายเซ็นไม่ตรง? ลองในตัวช่วยคำนวณลายเซ็น
รายการ endpoint
ทุก path อยู่ใต้ /api/p2p/provider และต้องเซ็นทุกคำขอ
| Method | Path | ใช้ทำอะไร |
|---|---|---|
| POST | /payout | สร้างคำสั่งถอน (หลังกันเงินแล้ว) |
| POST | /payin | สร้างคำสั่งฝาก ได้ยอดโอนและบัญชีปลายทาง |
| GET | /requests/{PAYIN|PAYOUT}/{referenceId} | ตามผลด้วยเลขของคุณ (ใช้ตอนไม่ได้คำตอบ) |
| GET | /status/{ref} | สถานะปัจจุบันด้วยเลข P2PW-… / P2PM-… |
| GET | /payout/{referenceId}/receive-link | ลิงก์หน้าผู้รับเงิน |
| POST | /payout/{ref}/cancel | ยกเลิกถอนที่ยังไม่เคยจับคู่ |
| GET | /events?cursor={eventId} | ดึงผลสุดท้ายทั้งหมดเพื่อกระทบยอด (ทีละ 100) |
| GET | /health | เช็กการเชื่อมต่อ และระบบรับงานใหม่อยู่ไหม |
จำกัดความถี่ต่อ IP: 100/วินาที, 1,000/10 วินาที, 5,000/นาที เกินได้ 429
สร้างคำสั่งถอน
| Field | จำเป็น | คำอธิบาย |
|---|---|---|
referenceId | ✓ | เลขคำสั่งของคุณ ไม่ซ้ำ A-Z a-z 0-9 _ . : - ยาว 1–128 |
holdReference | ✓ | ต้องเท่ากับ referenceId (ยืนยันว่ากันเงินแล้ว) |
amount | ✓ | string เช่น "1000.00" ทศนิยมไม่เกิน 2 |
currency | "THB" | |
gatewayAccountRef | ✓ | ค่าจากพอร์ทัลร้าน |
sourceMerchantRef | ✓ | รหัสร้านย่อยในระบบคุณ (≤128) |
bankCode | ✓ | KBANK, SCB, BBL, KTB, BAY, TTB, GSB … |
accountNumber | ✓ | เลขบัญชีผู้รับ ตัวเลข 6–20 หลัก |
accountName | ✓ | ชื่อบัญชีผู้รับ |
promptpayId | พร้อมเพย์ผู้รับ (มือถือ 10 หลัก หรือบัตร 13 หลัก) ใช้ทำ QR | |
sourcePartnerRef | รหัสอ้างอิงเพิ่มเติม | |
callbackUrl | ถ้าส่ง ต้องตรงกับที่ตั้งในพอร์ทัล |
{
"ref": "P2PW-9ef83b1bdb15d0fc52c4592d6b931e68",
"expiresAt": "2026-09-29T10:15:00.000Z",
"receiveUrl": "https://autop2p.tech/receive/<token>",
"lane": "SMALL",
"acceptance": "ACCEPTED",
"contractVersion": 1
}- เก็บ ref (P2PW-…) คู่กับคำสั่งของคุณ
- receiveUrl เป็นหน้าให้ผู้รับติดตามและกด "ได้รับยอดนี้แล้ว" ส่งให้เจ้าของบัญชีเท่านั้น
- อาจได้เงินหลายครั้งจากคนฝากหลายคน callback ส่งเฉพาะตอนจบ ระหว่างทางใช้ GET /status/{ref}
สร้างคำสั่งฝาก
| Field | จำเป็น | คำอธิบาย |
|---|---|---|
referenceId | ✓ | เลขคำสั่งของคุณ (กติกาเดียวกับ payout) |
amount | ✓ | string เช่น "500.00" |
currency | "THB" | |
gatewayAccountRef | ✓ | ค่าจากพอร์ทัลร้าน |
sourceMerchantRef | ✓ | รหัสร้านย่อยในระบบคุณ |
sourcePartnerRef | รหัสอ้างอิงเพิ่มเติม | |
dryRun | true = เช็กว่ามีคู่ไหม ไม่สร้างรายการ | |
callbackUrl | ถ้าส่ง ต้องตรงกับที่ตั้งในพอร์ทัล |
{
"ref": "P2PM-7d7c576c9c2fce92f618a45352b5acb2",
"transferAmount": 500.37,
"payUrl": "https://autop2p.tech/pay/P2PM-7d7c576c9c2fce92f618a45352b5acb2",
"expiresAt": "2026-09-29T10:15:00.000Z",
"matched": true,
"acceptance": "ACCEPTED",
"contractVersion": 1
}- พาคนฝากไปที่ payUrl (มีบัญชีปลายทาง ยอดที่ต้องโอน QR เวลาที่เหลือ แนบสลิป ไทย/อังกฤษ)
- transferAmount = ยอดฝาก + เศษสตางค์ 1–99 ที่ใช้แยกรายการ ต้องโอนตรงถึงสตางค์ เศษนี้ไม่ใช่ค่าธรรมเนียม ยอดที่ลงบัญชีคือ amount
- ACCEPTED ยังไม่ใช่เงินเข้า อย่าเครดิตร้านจนได้ callback COMPLETED
{ "matched": false, "acceptance": "REJECTED", "reason": "no liquidity in withdraw queue", "contractVersion": 1 }เมื่อไม่ได้คำตอบ (สำคัญที่สุดเรื่องเงิน)
| ผล | ทำต่อ |
|---|---|
| 200 | ได้ผลที่บันทึกไว้ ใช้ result เหมือนได้คำตอบปกติ |
| 404 | คำสั่งยังไม่ถึงเรา → ส่งคำสั่งเดิม referenceId เดิม body เดิมทุกตัวอักษรซ้ำได้ |
| อื่นๆ | ยังไม่ทราบผล ลองถามใหม่ภายหลัง |
ส่ง referenceId เดิมพร้อมข้อมูลเดิม = ได้ผลเดิม ไม่สร้างซ้ำ ส่ง referenceId เดิมแต่ข้อมูลต่าง = 409
สถานะและ endpoint อื่น
{
"status": "PENDING",
"engineStatus": "QUEUED",
"requestId": "WD-20260929-0001",
"remaining": "600",
"completedAmount": "400",
"expiresAt": "2026-09-29T10:15:00.000Z",
"terminalEvent": null
}status: PENDING / COMPLETED / FAILED / EXPIRED ถ้าจบแล้ว terminalEvent คือ callback เดิม ใช้แทน callback ที่หายได้
POST /payout/{ref}/cancel— ได้เฉพาะคำสั่งที่ยังไม่เคยจับคู่ ถ้าเคยจับคู่ได้ 409 เมื่อยกเลิกสำเร็จจะได้ callback PAYOUT FAILED ให้คืนเงินที่กันไว้GET /events→?cursor=<nextCursor>จน nextCursor เป็น null ใช้กระทบยอด
Error ที่พบบ่อย
| HTTP | ข้อความ | สาเหตุ / แก้ |
|---|---|---|
| 401 | Invalid integration authentication | header ขาด, timestamp ไม่ใช่ 13 หลักหรือเกิน ±5 นาที, ลายเซ็นไม่ใช่ hex 64 ตัว |
| 401 | Invalid signature | สตริงที่เซ็นไม่ตรง ดูกฎ 3 ข้อ |
| 401 | Integration unavailable | Account ID ผิด หรือร้านถูกระงับ |
| 400 | Unknown request field | ส่ง field ที่ไม่มีในตาราง |
| 400 | Invalid referenceId | ตัวอักษรไม่อนุญาต หรือยาวเกิน 128 |
| 400 | Gateway account does not match integration | gatewayAccountRef ไม่ตรง |
| 400 | Callback destination must match the registered integration | callbackUrl ไม่ตรงกับที่ตั้งในพอร์ทัล |
| 409 | CALLBACK_URL_REQUIRED | ยังไม่ได้ตั้ง Callback URL ในพอร์ทัล |
| 409 | Request ID was already used with different details | referenceId ซ้ำแต่ข้อมูลต่าง |
| 404 | Request outcome is not known; absence is not cancellation | lookup: เรายังไม่ได้รับคำสั่งนั้น |
| 429 | — | ส่งถี่เกินไป รอแล้วลองใหม่ |
400 ที่ได้หลังกันเงินแล้ว ให้ถือเป็น UNKNOWN คงเงินที่กันไว้และตรวจสาเหตุ
Callback: รูปแบบข้อมูล
เมื่อคำสั่งจบ AUTO P2P ส่ง POST ไปที่ Callback URL ของร้าน พร้อม header x-p2p-signature
{
"event": "p2p.match", "type": "PAYIN",
"eventId": "6f1c2d9e-8a41-4a3b-9c0e-2b7d5e1f0a11", "contractVersion": 1,
"client_id": "<ACCOUNT_ID>", "gateway_account_ref": "<GATEWAY_ACCOUNT_REF>",
"ref": "P2PM-7d7c576c9c2fce92f618a45352b5acb2", "reference_id": "DP-20260929-0001",
"status": "COMPLETED", "amount": "500.00", "transfer_amount": "500.37",
"slip_ref": null, "withdraw_ref": "P2PW-9ef83b1bdb15d0fc52c4592d6b931e68",
"currency": "THB", "occurredAt": "2026-09-29T10:05:12.345Z"
}{
"event": "p2p.withdraw", "type": "PAYOUT",
"eventId": "0b9e7a52-3d1f-4c8e-a6b2-91f4c7d8e203", "contractVersion": 1,
"client_id": "<ACCOUNT_ID>", "gateway_account_ref": "<GATEWAY_ACCOUNT_REF>",
"ref": "P2PW-9ef83b1bdb15d0fc52c4592d6b931e68", "reference_id": "WD-20260929-0001",
"status": "COMPLETED", "amount": "1000.00", "completed_amount": "1000.00",
"hold_reference": "WD-20260929-0001", "currency": "THB", "occurredAt": "2026-09-29T10:20:44.012Z"
}| type | status | ต้องทำ |
|---|---|---|
| PAYIN | COMPLETED | เครดิตร้าน amount ครั้งเดียวต่อ reference_id |
| PAYIN | EXPIRED | ไม่เครดิต — ถ้าภายหลังได้ COMPLETED ของรายการเดิม ให้เครดิตตอนนั้นครั้งเดียว |
| PAYIN | FAILED | ไม่เครดิต (ยืนยันแล้วว่าไม่มีเงินเข้า) |
| PAYOUT | COMPLETED | ปิดเงินที่กันไว้เป็นจ่ายแล้ว ยอด completed_amount ครั้งเดียว |
| PAYOUT | FAILED | ยกเลิก/หมดเวลาโดยไม่มีการจ่าย (completed_amount "0.00") คืนเงินที่กันไว้ครั้งเดียว |
Callback: ตรวจลายเซ็น
- อ่าน raw body ก่อน parse — ห้าม parse แล้ว stringify ใหม่ เพราะลำดับ key อาจต่าง
- HMAC_SHA256(callbackSecret, raw body) เป็น hex เทียบกับ x-p2p-signature แบบ timing-safe ไม่ตรง → 401
- ตรวจ contractVersion = 1, client_id, gateway_account_ref, currency, reference_id, ref, amount ตรงกับคำสั่งของคุณ ไม่ตรง → 4xx
- ใน transaction เดียว: บันทึก eventId (unique) + เปลี่ยนสถานะ + ลงบัญชี แล้วค่อยตอบ
// Express: read the RAW body; do not use express.json() before verifying
app.post('/p2p/callback', express.raw({ type: 'application/json' }), async (req, res) => {
const expected = crypto.createHmac('sha256', process.env.P2P_CALLBACK_SECRET).update(req.body).digest('hex');
const got = String(req.get('x-p2p-signature') || '');
if (got.length !== 64 || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected)))
return res.status(401).end();
const e = JSON.parse(req.body.toString('utf8'));
// check contractVersion / client_id / gateway_account_ref / reference_id / ref / amount
const status = await db.transaction(async (tx) => {
const r = await tx.query('INSERT INTO p2p_callback_receipts (event_id, payload) VALUES ($1,$2) ON CONFLICT DO NOTHING', [e.eventId, e]);
if (r.rowCount === 0) return 'duplicate'; // already handled: no second money movement
await applyTerminalResult(tx, e); // status + ledger, once per order
return 'processed';
});
res.status(200).json({ eventId: e.eventId, status });
});Callback: ตอบกลับและการส่งซ้ำ
{ "eventId": "6f1c2d9e-8a41-4a3b-9c0e-2b7d5e1f0a11", "status": "processed" }| คำตอบของคุณ | AUTO P2P ทำ |
|---|---|
| 2xx + eventId ตรง + processed หรือ duplicate | จบ |
| 2xx แต่ body ผิด (เช่น {"success":true}) | ส่ง event เดิมซ้ำ |
| 4xx/5xx, เกิน 8 วินาที, ต่อไม่ได้, redirect | ส่งซ้ำ เว้นระยะ ~2, 4, 8 … วินาที สูงสุด ~4 นาทีต่อครั้ง |
| 429 | รอ 15 วินาทีแล้วส่งใหม่ ไม่นับครั้ง |
| ล้มครบ 20 ครั้ง | หยุดส่ง ให้เจ้าหน้าที่ดูแล |
ตัวช่วยคำนวณลายเซ็น
ใส่ค่าเดียวกับที่ระบบคุณใช้ แล้วเทียบข้อความที่เซ็นกับลายเซ็น ถ้า body ไม่ตรงกับที่เซิร์ฟเวอร์จะเห็น ระบบจะบอกให้
ลำดับทดสอบ
GET /health→ 200- payin แบบ dryRun: true → 201 (ลายเซ็นผ่าน)
- ตั้ง Callback URL ในพอร์ทัล
- POST /payout "1000.00" → เก็บ ref และ receiveUrl
- POST /payin "1000.00" → ได้ payUrl และ transferAmount
- เปิด payUrl กด "ฉันโอนเงินแล้ว" (sandbox ไม่ต้องโอนจริง) แล้วเปิด receiveUrl กด "ได้รับยอดนี้แล้ว"
- ต้องได้ callback PAYIN COMPLETED และ PAYOUT COMPLETED ลงบัญชีครั้งเดียว
- ตัดการเชื่อมต่อก่อนได้คำตอบ /payout → ใช้ /requests/PAYOUT/{referenceId} ต้องไม่เกิดคำสั่งซ้ำ
- ดูรายการทั้งหมดในเมนู "รายการของร้าน" ของพอร์ทัล
เวอร์ชัน
| วันที่ | เปลี่ยนแปลง |
|---|---|
| 2026-09-29 | contractVersion 1: ร้านตั้ง Callback URL และสร้างคีย์เองในพอร์ทัล; คำสั่งจริงต้องมี Callback URL (409 CALLBACK_URL_REQUIRED) |