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 — suggested1s, 5s, 30s, 2m, 10m, 1h, then dead-letter with an alert. - Do not retry
4xxexcept429. A400or422means 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-Timestampbefore it goes out. Do not replay the original headers.
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
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
| Status | Meaning | Retry? |
|---|---|---|
200 | Accepted — check status in the body. | No |
400 | Malformed body, missing field, or store mismatch. | No — fix the payload |
401 | Bad signature, unknown vendor/store, stale timestamp, inactive connection. | No — check credentials and clock |
422 | Well-formed but unprocessable. The response names the fields. | No |
429 | Rate limited. | Yes — honour Retry-After |
5xx | Perx-side fault. | Yes, with backoff |
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.