Philippines Payment Collection and Disbursement: A Developer's Guide
If you've been handed a ticket that says "integrate Philippines payment collection and disbursement," the first thing worth knowing is that collection and disbursement are not mirror images of the same API. They move money in opposite directions, they fail in different ways, and they need different idempotency strategies. This guide walks through how to design both sides as a developer — not as a business owner deciding which wallet to use, but as the engineer who has to make the webhook handler, the retry logic, and the reconciliation job actually hold up in production.
We'll use the Philippine real-time rails — QRPH, InstaPay, and PesoNet — as the concrete examples, since most merchant integrations in this market end up touching at least one of them, whether directly or through an aggregator like SMARTPAY sitting on top.
Why Collection and Disbursement Are Not the Same API Problem
Collection (money coming in from a customer) and disbursement (money going out to a payee) look symmetrical on a whiteboard — both are just "move funds between account A and account B" — but the failure domains are different enough that treating them as one API surface causes bugs.
On the collection side, the customer initiates the action. Your system is mostly reactive: create an order, hand the customer a QR code or a deep link, then wait for a webhook. The main engineering risk is a webhook arriving late, arriving twice, or not arriving at all while the customer has already paid.
On the disbursement side, your system initiates the action. You're calling an API to push funds to a bank account, an e-wallet, or a batch file that a partner bank consumes. The main engineering risk flips: a request can time out at the network layer with no way to know, from the HTTP response alone, whether the payout actually went through on the other end. Retrying blindly can double-pay someone. Not retrying can mean a payroll or a commission run silently drops a payee.
That asymmetry is the reason this guide treats the two flows as separate API design problems rather than one generic "payment integration" topic — if you want the end-to-end fund-flow explanation aimed at a business audience first, this walkthrough of the Philippines collection-and-disbursement fund path is a useful companion read before you get into the API weeds below.
Picking the Rail: QRPH, InstaPay, and PesoNet for Integration
Before writing a line of integration code, decide which underlying rail your collection and disbursement flows will actually ride on. Each one has a different message format, a different settlement cadence, and different limits that your API client needs to respect rather than discover in production.
| Rail | Typical Direction | Settlement Cadence | Integration Characteristics |
|---|---|---|---|
| QRPH | Collection (QR-based) | Near real-time clearing between participants | Standardized QR payload across participating wallets and banks; good fit for a single code at a point of sale or on a checkout page |
| InstaPay | Both collection and disbursement | Real-time, transaction-by-transaction | Best for single, time-sensitive payouts or receipts; per-transaction limits apply and vary by participant |
| PesoNet | Mostly disbursement | Batch, cleared in scheduled windows | Suited to bulk payroll or commission runs where same-day finality isn't required for every line item; cut-off windows matter for your job scheduler |
In practice, most developer integrations don't pick one rail exclusively — a merchant backend will route collection through QRPH or a native wallet channel, and route disbursement through a mix of InstaPay for urgent single payouts and PesoNet-style batch files for scheduled runs. Your API layer should expose this as a routing decision inside your own service, not bake a single rail's assumptions into your data model. If you're still deciding how granular that routing needs to be, the developer-facing breakdown of Philippines payment API integration covers the decision points in more depth than we have room for here.
Designing the Collection Endpoint: Idempotency Keys and Request Signing
A collection order-creation endpoint has two jobs that are easy to under-engineer: making sure the same logical order never gets created twice, and making sure the request itself is authentic.
Idempotency on the create-order call matters because mobile clients retry on flaky networks. If your merchant order ID isn't enforced as a unique key on the gateway side, a doubled HTTP request becomes a doubled QR code, and a doubled QR code becomes a confused customer who scans it twice. The fix is boring but necessary: generate the merchant order ID client-side (or in your own backend, before calling out), and treat any repeat of that ID as a lookup, not a new creation.
Request signing matters for the opposite reason — it's not about your own retries, it's about making sure nobody else can forge a create-order call against your merchant ID. A typical signed request looks like this:
curl -X POST https://api.smartpay.example/v1/collection/orders \
-H "Content-Type: application/json" \
-H "X-Merchant-Id: MCH10234" \
-H "X-Timestamp: 1759472400" \
-H "X-Signature: 7c9e1b4f3a2d6e8c0f1a2b3c4d5e6f70918273645" \
-d '{
"merchant_order_id": "ORD-20261003-00981",
"amount": "1500.00",
"channel": "qrph",
"callback_url": "https://merchant.example.com/webhooks/smartpay",
"expires_in": 900
}'
The signature is typically an HMAC over a canonical string built from the sorted request parameters plus your merchant secret — the exact recipe differs by gateway, but the pattern is always the same: sort, concatenate, hash, compare server-side. Reject anything where the signature doesn't match before you even look at the payload. This is the same discipline you'll want on the disbursement side, where the stakes of a forged request are higher because money leaves your account rather than arrives in it. If QRPH is your primary collection rail, the QRPH merchant API reference on signature verification and the sandbox-to-production cutover lays out the exact header and canonicalization conventions you'll need before go-live.
Webhook Callbacks: Verifying Signatures Without Trusting the Network
The webhook is where most production incidents actually happen, because it's the one part of the flow your code didn't initiate. Three things need to be true for a webhook handler to be production-safe: it verifies the signature before trusting the payload, it's idempotent against duplicate deliveries, and it responds fast enough that the sender doesn't treat a slow database write as a failed delivery and retry on top of you.
const crypto = require('crypto');
app.post('/webhooks/smartpay', async (req, res) => {
const signature = req.headers['x-signature'];
const payload = req.rawBody; // raw bytes, not the parsed JSON
const expected = crypto
.createHmac('sha256', process.env.SMARTPAY_WEBHOOK_SECRET)
.update(payload)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).end();
}
const event = JSON.parse(payload);
// Idempotency: has this event id already been processed?
const existing = await db.events.findOne({ eventId: event.id });
if (existing) {
return res.status(200).end(); // ack again, do nothing new
}
await db.events.insertOne({ eventId: event.id, receivedAt: new Date() });
await queue.push('reconcile-order', event); // process asynchronously
return res.status(200).end(); // ack immediately
});
Note the order of operations: verify signature, check idempotency against a stored event ID (not the order ID — gateways can resend the same order's status more than once), persist the receipt, then hand off the actual business logic to a queue instead of doing it inline. That last point matters more than it looks — if your handler holds the HTTP connection open while it updates inventory, sends a confirmation email, and writes to three tables, a slow dependency anywhere in that chain looks like a failed webhook to the sender, which triggers a retry, which your idempotency check then has to absorb anyway. For the mechanics of registering callback URLs and the retry/backoff behavior you should expect from the sending side, this piece on merchant callback design is worth reading alongside this section.
Disbursement API: Single Calls vs Batch Files
Disbursement integrations split into two shapes: a synchronous-looking API call for one payout at a time, and a batch file submission for many payouts processed together. Which one you build first depends on the use case, and the two have genuinely different failure-handling requirements.
| Dimension | Single Payout API | Batch File Submission |
|---|---|---|
| Typical use case | On-demand refunds, one-off vendor payments, urgent withdrawals | Payroll runs, commission settlements, scheduled vendor payments |
| Response model | Request accepted, status confirmed via webhook or polling | File accepted, a results file or callback returns line-by-line status later |
| Failure granularity | One payout fails independently of others | Individual rows in a batch can fail while the rest succeed — your reconciliation has to be row-aware, not file-aware |
| Retry strategy | Retry by merchant reference, never by raw resubmission | Resubmit only the failed rows, keyed by the original line reference, not the whole file |
The mistake teams make most often is building the single-payout path first because it's simpler, then bolting batch support on by literally looping the single-payout call hundreds of times in a cron job. That works until volume grows past what the rail's per-transaction throughput comfortably supports, at which point you end up rebuilding batch handling under deadline pressure anyway. If payroll-style runs are anywhere on your roadmap, it's worth reading the batch file format and retry conventions early rather than retrofitting them — this breakdown of batch payout file format and retry handling covers the field-level detail.
Reconciliation Logic: Closing the Loop Between Your Ledger and the Bank's
Reconciliation is the part of the system that nobody asks for in the initial spec and everybody needs within the first month of going live. The core job is matching three independent records that should agree but don't always arrive at the same time: your internal order or payout record, the webhook event the gateway sent you, and the settlement line in the statement or report the gateway (or bank) produces later.
A workable reconciliation job treats these as three separate tables rather than trying to update one row in place from three different sources:
- Intent table — what you asked for (the order or payout you created), written the moment you make the call.
- Event table — every webhook or status update you received, appended, never overwritten, keyed by the sender's event ID.
- Settlement table — the authoritative record from the statement or settlement report, loaded on whatever cadence the gateway provides it.
Your reconciliation job's only responsibility is to join these three on the merchant reference and flag anything that doesn't line up — an intent with no event after a reasonable window, an event with no matching settlement line, or a settlement line with no matching intent at all (which usually means a webhook got lost and your system never knew the money moved). That last case is the one that actually loses money if it goes unnoticed, so it deserves its own alert rather than sitting in a daily report nobody reads.
One reason this reconciliation discipline matters more in the Philippines than in markets with a single dominant processor: a merchant account that settles through a BSP-licensed Operator of Payment System (OPS) and Electronic Money Issuer (EMI) entity like SMARTPAY is dealing with funds that pass through regulated rails with their own clearing timelines, so your settlement table needs to tolerate a gap between "event received" and "funds confirmed" rather than assuming the two happen in the same request cycle.
Retry Strategy, Dead Letters, and Idempotent Replays
Retry logic is where collection and disbursement diverge the most sharply. On collection, you're usually safe retrying a status check as many times as you want — reading the state of an order doesn't change it. On disbursement, retrying the wrong thing can move money twice, so the retry target has to be the status check, never the original "send money" call, unless that call is itself idempotent by merchant reference on the gateway's side.
A pattern that holds up well in production: every outbound payout call carries a merchant reference that the gateway treats as a dedupe key. If your HTTP client times out, you don't resend the payout — you call a status endpoint with that same reference first. Only if the gateway confirms it has no record of that reference do you resubmit, and even then, you resubmit the same reference rather than generating a new one. Anything that fails after a defined number of retries goes to a dead-letter queue for a human to look at, rather than silently dropping or looping forever. Flaky network conditions and partner-side maintenance windows are common enough that this shouldn't be treated as an edge case — it's a normal operating condition your retry logic has to assume from day one.
Compliance Context: What BSP OPS/EMI Licensing Changes About Your Integration
It's tempting to treat licensing as a legal detail that doesn't touch the code, but it does change a few concrete things about how you design your integration.
BSP recognizes two relevant categories here: an Operator of Payment System (OPS) runs and connects payment rails — the clearing and settlement plumbing — while an Electronic Money Issuer (EMI) actually issues and holds e-money balances on behalf of users. SMARTPAY carries both. For a developer, the practical distinction is where funds rest between your API call and final settlement: an EMI relationship means funds can sit in a regulated e-money float for a period, while a pure OPS relationship means your integration is talking to a system that routes and clears but doesn't itself hold a balance.
Why this matters for your code: if you're building reconciliation against a partner that only operates rails (OPS), your settlement table should expect to reconcile against the underlying bank or e-wallet's records, not the gateway's own ledger. If your partner also issues e-money (EMI), there's an intermediate balance state your reconciliation needs to account for — a payout can be "confirmed" by the gateway before it's actually cleared out to the final bank account, and your status model needs a state for that gap rather than collapsing straight from "sent" to "complete."
Frequently Asked Questions
What's the practical difference between a collection API and a disbursement API?
Collection is reactive — your system waits for a webhook after the customer acts — so the main risk is a missed or duplicate webhook. Disbursement is active — your system initiates the transfer — so the main risk is a timed-out request leaving you unsure whether money actually moved, which means retries have to be reference-based, not blind resubmissions.
How do I make a webhook handler idempotent against duplicate deliveries?
Key your idempotency check on the sender's event ID, not your own order ID, store received event IDs before doing any business logic, and return a success response even on a duplicate so the sender stops retrying. Push the actual processing to a queue so a slow downstream step never looks like a failed delivery.
Should I route disbursement through InstaPay or a batch rail like PesoNet?
It depends on urgency and volume. Single, time-sensitive payouts fit a real-time rail better. Scheduled runs with many line items — payroll, commission batches — are usually cheaper to operate and easier to reconcile as a batch file with per-row status, rather than looping a single-payout call hundreds of times.
How do I reconcile collection and disbursement ledgers without manual spreadsheet work?
Keep three append-only tables — intents, events, and settlement records — and join them on a shared merchant reference on a schedule. Flag anything that doesn't resolve within a defined window instead of trying to force a single mutable row to represent all three sources at once.
What does BSP OPS vs EMI licensing mean for where my settlement funds actually sit?
An OPS-only relationship routes and clears funds without holding a balance, so your reconciliation points at the underlying bank or wallet record. An EMI relationship can hold funds in a regulated e-money float for a period, which means your status model needs an intermediate state between "payout confirmed" and "funds cleared."
How many retries should a payout API client attempt before giving up?
There's no universal number, but the structure matters more than the count: check status by reference before ever resubmitting, cap the number of automated attempts, and route anything that exhausts those attempts to a dead-letter queue for manual review rather than looping indefinitely or dropping it silently.
Do I need separate signing keys for collection requests and disbursement requests?
Many gateways allow one merchant credential for both, but treating them as logically separate — different secrets, different IP allowlists, or at minimum different scopes — limits the blast radius if one set of credentials leaks, especially since a compromised disbursement credential is the more expensive mistake.
Next Steps
None of this requires choosing a rail before you've designed the data model underneath it — get the intent/event/settlement split and the idempotency keys right first, and swapping or adding rails later becomes a routing change instead of a rewrite.
If you're mapping this against SMARTPAY's actual API surface — sandbox credentials, signature recipes, or batch payout specs — reach out directly: Telegram @LouisSMARTPAY or email [email protected], and we can walk through your specific integration case.