@r3pos/ecom-core
v0.1.0
Published
Isomorphic (browser + Node 20+) HTTP core for the R3 Ecom Platform API: API-key auth, retries, idempotency, typed errors, cursor pagination, and webhook signature verification. Zero runtime dependencies — Web-standard globals only.
Maintainers
Readme
@r3pos/ecom-core
The HTTP layer the R3 Ecom Platform SDKs are built on: API-key auth, retries, timeouts, idempotency, typed errors, cursor pagination, and webhook signature verification. It owns transport and nothing else — no resource methods, no business logic, and it never reads the environment.
It is isomorphic: the same code runs unmodified in Node 20+ and in a browser,
because it uses only fetch, AbortController, crypto.subtle,
URLSearchParams and setTimeout. It has zero runtime dependencies.
Which package do you actually want?
Most integrations should not install this one directly:
| You are writing | Install | Key |
| ----------------------------------------------- | -------------------------------------------------------------------------------- | ---------------------- |
| Server code (Node, Deno, Bun, an edge function) | @r3pos/ecom | secret, r3_sk_… |
| Browser code (a customer-facing shop) | @r3pos/ecom-storefront | publishable, r3_pk_… |
Both of those depend on this package and re-export everything from it, so an
integration installs exactly one package. Reach for @r3pos/ecom-core directly
when you are building your own client over a route the SDKs do not wrap yet, or
when you want the webhook verification helpers without the rest of the server
SDK.
Install
npm install @r3pos/ecom-coreShips ESM and CommonJS, with TypeScript declarations and source maps for both.
Quickstart
import { createTransport, isRateLimitError, type Page } from '@r3pos/ecom-core';
const transport = createTransport({
apiKey: process.env.R3_SECRET_KEY!, // r3_sk_live_…
baseUrl: 'https://api.r3pos.com', // origin only — /ecom/v1 is added for you
});
// GET, with query params
const page = await transport.get<Page<unknown>>('/catalog/products', {
query: { limit: 50, q: 'latte' },
});
console.log(page.data.length, page.hasMore);
// POST, made replay-safe with an idempotency key
try {
const order = await transport.post(
'/orders',
{ items: [{ productId: 'prod_123', quantity: 2 }] },
{ idempotencyKey: crypto.randomUUID() },
);
console.log(order);
} catch (err) {
if (isRateLimitError(err)) {
console.warn(`rate limited; retry in ${err.retryAfterSeconds ?? 60}s`);
} else {
throw err;
}
}
// Cursor pagination, one round trip at a time
for await (const product of transport.paginate('/catalog/products', { q: 'latte' })) {
console.log(product);
}createTransport takes apiKey and baseUrl plus optional fetch,
timeoutMs (default 30000), maxRetries (default 2), userAgent,
defaultHeaders, and the retry/backoff knobs (retryBaseDelayMs,
retryMaxDelayMs, maxRetryAfterMs). delay, random and now are injection
points that make a test suite deterministic and instant.
Key classes: secret vs publishable
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. Server only. It must never reach a browser bundle, a mobile app, or a commit.r3_pk_…— publishable. Browser-safe. Limited tocatalog:read,inventory:readandorders:write, plus a per-key origin allowlist enforced by the API.
createTransport validates the format eagerly but deliberately does not
enforce the kind — that is the job of the SDK above it, via the two guards
this package exports:
import { assertSecretKey, assertPublishableKey, redactApiKey } from '@r3pos/ecom-core';
assertSecretKey(key); // throws R3EcomInvalidKeyError on a r3_pk_… key
assertPublishableKey(key); // throws R3EcomInvalidKeyError on a r3_sk_… key
console.log(redactApiKey(key)); // "r3_sk_live_AbCd…" — safe to logIf you mix them up: a publishable key in server code keeps working for
catalog reads and order creation, then 403s on orders:read and
webhooks:manage — often weeks later, looking like a permissions bug. A secret
key in browser code is worse, because nothing fails at all: the shop works
perfectly while every visitor can read the merchant's entire order history out
of the JS bundle and re-point their webhooks. That is why both SDKs call the
matching guard at construction. A secret key that has ever been served to a
browser is compromised — rotate it. Only a hash is stored server-side, so a
key is rotated, never recovered.
Error handling
Every failure is an R3EcomError or one of its subclasses, each carrying a
literal kind, the HTTP status (0 when no response was received), the
server's machine-readable code, and the X-Request-Id when there was one.
import {
isR3EcomError,
isValidationError,
isRateLimitError,
isRetryableError,
} from '@r3pos/ecom-core';
try {
await transport.get('/orders/ord_123');
} catch (err) {
if (isValidationError(err)) {
// 400 / 422 — the request itself is wrong; resending it unchanged will not help
return badRequest(err.message);
}
if (isRateLimitError(err)) {
// the transport already retried; this means the budget ran out
return scheduleRetry(err.retryAfterSeconds ?? 60);
}
if (isR3EcomError(err)) {
console.error(`${err.kind} ${err.status} ${err.code} req=${err.requestId ?? '-'}`);
}
throw err;
}| Class | kind | When |
| ---------------------------------- | ------------- | ----------------------------------------------------------------------------------- |
| R3EcomAuthError | auth | 401 — key missing, malformed, revoked, expired |
| R3EcomForbiddenError | forbidden | 403 — tenant not licensed for ecom, missing scope, or a disallowed browser origin |
| R3EcomNotFoundError | not_found | 404 — no such resource in this tenant |
| R3EcomValidationError | validation | 400 / 422 |
| R3EcomConflictError | conflict | 409 — state moved, or an idempotency key replayed with a different body |
| R3EcomRateLimitError | rate_limit | 429 — carries retryAfterSeconds |
| R3EcomServerError | server | 5xx |
| R3EcomTimeoutError | timeout | exceeded timeoutMs, aborted client-side |
| R3EcomNetworkError | network | fetch rejected: DNS, TLS, reset, or a browser CORS block |
| R3EcomInvalidKeyError | invalid_key | malformed key, or the wrong key class for this SDK |
| R3EcomSignatureVerificationError | signature | a webhook failed verification; carries reason |
| R3EcomPaginationError | pagination | the server repeated a cursor, so the walk refused to loop |
kind is a discriminated union, so a switch over it is exhaustive with no
default branch. isRetryableError(err) answers the "is it worth another
attempt" question in one call.
Retries and idempotency
The transport retries 429, 500, 502, 503 and 504 with full-jitter exponential
backoff and honours Retry-After (up to maxRetryAfterMs, default 60s — beyond
that it fails fast so the caller can reschedule rather than parking a
connection).
Status is only half the decision; the method is the other half. GET, PUT and
DELETE are replayed freely. A POST is replayed only when it carries an
Idempotency-Key, because without one a replay could create a second order.
Supply one on every write you care about:
await transport.post('/orders', body, { idempotencyKey: `cart:${cart.id}` });Deriving the key from something stable (a cart id) also collapses your own retries — a double-tapped button, a replayed job — into one order. A random UUID per call protects only against the transport's internal replays.
Webhook verification
import { constructEvent, isSignatureVerificationError } from '@r3pos/ecom-core';
const raw = await request.text(); // RAW body — see the warning below
try {
const event = await constructEvent({
payload: raw,
header: request.headers.get('r3-signature'),
secret: process.env.R3_WEBHOOK_SECRET!,
});
// de-duplicate on event.id: delivery is at-least-once
} catch (err) {
if (isSignatureVerificationError(err)) {
// err.reason: 'malformed_header' | 'timestamp_out_of_tolerance'
// | 'no_matching_signature' | 'invalid_payload'
return new Response('bad signature', { status: 400 });
}
throw err;
}⚠️ Pass the raw body string. The signature covers the exact bytes the
platform sent, so verifying JSON.stringify(await request.json()) fails every
time — key order and whitespace do not survive the round trip.
Answer a failed verification with a 4xx. A 2xx marks the delivery as accepted and stops the platform retrying it.
Pagination
import { collectPages, type Page } from '@r3pos/ecom-core';
// lazily, one round trip at a time — `limit` is clamped into range for you
for await (const row of transport.paginate('/orders', { limit: 500 })) {
/* … */
}
// whole pages, when you want to batch the work
for await (const page of transport.paginatePages('/orders')) {
await db.insertMany(page.data);
}
// or eagerly, with a hard ceiling so a runaway list cannot exhaust memory
const categories = await collectPages(
(cursor) => transport.get<Page<unknown>>('/catalog/categories', { query: { cursor } }),
1_000,
);autoPaginate throws R3EcomPaginationError if the server hands back a cursor
it has already handed back, rather than silently truncating — a silent stop is
indistinguishable from "no more data" and would hide missing rows.
Subpath exports
Everything is available from the root, and each module is also importable on its own if you want a narrower surface:
import { R3EcomError } from '@r3pos/ecom-core/errors';
import { assertSecretKey } from '@r3pos/ecom-core/keys';
import { autoPaginate } from '@r3pos/ecom-core/pagination';
import { createTransport } from '@r3pos/ecom-core/transport';
import { constructEvent } from '@r3pos/ecom-core/webhooks';
import { ECOM_SCOPES } from '@r3pos/ecom-core/contract';The build is code-split across those entry points in both ESM and CJS, so
there is exactly one R3EcomError class no matter which mix of subpaths you
import — instanceof and the is*Error guards work across them.
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
