What You'll Build
By the end of this tutorial, you'll have a working Dragonpay integration in a NestJS + Next.js stack that supports:
- Collect, accepting payments from customers via Dragonpay's payment page
- Payout, disbursing funds to a recipient's bank account or e-wallet
This covers the full flow: generating payment URLs, validating postbacks, handling order state, building a polling page in Next.js, and the critical Payout gotcha that the official docs don't mention.
Prerequisites
- A Dragonpay merchant account (sandbox credentials for testing)
- NestJS backend with
ConfigServiceset up - Next.js frontend (App Router)
- Environment variables:
DRAGONPAY_MERCHANT_ID,DRAGONPAY_PASSWORD,DRAGONPAY_BASE_URL,DRAGONPAY_PAYOUT_URL
Part 1: Dragonpay Collect
Step 1: Understand the Collect flow
Before writing any code, internalize this sequence:
- Customer places an order, your backend creates a
pendingorder and generates a Dragonpay payment URL - Customer is redirected to Dragonpay's hosted payment page
- Customer completes (or abandons) payment
- Dragonpay fires a GET postback to your registered URL
- Your backend validates the postback digest, updates the order status, and the customer is redirected to a success or failure page
The key insight: your order doesn't exist yet when the customer starts paying. You're creating a cart snapshot keyed by txnid, and only converting it to a real order once the postback confirms success. Design this flow before writing a single line of payment code.
Step 2: Generate the Dragonpay payment URL
The payment URL requires a SHA-1 digest, a hash of specific fields in a specific order, salted with your merchant password. Get this wrong and Dragonpay silently rejects the request.
Create dragonpay.service.ts:
import * as crypto from 'crypto';
generateCheckoutUrl(params: {
txnid: string;
amount: number;
currency: string;
description: string;
email: string;
}): string {
const { txnid, amount, currency, description, email } = params;
const merchantId = this.configService.get('DRAGONPAY_MERCHANT_ID');
const password = this.configService.get('DRAGONPAY_PASSWORD');
const base = this.configService.get('DRAGONPAY_BASE_URL'); // e.g. https://gw.dragonpay.ph/Pay.aspx
const amountFormatted = amount.toFixed(2);
// Digest order: merchantId:txnid:amount:currency:description:email:password
const digestStr = [merchantId, txnid, amountFormatted, currency, description, email, password].join(':');
const digest = crypto.createHash('sha1').update(digestStr).digest('hex');
const queryParams = new URLSearchParams({
merchantid: merchantId,
txnid,
amount: amountFormatted,
ccy: currency,
description,
email,
digest,
});
return `${base}?${queryParams.toString()}`;
}
Watch out: The description field has a character limit and strips special characters. Keep it short and plain ASCII. If Dragonpay truncates it, the digest they compute on their end won't match yours, and the transaction silently fails.
Step 3: Store a cart snapshot before redirecting
Before redirecting the customer to Dragonpay, persist what they were buying. You'll need this when the postback arrives later (possibly seconds, possibly minutes after the customer left your site).
// In your orders or payments service
async initiateCheckout(cartItems: CartItem[], email: string) {
const txnid = generateUniqueTxnId(); // e.g. ulid() or uuid()
const amount = calculateTotal(cartItems);
// Save cart snapshot keyed by txnid
await this.cartSnapshotRepository.save({ txnid, cartItems, email, status: 'pending' });
const paymentUrl = this.dragonpayService.generateCheckoutUrl({
txnid,
amount,
currency: 'PHP',
description: 'Order payment',
email,
});
return { paymentUrl, txnid };
}
Step 4: Set up the postback endpoint
Dragonpay fires a GET request to your postback URL after each transaction. Your endpoint must:
- Validate the digest they send
- Return the literal string
result=OKorresult=error(not JSON, not an HTTP status, the exact string)
In payments.controller.ts:
@Public() // bypass JWT auth - this comes from Dragonpay, not your users
@Get('dragonpay/postback')
async handleDragonpayPostback(@Query() query: DragonpayPostbackDto) {
const { txnid, refno, status, message, digest } = query;
const isValid = this.dragonpayService.validatePostbackDigest({ txnid, refno, status, message, digest });
if (!isValid) {
this.logger.warn(`Invalid postback digest for txnid: ${txnid}`);
return 'result=error';
}
await this.paymentsService.handleDragonpayPostback({ txnid, refno, status });
return 'result=OK';
}
In dragonpay.service.ts, add the digest validator:
validatePostbackDigest(params: {
txnid: string; refno: string; status: string; message: string; digest: string;
}): boolean {
const { txnid, refno, status, message, digest } = params;
const merchantId = this.configService.get('DRAGONPAY_MERCHANT_ID');
const password = this.configService.get('DRAGONPAY_PASSWORD');
// Postback digest order: merchantId:txnid:refno:status:message:password
const digestStr = [merchantId, txnid, refno, status, message, password].join(':');
const expected = crypto.createHash('sha1').update(digestStr).digest('hex');
return expected === digest;
}
Step 5: Handle the postback idempotently
Dragonpay will fire duplicate postbacks. Your handler must be safe to call multiple times for the same txnid.
// payments.service.ts
async handleDragonpayPostback(params: { txnid: string; refno: string; status: string }) {
const { txnid, status } = params;
// S = Success, F = Failure, U = Unknown, V = Void, others exist
if (status !== 'S') {
await this.cartSnapshotRepository.markFailed(txnid);
return;
}
const snapshot = await this.cartSnapshotRepository.findByTxnid(txnid);
if (!snapshot) {
this.logger.error(`No cart snapshot for txnid: ${txnid}`);
return;
}
// Guard: skip if already fulfilled (idempotency)
if (snapshot.status === 'fulfilled') return;
// Create the real order inside a DB transaction
await this.dataSource.transaction(async (manager) => {
const order = await this.ordersService.createFromSnapshot(snapshot, manager);
await this.cartSnapshotRepository.markFulfilled(txnid, order.id, manager);
});
}
Step 6: Build the Next.js waiting page
After redirecting the customer to Dragonpay, show them a "waiting for confirmation" screen. Poll your backend every few seconds until the order is confirmed or failed.
// app/payment/waiting/page.tsx
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
export default function PaymentWaitingPage() {
const router = useRouter();
const searchParams = useSearchParams();
const txnid = searchParams.get('txnid');
const [message, setMessage] = useState('Waiting for payment confirmation...');
useEffect(() => {
if (!txnid) return;
// Auto-timeout after 10 minutes
const timeout = setTimeout(() => {
clearInterval(interval);
setMessage('Payment confirmation timed out. Please contact support.');
}, 10 * 60 * 1000);
const interval = setInterval(async () => {
const res = await fetch(`/api/payments/status?txnid=${txnid}`);
const data = await res.json();
if (data.status === 'paid') {
clearInterval(interval);
clearTimeout(timeout);
router.push(`/orders/${data.orderId}`);
} else if (data.status === 'failed') {
clearInterval(interval);
clearTimeout(timeout);
router.push('/payment/failed');
}
}, 3000);
return () => {
clearInterval(interval);
clearTimeout(timeout);
};
}, [txnid]);
return {message}
;
}
Your backend needs a status endpoint that maps txnid to current order status. Make sure this route is registered before any :id wildcard route in NestJS, or it'll be swallowed by the param matcher.
Part 2: Dragonpay Payout
Step 7: Do this first, email Dragonpay to register your postback URL
This is the step that isn't documented anywhere. Dragonpay Payout does not have self-serve webhook configuration. You must contact their support team and ask them to manually register your payout postback URL on their end.
There is no error, no warning, no failed response if you skip this, your payout API calls will succeed, and postbacks will simply never arrive. This is extremely easy to miss.
Do this before you write any Payout code. Include your postback URL in the first message. It can take time, and you can't do end-to-end testing until it's done.
Step 8: Make a payout request
Payout uses a completely different auth mechanism from Collect. Instead of digest-based URL signing, it uses HTTP Basic Auth with your merchant credentials.
// dragonpay.service.ts
async initiatePayout(params: {
txnid: string;
amount: number;
currency: string;
description: string;
recipientName: string;
bankCode: string; // Dragonpay processor ID, e.g. 'BPIA', 'GCSH'
accountNumber: string;
}): Promise<{ refno: string; status: string }> {
const merchantId = this.configService.get('DRAGONPAY_MERCHANT_ID');
const password = this.configService.get('DRAGONPAY_PASSWORD');
const baseUrl = this.configService.get('DRAGONPAY_PAYOUT_URL');
const auth = Buffer.from(`${merchantId}:${password}`).toString('base64');
const response = await fetch(`${baseUrl}/api/collect/v1/${merchantId}/disburse`, {
method: 'POST',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
TxnId: params.txnid,
Amount: params.amount.toFixed(2),
Currency: params.currency,
Description: params.description,
ProcId: params.bankCode,
Param1: params.recipientName,
Param2: params.accountNumber,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Dragonpay Payout failed: ${error}`);
}
return response.json();
}
Step 9: Handle the payout postback
Once Dragonpay has registered your postback URL (Step 7), payout completions will fire a GET request to it, same format as Collect postbacks, same digest validation.
// payments.controller.ts
@Public()
@Get('dragonpay/payout-postback')
async handlePayoutPostback(@Query() query: DragonpayPostbackDto) {
const { txnid, refno, status, message, digest } = query;
const isValid = this.dragonpayService.validatePostbackDigest({ txnid, refno, status, message, digest });
if (!isValid) {
this.logger.warn(`Invalid payout postback digest for txnid: ${txnid}`);
return 'result=error';
}
await this.payoutsService.handlePostback({ txnid, refno, status });
return 'result=OK';
}
Handle all status codes explicitly, don't treat non-S as a binary failure:
S, Success: mark payout completeF, Failure: mark failed, surface to operatorU, Unknown: log and queue for manual review or retry, don't auto-fail thisV, Void: mark voided
Reference: Key Rules to Remember
Collect digest field order (for generating the payment URL):
merchantId : txnid : amount : currency : description : email : password
Postback digest field order (same for both Collect and Payout):
merchantId : txnid : refno : status : message : password
Postback response strings (must be exact, not JSON):
result=OK
result=error
Auth differences:
- Collect, digest in URL query params
- Payout, HTTP Basic Auth header
The three things that'll burn you if you don't know them upfront:
- Email Dragonpay to register your Payout postback URL before writing any Payout code
- Collect and Payout are not symmetric, different auth, different setup, different gotchas
- Design your order state machine (pending, confirmed, failed) before touching the payment integration