@r3pos/ecom-storefront
v0.1.0
Published
Browser-side SDK for the R3 Ecom Platform API. Used with a PUBLISHABLE key (r3_pk_…) in a customer-facing shop: catalog and inventory reads, order creation, and checkout. Exposes nothing a publishable key cannot do. Built on @r3pos/ecom-core.
Maintainers
Readme
@r3pos/ecom-storefront
The browser-side SDK for the R3 Ecom Platform API — the commerce API behind R3 POS. Use it in a customer-facing shop with a publishable key to read the catalog and inventory, place an order, run checkout, and follow that order to pickup.
For it: anyone building the shop itself — a merchant's own site, an agency's custom storefront, a React/Vue/Svelte front end, or the client half of a Next.js/Remix/Astro app.
Server code needs the sibling package,
@r3pos/ecom, which takes a secret
key and adds order listing, webhook management and webhook verification.
Install
npm install @r3pos/ecom-storefrontShips ESM and CommonJS with TypeScript declarations and source maps — CJS included because a shop is routinely server-rendered before it is hydrated, and the server half of a framework may still be loading CommonJS.
Quickstart
import { createStorefrontClient } from '@r3pos/ecom-storefront';
const client = createStorefrontClient({
apiKey: 'r3_pk_live_x8Kq2mBvR7nT4wYzC1aH6dLpF9sJgE3uN5oM0iX2rQw', // publishable: safe in a bundle
baseUrl: 'https://api.r3pos.com', // origin only — /ecom/v1 is added for you
});
// Shop front
const merchant = await client.merchant.get();
const { data: products } = await client.catalog.products.list({ q: 'latte', limit: 20 });
const { data: categories } = await client.catalog.categories.list();
// Cart → order. All money is computed server-side from the merchant's own
// prices, so a price the page sends is ignored and cannot be tampered with.
const order = await client.orders.create(
{ items: [{ productId: products[0]!.id, quantity: 2 }] },
{ idempotencyKey: cart.idempotencyKey },
);
// ⚠️ Keep the token — it is the only way this client can read the order again
sessionStorage.setItem(`order:${order.id}`, order.orderToken);
// Pay
const session = await client.orders.checkout.create(order.id);
// session.mode is 'stripe' | 'helcim' | 'mock' | 'free' — branch on it, not on your config
// in 'stripe' mode: session.clientSecret + session.publishableKey feed Stripe.js
// Confirmation page
const fresh = await client.orders.get(order.id, { orderToken: order.orderToken });
console.log(fresh.number, fresh.status); // "1042" "preparing"Build one client per page and share it. It is stateless apart from its configuration.
What is on the client
| Namespace | Methods |
| --------------------------- | --------------------------------------------------------- |
| client.merchant | get() |
| client.catalog.products | list(), get(id), listAll() |
| client.catalog.categories | list(), listAll() |
| client.inventory | list(), listAll() |
| client.orders | create(), get(id, { orderToken }) |
| client.orders.checkout | create(orderId), confirm(orderId, body) |
| client.key | the parsed key — kind, mode (live/test), prefix |
client.key.mode is how you render a "test mode" badge without guessing from
the hostname.
Two things are deliberately absent. There is no way to list orders, and no
webhook management or verification. GET /ecom/v1/orders needs orders:read,
which a publishable key can never hold — exposing it would put every customer's
order one devtools call away. Webhook verification needs the signing secret,
which is a server secret by definition. Neither is missing from the docs; the
methods do not exist on the object. Both live in @r3pos/ecom.
Reading an order back: the guest / orderToken flow
A shopper who never signs in still needs a confirmation page and a "your order
is ready" screen. That is what orderToken is for.
orders.create() returns the order plus a one-time orderToken that grants
read access to that single order and nothing else. It is the only order this
client can read at all:
const order = await client.orders.create({ items: cart.lines });
// 1. Persist the token with the customer's session, keyed by order id.
sessionStorage.setItem(`order:${order.id}`, order.orderToken);
// 2. Read it back — the token is required, and required by the TYPE, because
// there is no fallback: a publishable key holds no `orders:read` scope.
const status = await client.orders.get(order.id, { orderToken: order.orderToken });
// 3. Poll it on the confirmation page as the order moves
// received → accepted → preparing → ready → picked_up
const token = sessionStorage.getItem(`order:${orderId}`)!;
const latest = await client.orders.get(orderId, { orderToken: token });Treat the token as a bearer credential scoped to one order: hand it to the
customer's own session (sessionStorage, a signed cookie, a URL you email only
to them), never log it, and never render it anywhere another customer could see
it. Lose it and the order is unreadable from the browser — recovering it means
asking your server, which holds a secret key and can read any order with
@r3pos/ecom.
In Stripe mode the payment settles on a payment_intent.succeeded webhook
rather than in the browser, so do not call checkout.confirm(); poll
orders.get() with the token until paymentStatus reads succeeded.
checkout.confirm() answers 409 for Stripe-mode checkouts.
Key classes: publishable here, secret on the server
An R3 Ecom API key is r3_<sk|pk>_<live|test>_<43 URL-safe base64 chars>.
r3_pk_…— publishable. Browser-safe by design. Limited tocatalog:read,inventory:readandorders:write, plus a per-key origin allowlist the API enforces. This package requires one, and it is fine in your bundle.r3_sk_…— secret. Every scope, includingorders:readandwebhooks:manage. Server only, in@r3pos/ecom.
createStorefrontClient refuses a secret key at construction:
createStorefrontClient({ apiKey: 'r3_sk_live_…', baseUrl });
// R3EcomInvalidKeyError: @r3pos/ecom-storefront needs a PUBLISHABLE key
// (r3_pk_live_… / r3_pk_test_…) but was given a SECRET key (r3_sk_…). …
// If this key has ever been served to a browser, ROTATE IT NOW.If you mix them up: this is the failure worth caring about. A secret key
works from a browser — the shop functions perfectly, nothing errors, nothing
looks wrong — while the key sits in the JS bundle, in devtools, and in whatever
page source a crawler cached. It carries orders:read and webhooks:manage:
the merchant's entire order history, and the ability to re-point their webhooks.
So the check happens at construction, on the developer's machine, before any of
that can ship. A secret key that has ever reached a browser is compromised —
rotate it. Only a hash is stored server-side, so keys are rotated, never
recovered.
The other direction is harmless here: a publishable key is what this package wants. If it 403s, the cause is usually the origin allowlist rather than the key.
Error handling
Every failure is an R3EcomError subclass carrying a literal kind, the HTTP
status (0 when no response arrived), the server's code, and the
X-Request-Id when there was one. Each has a type guard, so a catch is one
check rather than a status comparison.
import {
isValidationError,
isConflictError,
isForbiddenError,
isNetworkError,
isRateLimitError,
isR3EcomError,
} from '@r3pos/ecom-storefront';
try {
const order = await client.orders.create({ items: cart.lines });
goToCheckout(order);
} catch (err) {
if (isValidationError(err)) {
// 422 — e.g. an empty cart, or a product id that no longer exists
return showCartError('Something in your cart is no longer available.');
}
if (isConflictError(err)) {
// 409 — the order already moved on (already paid, already cancelled)
return refreshOrder();
}
if (isForbiddenError(err)) {
// 403 — missing scope, or this origin is not on the key's allowlist
return showFatal('This shop is not configured correctly.');
}
if (isNetworkError(err) || isRateLimitError(err)) {
return showRetry('Connection problem — try again.');
}
if (isR3EcomError(err)) {
reportToSentry({ kind: err.kind, code: err.code, requestId: err.requestId });
}
throw err;
}Note that a CORS rejection surfaces as R3EcomNetworkError, not
R3EcomForbiddenError — the browser blocks the response before the SDK can
read the status. If every call fails with a network error from one origin but
works from another, check the key's origin allowlist first.
The full family: R3EcomAuthError (401), R3EcomForbiddenError (403),
R3EcomNotFoundError (404), R3EcomValidationError (400/422),
R3EcomConflictError (409), R3EcomRateLimitError (429, with
retryAfterSeconds), R3EcomServerError (5xx), R3EcomTimeoutError,
R3EcomNetworkError, R3EcomInvalidKeyError and R3EcomPaginationError — each
re-exported here with its guard, plus the catch-alls isR3EcomError and
isRetryableError.
Idempotency: the double-tapped "Place order" button
orders.create always sends an Idempotency-Key; without one you supply,
the SDK mints a UUID for that call. That covers the transport's own retries —
which matters on a phone, where a dropped connection otherwise surfaces as an
error on a request that may well have created the order.
It does not cover the customer tapping the button again, because a new call mints a new key. Mint one key when the cart is created and pass it every time:
import { generateIdempotencyKey } from '@r3pos/ecom-storefront';
// once, when the cart is created
cart.idempotencyKey = generateIdempotencyKey();
// on every "Place order" attempt
const order = await client.orders.create(
{ items: cart.lines },
{ idempotencyKey: cart.idempotencyKey },
);Replaying the same key with the same cart returns the same order. Replaying it
with a different cart is a 409 (R3EcomConflictError) — mint a new key when
the cart changes.
Pagination
Every list method returns a Page<T> — { data, nextCursor, hasMore } — and has
a listAll() twin that walks the cursor lazily, one round trip at a time:
const first = await client.catalog.products.list({ limit: 24 });
const next = await client.catalog.products.list({ limit: 24, cursor: first.nextCursor! });
for await (const product of client.catalog.products.listAll({ categoryId })) {
render(product);
if (enoughForNow()) break; // breaking out stops the walk — no wasted requests
}Configuration
createStorefrontClient accepts everything the transport does: apiKey,
baseUrl, fetch, timeoutMs (default 30000), maxRetries (default 2),
defaultHeaders, and the backoff knobs retryBaseDelayMs / retryMaxDelayMs /
maxRetryAfterMs. userAgent is accepted but inert in a browser — browsers
forbid setting that header and drop it silently.
Nothing is read from the environment; your app decides where the key comes from.
Reference
The wire contract — routes, scopes, error codes, pagination, idempotency and the
webhook event catalogue — is documented in
docs/ecom/CONTRACT.md.
License
MIT © R3 Lab
