DragonPay係菲律賓使用最廣泛嘅支付聚合平台之一,支援電子錢包(GCash、Maya、GrabPay、ShopeePay)、網上銀行(BPI、BDO、UnionBank)以及櫃檯支付渠道(7-Eleven、Cebuana、M Lhuillier)。佢哋嘅官方SDK已經過時且冇人維護。本文記錄咗點樣由一次真實嘅NestJS整合中,構建出一個乾淨、生產級別嘅SDK,涵蓋代收款、代付款、簽名驗證以及模擬模式。
點解冇好用嘅SDK
官方嘅PayMaya Node SDK自2020年以嚟就冇得到過實質性嘅維護。佢冇TypeScript型別,冇webhook驗證,亦唔支援代付款。DragonPay嘅PHP SDK同樣陳舊。
唯一權威嘅資料來源係Dragonpay PS API v2.26 PDF,呢次整合正正係直接依據佢構建嘅。
API概覽
DragonPay公開咗兩個完全獨立嘅API,各自採用完全唔同嘅身份驗證方式:
| API | 基礎URL | 身份驗證 | 用途 |
|---|---|---|---|
| Collect v2 | https://gw.dragonpay.ph/api/collect/v2 | HTTP Basic base64(merchantId:password) | 建立支付、查詢狀態、作廢 |
| Mass Payout v1 | https://gw.dragonpay.ph/api/payout/merchant/v1 | Bearer {payoutPassword} | 發放資金 |
| Keys | {collectBase/v1}/keys/callback | HTTP Basic(同collect) | 攞取用於回調驗證嘅RSA公鑰 |
注意: 即使你嘅collect基礎路徑係/v2,keys端點都使用/v1。呢個係規範中嘅一個古怪之處。
身份驗證
Collection API,HTTP Basic認證
function getCollectAuthHeaders(merchantId: string, password: string) {
const credentials = Buffer.from(`${merchantId}:${password}`, 'utf-8').toString('base64');
return {
'Content-Type': 'application/json',
Authorization: `Basic ${credentials}`,
};
}
Payout API,Bearer令牌
function getPayoutAuthHeaders(payoutPassword: string) {
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${payoutPassword}`,
};
}
呢兩者係刻意設計成唔同嘅憑證,代付密碼係同代收密碼唔同嘅另一個密鑰。
建立支付(Collection API)
端點: POST {baseUrl}/{txnid}/post
交易ID(txnid)係放喺URL路徑入面,而唔係請求體入面。呢個係對接呢個API嘅時候最常見嘅錯誤之一。
interface CreatePaymentInput {
txnid: string;
amount: number; // Will be formatted to 2 decimal places
currency?: string; // Default: 'PHP'
description: string;
email: string;
procId?: string; // Required in V2 to lock the processor
param1?: string; // Pass-through, returned in callback
param2?: string; // Pass-through, returned in callback
expiry?: string; // ISO datetime string
ipAddress?: string; // Recommended per onboarding
userAgent?: string;
billingDetails?: {
firstName?: string;
lastName?: string;
address1?: string;
city?: string;
state?: string; // Maps to 'Province' in DragonPay's API
country?: string;
zipCode?: string;
telNo?: string;
email?: string;
};
}
async function createPayment(
input: CreatePaymentInput,
merchantId: string,
password: string,
baseUrl: string,
): Promise<PaymentResult> {
const url = `${baseUrl}/${input.txnid}/post`;
// V2 spec requires PascalCase keys
const payload: Record<string, any> = {
Amount: input.amount.toFixed(2),
Currency: input.currency || 'PHP',
Description: input.description,
Email: input.email,
};
if (input.procId) payload.ProcId = input.procId;
if (input.param1) payload.Param1 = input.param1;
if (input.param2) payload.Param2 = input.param2;
if (input.expiry) payload.Expiry = input.expiry;
if (input.ipAddress) payload.IpAddress = input.ipAddress;
if (input.userAgent) payload.UserAgent = input.userAgent;
if (input.billingDetails) {
const bd = input.billingDetails;
payload.BillingDetails = {
...(bd.firstName && { FirstName: bd.firstName }),
...(bd.lastName && { LastName: bd.lastName }),
...(bd.address1 && { Address1: bd.address1 }),
...(bd.city && { City: bd.city }),
...(bd.state && { Province: bd.state }),
...(bd.country && { Country: bd.country }),
...(bd.zipCode && { ZipCode: bd.zipCode }),
...(bd.telNo && { TelNo: bd.telNo }),
...(bd.email && { Email: bd.email }),
};
}
const response = await fetch(url, {
method: 'POST',
headers: getCollectAuthHeaders(merchantId, password),
body: JSON.stringify(payload),
});
const data = await response.json();
return { txnid: input.txnid, refNo: data.RefNo, status: data.Status, message: data.Message, url: data.Url };
}
重要: 回調URL唔係隨每次請求傳送嘅,而係喺DragonPay管理後台註冊一次就得。呢一點同大多數支付網關唔同。
渠道處理器ID
DragonPay使用ProcId字串將支付路由去特定渠道。ProcId喺v2入面係必填嘅。
const DRAGONPAY_PROCESSOR_MAP: Record<string, string> = {
gcash: 'GCSH',
maya: 'PYMY',
paymaya: 'PYMY',
grabpay: 'GRAB',
shopeepay: 'SHPE',
bpi: 'BPI',
bdo: 'BDO',
unionbank: 'UBP',
metrobank: 'MBTC',
instapay: 'INST',
pesonet: 'PESO',
'7eleven': '7ELE',
bayad: 'BAYD',
cebuana: 'CEBL',
mlhuillier: 'MLH',
ecpay: 'ECPY',
credit_card: 'CC',
bank_account: 'BOG',
bog: 'BOG',
bogx: 'BOGX',
};
狀態碼(附錄3)
| 代碼 | 意思 |
|---|---|
S | 成功 |
F | 失敗 |
P | 待處理 |
U | 未知 |
V | 作廢/取消 |
回傳回調(Collection API)
當一筆支付完成後,DragonPay會向你註冊嘅回傳URL發送一個GET請求:
GET /postback?txnid=TXN123&refno=5678&status=S&message=...
&digest=...&signature=...&signatures=...
&merchantid=MERCHANTID¶m1=¶m2=
&amount=100.00&ccy=PHP&procid=GCSH&settledate=2025-09-29
DragonPay已經經歷咗三代簽名方案:
| 欄位 | 演算法 | 狀態 |
|---|---|---|
digest | SHA1 | 2025年12月10日已棄用 |
signature | HMAC-SHA256 | 2026年3月31日下線 |
signatures | RSA-SHA256 | 目前標準 |
簽名驗證,RSA-SHA256(目前標準)
呢個係呢次整合入面最複雜嘅部分。DragonPay使用由其API攞取嘅、會輪換嘅公鑰,以RSA-SHA256對回調進行簽名。
第一步:攞取公鑰
interface DragonPayPublicKey {
value: string; // Base64-encoded DER (SubjectPublicKeyInfo format)
status: 'Active' | 'Revoked';
}
const KEY_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
let cachedKeys: DragonPayPublicKey[] = [];
let keysCachedAt = 0;
async function getPublicKeys(baseUrl: string, merchantId: string, password: string, forceRefresh = false) {
const now = Date.now();
if (!forceRefresh && cachedKeys.length > 0 && now - keysCachedAt < KEY_CACHE_TTL_MS) {
return cachedKeys;
}
// Keys endpoint uses /v1 path even if collect base is /v2
const keysBaseUrl = baseUrl.replace(/\/v2$/, '/v1');
const response = await fetch(`${keysBaseUrl}/keys/callback`, { headers: getCollectAuthHeaders(merchantId, password) });
cachedKeys = await response.json();
keysCachedAt = now;
return cachedKeys;
}
第二步:構建訊息字串
訊息由8個欄位用冒號連接組成,順序至關重要:
function buildSignatureMessage(callback: {
txnid: string; refno: string; status: string; message: string;
merchantid?: string; param1?: string; param2?: string; amount?: string;
}): string {
const amountFormatted = parseFloat(callback.amount || '0').toFixed(2);
return [
callback.txnid, callback.refno, callback.status, callback.message,
callback.merchantid || '', callback.param1 || '', callback.param2 || '',
amountFormatted,
].join(':');
}
第三步:對所有活躍密鑰進行驗證
import * as crypto from 'crypto';
function derToPublicKeyPem(base64Der: string): string {
const derBuffer = Buffer.from(base64Der, 'base64');
const pemBody = derBuffer.toString('base64').match(/.{1,64}/g)!.join('\n');
return `-----BEGIN PUBLIC KEY-----\n${pemBody}\n-----END PUBLIC KEY-----`;
}
async function verifyRsaSha256Signature(callback: any, baseUrl: string, merchantId: string, password: string): Promise<boolean> {
if (!callback.signatures) return false;
const message = buildSignatureMessage(callback);
const signatureBuffer = Buffer.from(callback.signatures, 'base64');
const messageBytes = Buffer.from(message, 'utf-8');
const tryVerify = (keys: DragonPayPublicKey[]) =>
keys.filter(k => k.status === 'Active').some(key => {
try {
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(messageBytes);
return verifier.verify(derToPublicKeyPem(key.value), signatureBuffer);
} catch { return false; }
});
let keys = await getPublicKeys(baseUrl, merchantId, password);
if (tryVerify(keys)) return true;
// Key rotation: refresh and retry once
keys = await getPublicKeys(baseUrl, merchantId, password, true);
return tryVerify(keys);
}
呢種「失敗重試」嘅模式透明咁處理咗密鑰輪換嘅問題——即使DragonPay喺一日之內輪換咗密鑰,重新攞取並重試一次,都可以確保一個有效嘅回調唔會被漏咗。
Payout API
Mass Payout API同collection API係分開嘅,使用Bearer身份驗證。
端點: POST {payoutUrl}/{merchantId}/post
async function createPayout(input: CreatePayoutInput, merchantId: string, payoutPassword: string, payoutUrl: string): Promise<PayoutResult> {
const txnId = generateTxnId();
const url = `${payoutUrl}/${merchantId}/post`;
const payload = {
TxnId: txnId, FirstName: input.firstName, LastName: input.lastName,
Amount: input.amount.toFixed(2), Currency: input.currency || 'PHP',
Description: input.description, ProcId: input.procId, ProcDetail: input.procDetail,
RunDate: input.runDate || new Date().toISOString().slice(0, 10), Email: input.email,
};
const response = await fetch(url, { method: 'POST', headers: getPayoutAuthHeaders(payoutPassword), body: JSON.stringify(payload) });
const data = await response.json();
// Code 0 = queued. The Message field IS the reference number.
if (data.Code !== 0) throw new Error(data.Message || `Payout failed with code ${data.Code}`);
return { txnId, refNo: data.Message, status: 'Q', message: `Payout submitted: ${data.Message}` };
}
一個關鍵嘅API古怪之處: 被受理後,Code係0,而Message欄位實際上係參考編號,而唔係人類可讀嘅字串。每個新對接呢個API嘅人都會喺呢度中招。
代付回調簽名驗證,SHA1
Payout回傳使用嘅係SHA1,並且用嘅係你嘅代收API密碼(而唔係代付嘅Bearer令牌):
function verifyPayoutSignature(
callback: { merchantTxnId: string; refNo: string; status: string; message: string; digest?: string },
collectionPassword: string,
): boolean {
if (!callback.digest) return false;
const message = `${callback.merchantTxnId}:${callback.refNo}:${callback.status}:${callback.message}:${collectionPassword}`;
const expected = crypto.createHash('sha1').update(message).digest('hex').toLowerCase();
return expected === callback.digest.toLowerCase();
}
交易ID嘅生成
function generateTxnId(): string {
const timestamp = Date.now().toString(36).toUpperCase();
const random = Math.random().toString(36).substring(2, 8).toUpperCase();
return `TXN-${timestamp}-${random}`;
}
// Example: TXN-LQWERTY-A1B2C3
環境設定
# Collection API
DRAGONPAY_MERCHANT_ID=your_merchant_id
DRAGONPAY_PASSWORD=your_collection_password
DRAGONPAY_URL=https://gw.dragonpay.ph/api/collect/v2
# Payout API
DRAGONPAY_PAYOUT_PASSWORD=your_payout_password
DRAGONPAY_PAYOUT_URL=https://gw.dragonpay.ph/api/payout/merchant/v1
# Development / testing
DRAGONPAY_MOCK=true
DRAGONPAY_SKIP_SIGNATURE_VERIFICATION=true
沙盒URL:Collect,https://test.dragonpay.ph/api/collect/v2 | Payout,https://test.dragonpay.ph/api/payout/merchant/v1
模擬模式
對於本地開發,使用一個本地嘅「假」DragonPay服務:
POST /dragonpay-dummy/collect,建立一筆假嘅支付,返回一個模擬支付頁面嘅URL- 模擬頁面有三個掣:Success、Fail、Cancel
- 撳掣會向你嘅本地後端觸發回傳回調,唔需要ngrok或隧道工具
關鍵設計決策
1. 唔依賴官方SDK
自己寫一個薄薄嘅HTTP封裝,可以令你完全掌控重試邏輯、擁有正確嘅TypeScript型別、避免依賴被廢棄嘅風險,並攞到一個乾淨嘅模擬模式。
2. 喺調用網關之前先寫入數據庫記錄
喺調用DragonPay嘅API之前,一定要先以PENDING狀態保存好支付記錄。如果DragonPay嘅回傳喺你嘅API調用返回之前就觸發咗,嗰條記錄必須已經存在。
3. 回傳先係權威來源,返回URL只係用戶體驗
DragonPay會幾乎同時觸發回傳(伺服器對伺服器)同返回URL(瀏覽器重新導向)。絕對唔可以淨係憑返回URL就處理支付。
4. 密鑰輪換嘅處理
公鑰快取1個鐘。如果驗證失敗,立即重新攞取並重試一次——咁樣就可以透明咁處理密鑰輪換,而唔會漏咗任何回調。
5. Payout係「發送後唔理」嘅
Code: 0意味住代付已進入隊列,而唔係已完成。完成狀態會透過代付回傳送達,有可能係幾個鐘之後。先保存txnId + status: 'Q',等收到回傳先更新。
最簡實現中已知嘅缺口
上面嘅整合已經好好咁涵蓋咗理想路徑。以下係喺稱其為「生產級別」之前值得補返嘅一啲缺口:
1. 冇重試邏輯
每一次發出嘅調用都係一次性嘅。如果一次createPayment逾時咗,但實際上喺DragonPay嗰邊已經成功,嗰條記錄就會無限期咁停留喺PENDING狀態,唔會自動恢復。可選方案:
- 針對網絡錯誤同5xx回應(唔包括4xx)採用指數退避重試
- 或者依賴一個定時任務,對超過N分鐘仍處於PENDING狀態嘅記錄輪詢
getTransactionStatus
後者對支付類API嚟講更安全——重試POST有重複扣款嘅風險。
2. 冇JSON解析錯誤處理
如果DragonPay返回咗非JSON回應(例如故障期間嘅HTML錯誤頁面),解析器拋出嘅錯誤可能會被吞入一句籠統嘅「Failed to create payment」訊息入面。解析之前應該先記錄原始回應內容:
// Before: const data = await response.json();
const raw = await response.text();
let data: any;
try {
data = JSON.parse(raw);
} catch {
throw new Error(`DragonPay returned non-JSON response: ${raw.slice(0, 200)}`);
}
3. 請求逾時
為每一個發出嘅HTTP調用都設定逾時。如果唔設定,一旦DragonPay嘅回應卡住,你嘅請求線程就會被無限期阻塞:
// With axios:
const http = axios.create({ timeout: 30_000 }); // 30 seconds
// With fetch (Node 18+):
const response = await fetch(url, {
signal: AbortSignal.timeout(30_000),
// ...
});
4. 喺SDK層做輸入驗證
如果你構建嘅係一個獨立嘅SDK(而唔係喺NestJS內部、由class-validator喺DTO上運行嘅場景),要喺邊界處做驗證:
amount必須大於0email必須係有效地址txnid唔可以為空,且要喺DragonPay 40個字符嘅限制之內procId喺v2入面係必填嘅,應該盡早失敗,而唔係畀DragonPay去拒絕佢
建議嘅SDK介面
class DragonPayClient {
// Collection
createPayment(txnid: string, input: CreatePaymentInput): Promise<PaymentResult>
getTransactionStatus(txnid: string): Promise<TransactionStatus>
cancelTransaction(txnid: string): Promise<{ status: string; message: string }>
getAvailableProcessors(amount: number): Promise<Processor[]>
// Callbacks
verifyPostback(callback: PostbackParams): Promise<ProcessedPostback>
getPublicKeys(forceRefresh?: boolean): Promise<PublicKey[]>
// Payout
createPayout(input: CreatePayoutInput): Promise<PayoutResult>
getPayoutStatus(txnId: string): Promise<PayoutStatus>
verifyPayoutPostback(callback: PayoutPostbackParams): ProcessedPayoutPostback
// Utilities
generateTxnId(): string
mapProcessorCode(paymentMethodCode: string): string | undefined
}
本文基於一次生產環境嘅NestJS + TypeORM整合撰寫。參考資料:Dragonpay-PS-API-v2.26.pdf、Dragonpay-API-MassPayout.pdf。