Skip to content
UnknownPay
ไทย
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_id in 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;
}

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);

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"));
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