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.
A listing page
Same shape as the product page recipe, with the commerce parts added: sellers, prices and a counted link.
- Resolve once, store the idYour SKU carries a catalog id. Resolve when the SKU is created, not when a shopper loads the page.
- Read the record and the media togetherSpecifications, silhouette and photographs are independent calls, so they go in parallel.
- 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.
- Link out through the counted redirectThe outbound link is what proves a placement worked, and it is the only link a seller can measure.
- Cache the assembled pageOne cached page per record, invalidated on version, rather than four calls per view.
// 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',}))Prices are integers
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.
priceCentsis minor units: 58900 withcurrency: "USD"is $589.00. Divide only at render, insideIntl.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.
inStockis what a seller reports. Treat it as a hint in the UI and let the shop be the authority at checkout.
Outbound links are counted
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.
Pushing a seller feed
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.
TypeScript
A key becomes a vendor key by being named in Profile > Seller.
// 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 asunmatchedrather than failing the batch. - Repricing is
PATCHon the SKU, with only the fields that changed. Resending the whole row is how a price job silently rewrites a product URL. - Send
stock_qtyorin_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.
Accessories that actually fit
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.