@r3pos/ecom
v0.1.0
Published
Server-side SDK for the R3 Ecom Platform API. Used with a SECRET key (r3_sk_…) from a merchant's backend: full catalog/inventory/order access, webhook endpoint management, and webhook signature verification. Built on @r3pos/ecom-core.
Maintainers
Readme
@r3pos/ecom
The server-side SDK for the R3 Ecom Platform API — the commerce API behind R3 POS. Use it from a merchant's backend with a secret key to read the catalog and inventory, place and manage orders, run checkout, register webhook endpoints, and verify webhook deliveries.
For it: developers and agencies building on top of an R3 POS merchant — a custom storefront's backend, an order-routing service, an ERP or accounting sync, a Slack bot that pings the kitchen.
Browser code needs the sibling package,
@r3pos/ecom-storefront,
which takes a publishable key and exposes only what one can do.
Install
npm install @r3pos/ecomShips ESM and CommonJS with TypeScript declarations and source maps, so it works in an ESM service, a CommonJS Express app, and everything in between. Node 20+.
Quickstart
import { createEcomClient } from '@r3pos/ecom';
const client = createEcomClient({
apiKey: process.env.R3_SECRET_KEY!, // r3_sk_live_… (or r3_sk_test_…)
baseUrl: 'https://api.r3pos.com', // origin only — /ecom/v1 is added for you
});
// Who am I talking to?
const merchant = await client.merchant.get();
console.log(merchant.name, merchant.currency);
// Browse the catalog
const { data: products, hasMore } = await client.catalog.products.list({
q: 'latte',
limit: 20,
});
// Or walk all of it, lazily — one round trip per page
for await (const product of client.catalog.products.listAll()) {
console.log(product.id, product.name, product.priceCents);
}
// Place an order. Money is computed server-side from the merchant's own prices.
const order = await client.orders.create(
{ items: [{ productId: products[0]!.id, quantity: 2 }] },
{ idempotencyKey: `cart:${cartId}` },
);
console.log(order.number, order.totalCents, order.status); // "1042" 1130 "received"
// Take payment
const session = await client.orders.checkout.create(order.id);
// session.mode is 'stripe' | 'helcim' | 'mock' | 'free' — branch on it, not on your config
// Track it
const fresh = await client.orders.get(order.id);
const recent = await client.orders.list({ status: 'ready', limit: 50 });Build one client per process and share it. It is stateless apart from its configuration; constructing one per request only re-parses the key and re-allocates the namespaces for no benefit.
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), list(), listAll(), cancel(id) |
| client.orders.checkout | create(orderId), confirm(orderId, body) |
| client.webhooks.endpoints | list(), create(), update(id), delete(id), rotateSecret(id), deliveries(id) |
| client.webhooks | verify(), constructEvent() |
| client.key | the parsed key — kind, mode (live/test), prefix |
| client.transport | escape hatch for a /ecom/v1 route this SDK does not wrap yet |
client.key.mode is the cheap way to refuse to boot a production process
against a test key:
if (process.env.NODE_ENV === 'production' && client.key.mode !== 'live') {
throw new Error(`refusing to start on a ${client.key.mode} key (${client.key.prefix}…)`);
}Key classes: secret here, publishable in the browser
An R3 Ecom API key is r3_<sk|pk>_<live|test>_<43 URL-safe base64 chars>.
r3_sk_…— secret. Every scope:catalog:read,inventory:read,orders:read,orders:write,webhooks:manage. This package requires one. Keep it on the server: never in a browser bundle, a mobile app, or a commit.r3_pk_…— publishable. Browser-safe, limited tocatalog:read,inventory:readandorders:write, with a per-key origin allowlist. It belongs in@r3pos/ecom-storefront.
createEcomClient refuses a publishable key at construction:
createEcomClient({ apiKey: 'r3_pk_live_…', baseUrl });
// R3EcomInvalidKeyError: @r3pos/ecom needs a SECRET key (r3_sk_live_… / r3_sk_test_…)
// but was given a PUBLISHABLE key (r3_pk_…). …
// Use your secret key here, and @r3pos/ecom-storefront for the publishable one.If you mix them up: a publishable key on the server does not fail loudly —
catalog reads and order creation keep working, and the first symptom is a 403 on
orders.list or on webhook management, possibly weeks later and looking like a
permissions bug. Failing at construction turns that into one obvious error on
the developer's machine. In the other direction, a secret key that reaches a
browser leaks the merchant's entire order history and control of their webhooks
to every visitor, while the shop appears to work perfectly. A secret key that
has ever been served to a browser is compromised — rotate it. Only a hash is
stored server-side, so keys are rotated, never recovered.
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 block is
one check rather than a status comparison.
import {
isConflictError,
isNotFoundError,
isRateLimitError,
isValidationError,
isR3EcomError,
} from '@r3pos/ecom';
try {
await client.orders.cancel(orderId);
} catch (err) {
if (isNotFoundError(err)) return res.status(404).json({ error: 'no such order' });
if (isConflictError(err)) return res.status(409).json({ error: 'order already picked up' });
if (isValidationError(err)) return res.status(400).json({ error: err.message });
if (isRateLimitError(err)) {
res.setHeader('Retry-After', String(err.retryAfterSeconds ?? 60));
return res.status(503).end();
}
if (isR3EcomError(err)) {
logger.error({ kind: err.kind, status: err.status, code: err.code, requestId: err.requestId });
}
throw err;
}The full family: R3EcomAuthError (401), R3EcomForbiddenError (403 — not
licensed for ecom, or missing a scope), R3EcomNotFoundError (404),
R3EcomValidationError (400/422), R3EcomConflictError (409),
R3EcomRateLimitError (429, with retryAfterSeconds), R3EcomServerError
(5xx), R3EcomTimeoutError, R3EcomNetworkError, R3EcomInvalidKeyError,
R3EcomSignatureVerificationError and R3EcomPaginationError. Every one is
re-exported from this package, along with its guard and the catch-all
isR3EcomError / isRetryableError.
Idempotency
orders.create always sends an Idempotency-Key; if you do not supply one,
the SDK mints a UUID for that call. That protects you from the transport's own
retries — without a key, a POST is never replayed, so a dropped connection
surfaces as an error on a request that may already have created the order.
It does not protect you from a retry you make later, because a new call mints a new key. For end-to-end safety, derive the key from something stable:
await client.orders.create(body, { idempotencyKey: `cart:${cart.id}` });Replaying the same key with the same body returns the same order. Replaying it
with a different body is a 409 (R3EcomConflictError).
Webhooks
Register an endpoint
const endpoint = await client.webhooks.endpoints.create({
url: 'https://shop.example.com/webhooks/r3',
enabledEvents: ['order.paid', 'order.status_changed', 'inventory.updated'],
description: 'production receiver',
});
// This is the ONLY time the signing secret exists in plaintext. Store it now.
await secrets.put('r3_webhook_secret', endpoint.signingSecret);The secret is stored sealed server-side. It cannot be read back by any route or
recovered by support — a lost secret is rotated with
client.webhooks.endpoints.rotateSecret(id), which returns a new plaintext
secret exactly once. During a rotation the platform signs with both secrets, so
a receiver verifying with either keeps working while you deploy.
Event types: order.created, order.paid, order.status_changed,
order.cancelled, order.refunded, product.updated, inventory.updated. The
API is additive-only — tolerate a type you do not recognise rather than
crashing on it.
Verify a delivery
import express from 'express';
import { createEcomClient, isSignatureVerificationError } from '@r3pos/ecom';
const app = express();
const client = createEcomClient({ apiKey: process.env.R3_SECRET_KEY!, baseUrl });
// ⚠️ RAW body parser on this route — see the warning below
app.post('/webhooks/r3', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = await client.webhooks.constructEvent<{ orderId: string }>({
payload: req.body.toString('utf8'), // the exact bytes R3 sent
header: req.get('r3-signature'),
secret: process.env.R3_WEBHOOK_SECRET!,
});
} catch (err) {
if (isSignatureVerificationError(err)) {
// err.reason: 'malformed_header' | 'timestamp_out_of_tolerance'
// | 'no_matching_signature' | 'invalid_payload'
console.warn('rejected webhook:', err.reason);
return res.status(400).send('bad signature'); // 4xx stops the retries
}
throw err;
}
if (await alreadyHandled(event.id)) return res.sendStatus(200);
switch (event.type) {
case 'order.paid':
await fulfil(event.data.orderId);
break;
default:
break; // unknown types are not an error
}
await markHandled(event.id);
res.sendStatus(200);
});⚠️ The payload must be the RAW request body. The signature is an HMAC over
the exact bytes R3 sent, so verifying a re-serialized body — JSON.stringify(req.body)
after express.json(), or anything else that has round-tripped through a parser
— always fails, because key order and whitespace do not survive. Mount a raw
body parser on the webhook route (express.raw), or read await request.text()
in a fetch-style handler, before parsing.
Two more rules the platform enforces:
- Answer with a 2xx only when you have accepted the event. A 2xx marks the delivery done; anything else is retried at 0s, 30s, 2m, 10m, 1h and 6h, then marked failed. Reject an unverified delivery with a 4xx so it stops.
- Delivery is at-least-once. De-duplicate on
event.idbefore acting.
Use client.webhooks.verify() instead of constructEvent() when you only want
a boolean and will parse the body yourself. To answer "did my endpoint actually
receive order.paid?" without adding logging, read
client.webhooks.endpoints.deliveries(id).
Pagination
Every list method returns a Page<T> — { data, nextCursor, hasMore } — and has
a listAll() twin that walks the cursor lazily:
// one page
const page = await client.orders.list({ status: 'ready', limit: 100 });
// every page, one round trip at a time; breaking out early stops the walk
for await (const order of client.orders.listAll({ updatedSince: since })) {
await sync(order);
}Prefer updatedSince over re-walking the whole history on a schedule: order
history grows without bound and the retry budget does not.
Configuration
createEcomClient accepts everything the transport does:
| Option | Default | |
| -------------------------------------- | -------------- | ----------------------------------------- |
| apiKey | — | required; must be r3_sk_… |
| baseUrl | — | required; API origin, no /ecom/v1 |
| fetch | global fetch | inject your own instrumented one |
| timeoutMs | 30000 | per request |
| maxRetries | 2 | retries after the first attempt |
| userAgent | — | e.g. acme-sync/1.4.0 |
| defaultHeaders | — | merged into every request |
| retryBaseDelayMs / retryMaxDelayMs | 250 / 8000 | full-jitter backoff |
| maxRetryAfterMs | 60000 | longest Retry-After actually waited out |
Nothing is read from the environment — your application decides where the key comes from, which is what lets one process serve several tenants.
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
