Skip to main content

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:

# 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;
}
tip

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);
}
tip

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 accepted orders are a sale; placed/rejected never are; a later cancelled reverses 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 order total.
  • 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 / argWhat it does
BASE_URLRequired. Test host to point at, e.g. https://test-partners-api.scoffable.com.
TOKENRequired. 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=1One access-log line per request - method, URL, status, timing.
BODY=1Access log and each response body (implies VERBOSE).
STATE=1After 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=1Step 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