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.
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.
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.
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
| Method | What 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. |
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
adjustcall 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_FULLwaitsretryAfterSeconds, then sends the same batch again with the same key, at most 3 times; after that theRateLimitedErroris thrown. - wait().
job.wait()andruns.wait()poll every 5 seconds for up to 24 hours (intervalMs,timeoutMs,signal). They resolve even when the job failed: readfailureCode(orstatus). - 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
pushModebesidemode.
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.
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.