Vendors and keys
A vendor is a supplier whose products the merchant sells. The merchant creates the vendor and its keys; the vendor never logs in to anything and talks to the catalog only through the API.
Vendors
-
The merchant adds a vendor under Storefront › Vendors in the Product Catalog dashboard: a name
(creating takes a name only), and a status of
activeorpaused. The webhook URL is set separately, under Storefront › Vendors › webhook. A store holds at most 200 vendors. A vendor cannot be deleted; revoking its keys and archiving its products covers it. -
Each vendor has at most 2 active keys (
pc_vk_), so a key can rotate. A key is shown once when created; only a hash is kept. Revoking or pausing applies on the next request. -
A paused vendor's keys answer
403 VENDOR_PAUSEDbefore the body is read. Its products stay exactly as they are.
What a vendor key can reach
A vendor key reads and writes its own products only: products and variants, status (draft or
active), handle, archive and restore, stock, prices in the store's currencies, images by public URL,
attribute values, translations and categories (a path such as Coffee > Single origin
creates missing levels). It also reads its own products, jobs, runs and reports.
It cannot touch another vendor's or the merchant's products, set customer-group (trade) prices, create
new attribute types, or change collections, filters, price lists, locales, settings, keys, checkout or reviews.
Anything outside its reach answers 404 (a resource that is not its own) or 403 VENDOR_KEY_NOT_ALLOWED (a route a vendor key may not call).
The store key works on every push route and may act on any product, including assigning a product to a vendor.
Ownership, ids and clashes
- Every product has one owner: a vendor, or none (the merchant). Ownership never changes through a vendor key.
- externalId is the vendor's own product id, unique per vendor. Two vendors may use the same
id for two different products. The store key's own products have a separate id space. A row that creates a
vendor's product needs one (
EXTERNAL_ID_REQUIRED). - SKUs stay unique per store. A row whose SKU belongs to another vendor's or the merchant's
product fails with
SKU_CONFLICTand never takes the SKU over. - Adopting existing products. Products already in the catalog (CSV, dashboard) stay merchant-owned
until the merchant assigns them to a vendor, one or in bulk. The vendor's first push then links each assigned
product by SKU and records its
externalId. A push never claims an unassigned product. -
A new product with no
statusis created as a draft. Sendactiveto publish it; the vendor decides whether a product shows. -
Vendor batches are canonical rows. A mapping profile cannot set
externalId, so a vendor batch that needs one must not use a profile.
The vendor's push wins
On a vendor-owned product, a field the vendor sends ranks above the merchant's dashboard edit and above every
other source. That includes a rename: the vendor's handle replaces the merchant's. A
sent null blanks the field. Store-key pushes keep today's
precedence: a merchant's dashboard edit still wins over them.
Replace mode drops only the caller's own values for fields it did not send, so another source's value (the merchant's) shows instead, and archives only variants the caller supplied. Nothing the merchant added by hand is touched. A replace needs all of a product's rows together, adjacent in one batch (see Merge and replace).
import { createCatalogAdmin } from '@usethrottle/catalog/admin';
import type { PushRow } from '@usethrottle/catalog/admin';
const admin = createCatalogAdmin({ key: process.env.CATALOG_ADMIN_KEY ?? '' });
// Replace needs every row of the product adjacent in one batch. A variant of this
// vendor that is missing from the rows is archived; a field not sent is handed
// back to the other sources (the merchant's value shows instead).
const rows: PushRow[] = [
{
externalId: 'hr-1001',
productHandle: 'ethiopia-guji',
sku: 'HR-GUJI-250',
product: { title: 'Ethiopia Guji', brand: 'Highland Roasters' },
variant: { priceMinor: 1850, currency: 'USD' },
},
{
externalId: 'hr-1001',
productHandle: 'ethiopia-guji',
sku: 'HR-GUJI-1000',
variant: { priceMinor: 6400, currency: 'USD' },
},
];
await admin.jobs.run(rows, { mode: 'replace' });
// A null blanks the field for good (merge or replace). Use it to remove a
// sale price or a description the vendor no longer wants shown.
const blank: PushRow[] = [
{
externalId: 'hr-1001',
productHandle: 'ethiopia-guji',
sku: 'HR-GUJI-250',
product: { description: null },
variant: { salePriceMinor: null },
},
];
await admin.jobs.run(blank, { mode: 'merge' });
Holds: what the vendor cannot undo
If the merchant archives or drafts a vendor product in the dashboard, that is a hold. The vendor's pushes keep updating the product's data, but cannot republish or restore it until the merchant lifts the hold. This lets a merchant stop selling one vendor product without pausing the whole vendor.
An archive made through the API is different: it is the vendor's own and is lifted by a restore, or by a row
that sends status: "active" (see Products). The result of a
held product is held in an archive or restore answer.
Vendor on orders
The merchant can switch Vendor on orders on in the dashboard (it is off by default). Then each
Throttle order line created from a catalog cart for a vendor-owned product carries the vendor under the reserved metadata.lineResolver key, which merchant order reads and merchant order webhooks
return (for drop-ship routing, for example):
{
"metadata": {
"lineResolver": {
"vendorId": "ven_01",
"vendorName": "Highland Roasters"
}
}
}
- Only resolver carts record a vendor, the carts the catalog prices through the line resolver. The legacy checkout records no vendor on an order line, and cannot, because it cannot write that reserved key.
- The vendor is never part of a buyer's view of the cart or order.