Reference client
A complete, runnable example of consuming the Partners API correctly - the reference client our integration testing suite drives through every scenario. Use it as a starting point for your integration, or run it as-is against the test environment to check your understanding before you wire anything up.
It's a single plain-TypeScript file with no dependencies (just Node 18+), so you can run it straight from the command line with no install or build step:
- Download
integration-test-suite.ts- the API client + a sample projection, plus the scenario suite that drives them.
# run the whole scenario suite against the test environment
BASE_URL=https://test-partners-api.scoffable.com TOKEN=your-token npx tsx integration-test-suite.ts
The file has two parts you can copy independently: OrderSyncClient (talks to the API correctly -
Track 1) and Till (a sample projection of the stream into point-of-sale state - Track 2).
The polling loop
Start with no cursor, request pages while hasMore is true, and persist nextPageId before fetching
the next page so a restart resumes exactly where you left off. Dedupe by (id, version) so a re-delivered
page never double-processes an order.
async sync(): Promise<SyncStats> {
let cursor = await this.store.load(); // resume from the saved position
let restarts = 0;
while (true) {
const resumingFromCursor = cursor !== null;
const res = await this.requestPageWithRetries(cursor);
// ... failure handling (below) ...
const page = (await res.json()) as OrderUpdatesPage;
for (const order of page.data) {
const key = `${order.id}:${order.version}`;
if (this.seen.has(key)) continue; // a page may be re-delivered - never process twice
this.seen.add(key);
if (!order.latestVersion) continue; // superseded - a newer version already exists; skip it (see below)
this.till.apply(order);
}
// Persist the cursor after EVERY page - even the last. When hasMore is false you've caught up for
// now, but nextPageId still marks where the next new update (a fresh order, or a new *version* of one
// you already have) will appear. Saving it lets the next sync() resume from there.
if (page.nextPageId != null) {
cursor = page.nextPageId;
await this.store.save(cursor);
}
if (resumingFromCursor) restarts = 0;
if (!page.hasMore) break; // caught up - next sync() resumes from the saved cursor
}
return this.stats;
}
Persisting the cursor is pluggable - the example keeps it in memory, but a real integration writes it to a file, row, or key-value store so it survives restarts:
export interface CursorStore {
load(): Promise<string | null>;
save(cursor: string | null): Promise<void>;
}
Handling failures
Every page request goes through one helper that honours the documented failure responses. The golden rule
for 429 and 5xx: retry the same request - same cursor, same page - don't skip ahead. Bound the
retries so a request that never recovers raises an alert instead of looping forever.
if (res.status === 429) {
// Rate limited. Honour Retry-After (delta-seconds or an HTTP-date) and retry the IDENTICAL request -
// bounded, so a stuck request alerts rather than looping.
if (++rateLimitRetries > MAX_RATE_LIMIT_RETRIES) throw new Error("still rate limited after backing off");
await sleep(retryAfterMs(res) ?? backoffMs(rateLimitRetries));
continue;
}
if (res.status >= 500) {
// Transient - retry the same request with bounded exponential backoff.
if (++serverRetries > MAX_SERVER_RETRIES) throw new Error(`server error ${res.status} persisted`);
await sleep(backoffMs(serverRetries));
continue;
}
if (res.status === 401) {
// The token was rejected. There's nothing to retry - surface it and stop.
throw new AuthError("Partners API rejected the token (401)");
}
An expired or unknown pageId returns 404. Recover by restarting cleanly from the beginning (no
pageId) - but bound it, so a cursor that keeps expiring raises an alert instead of looping forever:
if (res.status === 404) {
if (++restarts > MAX_RESTARTS) {
throw new CursorExpiredError("pageId kept expiring after restarting");
}
cursor = null; // start over from the beginning
await this.store.save(null);
continue;
}
Only a successful cursor-follow clears the restart counter - a fresh first page doesn't. Otherwise a cursor that always expires (start → page → its cursor 404s → restart → repeat) would loop forever, because each restart's first page would keep resetting the bound.
Keeping the latest version
An order can change after it's placed: it gets re-quoted, an item goes out of stock, a substitution or
adjustment is added. Each change is a new version of the same order, and the amounts can differ between
them. latestVersion tells you which version is current - true on the newest one at the time it's served,
false on any a later version has replaced. The client ignores the false ones: a newer version already
exists, so there's no point applying one you'll overwrite a moment later. For the versions it does apply,
apply() only accepts a version newer than the one it already holds, so a re-delivered page - or an old
version that turns up late - changes nothing:
apply(order: Order): void {
const current = this.latest.get(order.id);
if (current && order.version <= current.version) return; // ignore older/equal versions
if (order.status === "accepted") this.everAccepted.add(order.id);
this.latest.set(order.id, order);
}
Skipping is safe because every real transition arrives as the current latest:
test-order-lifecycle delivers
placed → accepted → cancelled across polls, each latestVersion: true, so you still record the sale and
void it. A version is latestVersion: false only when it was superseded before you fetched it - e.g.
test-versioning batches v2 and v3 together, so v2 is
stale on arrival. Applying every version instead is equally correct - just an extra write. VERBOSE=1 logs
each skip.
Projecting orders into your system
Till is a sample projection showing the order-handling rules - your real projection lands in your
till/ledger, which we can't see. The key rules it demonstrates:
- Lifecycle - only
acceptedorders are a sale;placed/rejectednever are; a latercancelledreverses the sale you recorded when it was accepted. - Substitutions - record the items actually delivered (
quantityFulfilled > 0), not the originals they replaced. - Payments - a split order is paid in several parts; record each one with its
collectedBy, and check they reconcile to the ordertotal. - Routing - attribute each order to the correct location by
vendorId.
/** The product lines actually delivered (fulfilled > 0) - i.e. substitutes, not what they replaced. */
deliveredProducts(order: Order): OrderItem[] {
return order.items.filter((i) => i.type === "product" && i.quantityFulfilled > 0);
}
/** True when an order's payments add up to its total. (Returns false for an order with no payments -
* treat that as not-applicable, not as a reconciliation failure, if you reuse it.) */
paymentsReconcile(order: Order): boolean {
const paid = (order.customerPayments ?? []).reduce((sum, p) => sum + p.payment.amount, 0);
return (order.customerPayments?.length ?? 0) > 0 && paid === order.total.amount;
}
Run the whole test suite
integration-test-suite.ts points the client at every integration test scenario in turn and
asserts each behaves as documented - a quick way to confirm your environment and understanding end-to-end.
Run the whole suite in one go:
BASE_URL=https://test-partners-api.scoffable.com TOKEN=your-token npx tsx integration-test-suite.ts
Running 17 scenario(s) against https://test-partners-api.scoffable.com
PASS [T1] test-authentication (80ms)
PASS [T1] test-pagination (183ms)
PASS [T1] test-rate-limit (60412ms)
...
17/17 passed.
It scenario-routes purely by URL path segment - ${BASE_URL}/${scenario}/v1/... - exactly as a real caller does.
Options
Everything is driven by environment variables, with scenario ids as positional args:
| Variable / arg | What it does |
|---|---|
BASE_URL | Required. Test host to point at, e.g. https://test-partners-api.scoffable.com. |
TOKEN | Required. Your test bearer token. |
[scenario ...] | Run only the named scenarios (e.g. test-pagination test-vouchers); omit to run all. Handy to skip the ~60s test-rate-limit wait. |
VERBOSE=1 | One access-log line per request - method, URL, status, timing. |
BODY=1 | Access log and each response body (implies VERBOSE). |
STATE=1 | After each Track-2 scenario, print the projection state your till would hold - tailored per scenario (bookings for lifecycle, delivered-vs-replaced items for substitutions, the payment breakdown for payments, and so on). |
STEP=1 | Step through a scenario interactively: pause after each applied order, Enter to advance, q to run the rest. Prints without pausing when piped, so it's safe to run non-interactively. |
The suite exits non-zero if any scenario fails.
STATE=1 and STEP=1 earn their keep on the Track-2 (order-handling) scenarios - they render your projection as it builds up:
STATE=1 … npx tsx integration-test-suite.ts test-substitutions
# Item ord/ful Line Substitution
# Galaxy 200g 1/0 £0.00 → replaced by Dairy Milk 200g
# Dairy Milk 200g 0/1 £3.99 ✓ delivered, substitute for Galaxy 200g
STEP=1 … npx tsx integration-test-suite.ts test-order-lifecycle
# ── applied order 100 v2 [accepted] ── (waits for Enter)
# 100 v2 accepted SALE …
# 100 v3 cancelled VOID (reversed) …
Download
integration-test-suite.ts- theOrderSyncClient+ sampleTillprojection, and the whole-suite runner.