PerxDevelopers

Core concepts

Idempotency & retries

Perx is designed so that a network outage delays loyalty rather than losing it. That only holds if you queue and retry properly.

Idempotency

Idempotency is keyed on transaction.id, scoped to your vendor slug and store. Sending the same transaction twice earns points once.

This is enforced in two independent layers — a 24-hour cache at the gateway and a permanent transaction reference in the loyalty ledger — so a duplicate is safe even days apart. You do not need to track what you have already sent in order to be correct; you only need stable transaction IDs.

Refunds are keyed separately, on refund.id, so two partial refunds of the same sale are two distinct events rather than a duplicate.

Your obligations

  • Retry on 5xx. Those are Perx’s faults. Use exponential backoff — suggested 1s, 5s, 30s, 2m, 10m, 1h, then dead-letter with an alert.
  • Do not retry 4xx except 429. A 400 or 422 means the payload is wrong; retrying will fail identically. Log it and alert a human.
  • Never drop a sale silently. Queue webhooks locally so an outage delays loyalty rather than losing it. Perx accepts backdated occurredAt, so a sale delivered hours late still earns correctly.
  • Re-sign on retry. The signature binds a timestamp with a 300-second window, so a queued event must be re-signed with a fresh X-Perx-Timestamp before it goes out. Do not replay the original headers.
Never block a sale on Perx

If Perx is slow or down, complete the sale and queue the event. A loyalty platform must never be able to stop a merchant taking money. Set a short timeout — two seconds is plenty — and fail open.

Ordering

Ordering is not required. Perx handles a refund that arrives before its sale, and sales that arrive out of order. Do not build sequencing logic.

A worked retry loop

javascript
const BACKOFF_MS = [1_000, 5_000, 30_000, 120_000, 600_000, 3_600_000];

async function deliver(event, attempt = 0) {
  // Re-sign every attempt — the timestamp is inside the signature.
  const { headers, body } = signedRequest(event.payload, VENDOR, event.storeId, event.secret);

  let res;
  try {
    res = await fetch(BASE + '/webhooks/generic', {
      method: 'POST', headers, body,
      signal: AbortSignal.timeout(2_000),   // never block the till
    });
  } catch (err) {
    return schedule(event, attempt);        // network/timeout — retry
  }

  if (res.ok) {
    const result = await res.json();
    // 'duplicate' and 'ignored' are successes. Done either way.
    return markDelivered(event, result);
  }

  if (res.status === 429) {
    const after = Number(res.headers.get('retry-after') ?? 60) * 1000;
    return schedule(event, attempt, after);
  }

  if (res.status >= 500) return schedule(event, attempt);

  // 4xx — our payload or credentials are wrong. Retrying cannot help.
  return deadLetter(event, res.status, await res.text());
}

function schedule(event, attempt, overrideMs) {
  if (attempt >= BACKOFF_MS.length) return deadLetter(event, 'exhausted');
  const delay = overrideMs ?? BACKOFF_MS[attempt];
  setTimeout(() => deliver(event, attempt + 1), delay);
}

Status reference

StatusMeaningRetry?
200Accepted — check status in the body.No
400Malformed body, missing field, or store mismatch.No — fix the payload
401Bad signature, unknown vendor/store, stale timestamp, inactive connection.No — check credentials and clock
422Well-formed but unprocessable. The response names the fields.No
429Rate limited.Yes — honour Retry-After
5xxPerx-side fault.Yes, with backoff
A 401 on a previously working integration

That almost always means the merchant rotated or removed their connection. Surface it in your admin UI as “Perx connection needs attention” rather than retrying forever — no amount of retrying will fix a revoked secret, and the merchant is the only person who can.