SMARTPAY

Payment Gateway Webhooks and Rate Limits in the Philippines: Event Contracts and Throttling (2026)

I lead the payments platform for a software company headquartered outside the Philippines that sells subscriptions and one-off digital goods to Filipino customers through GCash, Maya and QR Ph. When we first went live, two things bit us within the same month. The first was a webhook that told us an order was pending twenty seconds after a different webhook had told us it was paid; our handler trusted arrival order, flipped the order back to pending, and a customer who had already paid was told to pay again. The second was a flash promotion where our status-polling job and our checkout both hit the gateway at the same time, the gateway started answering with HTTP 429, and our retry logic made it worse by retrying everything at once. Neither problem was the gateway's fault. Both came from reading the integration documentation as a list of endpoints instead of as a contract with two halves: what the gateway promises to tell you, and how much it lets you ask. This article is about those two halves. It deliberately does not re-cover retry schedules, idempotent handlers, signature verification or error-state classification; we have separate pieces on each and I link to them where they belong.

What a Webhook Contract Actually Promises

A webhook contract is the set of guarantees a gateway makes about the notifications it sends you. Before writing a single line of handler code, I now read the documentation looking for five specific things.

Event taxonomy. Every Philippine gateway I have integrated exposes a family of events across the payment lifecycle, even if the names differ. The shape is almost always: order created, payment pending or authorizing, payment succeeded, payment failed, payment expired, refund in progress, refund completed or rejected, payout or disbursement submitted, credited or returned, settlement posted, and dispute opened or updated. Wallet rails add their own nuance. A GCash or Maya checkout can sit in a pending state while the customer is inside the wallet app; a QR Ph dynamic code can expire without ever producing a pending event; an InstaPay-based payout can be "submitted" and later "returned" by the receiving bank. Your handler has to be ready for each of those as a distinct event, not as a status string you parse out of a generic notification.

Stable versus optional fields. Good documentation marks which fields are always present (event id, event type, version, created timestamp, your merchant reference, the gateway transaction id, the status) and which are optional or channel-specific (payer wallet identifier, bank code, failure reason, settlement batch id). We treat the stable set as the only fields allowed in our state machine. Everything optional is stored as raw JSON for support and reconciliation but never drives business logic. That single rule has saved us from several silent breakages when a gateway added or renamed an optional field.

Payload versioning. The gateways worth working with version their webhook payloads, either in a header or in a top-level field, and let you pin the version your endpoint expects. The migration pattern I recommend is to accept a list of supported versions, alert on anything outside that list, and acknowledge the unknown event with a 2xx so you do not stall the gateway's delivery queue while you investigate. Then run a second endpoint on the new version in parallel, compare the two streams for a few days in your sandbox and in production shadow mode, and switch the pin once the comparison is clean.

Delivery semantics. Every payment webhook I have seen is at-least-once. That means duplicates are not a bug; they are the contract. The handler must tolerate the same event id arriving several times. How to make that cheap is covered in our piece on webhook retry design and idempotent handlers, so I will not repeat it here beyond saying: the de-duplication key is the gateway's event id, never your order id, because one order legitimately produces many events.

One endpoint or many. Some gateways let you register a different URL per event family. We register one endpoint and route internally. The reason is operational: one URL means one signature secret to rotate, one allowlist entry on the WAF, one place to watch for backlog, and no risk that a payout event is silently dropped because someone forgot to register the payout URL in production. Signature handling for that single endpoint is the subject of our signature verification guide.

Here is how the event families map to what your system should actually do when each arrives.

Event familyWhat it means on the railWhat your system should doFulfil?
order / payment createdGateway accepted your order; customer has not actedStore gateway reference; start your own expiry timerNo
payment pending or authorizingCustomer is inside the wallet or bank flowShow a waiting state; keep inventory reservedNo
payment succeededFunds confirmed by the wallet or clearing railMark paid; enqueue fulfilment; schedule reconciliation checkYes
payment failedRail rejected, customer cancelled or wallet declinedRelease inventory; offer another channelNo
payment expiredCheckout or QR window closed without paymentRelease inventory; close order; no customer retry on same referenceNo
refund pending / completed / rejectedReversal moving through the original railUpdate ledger; notify customer only on a terminal refund staten/a
payout submitted / credited / returnedDisbursement progressing through InstaPay, PesoNet or walletUpdate payout ledger; a return needs human review before resubmissionn/a
settlement postedA batch of transactions settled to your accountTrigger three-way reconciliation of orders, gateway and bankn/a
dispute opened / updatedCustomer or wallet raised a complaintFreeze related payouts; start evidence collectionn/a

The "Fulfil?" column is the one I make every new engineer memorise. Only one event family releases goods. Everything else changes state, holds or releases inventory, or starts a back-office process.

Why Events Arrive Out of Order and How to Make That Harmless

Out-of-order delivery is not an edge case in the Philippine market; it is the normal case under load. There are three reasons. First, gateways retry failed deliveries, so an event that failed to reach you ten minutes ago can arrive after an event that was generated just now. Second, gateways run multiple delivery workers, and two events for the same order can be picked up by two workers that finish in either order. Third, mobile wallet latency is real: a customer on a weak connection can leave a checkout "pending" from the gateway's point of view long after the wallet has actually debited them, and the pending and succeeded events can be emitted seconds apart and delivered in reverse.

The fix is not to make delivery ordered; you cannot. The fix is to make your state transitions insensitive to arrival order. I use a precedence rule with three parts. Terminal states win: once an order is failed, expired or refunded, no payment-lifecycle event may move it. Rank beats time: a paid event always outranks a pending event regardless of which arrived first. Within the same rank, the gateway's own status-change timestamp or sequence number decides, and your server's arrival clock is never consulted.

Here is the shape of the handler. It assumes signature verification already happened in middleware and that duplicate event ids are already filtered upstream, so it concentrates purely on version checking and precedence.

// webhook.js — order-state precedence. Retry and de-duplication live elsewhere.
const express = require('express');
const app = express();
app.use(express.json({ type: '*/*' }));

const SUPPORTED_VERSIONS = ['2025-03', '2026-01'];

// Higher rank wins. A late "pending" can never roll back a "paid" or "refunded".
const RANK = {
  created: 10,
  pending: 20,
  paid: 30,
  failed: 30,
  expired: 30,
  refund_pending: 40,
  refunded: 50,
  disputed: 60
};
const TERMINAL = new Set(['failed', 'expired', 'refunded']);

app.post('/webhooks/payments', async (req, res) => {
  const evt = req.body;

  if (!SUPPORTED_VERSIONS.includes(evt.version)) {
    await alerts.notify('webhook_version_unknown', { version: evt.version, eventId: evt.id });
    await parkingLot.put(evt);
    return res.status(202).end();   // acknowledge so the gateway queue is not blocked
  }

  const order = await orders.findByReference(evt.data.reference);
  if (!order) {
    await parkingLot.put(evt);      // event raced ahead of our own order commit
    return res.status(202).end();
  }

  const incoming = evt.data.status;
  const current = order.status;
  const eventTime = Date.parse(evt.data.status_changed_at || evt.created_at);

  const higherRank = RANK[incoming] > RANK[current];
  const sameRankNewer =
    RANK[incoming] === RANK[current] &&
    !TERMINAL.has(current) &&
    eventTime > order.statusChangedAt;

  if (!(higherRank || sameRankNewer)) {
    await audit.log('webhook_stale_ignored', { orderId: order.id, current, incoming, eventId: evt.id });
    return res.status(200).end();
  }

  await orders.transition(order.id, {
    status: incoming,
    statusChangedAt: eventTime,
    gatewayEventId: evt.id,
    gatewayTxnId: evt.data.transaction_id
  });
  await queue.enqueue('post-payment-work', { orderId: order.id, status: incoming });
  return res.status(200).end();
});

Two details matter. The parking lot is a table of events we could not apply yet, drained by a job that re-applies them once the order exists or once we have added the new version to the supported list. And the stale-ignored audit log is not noise: a sudden rise in stale events is the earliest signal we have that the gateway's delivery queue is backlogged, which is the subject of the next section.

Webhooks Are a Notification, Not the Ledger

The most expensive mental model error I see in merchant integrations is treating the webhook stream as the system of record. It is not. It is a best-effort push of changes that also exist, authoritatively, behind the gateway's query endpoints and in its settlement reports. Our architecture has three layers of truth, and webhooks are the fastest but least authoritative one.

Status query on a schedule. Every order that is not in a terminal state after its expected window is re-queried against the gateway's status endpoint. The window is tuned per channel because a QR Ph code and a wallet redirect have different natural lifetimes. The query result is applied through the same precedence rule as a webhook, so a query can never roll back a state either. When a query and a webhook disagree, the later gateway-side timestamp wins and the disagreement is logged.

Settlement reports. When a settlement-posted event or a daily report arrives, we reconcile three ways: our orders, the gateway's transaction list for the batch, and the bank credit. Anything in the gateway list that we never saw a webhook for is a missed notification and is applied; anything we marked paid that is not in the gateway list is escalated immediately.

Replay and backfill. Ask, before you sign, whether the gateway offers an endpoint or dashboard action to replay events for a time range or re-deliver a single event id. During one incident, a gateway's delivery queue was backlogged for most of an afternoon; our stale-event counter spiked, status queries kept us trading, and once delivery resumed the replayed events were harmlessly absorbed by the precedence rule. Without the replay facility we would have had to reconcile that afternoon by hand. How to detect provider-side incidents from your own metrics rather than waiting for a status page is covered in our guide to gateway uptime and status monitoring.

The practical rule: a webhook may trigger fulfilment, but only because it is cheap and fast; the query endpoint and the settlement report are what you reconcile against, and your design must survive the webhook stream disappearing entirely for an afternoon.

Where Rate Limits Bite in a Philippine Integration

The second half of the contract is how much you are allowed to ask. Every production gateway applies limits; the differences are in where they sit and how they are shaped. Across the integrations I have run, limits typically attach to five surfaces.

  • Order creation. The checkout endpoint. Under a flash sale this is the one you least want throttled and the one most likely to be, because every customer click becomes a request.
  • Status polling. The query endpoint. Merchants throttle themselves here without noticing, because a naive polling loop over all open orders scales with your backlog, not with your traffic.
  • Payout submission. Disbursements to wallets and banks. Usually the tightest budget, because each call moves money outward and the rails themselves have throughput ceilings.
  • Refunds. Similar to payouts, sometimes sharing a budget with them.
  • Report download. Settlement and transaction exports. Often limited by concurrency rather than rate, and often the cause of a 503 that looks like a 429.

Budgets also differ in scope. A per-merchant budget is consumed by every one of your servers combined, so horizontal scaling does not buy you more. A per-endpoint budget means a storm of status polls does not starve order creation, which is good, but it also means you must track them separately. A per-IP budget punishes you for running behind a single egress address and rewards you for nothing; find out early whether egress IPs are allowlisted and whether the limit is applied before or after that allowlist. Finally, distinguish burst from sustained: most gateways allow a short burst above the steady rate, and understanding the burst size is what lets you survive the first seconds of a promotion.

The shape of the algorithm determines how a limit feels from your side and therefore how you should plan around it.

AlgorithmWhat the client experiencesHow to recognise itClient-side strategy
Fixed windowRequests sail through, then a hard wall until the clock boundary, then another burst is allowed429s cluster at consistent clock boundaries; Retry-After points to the next boundarySpread load across the window; never align cron jobs to the top of the minute
Sliding windowSmoother; a burst now quietly reduces capacity for the next window length429s appear some time after a burst, with varying Retry-After valuesKeep your own rolling count and leave headroom for checkout traffic
Token bucketA burst up to the bucket size is accepted, then a steady dripFirst batch succeeds, then 429s at a roughly constant intervalMirror the bucket client-side so you never send a request that will be refused
Concurrency limitDepends on requests in flight, not on rate; slow gateway responses make it worse429 or 503 correlates with gateway latency rather than with your request rateCap in-flight requests per endpoint, set timeouts, isolate connection pools

If the documentation does not say which algorithm applies, you can usually infer it in the sandbox by running a controlled burst and plotting the response codes against time. We do this as part of every pre-launch sandbox test plan, because the sandbox is the only place you can hit a limit on purpose without affecting customers.

Reading Limit Headers and Telling 429 From 503

When a gateway throttles you it will say so in one of three ways, and they need different responses.

HTTP 429 with limit headers. The well-behaved case. Look for a Retry-After header, in seconds or as a date, and for remaining-budget and reset-time headers. Treat Retry-After as authoritative: your backoff should wait at least that long. Treat remaining-budget as advisory: it is a snapshot that other servers of yours may already have consumed.

HTTP 503 or 502. Usually not a rate limit at all but a capacity or upstream problem at the gateway or at the wallet rail behind it. Retrying a 503 quickly is how merchants turn a brief gateway wobble into a self-inflicted outage. Our rule is that a 503 pauses the whole lane for that endpoint, not just the one request.

Gateway-specific "busy" codes. Some gateways return a 200 with an application-level code meaning "system busy, try later", often when the underlying wallet is in maintenance. These must be mapped into your error taxonomy as retryable-after-delay, not as success and not as a hard failure. The full taxonomy, and why "just retry" is the most expensive bug in a payment stack, is in our payment API error handling guide.

Three rules govern what we do next. Back off with jitter, in integer milliseconds: a base delay doubled per attempt plus a random offset, so that a fleet of servers does not retry in lockstep. Keep separate retry budgets for reads and writes: a status query can be retried generously because it is harmless; a refund or payout can be retried only if the request carries an idempotency key the gateway honours. And never retry order creation blindly: a 429 on checkout means the customer's request may or may not have created an order, and the right move is to query by your reference before deciding, exactly as described in our idempotency keys article.

Client-Side Throttling: A Token Bucket With a Priority Lane

Once you know the shape of the limits, the cleanest defence is to never send a request the gateway will refuse. We wrap the gateway client in a small scheduler: one token bucket per endpoint family, a queue with two lanes, and a global pause that engages when the gateway tells us to stop. The high-priority lane exists for one reason: during an incident, status queries for orders that customers are waiting on must jump ahead of background polling and report downloads.

// gatewayScheduler.js — per-endpoint token buckets with a priority lane.
// Capacities below are illustrative. Take real budgets from your gateway's documentation.
class TokenBucket {
  constructor({ capacity, refillPerSecond }) {
    this.capacity = capacity;
    this.tokens = capacity;
    this.refillPerSecond = refillPerSecond;
    this.last = Date.now();
  }
  refill() {
    const now = Date.now();
    const add = Math.floor(((now - this.last) * this.refillPerSecond) / 1000);
    if (add > 0) {
      this.tokens = Math.min(this.capacity, this.tokens + add);
      this.last = now;
    }
  }
  tryTake() {
    this.refill();
    if (this.tokens > 0) { this.tokens -= 1; return true; }
    return false;
  }
}

const buckets = {
  createOrder:  new TokenBucket({ capacity: 20, refillPerSecond: 10 }),
  queryStatus:  new TokenBucket({ capacity: 40, refillPerSecond: 20 }),
  submitPayout: new TokenBucket({ capacity: 5,  refillPerSecond: 2 }),
  refund:       new TokenBucket({ capacity: 5,  refillPerSecond: 2 }),
  reports:      new TokenBucket({ capacity: 2,  refillPerSecond: 1 })
};

const lanes = { high: [], normal: [] };
let pausedUntil = 0;

function schedule(endpoint, fn, { priority = 'normal', idempotent = false } = {}) {
  return new Promise((resolve, reject) => {
    lanes[priority].push({ endpoint, fn, resolve, reject, idempotent, attempts: 0 });
  });
}

function pump() {
  if (Date.now() < pausedUntil) return;
  for (const laneName of ['high', 'normal']) {
    const lane = lanes[laneName];
    for (let i = 0; i < lane.length; i += 1) {
      const job = lane[i];
      if (!buckets[job.endpoint].tryTake()) continue;
      lane.splice(i, 1);
      i -= 1;
      run(job);
    }
  }
}

async function run(job) {
  try {
    job.resolve(await job.fn());
  } catch (err) {
    const throttled = err.status === 429 || err.status === 503;
    if (!throttled) return job.reject(err);

    const base = 1000 * Math.pow(2, job.attempts);
    const jitter = Math.floor(Math.random() * 300);
    const waitMs = err.retryAfterMs || (base + jitter);
    pausedUntil = Math.max(pausedUntil, Date.now() + waitMs);
    job.attempts += 1;

    // Reads always re-queue. Writes re-queue only when the request carries an idempotency key.
    if (job.endpoint === 'queryStatus' || job.idempotent) lanes.high.unshift(job);
    else job.reject(err);
  }
}

setInterval(pump, 50);

// During an incident, customer-facing status checks jump the queue:
// schedule('queryStatus', () => gateway.query({ reference }), { priority: 'high' });

The scheduler is deliberately simple. The important design choices are the ones around it. Before a promotion we pre-warm: connection pools are opened, tokens are allowed to refill to capacity, and background jobs such as report downloads are paused for the window. Payouts are moved to the gateway's batch endpoint wherever one exists, because one batch call that carries many beneficiaries consumes one token instead of hundreds. Status polling is spread: instead of polling every open order every few seconds, each order gets its own next-check time that grows with age, so the polling load is proportional to new orders rather than to the whole backlog. And during an incident the normal lane is drained by attrition while the high lane keeps the customers who are actually waiting informed.

What a Licensed Gateway Should Document and Expose

Everything above is easier when the gateway treats its webhook contract and its limits as published, versioned artefacts rather than tribal knowledge in a support chat. When evaluating a provider for the Philippine market I now ask for six things in writing: a complete event catalogue with the stable and optional fields marked; a version policy that states how long a retired payload version keeps being delivered; documented limits per endpoint family with the algorithm named; sandbox parity, meaning the sandbox enforces the same limits and emits the same events, including failures and expiries, so that your throttling and precedence logic can be tested; a replay or re-delivery facility; and a status channel that distinguishes gateway incidents from wallet-rail maintenance.

SMARTPAY operates as a payment gateway in the Philippines under Bangko Sentral ng Pilipinas supervision, holding BSP OPS/EMI authorizations. Supervised providers are expected to run documented, auditable integration interfaces: a published webhook event catalogue with versioning, stated request limits per endpoint, a sandbox that mirrors production behaviour, and transaction query and settlement reporting that merchants can reconcile against independently of the webhook stream.

Why does the licence matter for something as technical as a rate limit? Because a supervised operator has reporting and operational-resilience obligations to the regulator, which in practice means the limits and event contracts are engineered and documented rather than improvised, and because the settlement reports you reconcile against are produced by an entity the central bank can examine. An unlicensed reseller sitting in front of someone else's API can neither guarantee the contract nor explain the limits, since it does not own either.

Frequently Asked Questions

Should I register one webhook endpoint or one per event type?

One endpoint, routed internally by event type. It means one secret to rotate, one WAF allowlist entry and one backlog to watch, and it removes the risk that an event family is never registered in production. Split endpoints only if the gateway forces it or if you need to isolate a very high-volume event family on separate infrastructure.

How long should I keep accepting an old payload version?

For as long as the gateway's version policy says the old version may still be delivered, plus the length of your own replay window. Events replayed from a backfill can arrive in the version that was current when they were generated. Keep the old parser behind a supported-versions list and remove it only after the replay window for its last delivery has passed.

What if a webhook arrives before my own order row has been committed?

Acknowledge it with a 2xx and park it. This race is common when checkout creates the gateway order before your own transaction commits. A small drain job re-applies parked events once the order exists, through the same precedence rule as live events. Rejecting the webhook instead only causes the gateway to retry into the same race.

Is polling the status endpoint an acceptable substitute for webhooks?

As a complement, yes; as a replacement, no. Polling alone scales with your open-order backlog and will walk you straight into the query rate limit during a promotion. Webhooks give you speed, polling gives you certainty for stragglers, and settlement reports give you the authoritative record. Use all three.

How do I test rate-limit behaviour before going live?

In the sandbox, run a controlled burst against each endpoint family and record response codes and headers against time. The pattern tells you the algorithm. Then run your scheduler against the sandbox with capacities set slightly below what you observed and confirm you receive no 429s at all. If the sandbox does not enforce limits, ask the provider for the production budgets in writing and test your scheduler against a local stub that enforces them.

Why am I being rate limited when my traffic is low?

Usually one of four reasons: a background job such as reconciliation or report download is sharing the budget with checkout; several of your environments or servers share one merchant credential; a per-IP limit is being applied to a shared egress address; or a retry loop is amplifying a small number of failures into a large number of requests. Log every outbound call with its endpoint family and originating job and the culprit is normally obvious within an hour.

Do rate limits apply to the webhooks I receive?

Not in the sense of a budget you consume, but there is an implicit limit: the gateway expects your endpoint to acknowledge within a short timeout, and slow responses cause retries that increase your inbound volume. Respond quickly and process asynchronously; if you cannot keep up, the gateway's delivery queue backs up and you will see the stale-event pattern described above.

Where to Go From Here

Read the integration documentation twice: once as a list of endpoints, and once as a contract. On the webhook side, extract the event catalogue, the stable fields, the version policy and the delivery semantics, and build a precedence rule so arrival order cannot hurt you. On the request side, find out where the limits sit, which algorithm shapes them and how the gateway signals them, then put a scheduler in front of your client so you never send a request you know will be refused. Keep the query endpoint and the settlement report as your sources of truth, and treat the webhook stream as the fast path that may occasionally go quiet. If you are integrating a Philippine payment gateway from abroad and want to review your event handling, throttling design or the documentation you have been given, reach out on Telegram at @LouisSMARTPAY or by email at [email protected].

相关推荐