Integrating Paymongo & UnionBank without tears
Webhooks, idempotency keys, and reconciling payments so money never goes missing.
Payment integrations fail in the boring ways — a dropped webhook, a retried request that double-charges, a mismatch nobody notices until a customer complains. Here’s how to avoid all three.
Key Takeaways
- Every charge request needs an idempotency key; if a request retries, the provider returns the original result, not a duplicate charge
- Webhooks are hints, not truth; always verify the final state by querying the provider’s API
- Reconcile payments on a schedule (nightly) against both the provider’s dashboard and your database; automated reconciliation catches issues fast
- Test the unhappy path: network failures, webhook delays, provider downtime
Idempotency keys on every write
Every charge request carries a key generated from the order ID. If a request gets retried — network blip, client timeout, whatever — the provider returns the original result instead of creating a second charge.
Paymongo and UnionBank both support idempotency keys. Use one per order:
// Generate a deterministic key from the order
const generateIdempotencyKey = (orderId) => {
return `order-${orderId}`;
};
// Charge request with idempotency key
const chargeOrder = async (orderId, amount) => {
const idempotencyKey = generateIdempotencyKey(orderId);
const response = await fetch('https://api.paymongo.com/v1/charges', {
method: 'POST',
headers: {
'Authorization': `Basic ${btoa(PAYMONGO_SECRET_KEY)}`,
'Idempotency-Key': idempotencyKey // The key
},
body: JSON.stringify({
data: {
attributes: {
amount: amount * 100, // Paymongo uses centavos
currency: 'PHP',
source: { id: sourceId },
description: `Order #${orderId}`
}
}
})
});
const charge = await response.json();
return charge.data;
};
If the request times out and the client retries, Paymongo recognizes the idempotency key and returns the original charge — not a new one.
Webhooks are a hint, not source of truth
Webhooks tell you “something happened,” but they’re not the source of truth. They can be:
- Delayed (arrive 10 seconds or 10 minutes later)
- Duplicated (Paymongo retries if you don’t ACK in time)
- Malformed (provider bug or network issue)
Treat them as a nudge to go re-check the provider’s API:
// Webhook handler
app.post('/webhooks/paymongo', async (req, res) => {
const event = req.body.data;
if (event.type === 'charge.paid') {
// Don't trust the webhook. Query the API.
const charge = await verifyChargeWithProvider(event.data.id);
if (charge.status === 'paid') {
// Now update your database
await db.transactions.update(
{ paymongo_charge_id: event.data.id },
{ status: 'completed', paid_at: new Date() }
);
}
}
res.sendStatus(200); // Always 200 so webhook retries stop
});
// Verify with the provider, not the webhook
const verifyChargeWithProvider = async (chargeId) => {
const response = await fetch(`https://api.paymongo.com/v1/charges/${chargeId}`, {
headers: {
'Authorization': `Basic ${btoa(PAYMONGO_SECRET_KEY)}`
}
});
return response.json();
};
This pattern prevents:
- Double-crediting if the webhook arrives twice
- Marking paid if the charge actually failed
- Getting stuck in a wrong state if the webhook arrives after your database gets reset
Reconcile on a schedule
A nightly job diffs local transaction records against both providers’ dashboards and flags anything that doesn’t match. It’s caught more issues than any amount of manual testing:
// Job: run nightly at 2 AM
const reconcilePayments = async () => {
// 1. Fetch all charges from Paymongo (last 24 hours)
const paymongoCharges = await fetch(
`https://api.paymongo.com/v1/charges?created[gte]=${24hoursAgo}`,
{ headers: { 'Authorization': `Basic ${btoa(PAYMONGO_SECRET_KEY)}` } }
).then(r => r.json());
// 2. Fetch local transactions from the same period
const localTransactions = await db.transactions.find({
created_at: { $gte: 24hoursAgo }
});
// 3. Compare
const paymongoIds = new Set(paymongoCharges.data.map(c => c.id));
const localIds = new Set(localTransactions.map(t => t.paymongo_charge_id));
// Charges in Paymongo but not in our DB (shouldn't happen)
const missingLocally = paymongoCharges.data.filter(
c => !localIds.has(c.id) && c.status === 'paid'
);
// Charges in our DB but not in Paymongo (suspicious)
const missingInPaymongo = localTransactions.filter(
t => !paymongoIds.has(t.paymongo_charge_id) && t.status === 'completed'
);
// Log discrepancies
if (missingLocally.length > 0) {
console.error('Charges missing from DB:', missingLocally.map(c => c.id));
// Send alert to ops
notifyOps({ type: 'missing_charges', count: missingLocally.length });
}
if (missingInPaymongo.length > 0) {
console.error('Charges missing from Paymongo:', missingInPaymongo.map(t => t.id));
// Send alert to ops
notifyOps({ type: 'orphaned_charges', count: missingInPaymongo.length });
}
return { missingLocally, missingInPaymongo };
};
// Schedule in Node with node-cron
import cron from 'node-cron';
cron.schedule('0 2 * * *', reconcilePayments); // 2 AM daily
Run this nightly and you’ll catch:
- Webhook failures (charged but not recorded)
- Double-charges (query API, find duplicates)
- Provider downtime (gaps in the data)
- Refunds you forgot to process
Test the unhappy path
Your payment code is only as good as its test coverage of failures.
// Test: network timeout on charge request
test('retries charge request on timeout', async () => {
let attempts = 0;
fetch.mockImplementation(() => {
attempts++;
if (attempts === 1) throw new Error('timeout');
return Promise.resolve({ json: () => successfulCharge });
});
const result = await chargeWithRetry(orderId, 5000);
expect(result.status).toBe('paid');
expect(attempts).toBe(2);
});
// Test: webhook arrives out of order
test('handles out-of-order webhooks', async () => {
// Mark charge as paid
await chargeOrder(orderId, 5000);
// Webhook arrives with "pending" status (old state)
await webhookHandler({ type: 'charge.pending', data: { id: chargeId } });
// Check: transaction is still marked paid, not reverted
const tx = await db.transactions.findOne({ id: orderId });
expect(tx.status).toBe('completed');
});
// Test: provider API is down
test('gracefully handles provider downtime', async () => {
fetch.mockRejectedValue(new Error('503 Service Unavailable'));
const result = await chargeWithRetry(orderId, 5000);
// Should queue for retry, not crash
expect(retryQueue).toContain({ orderId, status: 'pending_retry' });
});
Real-world example: UnionBank + Paymongo
UnionBank is the issuing bank; Paymongo is the payment processor. The flow is:
Customer → Paymongo → UnionBank → Customer's Bank
↓
Your Database
↓
Reconciliation Job
When a customer pays with their UnionBank card through Paymongo:
- Paymongo captures the charge
- UnionBank either approves or declines
- Paymongo sends a webhook to your app
- Your app verifies the charge with Paymongo’s API
- Your app updates the transaction in the database
- At night, reconciliation diffs the three systems
If any step fails, reconciliation catches it and alerts you.
Takeaways
Payments are boring only when they work. The unglamorous part — idempotency keys, webhook verification, nightly reconciliation — is what keeps money safe. Generate a key for every charge so retries don’t duplicate charges. Treat webhooks as hints, not truth; always verify with the provider. Reconcile nightly and automated discrepancy detection catches issues hours before customers call. Test the unhappy path: timeouts, dropped webhooks, provider downtime. Do that and payment integrations become reliable and forgettable.