← ALL POSTS
PAYMENTS JUN 08, 2026·10 MIN

Integrating Paymongo & UnionBank without tears

Webhooks, idempotency keys, and reconciling payments so money never goes missing.

rj
Rollie John Jaictin
Senior Software Developer
Hand tapping a contactless payment card against a card reader terminal

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:

  1. Paymongo captures the charge
  2. UnionBank either approves or declines
  3. Paymongo sends a webhook to your app
  4. Your app verifies the charge with Paymongo’s API
  5. Your app updates the transaction in the database
  6. 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.

#Payments
Discuss this ↗