@mercaria.co/sdk
v0.2.0
Published
The canonical, headless TypeScript client for Mercaria's public commerce API — product, store and collection reads, portable references, typed errors and canonical links for Node, Bun, browsers and React Native.
Maintainers
Readme
@mercaria.co/sdk
The canonical TypeScript client for Mercaria's public commerce API. If an Oxy app — Mention, Goway, Nilo, an assistant or a service — needs Mercaria products, stores, collections or a store's locations, it reads them through this package instead of knowing Mercaria's HTTP routes, copying its types or building its URLs.
- Headless and isomorphic. Node 18+, Bun, browsers and React Native (Expo)
from one entry. No React; one runtime dependency,
zod. - Typed end to end. The public contract's types ship with the package, and
every response is parsed with the contract's own zod schemas — the same ones
the server validates with — before you see it. TypeScript consumers need the
DOM lib or
@types/node(zod's declarations nameURL). - Refs are identity; reads are current truth. Persist a ref, hydrate it every time you render.
bun add @mercaria.co/sdk # or: npm install @mercaria.co/sdkContents
- Create a client
- Anonymous and Oxy-authenticated reads
- Products and search
- Variants
- Stores
- Collections
- Locations: products at a shop on a map
- Links
- References
- Pagination
- Locale
- Errors
- Freshness and caching
- Privacy and security boundaries
- Example: a Mention-like integration
- Do not do this
Create a client
import { createMercariaClient } from '@mercaria.co/sdk';
const mercaria = createMercariaClient();Every option is optional:
| Option | Default | |
| --- | --- | --- |
| apiBaseUrl | https://api.mercaria.co | The API origin. |
| webBaseUrl | https://mercaria.co | The origin links are built on. |
| fetch | the global fetch | Looked up at request time. Pass one for tests or old runtimes. |
| getAccessToken | none | Supplies the current Oxy access token. See below. |
| locale | none | Default locale for the list reads that take one; each call can override it. |
| timeoutMs | 15000 | Per request, token acquisition and body included. |
| headers | none | Extra non-auth headers (a tracing id, say). Authorization and Accept are refused. |
Create one client per configuration and reuse it; it holds no connection and no state beyond its options.
Anonymous and Oxy-authenticated reads
With no getAccessToken, every request is anonymous. Every read in this
release works anonymously.
To act as the signed-in Oxy user, hand the SDK a function that returns the current access token:
const mercaria = createMercariaClient({
getAccessToken: () => currentOxyAccessToken(), // however your app reads its Oxy session
});- It is called before every request and its result is never cached,
stored or logged. Return
null,undefinedor''when there is no session: that request is sent anonymously. - The token is sent as
Authorization: Bearer <token>and nowhere else. The SDK sends no cookies (credentials: 'omit'). - Session ownership stays with Oxy. Sign-in, refresh and sign-out belong to
Oxy's auth packages. The SDK never refreshes a token; if a request fails with
MercariaUnauthorizedError, refresh through Oxy and try again. - An error thrown by your getter is passed through unchanged.
- Today the only authenticated difference is
product.viewer, which is{ saved }for a signed-in caller andnullfor an anonymous one.
Service-to-service authority is not provided. There is no client secret, API key or service token option, and the SDK does not fake one. When a Mercaria method needs service authority, it will use Oxy's canonical service authorization and be added here explicitly.
Products and search
const page = await mercaria.products.search({
query: 'Cyberpunk 2077',
inStock: true,
sort: 'relevance', // 'relevance' (needs a query) | 'newest' | 'price_asc' | 'price_desc'
limit: 20, // 1–50
});
for (const item of page.items) {
item.ref; // { kind: 'product', id } — persist this
item.title;
item.primaryImage; // { url, alt } | null
item.price; // { amount, currency } — integer minor units, native currency
item.availability; // 'in_stock' | 'out_of_stock' | 'sold'
item.seller; // a store (with its ref and current handle) or a person (Oxy user id)
item.url; // canonical web URL
}
const product = await mercaria.products.get(page.items[0].ref); // or an id string
product.description;
product.images;
product.purchaseOptions; // each with its own variant ref, price and availability
product.updatedAt;search also filters by store and collection, each a ref or an id. A
filter naming a store or collection that does not exist, or is no longer
public, rejects with MercariaNotFoundError / MercariaGoneError rather than
returning an empty page.
inStock: true keeps only products that can be bought now. There is no "only
out of stock" filter: inStock: false is the same as leaving it out, and is not
sent.
price.amount is an integer count of the currency's minor units (1999 EUR is
€19.99; FAIR has 8 decimals; JPY has none). Format it with your app's money
formatter; never print the raw number.
A sold one-off product still reads successfully, with
availability: 'sold': show it, but do not offer to buy it.
Variants
A variant (purchase option) is resolved through its product:
import { variantRef } from '@mercaria.co/sdk';
const { product, option } = await mercaria.products.resolveVariant(variantRef(productId, variantId));
option.title; // e.g. 'Blue / M'
option.price;
option.availability; // 'in_stock' | 'out_of_stock'If the product no longer offers that option, it rejects with
MercariaNotFoundError (with status: null, because the product itself was
found).
Stores
const store = await mercaria.stores.get(storeRef); // by ref or id
const same = await mercaria.stores.lookup({ handle: 'night-city-games' });
store.ref; // persist this, never the handle
store.handle; // current handle; a merchant can change it
store.oxyAccountId; // the owning Oxy account — the cross-app key for the business
store.name;
store.logoUrl;
store.brandColor; // CSS hex
store.rating; // 0–5 or null
store.url;
const products = await mercaria.stores.products(store.ref, { sort: 'newest', limit: 24 });
const collections = await mercaria.stores.collections(store.ref);
const locations = await mercaria.stores.locations(store.ref); // its public shop frontsA closed or suspended store rejects with MercariaGoneError.
Collections
const collection = await mercaria.collections.get(collectionRef);
collection.store; // the store's ref
collection.title;
collection.image;
const items = await mercaria.collections.products(collection.ref, { limit: 12 });Locations: products at a shop on a map
A store's physical shop fronts are locations. Where a location is — its
name, address, hours, photos and rating — belongs to the GoWay place it trades
from, and is read from GoWay with location.goWayPlaceId
(@goway.to/sdk); Mercaria serves its own half only: the store, the
collection terms and what is on the shelf.
// A GoWay place page: which Mercaria shop fronts trade from this place?
const { items } = await mercaria.locations.list({ goWayPlaceId: place.id });
for (const location of items) {
location.ref; // persist this — { kind: 'location', id }
location.store; // { ref, handle, name, logoUrl }
location.pickup; // { identityRequirement, paymentRequirement, instructions } or null
location.discoverable; // Mercaria's own nearby search routes shoppers here now
location.url; // the store page, opened on this shop front
// What is on the shelf there, bounded.
const page = await mercaria.locations.products(location.ref, { inStock: true, limit: 12 });
for (const item of page.items) {
item.product; // a product summary, as everywhere else
item.availability; // 'in_stock' | 'low_stock' | 'out_of_stock' AT THIS LOCATION
item.exactQuantity; // a number ONLY where the merchant discloses it, else absent
item.stockConfirmedAt; // when the shop last confirmed it
}
}- A location is listed only while its GoWay place names it back (as its business, at the claimant's or GoWay's tier). A place nobody trades from is an empty page, never an error.
locations.get/resolveRefreject withMercariaGoneErroronce a location is withdrawn, restricted, closed with its store, or its place stops naming it;MercariaNotFoundErrorwhen it was never public.MercariaUnavailableError(503) means Mercaria could not ask GoWay, and is NOT a reason to drop a stored ref — retry later.- A stale count is
out_of_stock. Availability is derived from counts the shop confirmed within its own declared interval; an older count proves nothing about the shelf and carries no number. inStock: truekeeps what is on THIS shelf now;query,sort,locale,limitandcursorwork as onstores.products.
Links
Never build Mercaria URLs by hand. The link helpers produce the same strings the
server puts in each DTO's url:
mercaria.links.product(product); // or a product ref, or an id
mercaria.links.store(store); // or a handle, or a store seller
mercaria.links.collection(collection, store); // a collection needs its store's handle
mercaria.links.location(location, location.store); // a location, on its store's pageA store link needs the store's current handle, which a ref deliberately
does not carry — hydrate the store (or use the handle on a product's store
seller) first. When you already have the DTO, its url is the same string.
References
A ref names an entity and nothing else — no title, image, price or availability, and no store handle. That is what makes it safe to persist.
import {
productRef, variantRef, storeRef, collectionRef, locationRef,
parseMercariaRef, isMercariaRef,
formatMercariaRef, parseMercariaRefString,
} from '@mercaria.co/sdk';
productRef('prod_1'); // { kind: 'product', id: 'prod_1' } (frozen)
variantRef('prod_1', 'var_1'); // { kind: 'variant', productId, variantId }
// Reading back from your own database or a request body: strict, returns null on anything off.
const ref = parseMercariaRef(row.mercariaRef);
// As a string column or a URL parameter: one canonical string per ref.
formatMercariaRef(productRef('prod_1')); // 'mercaria:product:prod_1'
parseMercariaRefString('mercaria:store:store_1'); // { kind: 'store', id: 'store_1' }
formatMercariaRef(locationRef('loc_1')); // 'mercaria:location:loc_1'parseMercariaRef accepts only a plain object with exactly the keys of its
kind and non-empty string ids; an extra key (a cached title, a price) makes it
null. Ids in the string form are percent-encoded, so any id round-trips.
Pagination
List reads return { items, nextCursor }. The cursor is opaque: pass it back
verbatim, and stop when it is null.
A cursor belongs to the list and the filters that produced it. Send it back
with the same query, inStock, sort, locale and store or collection —
a cursor from a different list or different filters is refused with
MercariaBadRequestError. Changing limit between pages is fine. A list ends
after 10,000 items (MERCARIA_PUBLIC_LIST_MAX_OFFSET); narrow the filters to
reach further.
const first = await mercaria.stores.products(store.ref, { limit: 50 });
const second = first.nextCursor
? await mercaria.stores.products(store.ref, { limit: 50, cursor: first.nextCursor })
: null;Or walk every page:
import { iterateMercariaPages } from '@mercaria.co/sdk';
for await (const page of iterateMercariaPages((cursor) => mercaria.collections.products(ref, { cursor }))) {
render(page.items);
}Locale
Locale is the one request-context dimension the public API supports. Set a default on the client and override it per call:
const mercaria = createMercariaClient({ locale: 'es' });
await mercaria.products.search({ query: 'zapatillas' }); // locale=es
await mercaria.stores.products(store.ref, { locale: 'pt-BR' }); // per-call overrideIt applies to products.search, stores.products and locations.products.
Detail reads (products.get, stores.get, collections.get, locations.get
and the resolve* helpers), collection product pages and the location lists
take no locale, and the SDK never sends one on them.
Locale changes presentation only, never which entity a ref names.
There is no market or currency option, on purpose. Public reads serve each price in the listing's native currency and convert nothing, so there is no server-side currency or market to select. A consumer that wants to show another currency does its own labelled conversion; the SDK will not invent one.
Errors
Every failure is a MercariaError with a stable snake_case code, the HTTP
status (or null), retryable, and details — the server's scalars, such as
the refused field, or null. Branch on the class or the code — never on
message. The server's error body is { error: { code, message, details? } }.
| Class | When | retryable |
| --- | --- | --- |
| MercariaNotFoundError | 404 not_found: no such entity, never existed | no |
| MercariaGoneError | 410 gone: existed, no longer publicly available (archived, withdrawn, store closed) | no |
| MercariaUnavailableError | 500 internal_error, 503 service_unavailable, 502, 504…: Mercaria is temporarily unable to answer | yes |
| MercariaNetworkError | network_error: the request never completed (offline, DNS, reset) | yes |
| MercariaTimeoutError | timeout: exceeded timeoutMs (a network error) | yes |
| MercariaAbortError | aborted: your signal aborted it | no |
| MercariaRateLimitError | 429 rate_limited; retryAfterSeconds from the body or Retry-After | yes |
| MercariaUnauthorizedError | 401 unauthorized | no |
| MercariaForbiddenError | 403 forbidden | no |
| MercariaConflictError | 409 conflict | no |
| MercariaBadRequestError | 400 bad_request: not well-formed — a wrong type, an unknown or repeated parameter, a cursor from another list; or refused before sending | no |
| MercariaValidationError | 422 validation_failed: well-formed, but a value is refused (a limit out of range, relevance without a query, an empty id); or refused before sending | no |
| MercariaResponseError | malformed_response: the response was not the contract | no |
| MercariaUnknownRouteError | 404 unknown_route: this SDK version and the server disagree about a route — never a missing entity | no |
| MercariaApiError | http_error: any other non-2xx, including a 404/410 with no Mercaria error body (a proxy); never means the entity is gone. MercariaUnknownRouteError extends it | usually no |
A query the server would refuse is refused before it is sent, by the same
contract schema the server validates with: MercariaBadRequestError or
MercariaValidationError with status: null and details.field naming the
parameter.
import { MercariaGoneError, MercariaNotFoundError, isMercariaError } from '@mercaria.co/sdk';
try {
return { state: 'ok', product: await mercaria.products.resolveRef(ref) };
} catch (error) {
if (error instanceof MercariaGoneError) return { state: 'no-longer-available' };
if (error instanceof MercariaNotFoundError) return { state: 'not-found' };
if (isMercariaError(error) && error.retryable) return { state: 'temporarily-unavailable' };
throw error;
}Two rules worth knowing:
- Not found and gone are only reported when Mercaria says so. A 404 or 410
without a Mercaria error body (a proxy, a misrouted gateway) is a
MercariaApiError, because it proves nothing about the product. Even onMercariaNotFoundError, prefer hiding an attachment to deleting the stored ref: a hidden ref costs nothing if the answer was wrong. - The SDK never retries. Use
retryable(andretryAfterSeconds) to decide whether and when to try again.
Cancel with an AbortSignal on any call: { signal: controller.signal }.
Messages never contain your token, request headers or response bodies; a
server message is included only as a bounded single line. JSON.stringify(error)
gives { name, code, status, retryable, details, message }. instanceof works even when
your app loads both the ESM and the CommonJS build.
Freshness and caching
Every read returns current Mercaria truth at the moment it was served.
- Price and availability are perishable. Show them from a fresh read, or from a short-lived cache you are willing to be wrong from; never store them as authoritative and never treat a cached price as what a buyer will pay — the price a buyer pays is decided by Mercaria at checkout.
- Refs are what you persist. Store
product.ref,store.ref,collection.ref(or their string form) and hydrate when you render. - Order and payment history is not reconstructed from these reads. A past order's price lives in Mercaria's order records, not in the current product.
- The SDK does no caching. Use your own layer (TanStack Query, Redis) with short
lifetimes, and handle
MercariaGoneErrorby showing "no longer available". - Responses vary by caller. Every response carries
Vary: Authorization: an authenticated product read includesviewerfacts about that user. Key any shared cache by user (or cache only anonymous reads); never serve one user's cached authenticated response to another.
Privacy and security boundaries
The public reads are built field by field from a dedicated public projection, and the SDK parses them again into fresh objects holding only contract fields, so a field the server leaked could still not reach you. Through this package you can never receive:
- wholesale or supplier cost, supplier identity or supplier references
- procurement offers, activation keys, license keys or download secrets
- variant SKUs, barcodes or connector provenance
- inventory counts, except a location's
exactQuantitywhere its merchant chose to disclose it - a location's operational name or address, or why it is paused or restricted
- a manual collection's raw member ids or automation rules
- moderation evidence, risk or fraud signals
- payment credentials, guest order-access tokens, or buyer identity
- anything under Mercaria's private or admin APIs
A person seller is identified by their public Oxy user id, display name and
username only. Image and link URLs are always absolute http(s).
Example: a Mention-like integration
A post attachment persists a ref and hydrates it into a card:
import {
createMercariaClient, parseMercariaRef, isMercariaError,
MercariaGoneError, MercariaNotFoundError,
} from '@mercaria.co/sdk';
const mercaria = createMercariaClient({ getAccessToken: () => session.accessToken ?? null });
// Writing: persist only the ref.
await db.attachments.insert({ postId, mercariaRef: picked.ref });
// Reading: validate what came back from storage, then hydrate current facts.
export async function productCard(stored: unknown) {
const ref = parseMercariaRef(stored);
if (ref?.kind !== 'product') return { state: 'invalid' as const };
try {
const product = await mercaria.products.resolveRef(ref);
return {
state: 'ok' as const,
title: product.title,
image: product.primaryImage,
price: product.price, // render now; do not store
buyable: product.availability === 'in_stock',
href: mercaria.links.product(product),
};
} catch (error) {
if (error instanceof MercariaGoneError) return { state: 'unavailable' as const };
if (error instanceof MercariaNotFoundError) return { state: 'missing' as const };
if (isMercariaError(error) && error.retryable) return { state: 'retry-later' as const };
throw error;
}
}An account linked to a store renders its storefront:
// The account stores a store REF (never the handle, which can change).
export async function shopTab(linkedStoreRef: unknown, cursor?: string) {
const ref = parseMercariaRef(linkedStoreRef);
if (ref?.kind !== 'store') return null;
const [store, products, collections] = await Promise.all([
mercaria.stores.resolveRef(ref),
mercaria.stores.products(ref, { sort: 'newest', limit: 24, cursor }),
mercaria.stores.collections(ref),
]);
return {
header: { name: store.name, logo: store.logoUrl, color: store.brandColor, href: store.url },
products: products.items,
nextCursor: products.nextCursor,
collections: collections.items.map((c) => ({ title: c.title, href: mercaria.links.collection(c, store) })),
};
}A product picker is mercaria.products.search({ query, limit: 20 }), storing
item.ref for the picked item.
Do not do this
- Do not persist the current price or availability as truth. Store the ref; hydrate every render. A stored price is wrong the moment the seller changes it.
- Do not copy Mercaria DTOs or types into your app. Import them from
@mercaria.co/sdk. A local copy drifts silently. - Do not call Mercaria's private backend routes. Only what this package exposes is a supported contract; everything else can change or disappear, and may be authorized differently.
- Do not build Mercaria URLs by hand. Use
links.*or the DTO'surl. - Do not store a store handle as its identity. Handles change; refs do not.
- Do not expose, infer or proxy supplier or procurement information. The public contract has none, by design; do not try to reconstruct it.
- Do not put the SDK's token getter behind a cache. Return the live session token; Oxy owns its lifetime.
Versioning
0.x releases follow the rule that a minor version may break and a patch never
does. See CHANGELOG.md.
License
Apache-2.0 — see LICENSE and NOTICE.
