Reference
Code Samples (Node.js & PHP)
Full merchant-side integration samples — automatic HMAC signing, deposit/withdrawal/balance/banks calls, and webhook verification, in Node.js and PHP
The samples below are merchant-side reference code ready to adapt. They cover: automatic HMAC signing, create/get/cancel deposit, create/get withdrawal, check balance, fetch the bank list, and receive + verify a webhook.
Before you start
- This code is a starting skeleton — in production add retry/timeout, logging, and storage of the idempotency-key and
event_idin your DB to prevent reprocessing, per your own system. - Store the
secret/ webhook secret in a secret store / env — never hardcode them in source. - The idempotency key must be stable per item: when retrying the same request (timeout/5xx) you must send the same key + same body — do not randomize a new key on every call. The samples therefore "take the key as an argument" (bind it to your order id).
Pending confirmation — confirm the runtime the merchant actually uses — the Node.js code uses global `fetch` (requires Node.js 18+); PHP uses ext-curl
S2S Client — HMAC signing + API calls
import { createHash, createHmac, randomUUID, timingSafeEqual } from "crypto";
export interface UnknownPayConfig {
baseUrl: string; // เช่น "https://api.unkpay.co"
apiKey: string; // X-Api-Key เช่น "unk_live_..." หรือ "unk_test_..."
secret: string; // secret สำหรับเซ็น HMAC (show-once จาก Portal)
}
export interface ApiError { code: string; message: string; request_id?: string }
export class UnknownPayError extends Error {
constructor(
public status: number,
public code: string,
message: string,
public requestId?: string,
) {
super(message);
this.name = "UnknownPayError";
}
}
export class UnknownPayClient {
constructor(private cfg: UnknownPayConfig) {}
/** canonical string ที่ต้องเซ็น: METHOD\n<path+query>\n<ts>\nsha256hex(body) */
private sign(method: string, target: string, ts: string, rawBody: string): string {
const bodyHash = createHash("sha256").update(rawBody, "utf8").digest("hex");
const canonical = `${method}\n${target}\n${ts}\n${bodyHash}`;
return createHmac("sha256", this.cfg.secret).update(canonical, "utf8").digest("hex");
}
/** เรียก S2S endpoint ใด ๆ โดยเซ็น HMAC ให้อัตโนมัติ */
private async request<T>(
method: "GET" | "POST",
target: string, // เช่น "/v1/deposits" (รวม query ถ้ามี — ต้องตรงกับที่ยิงจริง)
opts: { body?: unknown; idempotencyKey?: string } = {},
): Promise<T> {
// สร้าง JSON "ครั้งเดียว" แล้วใช้ทั้งเซ็นและส่ง — ห้าม re-serialize ภายหลัง (ไบต์ต้องตรงกัน)
const rawBody = opts.body === undefined ? "" : JSON.stringify(opts.body);
const ts = Math.floor(Date.now() / 1000).toString();
const signature = this.sign(method, target, ts, rawBody);
const headers: Record<string, string> = {
"X-Api-Key": this.cfg.apiKey,
"X-Signature": signature,
"X-Timestamp": ts,
};
if (rawBody) headers["Content-Type"] = "application/json";
if (opts.idempotencyKey) headers["Idempotency-Key"] = opts.idempotencyKey;
const res = await fetch(this.cfg.baseUrl + target, {
method,
headers,
body: rawBody || undefined, // ส่ง "ไบต์เดิม" ที่เพิ่งเซ็น
});
const text = await res.text();
const json = text ? JSON.parse(text) : {};
if (!res.ok) {
const e: ApiError = json.error ?? { code: "UNKNOWN", message: text };
throw new UnknownPayError(res.status, e.code, e.message, e.request_id);
}
return json as T;
}
// ---------- Deposit ----------
createDeposit(input: CreateDepositInput, idempotencyKey: string): Promise<Deposit> {
return this.request("POST", "/v1/deposits", { body: input, idempotencyKey });
}
getDeposit(id: string): Promise<Deposit> {
return this.request("GET", `/v1/deposits/${id}`);
}
cancelDeposit(id: string): Promise<Deposit> {
return this.request("POST", `/v1/deposits/${id}/cancel`); // ไม่ต้องมี Idempotency-Key
}
// ---------- Withdrawal ----------
createWithdrawal(input: CreateWithdrawalInput, idempotencyKey: string): Promise<Withdrawal> {
return this.request("POST", "/v1/withdrawals", { body: input, idempotencyKey });
}
getWithdrawal(id: string): Promise<Withdrawal> {
return this.request("GET", `/v1/withdrawals/${id}`);
}
// ---------- Balance / Banks ----------
getBalance(): Promise<Balance> {
return this.request("GET", "/v1/balance");
}
getBanks(): Promise<Record<string, Bank>> {
return this.request("GET", "/v1/banks"); // public — เซ็นไปด้วยก็ไม่เป็นไร
}
}
// ---------- Types (money ทุก field = สตริงบาท เช่น "100.50") ----------
export interface CreateDepositInput {
amount: string; // บาท เช่น "100.50"
payer_bank_provider: string; // bank_code เช่น "KBANK" (required ทั้ง 2 method)
payer_bank_account_name: string; // required
payer_bank_account_number: string; // required
currency?: string; // default "THB"
payment_method_type?: "PROMPTPAY_QR" | "BANK_TRANSFER"; // default PROMPTPAY_QR
additional_data?: { description?: string };
user_ref?: string; // reference ของร้านค้า (echo กลับใน webhook)
callback_meta?: Record<string, unknown>;
}
export interface Deposit {
id: string;
amount: string;
expected_amount: string; // ⚠️ ยอดที่ลูกค้าต้องโอนเป๊ะ
matched_amount: string | null;
currency: string;
status: "PENDING" | "CREDITED" | "EXPIRED" | "CANCELLED";
payment_method_type: string;
pay_to: {
bank: string;
account_holder: string;
account_no?: string; // เฉพาะ BANK_TRANSFER
qr_payload?: string; // เฉพาะ PROMPTPAY_QR (เอาไปสร้าง QR)
};
payer: { bank: string; account_no: string; name: string };
display_expires_at: string;
match_window_until: string;
}
export interface CreateWithdrawalInput {
amount: string; // ยอดที่ปลายทางได้รับ (net) บาท
receiver_bank_provider: string; // bank_code ปลายทาง เช่น "SCB"
receiver_bank_account_name: string;
receiver_bank_account_number: string;
currency?: string; // default "THB"
kind?: "customer"; // default "customer" (ค่าอื่น → 422 INVALID_KIND)
additional?: { description?: string; reference_user_id?: string };
}
export interface Withdrawal {
id: string;
amount: string;
fee: string;
net_payout: string;
currency: string;
receiver_bank_provider: string;
destination: { bank: string; account_no: string; name: string };
kind: string;
status: "PENDING" | "APPROVED" | "PROCESSING" | "IN_PROGRESS" | "SUCCESS" | "FAILED" | "REJECTED";
}
export interface Balance { available: string; pending: string; currency: string }
export interface Bank {
bank_code: string; bank_number: string;
name_th: string; fullname_th: string; name_en: string;
}
<?php
class UnknownPayException extends \Exception
{
public function __construct(
public int $status,
public string $errorCode,
string $message,
public ?string $requestId = null,
) {
parent::__construct($message);
}
}
class UnknownPayClient
{
public function __construct(
private string $baseUrl, // "https://api.unkpay.co"
private string $apiKey, // "unk_live_..." / "unk_test_..."
private string $secret, // secret จาก Portal (show-once)
) {}
/** canonical: METHOD\n<path+query>\n<ts>\nsha256hex(body) */
private function sign(string $method, string $target, string $ts, string $rawBody): string
{
$bodyHash = hash('sha256', $rawBody); // hex (lowercase)
$canonical = $method . "\n" . $target . "\n" . $ts . "\n" . $bodyHash;
return hash_hmac('sha256', $canonical, $this->secret); // hex
}
/** เรียก S2S endpoint พร้อมเซ็น HMAC อัตโนมัติ */
private function request(string $method, string $target, ?array $body = null, ?string $idempotencyKey = null): array
{
// json_encode "ครั้งเดียว" แล้วใช้ทั้งเซ็นและส่ง — ไบต์ต้องตรงกัน
$rawBody = $body === null ? '' : json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$ts = (string) time();
$sig = $this->sign($method, $target, $ts, $rawBody);
$headers = [
'X-Api-Key: ' . $this->apiKey,
'X-Signature: ' . $sig,
'X-Timestamp: ' . $ts,
];
if ($rawBody !== '') $headers[] = 'Content-Type: application/json';
if ($idempotencyKey !== null) $headers[] = 'Idempotency-Key: ' . $idempotencyKey;
$ch = curl_init($this->baseUrl . $target);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $rawBody, // ส่งไบต์เดิมที่เซ็น
CURLOPT_TIMEOUT => 30,
]);
$resp = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($resp === false) {
throw new UnknownPayException(0, 'NETWORK_ERROR', $err);
}
$json = $resp === '' ? [] : json_decode($resp, true);
if ($status < 200 || $status >= 300) {
$e = $json['error'] ?? ['code' => 'UNKNOWN', 'message' => $resp];
throw new UnknownPayException($status, $e['code'], $e['message'], $e['request_id'] ?? null);
}
return $json;
}
// ---------- Deposit ----------
public function createDeposit(array $input, string $idempotencyKey): array
{
return $this->request('POST', '/v1/deposits', $input, $idempotencyKey);
}
public function getDeposit(string $id): array
{
return $this->request('GET', '/v1/deposits/' . rawurlencode($id));
}
public function cancelDeposit(string $id): array
{
return $this->request('POST', '/v1/deposits/' . rawurlencode($id) . '/cancel'); // ไม่ต้องมี Idempotency-Key
}
// ---------- Withdrawal ----------
public function createWithdrawal(array $input, string $idempotencyKey): array
{
return $this->request('POST', '/v1/withdrawals', $input, $idempotencyKey);
}
public function getWithdrawal(string $id): array
{
return $this->request('GET', '/v1/withdrawals/' . rawurlencode($id));
}
// ---------- Balance / Banks ----------
public function getBalance(): array
{
return $this->request('GET', '/v1/balance');
}
public function getBanks(): array
{
return $this->request('GET', '/v1/banks'); // public
}
/** helper สร้าง UUID v4 ไว้ใช้เป็น Idempotency-Key (หรือ map กับ order id ของคุณก็ได้) */
public static function uuidv4(): string
{
$d = random_bytes(16);
$d[6] = chr((ord($d[6]) & 0x0f) | 0x40);
$d[8] = chr((ord($d[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($d), 4));
}
}
Usage example — create deposit → check balance → withdraw
import { UnknownPayClient, UnknownPayError } from "./unknownpay";
const client = new UnknownPayClient({
baseUrl: "https://api.unkpay.co",
apiKey: process.env.UNKPAY_API_KEY!, // "unk_live_..." (test ใช้ "unk_test_...")
secret: process.env.UNKPAY_SECRET!,
});
async function main() {
// 1) สร้าง deposit — ใช้ idempotency key ที่ผูกกับ order ของคุณ (retry-safe)
const orderId = "order-2026-0001";
const dep = await client.createDeposit(
{
amount: "100.50",
payment_method_type: "PROMPTPAY_QR",
payer_bank_provider: "KBANK",
payer_bank_account_name: "สมชาย ใจดี",
payer_bank_account_number: "1234567890",
user_ref: orderId,
},
`dep-${orderId}`, // Idempotency-Key เสถียร (จะ retry ด้วย key เดิมได้)
);
console.log("deposit id :", dep.id);
console.log("ให้ลูกค้าโอนยอด :", dep.expected_amount); // ⚠️ ไม่ใช่ amount
console.log("QR payload :", dep.pay_to.qr_payload);
// → แสดง QR (จาก qr_payload) + ยอด expected_amount ให้ลูกค้า แล้วรอ webhook deposit.success
// 2) เช็คยอดก่อนถอน
const bal = await client.getBalance();
console.log("available:", bal.available);
// 3) สร้างคำขอถอน (รออนุมัติ — สถานะเริ่มต้น PENDING, รอ webhook withdrawal.success)
try {
const wd = await client.createWithdrawal(
{
amount: "500.00",
receiver_bank_provider: "SCB",
receiver_bank_account_name: "สมหญิง รักดี",
receiver_bank_account_number: "9876543210",
additional: { description: "payout #A-1042", reference_user_id: "cust-7" },
},
"wd-A-1042", // Idempotency-Key เสถียร
);
console.log("withdrawal status:", wd.status); // "PENDING"
} catch (e) {
if (e instanceof UnknownPayError && e.code === "INSUFFICIENT_BALANCE") {
console.error("ยอดไม่พอ:", e.message, "request_id:", e.requestId);
} else {
throw e;
}
}
}
main().catch(console.error);
<?php
require 'UnknownPayClient.php';
$client = new UnknownPayClient(
'https://api.unkpay.co',
getenv('UNKPAY_API_KEY'), // "unk_live_..." (test ใช้ "unk_test_...")
getenv('UNKPAY_SECRET'),
);
// 1) สร้าง deposit — idempotency key ผูกกับ order ของคุณ (retry-safe)
$orderId = 'order-2026-0001';
$dep = $client->createDeposit([
'amount' => '100.50',
'payment_method_type' => 'PROMPTPAY_QR',
'payer_bank_provider' => 'KBANK',
'payer_bank_account_name' => 'สมชาย ใจดี',
'payer_bank_account_number' => '1234567890',
'user_ref' => $orderId,
], 'dep-' . $orderId);
echo "ให้ลูกค้าโอนยอด: " . $dep['expected_amount'] . "\n"; // ⚠️ ยอดเป๊ะ ไม่ใช่ amount
echo "QR: " . ($dep['pay_to']['qr_payload'] ?? '') . "\n";
// 2) เช็คยอดก่อนถอน
$bal = $client->getBalance();
echo "available: " . $bal['available'] . "\n";
// 3) สร้างคำขอถอน (รออนุมัติ — รอ webhook withdrawal.success)
try {
$wd = $client->createWithdrawal([
'amount' => '500.00',
'receiver_bank_provider' => 'SCB',
'receiver_bank_account_name' => 'สมหญิง รักดี',
'receiver_bank_account_number' => '9876543210',
'additional' => ['description' => 'payout #A-1042', 'reference_user_id' => 'cust-7'],
], 'wd-A-1042');
echo "withdrawal status: " . $wd['status'] . "\n"; // "PENDING"
} catch (UnknownPayException $e) {
if ($e->errorCode === 'INSUFFICIENT_BALANCE') {
echo "ยอดไม่พอ: {$e->getMessage()} (request_id: {$e->requestId})\n";
} else {
throw $e;
}
}
Webhook receiver — verify signature + handle events
You must read the RAW body (do not parse JSON before verifying) — the signature is computed over the raw bytes. Verify constant-time, then return 2xx fast and process asynchronously; if you don't return 2xx the system retries for up to ~24 hours.
import express from "express";
import { createHmac, timingSafeEqual } from "crypto";
const WEBHOOK_SECRET = process.env.UNKPAY_WEBHOOK_SECRET!; // show-once จาก Portal
const app = express();
// ⚠️ ต้องอ่าน RAW body (อย่าใช้ express.json() ก่อน verify) — ลายเซ็นคำนวณจากไบต์ดิบ
app.post("/webhooks/unknownpay", express.raw({ type: "*/*" }), (req, res) => {
const raw: Buffer = req.body; // Buffer ไบต์ดิบ
const signature = req.header("X-Webhook-Signature") ?? "";
const expected = createHmac("sha256", WEBHOOK_SECRET).update(raw).digest("hex");
const ok =
signature.length === expected.length &&
timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) return res.status(401).send("bad signature");
const evt = JSON.parse(raw.toString("utf8"));
// กันประมวลผลซ้ำด้วย event_id (เช่น "<id>:deposit.success") — แนะนำเก็บลง DB
// if (alreadyProcessed(evt.event_id)) return res.sendStatus(200);
switch (evt.event_type) {
case "deposit.success":
// evt.deposit_id, evt.user_ref, evt.credited_amount (net ที่เข้ากระเป๋า), evt.amount
markOrderPaid(evt.user_ref, evt.credited_amount);
break;
case "deposit.expired":
markOrderExpired(evt.user_ref);
break;
case "withdrawal.success":
markPayoutDone(evt.withdrawal_id);
break;
case "withdrawal.failed":
case "withdrawal.rejected":
// evt.reason; เงิน gross จะถูกคืนเข้ากระเป๋า (จะมี withdrawal.refunded ตามมา)
markPayoutFailed(evt.withdrawal_id, evt.reason);
break;
case "withdrawal.refunded":
// เงินคืนเข้ากระเป๋าแล้ว — กระทบยอดภายในของร้าน
break;
}
// ⚠️ ตอบ 2xx ให้เร็ว (งานหนักไป process แบบ async) ถ้าไม่ตอบ 2xx ระบบจะ retry นานสุด ~24 ชม.
res.sendStatus(200);
});
// ตัวอย่าง stub — แทนที่ด้วย logic จริงของคุณ
function markOrderPaid(ref: string, credited: string) {}
function markOrderExpired(ref: string) {}
function markPayoutDone(id: string) {}
function markPayoutFailed(id: string, reason: string) {}
app.listen(3000, () => console.log("webhook listener on :3000"));
<?php
$secret = getenv('UNKPAY_WEBHOOK_SECRET'); // show-once จาก Portal
// ⚠️ อ่าน RAW body — ห้าม json_decode ก่อน verify (ลายเซ็นคำนวณจากไบต์ดิบ)
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $raw, $secret);
if (!hash_equals($expected, $sig)) {
http_response_code(401);
echo 'bad signature';
exit;
}
$evt = json_decode($raw, true);
// กันประมวลผลซ้ำด้วย event_id (เช่น "<id>:deposit.success") — แนะนำเก็บลง DB
// if (already_processed($evt['event_id'])) { http_response_code(200); exit; }
switch ($evt['event_type']) {
case 'deposit.success':
// $evt['deposit_id'], $evt['user_ref'], $evt['credited_amount'] (net), $evt['amount']
mark_order_paid($evt['user_ref'], $evt['credited_amount']);
break;
case 'deposit.expired':
mark_order_expired($evt['user_ref']);
break;
case 'withdrawal.success':
mark_payout_done($evt['withdrawal_id']);
break;
case 'withdrawal.failed':
case 'withdrawal.rejected':
// $evt['reason']; เงิน gross จะถูกคืน (มี withdrawal.refunded ตามมา)
mark_payout_failed($evt['withdrawal_id'], $evt['reason'] ?? '');
break;
case 'withdrawal.refunded':
// เงินคืนเข้ากระเป๋าแล้ว
break;
}
// ⚠️ ตอบ 2xx เร็ว ๆ ไม่งั้นระบบจะ retry นานสุด ~24 ชม.
http_response_code(200);
echo 'ok';
// ----- stub: แทนที่ด้วย logic จริงของคุณ -----
function mark_order_paid($ref, $credited) {}
function mark_order_expired($ref) {}
function mark_payout_done($id) {}
function mark_payout_failed($id, $reason) {}
Pending confirmation — adapt the webhook endpoint path (`/webhooks/unknownpay`) and all stub functions to your real system; add storage of `event_id` in your DB for merchant-side idempotency. If you use a framework (Laravel/Slim/NestJS etc.), adapt how you read the raw body — the key point is to verify the raw bytes before parsing JSON
