Skip to content

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.

ParameterTypeDefaultNotes
per_pageinteger251 to 100, and 100 is almost always the right answer
pageinteger1Offset mode
cursorstringCursor mode, see Pagination
date_from, date_toYYYY-MM-DDBounds on created_at
statusstringOne status, not a list

The mode you use changes the shape of meta, which is the part that catches people.

Modemeta contains
Offsetcurrent_page, last_page, per_page, total
Cursorper_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 }'

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.

ResourceEndpointReasonable TTL
Payment channelsGET /channels1 hour
Supported countriesGET /countries24 hours
Supported currenciesGET /currencies24 hours

These are also the only routes a restricted key can reach without a scope, which tells you how little they contain.

A cache small enough to not need a library
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' }),
);

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.

Bounded concurrency with p-limit
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.

A shorter leash where someone is waiting
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.

SignalWatchActing on it
API latencyp95, not the meanThe mean hides the requests that time out
429 rateAny sustained value above zeroSomething is looping, find it before it grows
5xx rateA rise, not a spikeOne is noise, a trend is an incident
Webhook lagEvent timestamp to your processingA 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.

What did you think of this content?