Skip to content

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.

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, next_cursor, has_more
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.xxxxxxxxxxxxxxxx…" \
  | 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 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.

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.

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.

ResourcePay as you goGrowthScaleEnterprise
/payments3090180No ceiling
/transfers2060120No ceiling
/refunds103060No ceiling
/customers50150300No 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.

One read every 2.5 seconds, and the wait a 429 asks for
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.

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. 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.

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?