Your first vendor feed in 10 minutes
Seven steps take two products from your system to the live storefront. Every step has the request as curl and as SDK
code, and the answer you should get. All of it was run against production before publication; ids are real examples
and your ids differ. Replace the demo- and DEMO- ids with your own.
curl), and
npm install @usethrottle/catalog for the SDK form. The HTTP answers are the contract; the
SDK returns the inner object (for example the job without the job wrapper).
1. Create a key
Keys are created by the merchant, in the Product Catalog dashboard: Storefront › Vendors, open your
vendor, Create key. A vendor key starts with pc_vk_ and is shown
once; copy it into your server's secret store. If you are the merchant, use a store key
(pc_sk_) instead. Export it, then prove it works with any read:
export CATALOG_VENDOR_KEY=pc_vk_…
# Any read proves the key works. A wrong key answers 401 AUTHENTICATION_FAILED.
curl "https://catalog.usethrottle.dev/v1/catalog/products?limit=1" \
-H "Authorization: Bearer $CATALOG_VENDOR_KEY"
import { createCatalogAdmin } from '@usethrottle/catalog/admin';
const admin = createCatalogAdmin({ key: process.env.CATALOG_VENDOR_KEY ?? '' });
// A vendor key sees only its own products: a new vendor gets an empty list.
const page = await admin.products.list({ limit: 1 });
console.log(JSON.stringify(page));
{ "products": [], "nextCursor": null }
Check before moving on: 200 with a products list means the key is valid. 401 AUTHENTICATION_FAILED means the key is wrong or revoked; 403 VENDOR_PAUSED means the merchant paused you. A vendor that already has products sees them here (the SDK returns the same object).
2. Open a job
A job is a staged push: nothing changes in the catalog until you close it. Open one and keep its id.
curl -X POST https://catalog.usethrottle.dev/v1/catalog/jobs \
-H "Authorization: Bearer $CATALOG_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{ "mode": "merge", "fullSync": false }'
import { createCatalogAdmin } from '@usethrottle/catalog/admin';
const admin = createCatalogAdmin({ key: process.env.CATALOG_VENDOR_KEY ?? '' });
const job = await admin.jobs.open({ mode: 'merge', fullSync: false });
// Keep the id: every later call needs it.
console.log(job.jobId);
console.log(JSON.stringify(await job.get()));
{
"job": {
"jobId": "209dd3df-7dab-4034-a9f5-e072507d95b5",
"state": "open",
"mode": "merge",
"fullSync": false,
"vendorId": "b2bab2db-5f77-47b4-9b23-d4ab1d4d2ef4",
"openedAt": "2026-11-01T19:24:09.196Z",
"expiresAt": "2026-11-02T19:24:09.196Z",
"closedAt": null,
"finishedAt": null,
"failureCode": null,
"batchesReceived": 0,
"rowsReceived": 0,
"rowsValidated": 0,
"rowsRefused": 0
}
}
Check before moving on: 201, state: "open", failureCode: null and an expiresAt 24 hours away. Export the id: export JOB_ID=<jobId>. The SDK's job.get() returns the object inside job.
3. Send a batch
A batch is up to 1,000 rows and 5 MiB. All rows of one product go in the same batch. Send an Idempotency-Key so that a retry never stages the batch twice.
# JOB_ID is the id from step 2. The Idempotency-Key makes a retry safe.
curl -X POST "https://catalog.usethrottle.dev/v1/catalog/jobs/$JOB_ID/batches" \
-H "Authorization: Bearer $CATALOG_VENDOR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $JOB_ID:0" \
-d '{ "rows": [
{ "externalId": "demo-1001", "productHandle": "demo-ethiopia-guji", "sku": "DEMO-GUJI-250", "status": "active",
"product": { "title": "Ethiopia Guji", "brand": "Highland Roasters" },
"variant": { "priceMinor": 1850, "currency": "USD", "quantity": 40 } },
{ "externalId": "demo-1002", "productHandle": "demo-kenya-aa", "sku": "DEMO-KAA-250", "status": "active",
"product": { "title": "Kenya AA", "brand": "Highland Roasters" },
"variant": { "priceMinor": 1950, "currency": "USD", "quantity": 25 } }
] }'
import { createCatalogAdmin } from '@usethrottle/catalog/admin';
import type { PushRow } from '@usethrottle/catalog/admin';
const admin = createCatalogAdmin({ key: process.env.CATALOG_VENDOR_KEY ?? '' });
const jobId = process.env.JOB_ID ?? '';
const job = admin.jobs.resume(jobId);
const rows: PushRow[] = [
{
externalId: 'demo-1001',
productHandle: 'demo-ethiopia-guji',
sku: 'DEMO-GUJI-250',
status: 'active',
product: { title: 'Ethiopia Guji', brand: 'Highland Roasters' },
variant: { priceMinor: 1850, currency: 'USD', quantity: 40 },
},
{
externalId: 'demo-1002',
productHandle: 'demo-kenya-aa',
sku: 'DEMO-KAA-250',
status: 'active',
product: { title: 'Kenya AA', brand: 'Highland Roasters' },
variant: { priceMinor: 1950, currency: 'USD', quantity: 25 },
},
];
const batch = await job.send(rows, { idempotencyKey: `${jobId}:0` });
// Check before moving on: rowsRefused is 0 and issues is empty.
console.log(JSON.stringify(batch));
{ "batchId": "d9f3d13c-2cb5-48b8-809f-38914c9d75fc", "sequence": 1, "rowsAccepted": 2, "rowsRefused": 0, "issues": [] }
Check before moving on: 202, rowsAccepted equals the rows you sent, rowsRefused: 0 and an empty issues. Nothing is applied yet.
A row-level error
One bad row does not fail the batch. Here the second row has no sku: the batch still
answers 202, the bad row is listed in issues by its
position (index, 0 based), and the good row is accepted. The error report (after the job
finishes) names the field and counts rows from 1 across the whole job. This example runs in a scratch job, so the main
feed stays clean.
import { createCatalogAdmin } from '@usethrottle/catalog/admin';
import type { PushRow } from '@usethrottle/catalog/admin';
const admin = createCatalogAdmin({ key: process.env.CATALOG_VENDOR_KEY ?? '' });
// A scratch job, so the main feed stays clean.
const job = await admin.jobs.open({ mode: 'merge' });
const jobId = job.jobId;
console.log('scratch job', jobId);
// The second row has no sku, so it is refused. The first row is still accepted.
const rows = [
{ externalId: 'demo-1001', productHandle: 'demo-ethiopia-guji', sku: 'DEMO-GUJI-250', status: 'active' },
{ externalId: 'demo-bad', productHandle: 'demo-bad', product: { title: 'No SKU' } },
] as unknown as PushRow[];
const batch = await job.send(rows, { idempotencyKey: `${jobId}:bad` });
console.log(JSON.stringify(batch));
// index is the row's position in this batch (0 based). Fix the row and send it
// again in the same job, with a new Idempotency-Key because the body changed.
await job.close();
console.log(await job.wait().then((done) => `${done.rowsApplied} applied, ${done.rowsFailed} failed`));
console.log(await job.errorsCsv());
{
"answer": {
"batchId": "a43f104d-bcb0-4a41-93d0-e2316458d52a",
"sequence": 1,
"rowsAccepted": 1,
"rowsRefused": 1,
"issues": [{ "index": 1, "code": "INVALID_ROW" }]
},
"errorsCsv": "row_number,sku,field,severity,code,message,external_id\n2,,sku,error,INVALID_ROW,This row does not match the push row format.,demo-bad\n"
}
Fix: correct the row and send it in the same job as a new batch with a new Idempotency-Key (the body changed). Rows already accepted stay staged.
4. Close the job
Closing tells the catalog the feed is complete; it applies the staged batches in the order they arrived, in the background.
curl -X POST "https://catalog.usethrottle.dev/v1/catalog/jobs/$JOB_ID/close" \
-H "Authorization: Bearer $CATALOG_VENDOR_KEY"
import { createCatalogAdmin } from '@usethrottle/catalog/admin';
const admin = createCatalogAdmin({ key: process.env.CATALOG_VENDOR_KEY ?? '' });
const job = admin.jobs.resume(process.env.JOB_ID ?? '');
// Closing is safe to repeat: a second close answers the job again.
await job.close();
console.log(JSON.stringify(await job.get()));
{
"job": {
"jobId": "209dd3df-7dab-4034-a9f5-e072507d95b5",
"state": "closed",
"mode": "merge",
"fullSync": false,
"vendorId": "b2bab2db-5f77-47b4-9b23-d4ab1d4d2ef4",
"openedAt": "2026-11-01T19:24:09.196Z",
"expiresAt": "2026-11-02T19:24:09.196Z",
"closedAt": "2026-11-01T19:24:10.832Z",
"finishedAt": null,
"failureCode": null,
"batchesReceived": 1,
"rowsReceived": 2,
"rowsValidated": 2,
"rowsRefused": 0,
"batchesApplied": 0,
"rowsApplied": 0,
"rowsFailed": 0,
"created": 0,
"updated": 0,
"unchanged": 0,
"reportExpiresAt": null
}
} Check before moving on: 202, state: "closed", finishedAt: null. Closing twice is harmless (the second call answers 200 with the job). Any batch sent now answers 409 JOB_CLOSED.
5. Poll until completed (or use a webhook)
Poll the job every 5 to 10 seconds until finishedAt is set. A small job took about 10 seconds; a 120,000-product feed took about an hour.
# Repeat every 5 to 10 seconds until job.finishedAt is not null.
curl "https://catalog.usethrottle.dev/v1/catalog/jobs/$JOB_ID" \
-H "Authorization: Bearer $CATALOG_VENDOR_KEY"
import { createCatalogAdmin } from '@usethrottle/catalog/admin';
const admin = createCatalogAdmin({ key: process.env.CATALOG_VENDOR_KEY ?? '' });
const job = admin.jobs.resume(process.env.JOB_ID ?? '');
// wait() polls every 5 s and resolves when the job has finished or expired.
const done = await job.wait();
console.log(JSON.stringify(done));
if (done.failureCode !== null || (done.rowsFailed ?? 0) > 0) console.error(await job.errorsCsv());
{
"job": {
"jobId": "209dd3df-7dab-4034-a9f5-e072507d95b5",
"state": "closed",
"mode": "merge",
"fullSync": false,
"vendorId": "b2bab2db-5f77-47b4-9b23-d4ab1d4d2ef4",
"openedAt": "2026-11-01T19:24:09.196Z",
"expiresAt": "2026-11-02T19:24:09.196Z",
"closedAt": "2026-11-01T19:24:10.832Z",
"finishedAt": "2026-11-01T19:24:21.409Z",
"failureCode": null,
"batchesReceived": 1,
"rowsReceived": 2,
"rowsValidated": 2,
"rowsRefused": 0,
"batchesApplied": 1,
"rowsApplied": 2,
"rowsFailed": 0,
"created": 2,
"updated": 0,
"unchanged": 0,
"reportExpiresAt": "2026-12-01T19:24:21.409Z"
}
} Check before moving on: finishedAt is set, failureCode is null and rowsFailed is 0. If not, download GET /v1/catalog/jobs/{jobId}/errors.csv. On a repeat run of the same feed the counts read unchanged instead of created.
The webhook alternative
Instead of polling, the merchant sets a webhook URL for your vendor (Storefront › Vendors › webhook) and you get a
signed catalog.run.completed or catalog.run.failed event.
Verify the signature on the raw body, then act. Dedupe on id, because an event can arrive
twice or late.
import { verifyCatalogWebhook } from '@usethrottle/catalog/admin';
import type { PushWebhookEvent } from '@usethrottle/catalog/admin';
export async function POST(request: Request): Promise<Response> {
// The body text exactly as received: parsing and re-serialising breaks the signature.
const rawBody = await request.text();
const ok = await verifyCatalogWebhook({
rawBody,
signature: request.headers.get('x-catalog-signature') ?? '',
secret: process.env.CATALOG_WEBHOOK_SECRET ?? '',
});
if (!ok) return new Response('bad signature', { status: 401 });
const event = JSON.parse(rawBody) as PushWebhookEvent;
// The same event can arrive twice: dedupe on event.id.
if (event.type === 'catalog.run.failed') console.error(event.data.runId, event.data.failureCode);
else console.log(event.data.runId, event.data.created, event.data.updated, event.data.rowsFailed);
return new Response(null, { status: 204 });
}
{
"id": "evt_01",
"type": "catalog.run.completed",
"createdAt": "2026-11-01T10:15:00.000Z",
"data": {
"runId": "run_01",
"vendorId": "ven_01",
"mode": "merge",
"fullSync": false,
"failureCode": null,
"received": 1000,
"created": 12,
"updated": 980,
"unchanged": 5,
"rowsFailed": 3,
"rowsWarned": 0,
"finishedAt": "2026-11-01T10:15:00.000Z",
"reportExpiresAt": "2026-12-01T10:15:00.000Z"
}
}
Check: your receiver answers 2xx within 3 seconds, and data.runId equals your job id. See Webhooks.
6. Read a product back
Address the product by your own id. This is the push API's view: status, hold, archive and purge state, variants.
# By your own product id. Exactly one of externalId, sku or handle.
curl "https://catalog.usethrottle.dev/v1/catalog/products?externalId=demo-1001" \
-H "Authorization: Bearer $CATALOG_VENDOR_KEY"
import { createCatalogAdmin } from '@usethrottle/catalog/admin';
const admin = createCatalogAdmin({ key: process.env.CATALOG_VENDOR_KEY ?? '' });
const product = await admin.products.get({ externalId: 'demo-1001' });
console.log(JSON.stringify(product));
{
"product": {
"title": "Ethiopia Guji",
"brand": "Highland Roasters",
"draft": false,
"externalId": "demo-1001",
"handle": "demo-ethiopia-guji",
"status": "active",
"held": false,
"apiArchived": false,
"purgeAfter": null,
"vendorId": "b2bab2db-5f77-47b4-9b23-d4ab1d4d2ef4",
"attributes": {},
"variants": [{ "priceMinor": 1850, "currency": "USD", "quantity": 40, "sku": "DEMO-GUJI-250", "status": "active" }]
}
}
Check before moving on: status: "active", held: false, the price and quantity you sent. 404 means the id is not yours or the job has not finished.
7. Verify on the storefront
Shoppers see the product through the storefront API with a publishable key (pc_pk_, type server; ask the merchant). This is the final proof.
# A publishable key (type server) in X-Catalog-Key, not the vendor key.
curl "https://catalog.usethrottle.dev/v1/storefront/products/demo-ethiopia-guji" \
-H "X-Catalog-Key: $CATALOG_STOREFRONT_KEY"
import { createCatalogClient } from '@usethrottle/catalog';
const catalog = createCatalogClient({ publishableKey: process.env.CATALOG_STOREFRONT_KEY ?? '' });
const answer = await catalog.products.get('demo-ethiopia-guji');
// A renamed product answers redirectTo instead of the product.
if (!('redirectTo' in answer)) {
console.log(JSON.stringify({ title: answer.product.title, priceRange: answer.product.priceRange }));
}
{ "title": "Ethiopia Guji", "priceRange": { "minMinor": 1850, "maxMinor": 1850, "currency": "USD" } }
Check before moving on: the title and priceRange match what you sent. A few seconds can pass between finishedAt and the product appearing; a 404 for longer means the product is a draft (send status: "active"), held, or archived.
All seven steps in one script
import { createCatalogAdmin } from '@usethrottle/catalog/admin';
import type { PushRow } from '@usethrottle/catalog/admin';
import { createCatalogClient } from '@usethrottle/catalog';
// 1. The key: a vendor key (pc_vk_) from the merchant. Server-side only.
const admin = createCatalogAdmin({ key: process.env.CATALOG_VENDOR_KEY ?? '' });
// Two products. All rows of one product sit next to each other.
const rows: PushRow[] = [
{
externalId: 'demo-1001',
productHandle: 'demo-ethiopia-guji',
sku: 'DEMO-GUJI-250',
status: 'active',
product: { title: 'Ethiopia Guji', brand: 'Highland Roasters' },
variant: { priceMinor: 1850, currency: 'USD', quantity: 40 },
},
{
externalId: 'demo-1002',
productHandle: 'demo-kenya-aa',
sku: 'DEMO-KAA-250',
status: 'active',
product: { title: 'Kenya AA', brand: 'Highland Roasters' },
variant: { priceMinor: 1950, currency: 'USD', quantity: 25 },
},
];
// 2. Open a job. Nothing is applied yet.
const job = await admin.jobs.open({ mode: 'merge' });
// 3. Send batches (one here). The same idempotencyKey on a retry is safe.
const batch = await job.send(rows, { idempotencyKey: `${job.jobId}:0` });
console.log(batch.rowsAccepted, batch.rowsRefused, batch.issues);
// 4. Close: the staged batches are applied in the background.
await job.close();
// 5. Wait for the end (polls every 5 s). A failed job still resolves.
const done = await job.wait();
console.log(done.state, done.created, done.updated, done.rowsFailed, done.failureCode);
// 6. Verify on the storefront with a publishable key.
const catalog = createCatalogClient({ publishableKey: process.env.CATALOG_STOREFRONT_KEY ?? '' });
const answer = await catalog.products.get('demo-ethiopia-guji');
if (!('redirectTo' in answer)) console.log(answer.product.title, answer.product.priceRange);
What to do next
- Concepts: merge and replace, the job lifecycle, ownership and holds.
- Full sync: archive what your feed no longer lists.
- Stock: live stock with
PATCH /inventory. - Limits and throughput: plan a large feed.
- Troubleshooting: every error code with a fix.
- Reference for AI agents.