Performance
Walk lists properly, cache what is stable, and parallelise without tripping a ceiling.
Performance work on a payment integration is rarely about milliseconds. It is about not spending a thousand requests where thirty would do, because the quota you burn on a nightly export is the same quota checkout needs at noon.
Three things cover almost all of it: how you walk a list, what you are allowed to cache, and how many calls you let run at once.
Walking a list
Every list endpoint is paginated, and the response is an items array with a meta object beside
it. The parameters are the same everywhere.
| Parameter | Type | Default | Notes |
|---|---|---|---|
per_page | integer | 25 | 1 to 100, and 100 is almost always the right answer |
page | integer | 1 | Offset mode |
cursor | string | Cursor mode, see Pagination | |
date_from, date_to | YYYY-MM-DD | Bounds on created_at | |
status | string | One status, not a list |
The mode you use changes the shape of meta, which is the part that catches people.
| Mode | meta contains |
|---|---|
| Offset | current_page, last_page, per_page, total |
| Cursor | per_page, next_cursor, prev_cursor, has_more |
Writing the loop by hand is where the bugs are: an off-by-one on the last page, a page counter that never increments, a list that grows while you walk it. Every SDK ships an iterator that handles it.
# Offset mode: walk until current_page reaches last_page
curl "https://api.wajub.com/payments?per_page=100&page=1&date_from=2026-09-01&date_to=2026-09-30" \
-H "Authorization: sk_test.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…" \
| jq '{ count: (.items | length), page: .meta.current_page, last: .meta.last_page }'The SDK renames the array
Over HTTP the list arrives as items. The SDKs hand it back as data, with the same meta.
Destructuring items off an SDK result gives you undefined, and the loop that follows throws on
the first iteration.
For a full export, iterate by date window rather than by page. A month at a time gives you bounded, repeatable queries, and a rerun of one window cannot be disturbed by rows inserted since. Paging deep into a table that is still receiving writes can show you the same row twice, or skip one.
Cache what does not change
Three endpoints describe the platform rather than your account, and they are the ones worth caching.
| Resource | Endpoint | Reasonable TTL |
|---|---|---|
| Payment channels | GET /channels | 1 hour |
| Supported countries | GET /countries | 24 hours |
| Supported currencies | GET /currencies | 24 hours |
These are also the only routes a restricted key can reach without a scope, which tells you how little they contain.
const cache = new Map();
async function cached(key, ttlMs, fetcher) {
const hit = cache.get(key);
if (hit && Date.now() - hit.at < ttlMs) return hit.value;
const value = await fetcher();
cache.set(key, { value, at: Date.now() });
return value;
}
const channels = await cached('channels:CM', 3_600_000, () =>
wajub.global.channels({ country: 'CM' }),
);Never cache a transaction's state
A payment can move from processing to succeeded at any moment, and a refund can fail an hour
after it was created. Cache a status and you will ship an order that was never paid for. The state
of a transaction comes from a webhook, or from a fresh read.
That rule has a corollary worth stating plainly: do not poll for it either. Polling every payment every few seconds is the single most common way to sit at a rate limit while learning nothing. The webhook already tells you, and Polling covers the narrow cases where a read is still the right tool.
Parallelise, but bounded
Sequential reads are slow for no reason when the calls are independent. Unbounded parallel reads are
worse, because a hundred simultaneous requests trip a ceiling and you get a hundred 429s instead of
a hundred answers.
import pLimit from 'p-limit';
const limit = pLimit(8);
const payments = await Promise.all(
paymentIds.map((id) => limit(() => wajub.payments.retrieve(id))),
);Five to ten in flight is the useful range. It is fast enough to matter and low enough to stay under the per-endpoint ceilings, which are tighter than the team quota and are what a burst actually hits. Rate limiting has the numbers.
Timeouts belong on the critical path
The SDK waits 30 seconds before giving up. That is right for a background job and far too long for a customer watching a spinner: they will reload the page, and your handler will run twice.
const payment = await wajub.payments.create(
{ amount: order.total, currency: order.currency, email: order.email },
{ idempotencyKey: `ORDER-${order.id}`, timeout: 8000 },
);Shortening the timeout makes an ambiguous outcome more likely, not less: you now give up on requests that were about to succeed. That is exactly why the idempotency key is on the same call. A short timeout without one turns every slow request into a possible double charge.
What to measure
Four numbers tell you whether the integration is healthy, and none of them are averages.
| Signal | Watch | Acting on it |
|---|---|---|
| API latency | p95, not the mean | The mean hides the requests that time out |
429 rate | Any sustained value above zero | Something is looping, find it before it grows |
5xx rate | A rise, not a spike | One is noise, a trend is an incident |
| Webhook lag | Event timestamp to your processing | A growing gap means your queue is behind |
Konsole has the per-request view for a single payment, and Monitoring covers wiring these into your own alerting.