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。