DragonPay is one of the most widely used payment aggregators in the Philippines, supporting e-wallets (GCash, Maya, GrabPay, ShopeePay), online banking (BPI, BDO, UnionBank), and over-the-counter channels (7-Eleven, Cebuana, M Lhuillier). Their official SDK is outdated and unmaintained. This article documents how a clean, production-grade SDK was built from a real NestJS integration, covering collection, payouts, signature verification, and mock mode.
Why There's No Good SDK
The official PayMaya Node SDK hasn't been meaningfully maintained since 2020. It has no TypeScript types, no webhook verification, and no payout support. The DragonPay PHP SDK is similarly stale.
The only authoritative source is the Dragonpay PS API v2.26 PDF, which is what this integration is built from directly.
API Overview
DragonPay exposes two separate APIs with completely different authentication schemes:
| API | Base URL | Auth | Purpose |
|---|---|---|---|
| Collect v2 | https://gw.dragonpay.ph/api/collect/v2 | HTTP Basic base64(merchantId:password) | Create payments, check status, void |
| Mass Payout v1 | https://gw.dragonpay.ph/api/payout/merchant/v1 | Bearer {payoutPassword} | Disburse funds |
| Keys | {collectBase/v1}/keys/callback | HTTP Basic (same as collect) | Fetch RSA public keys for callback verification |
Note: The keys endpoint uses/v1even if your collect base is/v2. This is a quirk in the spec.
Authentication
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 Token
function getPayoutAuthHeaders(payoutPassword: string) {
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${payoutPassword}`,
};
}
These are intentionally separate credentials, the payout password is a different secret from the collection password.
Creating a Payment (Collection API)
Endpoint: POST {baseUrl}/{txnid}/post
The transaction ID (txnid) goes in the URL path, not the body. This is one of the most common mistakes when implementing against this 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 };
}
Important: Callback URLs are not passed per-request. They are registered once in the DragonPay Admin Portal. This differs from most payment gateways.
Processor IDs
DragonPay uses ProcId strings to route payments to specific channels. ProcId is required in 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',
};
Status Codes (Appendix 3)
| Code | Meaning |
|---|---|
S | Success |
F | Failure |
P | Pending |
U | Unknown |
V | Void / Cancelled |
Postback Callbacks (Collection API)
When a payment completes, DragonPay sends a GET request to your registered postback URL:
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 has gone through three signature schemes:
| Field | Algorithm | Status |
|---|---|---|
digest | SHA1 | Deprecated Dec 10, 2025 |
signature | HMAC-SHA256 | Sunset March 31, 2026 |
signatures | RSA-SHA256 | Current standard |
Signature Verification, RSA-SHA256 (Current Standard)
This is the most complex part of the integration. DragonPay signs callbacks with RSA-SHA256 using rotating public keys fetched from their API.
Step 1: Fetch Public Keys
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;
}
Step 2: Build the Message String
The message is 8 fields joined by colons. Order matters exactly:
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(':');
}
Step 3: Verify Against All Active Keys
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);
}
The retry-on-failure pattern handles key rotation transparently, if DragonPay rotates keys mid-day, refreshing and retrying once means a valid callback is never dropped.
Payout API
The Mass Payout API is separate from the collection API and uses Bearer authentication.
Endpoint: 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}` };
}
Critical API quirk: On acceptance, Code is 0 and Message contains the reference number, not a human-readable string. This trips up every new integrator.
Payout Signature Verification, SHA1
The Payout postback uses SHA1 and your collection API password (not the payout bearer token):
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();
}
Transaction ID Generation
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
Environment Configuration
# 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
Sandbox URLs: Collect, https://test.dragonpay.ph/api/collect/v2 | Payout, https://test.dragonpay.ph/api/payout/merchant/v1
Mock Mode
For local development, use a local "dummy" DragonPay service:
POST /dragonpay-dummy/collect, creates a fake payment, returns a mock payment page URL- The mock page has three buttons: Success, Fail, Cancel
- Clicking fires the postback callback to your local backend, no ngrok or tunnels needed
Key Design Decisions
1. No SDK dependency
Rolling your own thin HTTP wrapper gives full control over retry logic, proper TypeScript types, no abandoned-dependency risk, and a clean mock mode.
2. DB record before gateway call
Always save the payment record with PENDING status before calling DragonPay's API. If DragonPay's postback fires before your API call returns, the record must already exist.
3. Postback is authoritative, return URL is UX only
DragonPay fires postback (server-to-server) and return URL (browser redirect) roughly simultaneously. Never process payment based on the return URL alone.
4. Key rotation handling
Cache public keys for 1 hour. If verification fails, refresh immediately and retry once, this handles rotation transparently without dropping callbacks.
5. Payout is fire-and-forget
Code: 0 means the payout is queued, not completed. Completion arrives via payout postback, potentially hours later. Store txnId + status: 'Q' and update on postback.
Known Gaps in a Minimal Implementation
The integration above covers the happy path well. Here are the gaps worth addressing before calling it production-grade:
1. No Retry Logic
Every outgoing call is fire-once. For a timed-out createPayment that actually succeeded on DragonPay's end, the record stays PENDING indefinitely, with no automatic recovery. Options:
- Retry with exponential backoff on network errors and 5xx responses (not on 4xx)
- Or rely on a scheduled
getTransactionStatuspoll for any PENDING records older than N minutes
The latter is safer for payment APIs, retrying a POST risks double-charging.
2. No JSON Parse Error Handling
If DragonPay returns a non-JSON response (e.g. an HTML error page during an outage), a parser can throw an error that gets swallowed into a generic "Failed to create payment" message. Log the raw response body before parsing:
// 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. Request Timeout
Set a timeout on every outgoing HTTP call. Without one, a hung DragonPay response will block your request thread indefinitely:
// 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. Input Validation at the SDK Layer
If you're building a standalone SDK (not inside NestJS where class-validator runs on DTOs), validate at the boundary:
amountmust be > 0emailmust be a valid addresstxnidmust be non-empty and within DragonPay's 40-char limitprocIdis required in v2, fail fast rather than let DragonPay reject it
Proposed SDK Interface
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
}
Written from a production NestJS + TypeORM integration. References: Dragonpay-PS-API-v2.26.pdf, Dragonpay-API-MassPayout.pdf.