Product Catalog · Push API

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.

You need
A vendor key from the merchant, Node 20+ (or just 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_…

curl
# 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"
SDK (@usethrottle/catalog/admin)
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));
Expected answer for a new vendor (HTTP body)
{ "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
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 }'
SDK (@usethrottle/catalog/admin)
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()));
Expected answer (HTTP body)
{
  "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.

curl
# 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 } }
  ] }'
SDK (@usethrottle/catalog/admin)
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));
Expected answer (HTTP body)
{ "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.

Send a batch with one bad row (SDK)
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());
Expected: the batch answer, then the error report
{
  "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
curl -X POST "https://catalog.usethrottle.dev/v1/catalog/jobs/$JOB_ID/close" \
  -H "Authorization: Bearer $CATALOG_VENDOR_KEY"
SDK (@usethrottle/catalog/admin)
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()));
Expected answer (HTTP body)
{
  "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.

curl
# 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"
SDK (@usethrottle/catalog/admin)
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());
Expected answer (HTTP body)
{
  "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.

A webhook receiver (SDK)
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 });
}
A catalog.run.completed event
{
  "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.

curl
# 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"
SDK (@usethrottle/catalog/admin)
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));
Expected answer (HTTP body)
{
  "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.

curl
# 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"
SDK (@usethrottle/catalog)
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 }));
}
Expected answer (HTTP body)
{ "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

The whole feed with the admin SDK
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