markaDocs

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/sdk

Node.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_HERE

A 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 server

Or 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

On this page