SDK cookbook
Six end-to-end recipes with runnable code — set up the client, create and publish a listing, search the market, reconcile inventory, process orders across both rails, and wire the MCP bridge into an AI client.
Complete, runnable recipes for the jobs people actually use @joinmarka/sdk for. Each one is a
whole task rather than a snippet, and every method and field below comes from the same contract
that generates the API reference.
npm install @joinmarka/sdkNode.js 20.19 or newer, ESM only. Everything below assumes an API key in MARKA_API_KEY — see
Getting started to create one.
1. Set up the client and check what your key can do
Do this once at startup. getMe() is the cheapest call on the API and it tells you the two things
that determine whether the rest of your integration will work: which account the key belongs to,
and whether its plan allows programmatic writes.
import { MarkaClient, MarkaApiError } from "@joinmarka/sdk";
const marka = new MarkaClient({
apiKey: process.env.MARKA_API_KEY!,
// Optional. Scheme and host ONLY — the client appends /api and /v1/….
// baseUrl: "http://localhost:3001",
});
const { user, plan } = await marka.getMe();
console.log(`${user.email} — ${plan.tier}`);
console.log(`entitlements: ${plan.entitlements.join(", ")}`);
// Check this BEFORE attempting a write, rather than discovering it as a 402 later.
const canWrite = plan.entitlements.includes("api_write");
if (!canWrite) {
throw new Error(
"This key's plan cannot write through the API. Programmatic writes need Pro or Business.",
);
}
// `null` means unlimited — JSON has no way to write infinity.
const cap = plan.limits.maxActiveListings;
console.log(`active listing cap: ${cap === null ? "unlimited" : cap}`);baseUrl is an origin
Scheme and host only. Passing https://joinmarka.com/api or .../api/v1 produces a doubled path
that 404s. It defaults to production.
The SDK reads no environment variables of its own — configuration is constructor arguments only, so the same process can hold clients for different accounts without them interfering.
2. Create a listing and publish it
Creation and publication are two calls, on purpose. createListing always produces a draft;
publishListing runs the domain's activation guards and is what makes it visible to buyers.
import { MarkaApiError } from "@joinmarka/sdk";
// Only title, description and price are required. Everything else has a server-side default:
// categoryPath → "general", condition → "good", quantityTotal → 1, inventoryType → "unique".
const { listing: draft } = await marka.createListing({
title: "Vintage road bike",
description: "Steel frame, recently serviced. Some paint wear on the top tube.",
price: 45_000, // integer minor units — CA$450.00
currency: "CAD",
categoryPath: "sports",
condition: "good",
// At least one fulfilment option and one order rail are required to PUBLISH.
// Setting them at creation avoids a round-trip before publishing.
pickupEnabled: true,
directEnabled: true,
});
console.log(draft.id, draft.status); // → "lst_…" "draft"
try {
const { listing: live } = await marka.publishListing(draft.id);
console.log(live.status); // → "active"
} catch (error) {
if (error instanceof MarkaApiError && error.status === 400) {
// The activation guards rejected it. `error.code` names which one:
// missing_category | invalid_category | missing_title | missing_description | invalid_price | out_of_stock
// missing_fulfillment | missing_rail | shipping_requires_price
console.error(`cannot publish: ${error.code} — ${error.message}`);
} else if (error instanceof MarkaApiError && error.code === "email_unverified") {
// 403. Publishing requires a confirmed email address on the account this key belongs to —
// it is one of the two surfaces Marka gates that way (the other is Stripe Connect
// onboarding). Confirm it in the Marka app; no request shape can work around it.
console.error("confirm the account's email address, then publish again");
} else {
throw error;
}
}Why the request body is strict
An unknown or misspelled key is a 400, not a silently dropped field. That includes status:
{"status": "active"} on a PATCH is rejected and points you here, because a status write would
bypass every guard above. See
the mutation pipeline.
Shipping instead of pickup
A shipping-enabled listing needs a flat shipping amount above zero before it will publish — the guard is explicit about it. Shipping is charged once per order, not per unit.
const { listing } = await marka.createListing({
title: "Boxed board game",
description: "Played twice, all pieces present.",
price: 6_000,
currency: "CAD",
shippingEnabled: true,
shippingFlatAmount: 1_200, // CA$12.00 flat, per order
checkoutEnabled: true, // shipping exists only on the card-checkout rail
});3. Search the public catalogue
const { query, results, limit, offset, hasMore } = await marka.search({
q: "road bike",
minPrice: 10_000, // integer minor units
maxPrice: 80_000,
pickup: true,
limit: 20,
});
console.log(`"${query}" → ${results.length} results (hasMore: ${hasMore})`);
for (const listing of results) {
// Results are summaries, not full records — id, title, price, currency, condition,
// categoryPath, imageUrls, location, createdAt and the two fulfilment flags.
const major = (listing.price / 100).toFixed(2);
console.log(`${listing.title} — ${listing.currency} ${major} — ${listing.location.city ?? "—"}`);
}Paging, with the ceiling handled correctly:
// Search ranks the 200 most recently listed ACTIVE listings. An offset at or past that
// returns an empty page — it does not reach older items. Stop on `hasMore`, and cap the
// loop so a surprise can never make it run forever.
const SCAN_CEILING = 200;
const pageSize = 50;
const all = [];
for (let offset = 0; offset < SCAN_CEILING; offset += pageSize) {
const page = await marka.search({ q: "bike", limit: pageSize, offset });
all.push(...page.results);
if (!page.hasMore) break;
}Two behaviours to design around: a city that matches nothing in the catalogue returns no
results rather than silently dropping the filter, and a limit outside 1–100 is a 400 rather
than a silent clamp.
4. Reconcile prices from your own system
The common integration: you hold stock elsewhere and want Marka to agree with it. PATCH accepts
only the fields it can safely change, so send the diff.
/** Your source of truth, keyed by Marka listing id. */
const desired = new Map<string, { price: number; title: string }>([
["lst_abc", { price: 42_500, title: "Vintage road bike — reduced" }],
]);
const { listings } = await marka.listListings({ limit: 100 });
for (const listing of listings) {
const target = desired.get(listing.id);
if (!target) continue;
// Send only what actually differs. `{}` is a legal no-op request, but a call you
// did not need to make still spends rate-limit budget.
const patch: { price?: number; title?: string } = {};
if (listing.price !== target.price) patch.price = target.price;
if (listing.title !== target.title) patch.title = target.title;
if (Object.keys(patch).length === 0) continue;
await marka.updateListing(listing.id, patch);
console.log(`updated ${listing.id}: ${Object.keys(patch).join(", ")}`);
}PATCH accepts title, description, price, imageUrls, pickupEnabled, shippingEnabled,
directEnabled and checkoutEnabled. Quantity and status are not patchable fields.
imageUrls takes absolute http or https URLs, in display order (the first is the cover), and
replaces the whole list. A relative path, a protocol-relative //host/path, or a javascript: or
data: URI answers 400 naming the offending entry, for example imageUrls.1. Only photos under
Marka's own media storage become the listing's cover image; a URL on another host is stored but
not shown.
5. Process orders across both rails
Orders are read-only on this API — fulfilment happens in the Marka app. What the API is for is noticing them and routing them into your own systems, which means knowing which rail an order is on.
const { orders } = await marka.listOrders({ status: "payment_captured", limit: 50 });
for (const order of orders) {
const { itemSubtotal, shippingAmount, currency } = order.priceSnapshot;
const total = itemSubtotal + shippingAmount; // integer minor units
if (order.rail === "DIRECT") {
// In-person handoff. No Stripe, no shipping, no buyer address — completion is a
// QR scan or a 6-digit OTP in the app.
console.log(`[pickup] ${order.id} — ${currency} ${total} ×${order.quantity}`);
continue;
}
// CHECKOUT: a Stripe Connect direct charge on your own connected account.
if (order.fulfillmentMode === "SHIPPING") {
// checkoutShippingDetails carries the buyer's name, email, phone and postal
// address. It is PII: use it to fulfil, do not log it, do not persist it further
// than you must. It is null on the DIRECT rail.
const details = order.checkoutShippingDetails;
if (details === null) {
console.warn(`${order.id}: shipping order with no address yet`);
continue;
}
await queueForFulfilment(order.id, details);
}
console.log(`[checkout] ${order.id} — ${currency} ${total}`);
}The listing as it was when the order was placed is frozen onto order.listingSnapshot, so
later edits to the live listing never change what an existing order was for. The buyer is otherwise
an opaque buyerId — there are no top-level buyer name or email fields.
6. Handle errors and rate limits properly
Failures are thrown, never returned, so a caller who only writes the happy path cannot silently proceed on a failed request.
import { MarkaApiError, MarkaNetworkError } from "@joinmarka/sdk";
async function withRetry<T>(operation: () => Promise<T>, attempts = 4): Promise<T> {
for (let attempt = 1; ; attempt++) {
try {
return await operation();
} catch (error) {
const last = attempt >= attempts;
if (error instanceof MarkaApiError) {
// 429 is the only status worth retrying. The per-key budget is 1000 requests
// over a 60-second IDLE window — the counter resets only after 60 consecutive
// quiet seconds, so backing off properly is the actual remedy.
if (error.status === 429 && !last) {
await sleep(2 ** attempt * 1000);
continue;
}
// 402 means the plan lacks api_write. Retrying cannot fix it.
if (error.status === 402 && error.code === "entitlement_required") {
throw new Error("Programmatic writes need a Pro or Business plan.");
}
// 403 email_unverified: publishing requires a confirmed email address on the
// account the key belongs to. Confirm it in the Marka app, then retry.
if (error.status === 403 && error.code === "email_unverified") {
throw new Error("Confirm the account's email address before publishing.");
}
throw error; // 400/401/403/404 are all permanent.
}
if (error instanceof MarkaNetworkError && !last) {
// No answer at all: DNS, TLS, timeout, dropped socket. The original is on `.cause`.
await sleep(2 ** attempt * 250);
continue;
}
throw error;
}
}
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));Branch on status first and treat code as a refinement — it is not an enum, and it carries
domain codes such as listing.authority_locked alongside the API-level ones. There is no
retry-after header on this surface.
7. Wire the MCP bridge into an AI client
The SDK ships a stdio MCP server so an assistant that can only spawn a subprocess — Claude Desktop most notably — can work with your listings. It runs under plain Node with no loader.
Your API key goes in these config files
Every block below embeds a live credential in a file on disk. Use a key minted for this purpose with a short expiry, and revoke it from Marka settings rather than editing it out if a machine is lost.
Claude Desktop
claude_desktop_config.json:
{
"mcpServers": {
"marka": {
"command": "npx",
"args": ["-y", "-p", "@joinmarka/sdk", "marka-mcp-stdio"],
"env": {
"MARKA_API_KEY": "mk_YOUR_KEY_HERE"
}
}
}
}Claude Code
claude mcp add marka -- npx -y -p @joinmarka/sdk marka-mcp-stdio --api-key=mk_YOUR_KEY_HEREA client that speaks streamable HTTP
Most other clients can skip the bridge entirely and use the hosted gateway, which exposes a different — and for reading, richer — tool set:
{
"mcpServers": {
"marka": {
"type": "http",
"url": "https://joinmarka.com/api/mcp",
"headers": {
"x-api-key": "mk_YOUR_KEY_HERE"
}
}
}
}Generating these blocks programmatically
import { getMcpServerConfig } from "@joinmarka/sdk/mcp";
const config = getMcpServerConfig({
apiKey: process.env.MARKA_API_KEY!,
bridgeCommand: ["npx", "-y", "-p", "@joinmarka/sdk", "marka-mcp-stdio"],
});
config.http; // → the hosted streamable-HTTP block
config.claudeDesktop; // → the stdio block that runs this package's local serverOr embed the server in a process you control:
import { createMarkaMcpServer } from "@joinmarka/sdk/mcp";
const server = createMarkaMcpServer({ apiKey: process.env.MARKA_API_KEY! });
await server.startStdio();Remember that neither MCP server can publish, and that the two have different tool sets and
different write gating — the local bridge goes through /api/v1 and so needs api_write, while
the hosted gateway spends AI copilot runs instead. MCP overview puts them side by
side.
Where to next
Upgrading from 0.1.0
What changed in @joinmarka/sdk 0.2.0: renamed listing and order fields, a typed checkoutShippingDetails, three removed order statuses, categoryPath in the MCP tools, and the Node versions each part needs.
MCP servers
Marka ships two MCP servers with different tool sets — the hosted gateway, which signs you in through your browser, and the SDK's local bridge. Which to use, and why they differ.