Performance
Walk lists properly, cache what is stable, and pace your calls under the ceilings.
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 fast you send your calls.
Walking a list
Every list endpoint is paginated, and the response is an items array with a meta object beside
it, except GET /events, whose array is events. 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, next_cursor, has_more |
| 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.xxxxxxxxxxxxxxxx…" \
| 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 objects created since. Paging deep into a list that keeps growing can show you the same item 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.
Pace your calls by the minute
A hundred simultaneous requests trip a ceiling, and you get a hundred 429s instead of a hundred
answers. The usual fix is a pool: eight calls in flight, the rest waiting. It bounds the wrong
thing. The API does not count the calls you have open, it counts the calls that arrive in a minute,
and eight in flight on a read that answers in 200 ms is 2 400 a minute.
What to set is a rate, under the ceiling of the resource you call, on your plan. These are calls a minute, and every call under the resource counts, reads included, whichever key sends it.
| Resource | Pay as you go | Growth | Scale | Enterprise |
|---|---|---|---|---|
/payments | 30 | 90 | 180 | No ceiling |
/transfers | 20 | 60 | 120 | No ceiling |
/refunds | 10 | 30 | 60 | No ceiling |
/customers | 50 | 150 | 300 | No ceiling |
Limits carries that table, with the team ceiling that sits above it.
Your checkout draws on the same counter as your batch, so a batch that runs at the ceiling leaves
checkout nothing. Four fifths of the ceiling is a workable share: on /payments, 24 calls a minute
on Pay as you go, 72 on Growth, 144 on Scale.
import { WajubRateLimitError } from '@wajub/node';
// /payments takes 30 calls a minute on Pay as you go: 24 leaves room for checkout.
const PER_MINUTE = 24;
const gap = 60_000 / PER_MINUTE;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const payments = [];
for (const id of paymentIds) {
const startedAt = Date.now();
let payment;
try {
payment = await wajub.payments.retrieve(id);
} catch (err) {
if (!(err instanceof WajubRateLimitError)) throw err;
// Something else spent the minute: wait the time the API gives, then read once more.
await sleep((err.retryAfter ?? 60) * 1000);
payment = await wajub.payments.retrieve(id);
}
payments.push(payment);
await sleep(Math.max(0, gap - (Date.now() - startedAt)));
}A rate this low rarely needs calls in parallel: 24 a minute is one call every 2.5 seconds, and 144
a minute on Scale is still one every 417 ms. The Node.js SDK already waits the Retry-After of a
429 and repeats a read up to three times. The catch is for the 429 that comes back anyway,
usually because another job is spending the same minute.
Rate limiting has the three counters and the two shapes of 429.
Timeouts belong on the critical path
The SDK waits up to 30 seconds for each attempt and retries a call that timed out, up to three
times, so an unreachable API surfaces as an error after about two minutes. 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. timeout sets the wait per attempt.
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. Sent again while the
first call is still running, the same key gets a 409 with errors.idempotency_key: the server SDKs
wait and ask again, so you receive the first call's result rather than an error.
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.