GunSpec
Recipe

Build a marketplace

A storefront reads specifications and artwork from the catalog; a seller pushes prices and stock back. Both sides, with the rules that catch people.

Same shape as the product page recipe, with the commerce parts added: sellers, prices and a counted link.

  1. Resolve once, store the idYour SKU carries a catalog id. Resolve when the SKU is created, not when a shopper loads the page.
  2. Read the record and the media togetherSpecifications, silhouette and photographs are independent calls, so they go in parallel.
  3. Ask for offers in the shopper regionSellers who do not ship there are noise, and a listing nobody refreshed in thirty days is hidden for you.
  4. Link out through the counted redirectThe outbound link is what proves a placement worked, and it is the only link a seller can measure.
  5. Cache the assembled pageOne cached page per record, invalidated on version, rather than four calls per view.
listing.ts
javascript
// A listing page, assembled from the catalog.const [firearm, media, offers] = await Promise.all([  gunspec.firearms.get(id),  gunspec.firearms.listMedia(id),  gunspec.firearms.getOffers(id, { region: shopper.country }),]) const rows = offers.data.map((offer) => ({  vendor: offer.vendor.name,  // Integer minor units. Format, never divide into a float.  price: new Intl.NumberFormat(locale, { style: 'currency', currency: offer.currency })    .format(offer.priceCents / 100),  inStock: offer.inStock,  // The counted redirect, not offer.url. This is what tells a  // seller their placement worked.  href: `${API}/v1/out/${offer.clickId}`,  rel: 'nofollow sponsored',}))

Money on this API is integer minor units plus an ISO 4217 code. A float is refused at the boundary, and the reason is the one every commerce team has already met.

  • priceCents is minor units: 58900 with currency: "USD" is $589.00. Divide only at render, inside Intl.NumberFormat.
  • Never round on the way in. A price stored as 589.0 is a price that becomes 588.99 somewhere downstream.
  • Show the currency the offer names rather than converting. A converted price is a quote you cannot honour.
  • inStock is what a seller reports. Treat it as a hint in the UI and let the shop be the authority at checkout.

Send shoppers to /v1/out/{clickId} rather than to offer.url. The redirect records the visit for that listing and then sends the shopper to the shop, which is the one number that justifies a seller’s placement. The destination comes from the offer row and never from the request, so the link cannot be pointed anywhere else. Mark the anchor rel="nofollow sponsored", because a paid placement is what it is. The seller reference documents the route.

A shop writes with an ordinary key that has been named in Profile > Seller. There is no vendor scope on a key, which is why an integration reads /v1/vendor/shops first: it is handed a key and never a shop id.

vendor-feed
TypeScript
A key becomes a vendor key by being named in Profile > Seller.
1/3
// There is no scope on the key itself, and an integration is handed// a key rather than a shop id, so read the shops first.const { data: shops } = await gunspec.vendor.shops()const vendor = shops[0].id const { data } = await gunspec.vendor.pushOffers(  {    offers: feed.map((row) => ({      sku: row.sku,      attachment_id: row.gunspecId,      price_cents: row.priceCents,      currency: row.currency,      url: row.productUrl,      in_stock: row.qty > 0,      region: row.shipsTo,    })),  },  { vendor },)
  • Full push is PUT, up to 500 rows, keyed by your own SKU. Ids we do not hold come back as unmatched rather than failing the batch.
  • Repricing is PATCH on the SKU, with only the fields that changed. Resending the whole row is how a price job silently rewrites a product URL.
  • Send stock_qty or in_stock, never both: a row carrying a quantity derives its flag from it. The quantity stays private; the flag is what shoppers see.
  • A feed row is hidden after thirty days without a refresh, so run the job daily even when nothing changed.
  • One key per storefront where you run several. A key named by two shops must say which one it is acting for.

The reason to build a marketplace on this catalog rather than a spreadsheet is that fitment is computed from mount interfaces, so a cross-sell rail can be correct. What fits what covers the engine and its confidence floors, and the artwork guide covers the pictures beside each row.