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は、まったく異なる認証方式を持つ2つの別々の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公開鍵を取得 |
注意: keysエンドポイントは、collectのベースが/v2であっても/v1を使う。これは仕様上の癖だ。
認証
Collection API、HTTP Basic Auth
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',
};
ステータスコード(Appendix 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はこれまで3つの署名方式を経てきている。
| フィールド | アルゴリズム | ステータス |
|---|---|---|
digest | SHA1 | 2025年12月10日に非推奨化 |
signature | HMAC-SHA256 | 2026年3月31日に廃止予定 |
signatures | RSA-SHA256 | 現在の標準 |
署名検証、RSA-SHA256(現在の標準)
これが統合の中で最も複雑な部分だ。DragonPayは、彼らのAPIから取得したローテーションする公開鍵を使い、RSA-SHA256でコールバックに署名する。
ステップ1: 公開鍵を取得する
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;
}
ステップ2: メッセージ文字列を組み立てる
メッセージは、コロンで結合された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(':');
}
ステップ3: すべてのアクティブな鍵に対して検証する
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が日中に鍵をローテーションした場合でも、1回だけ再取得して再試行することで、有効なコールバックが取りこぼされることはない。
Payout API
Mass Payout APIはコレクション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は人間が読める文字列ではなく参照番号を含む。これは新規に統合するすべての人がつまずくポイントだ。
ペイアウト署名検証、SHA1
Payoutのポストバックは、SHA1と(ペイアウトのBearerトークンではなく)あなたのコレクションAPIパスワードを使う。
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を返す- モックページには3つのボタンがある。Success、Fail、Cancel
- クリックすると、ngrokやトンネルを使わずに、ローカルのバックエンドへポストバックコールバックが発火する
主要な設計上の決定
1. SDK依存関係を持たない
自前の薄いHTTPラッパーを作ることで、リトライロジックへの完全な制御、適切なTypeScriptの型、放棄された依存関係のリスクがないこと、そしてクリーンなモックモードが得られる。
2. ゲートウェイ呼び出しの前にDBレコードを作成する
DragonPayのAPIを呼び出す前に、必ずPENDINGステータスで支払いレコードを保存すること。もしDragonPayのポストバックが自分のAPI呼び出しが返る前に発火した場合、そのレコードはすでに存在していなければならない。
3. ポストバックが正、リターンURLはUXのみ
DragonPayは、ポストバック(サーバー間)とリターンURL(ブラウザリダイレクト)をほぼ同時に発火させる。リターンURLだけを根拠に支払いを処理してはならない。
4. 鍵ローテーションの処理
公開鍵は1時間キャッシュする。検証が失敗した場合、即座に再取得して1回だけ再試行する。これにより、コールバックを取りこぼすことなく、ローテーションを透過的に処理できる。
5. Payoutは撃ちっぱなし
Code: 0は、ペイアウトが完了したのではなくキューに入ったことを意味する。完了はペイアウトのポストバック経由で届き、数時間後になることもある。txnId + status: 'Q'を保存し、ポストバックで更新すること。
最小限の実装における既知のギャップ
上記の統合はハッピーパスをよくカバーしている。以下は、これをプロダクショングレードと呼ぶ前に対処する価値のあるギャップだ。
1. リトライロジックがない
すべての送信呼び出しは一度きりだ。タイムアウトしたが実際にはDragonPay側で成功していたcreatePaymentがあると、そのレコードは自動回復されることなく無期限に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を構築している場合(class-validatorがDTOで動作するNestJS内部ではなく)、境界で検証すること。
amountは0より大きくなければならないemailは有効なアドレスでなければならない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。