We already had DragonPay working. The checkout flow was solid, the postback was reliable, orders were being created correctly. Adding PayMongo felt like it should be a weekend task, just plug in the new provider and reuse everything.
It took longer than a weekend. Partly because of PayMongo's different assumptions. Mostly because of how DragonPay was integrated in the first place.
DragonPay's documentation is functional, but thin. The Collect flow is documented well enough to get through, not great, but workable. The Payout side is a different story. You don't just read the docs and implement it. You have to reach out to their team directly, go back and forth over email, and get specific setup steps that aren't written anywhere public. The postback registration for Payout is one of those things, it's a required step, it's not in the docs, and if you miss it, the API accepts your requests without complaint and just silently doesn't fire the payout. No error. No indication anything is wrong. You wait for a payout that never comes, then eventually figure out through support that there was a setup step nobody told you about.
That's covered in more detail in the DragonPay Collect and Payout integration post. But the point here is: by the time PayMongo came up, real time had already gone into fighting DragonPay's gaps. The existing integration had assumptions baked into it, shaped around how DragonPay works, not around a clean abstraction. Adding a second gateway wasn't just "add another service." It meant figuring out what was actually reusable and what was DragonPay-specific logic wearing the costume of generic code.
This post is partly implementation notes, partly documenting why certain calls were made, so they're easier to revisit later.
DragonPay vs PayMongo: the mental model difference
The two gateways don't just differ in API shape. They have completely different assumptions about what a "checkout" is, and what exists when.
With DragonPay, the flow is: customer adds to cart, cart snapshot gets saved, customer is sent to the payment page, payment fires a postback to your backend, then the order gets created. The order doesn't exist until money moves. No orphaned orders, no inventory held in limbo. Clean model, especially for e-commerce where you don't want to reserve stock for someone who might abandon the flow.
PayMongo flips this. You create the order first, then attach a payment intent to it. The customer pays, a webhook fires, and you update the order you already created. The order exists before anyone has paid a cent.
Neither is wrong, they're just different models with different tradeoffs. DragonPay's model means no cleanup needed for abandoned checkouts, but you lose context if a postback arrives and you need to reconstruct what the customer was buying. PayMongo's model gives you a full order record to attach payment state to, but now you have to handle the case where someone creates an order and never pays, which means pending orders sitting in your database, potentially holding inventory, needing to be expired.
The mobile app's payment waiting page was written entirely around the DragonPay model, it polled a status endpoint using a transaction number. That endpoint didn't even exist on the backend yet. It was just assumed to be there. So before wiring up PayMongo at all, the thing the mobile was already calling had to be built.
Environment variables
You'll need credentials from both PayMongo (secret key, public key, webhook secret, merchant ID) and a flag to control which gateway is active by default. The service reads these at startup, if the PayMongo credentials aren't present, PayMongo is marked as unavailable in the admin settings response.
Keep these out of version control. Use your deployment platform's secrets manager rather than committing them anywhere, even in a private repo.
Building the polling endpoint, gateway-agnostic
The mobile polls a status endpoint every few seconds after checkout. Rather than two separate endpoints, one resolves against whichever gateway created the record:
// payments.service.ts
async getStatusByTxnid(txnid: string): Promise<{ status: string; order_ids: number[] }> {
// Try DragonPay transaction_number first, then PayMongo gateway_transaction_id
let payment = await this.repository.findByTransactionNumber(txnid);
if (!payment) {
payment = await this.repository.findByGatewayTransactionId(txnid);
}
if (!payment) return { status: 'awaiting_payment', order_ids: [] };
const simplifiedStatus =
payment.status === PaymentStatusEnum.COMPLETED ? 'paid'
: payment.status === PaymentStatusEnum.FAILED ||
payment.status === PaymentStatusEnum.CANCELLED ||
payment.status === PaymentStatusEnum.EXPIRED ? 'failed'
: 'awaiting_payment';
return { status: simplifiedStatus, order_ids: [] };
}
The endpoint is public (no JWT), the transaction ID is opaque enough to serve as a lookup key. One thing to watch: if you're using NestJS with a numeric :id param route on the same controller, make sure the named route is declared first, or the framework will try to parse the route name as a number and the request will never reach the right handler:
// payments.controller.ts
@Public()
@Get('status') // must come before @Get(':id')
getStatus(@Query('txnid') txnid: string) {
return this.service.getStatusByTxnid(txnid);
}
@Get(':id') // this would swallow the named route if ordered first
findById(@Param('id') id: number) { ... }
The return URL and mobile deep link
After GCash/Maya/card payment, PayMongo redirects the customer back to a URL you specify at payment initiation. For web this is a frontend page. For mobile it needs to be a deep link back into the app.
When initiating payment, the mobile passes a source parameter in the request body. The backend appends it to the return URL so the return handler knows where to redirect afterward:
// payments.service.ts
const sourceParam = input.source ? `?source=${input.source}` : '';
const returnUrl = `${backendDomain}/your-return-endpoint${sourceParam}`;
The return handler reads the mobile app scheme from config to build the redirect, keep this in your environment config rather than hardcoded:
// payments.controller.ts
@Public()
@Get('return')
async handleReturn(
@Query('payment_intent_id') paymentIntentId: string,
@Query('source') source: string,
@Res() res: Response,
) {
const mobileScheme = this.configService.get<string>('MOBILE_APP_SCHEME');
const frontendDomain = this.configService.get<string>('FRONTEND_DOMAIN');
const isMobile = source === 'mobile';
const redirect = (path: string, params: string) =>
isMobile
? `${mobileScheme}://${path}?${params}`
: `${frontendDomain}/payment/${path}?${params}`;
// verify payment with PayMongo, call handlePaymentCallback, then:
if (verification.status === 'paid') {
return res.redirect(redirect('success', `txn=${paymentIntentId}`));
}
// ...
}
On the Flutter side, didChangeAppLifecycleState(resumed) fires when the deep link brings the app back to the foreground, which immediately triggers a poll:
// payment_waiting_page.dart
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed && !_isComplete) {
_pollOnce();
}
}
The webhook
Register a webhook endpoint in the PayMongo dashboard. The handler verifies the signature header using HMAC-SHA256 with your webhook secret, then calls the payment callback handler with the extracted transaction ID. This endpoint has no JWT, signature verification is the auth mechanism. Make sure your webhook secret is rotated if it's ever exposed, and that the verification step happens before any processing.
The runtime gateway toggle
An admin-protected endpoint manages the active gateway at runtime. The active gateway lives in memory, seeded from an environment variable on startup:
// payment-settings.controller.ts
let activeGateway: 'dragonpay' | 'paymongo' =
(process.env.DEFAULT_PAYMENT_GATEWAY as any) || 'dragonpay';
export function getActiveGateway() {
return activeGateway;
}
@Get()
getSettings() {
return {
default_gateway: activeGateway,
gateways: {
dragonpay: { available: !!process.env.DRAGONPAY_MERCHANT_ID },
paymongo: { available: !!process.env.PAYMONGO_SECRET_KEY },
},
};
}
@Patch()
updateSettings(@Body() dto: UpdatePaymentSettingsDto) {
activeGateway = dto.default_gateway;
return this.getSettings();
}
Resets to the env default on restart, which is intentional. Once you've decided which gateway to use long-term, set the default in your environment config and it'll survive restarts.
DTO fix: whitelisted fields
NestJS validation uses forbidNonWhitelisted: true, so any field the mobile sends that isn't declared in the DTO causes a 422. Add any extra fields the client sends to your order DTO even if the service doesn't use them yet:
// place-order.dto.ts
@IsOptional() @IsString()
payment_method_code?: string;
@IsOptional() @IsString()
checkout_source?: string;
@IsOptional() @IsArray()
vouchers?: number[];
What might be done differently next time
Starting this from scratch, it would make sense to define a payment gateway interface before writing a single implementation:
interface PaymentGateway {
initiatePayment(order, options): Promise<{ checkoutUrl: string; txnid: string }>
verifyPayment(txnid: string): Promise<PaymentStatus>
handleWebhook(payload: any, signature: string): Promise<void>
}
Then DragonPay and PayMongo would just be two implementations behind that interface. Instead, PayMongo got added by bending it around DragonPay's existing shape. It works, but the seams are visible.
The polling endpoint ended up being the right call though, one contract, gateway logic hidden behind it. Adding a third gateway now would be significantly cheaper than adding the second was. That's usually how you know the boundary was drawn in the right place.