Product Catalog · Push API

Admin SDK

@usethrottle/catalog/admin wraps the push API: it opens jobs, cuts a feed into batches, waits out a full queue, makes the idempotency keys and follows the run to the end.

Experimental
The admin entry is outside the package's 1.x freeze: a breaking change to it ships as a minor release, named in the changelog, until it is frozen. Every export carries a TSDoc @experimental tag. The HTTP API itself is the stable surface.

Install and create the client

The admin entry ships in @usethrottle/catalog 1.1.0 and later.

Install
npm install @usethrottle/catalog
# or
pnpm add @usethrottle/catalog

createCatalogAdmin({ key }) takes a store key (pc_sk_) or a vendor key (pc_vk_). It is server-only: in a browser environment it throws CatalogError SERVER_ONLY, and it throws INVALID_ADMIN_KEY for any other key (a publishable key gets its own message). The key is never part of an error message. Options: baseUrl, timeoutMs (default 60 seconds) and fetch.

A whole feed as one job
import { createCatalogAdmin } from '@usethrottle/catalog/admin';
import type { PushRow } from '@usethrottle/catalog/admin';

const admin = createCatalogAdmin({ key: process.env.CATALOG_ADMIN_KEY ?? '' });

// The rows of one product must be next to each other, in one batch.
const rows: PushRow[] = [
  {
    externalId: 'hr-1001',
    productHandle: 'ethiopia-guji',
    sku: 'HR-GUJI-250',
    status: 'active',
    product: { title: 'Ethiopia Guji', brand: 'Highland Roasters' },
    variant: { priceMinor: 1850, currency: 'USD', quantity: 40 },
  },
];

// Open a job, stage the batches, close it: nothing applies before the close.
const job = await admin.jobs.run(rows, { mode: 'merge' });

// wait() resolves when the job has finished or expired. A job that failed
// still resolves: read failureCode.
const done = await job.wait();
if (done.failureCode !== null) {
  console.error(done.failureCode);
  console.error(await job.errorsCsv());
} else {
  console.log(done.created, done.updated, done.unchanged, done.rowsFailed);
}

Methods

MethodWhat it does
jobs.run(rows, { mode?, fullSync?, batchRows? })Opens a job, sends the rows in batches of at most batchRows (default and maximum 1,000) that also stay under 5 MiB, closes it and returns the handle. Takes any iterable or async iterable. A product’s rows are never split across batches, and a product with more than 1,000 rows throws INVALID_REQUEST.
jobs.open({ mode?, fullSync? }), jobs.get(id), jobs.resume(id)A job handle with send(rows, { idempotencyKey? }), close(), get({ includeBatches? }), wait(), errorsCsv(), stockResults() and stockCsv(). resume sends no request.
batches.send(rows, { mode?, idempotencyKey? })One batch to /v1/catalog/variants, answered with a run. mode needs a vendor key; with a store key it throws INVALID_REQUEST (use jobs.run).
runs.get(id), runs.wait(id), runs.errorsCsv(id), runs.stockResults(id), runs.stockCsv(id)Follow a run. wait resolves on completed or failed.
products.get(ref), products.list({ cursor?, limit? }), products.archive(refs), products.restore(refs), products.rename(ref, handle)A ref is exactly one of { externalId }, { sku } or { handle }.
inventory.set(rows), inventory.adjust(rows, { idempotencyKey? })PATCH /v1/catalog/inventory. adjust sends every row with quantityMode adjust.
verifyCatalogWebhook({ rawBody, signature, secret })Checks a webhook signature. See Webhooks.
Staging a job by hand
import { createCatalogAdmin } from '@usethrottle/catalog/admin';
import type { PushRow } from '@usethrottle/catalog/admin';

const admin = createCatalogAdmin({ key: process.env.CATALOG_ADMIN_KEY ?? '' });

const feed: PushRow[][] = [
  [{ externalId: 'hr-1001', productHandle: 'ethiopia-guji', sku: 'HR-GUJI-250' }],
  [{ externalId: 'hr-1002', productHandle: 'kenya-aa', sku: 'HR-KAA-250' }],
];

// fullSync runs once, at close, over everything the job sent.
const job = await admin.jobs.open({ mode: 'replace', fullSync: true });

for (const [index, rows] of feed.entries()) {
  // The same key on a retry answers the first answer again. The SDK makes one
  // when you pass none; naming it yourself lets a restarted process resend.
  const batch = await job.send(rows, { idempotencyKey: `${job.jobId}:${index}` });
  if (batch.rowsRefused > 0) console.warn(batch.issues);
}

// Applies every staged batch, in the order they arrived.
await job.close();

// Later, even from another process: no request is sent by resume().
const same = admin.jobs.resume(job.jobId);
const progress = await same.get({ includeBatches: true });
console.log(progress.state, progress.batchesApplied, progress.rowsApplied);

What the SDK does for you

  • Idempotency keys. It makes a UUID for every job batch, vendor batch and adjust call that has none, and one retry after a network error reuses it, so a delta is never applied twice. A key you pass is 1 to 200 characters.
  • A full queue. A 429 PUSH_QUEUE_FULL waits retryAfterSeconds, then sends the same batch again with the same key, at most 3 times; after that the RateLimitedError is thrown.
  • wait(). job.wait() and runs.wait() poll every 5 seconds for up to 24 hours (intervalMs, timeoutMs, signal). They resolve even when the job failed: read failureCode (or status).
  • A job that failed client-side is left open and expires after 24 hours; its staged batches are discarded, never applied.
  • A vendor run answer has pushMode beside mode.

Stock in a job still applies at close; use inventory.set or inventory.adjust for live stock (Stock). Reports: job row numbers are job-wide, and a job whose CSV is above 900 parts answers 409 REPORT_TOO_LARGE; page stockResults() for stock, and split a very large feed into smaller jobs for error reports.

Errors
import { createCatalogAdmin, isCatalogError, RateLimitedError } from '@usethrottle/catalog/admin';

const admin = createCatalogAdmin({ key: process.env.CATALOG_ADMIN_KEY ?? '' });

try {
  await admin.inventory.set([{ sku: 'HR-GUJI-250', quantity: 40 }]);
} catch (error) {
  if (error instanceof RateLimitedError) {
    // RATE_LIMITED and PUSH_QUEUE_FULL: wait this long, then send again.
    console.log(error.retryAfterSeconds);
  } else if (isCatalogError(error)) {
    // Branch on the code, and read the per-field problems.
    console.log(error.statusCode, error.code, error.issues, error.retryable);
  } else {
    throw error;
  }
}

Images

Image copies run in two phases. While a job applies, they run alongside it: about 200 to 285 copies per minute at peak in one production run (2026-11-01, Worker e2f3c98a, a single run on one store). Copies still waiting when the job ends are picked up by a sweep that runs every 5 minutes and publishes up to 100 jobs per run, about 20 copies per minute (in the first run, 1,607 leftover copies took about 40 minutes). Until a copy lands, the storefront serves the vendor's own image URL, so keep those URLs public and stable. A catalog also has a budget of 20,000 image resizes per calendar month (roughly 6,700 images), so a feed with more images than that stays partly on vendor URLs for longer.