Product Catalog · Push API

Jobs, batches and runs

A row describes one variant (a SKU) and the product it belongs to. Rows travel in batches; many batches make a sync job; every job and every single batch is applied as a run that you can follow.

Rows

A row is keyed by sku and grouped into a product by productHandle. A vendor row also carries externalId, the vendor's own product id, which is how the product is found again after a rename. Optional parts: product (title, description, brand, images, categories, tags, options, draft, taxCode), variant (price, currency, stock, GTIN, dimensions), status (draft or active), attributes, prices (other currencies), translations and quantityMode (set or adjust). The API reference lists every field and its limits.

Images are public URLs; the catalog copies them through its media pipeline (see the image limit). Categories can be an id or a path, up to 10 levels deep; missing levels are created.

Merge and replace

ModeWhat a push does
mergePartial update (the default). A field, variant or value you do not send is left alone.
replaceFull update of the caller’s own data. For every product in the job, the caller’s values for fields, images, attributes, translations and prices it did not send are dropped (another source’s value shows instead), and only variants the caller supplied and did not send are archived.
  • Replace needs a product's rows together. All of a product's rows must sit next to each other in one batch. Rows of a product that an earlier batch of the job already claimed, or rows of it that are not adjacent, are refused with SCATTERED_PRODUCT_ROWS, and nothing of that product in the batch applies. The SDK never splits a product across a batch boundary.
  • A row that is refused still counts as sent for the whole job, so its variant is never archived and its values are never withdrawn by a replace.
  • A sent null blanks a field in either mode. A mode is chosen per job (or per single batch), never per row.

Sync jobs

Open a job with POST /v1/catalog/jobs (mode, and optionally fullSync), send batches to POST /v1/catalog/jobs/{jobId}/batches, then close it with POST /v1/catalog/jobs/{jobId}/close.

Send a batch, close, and read the job
curl -X POST https://catalog.usethrottle.dev/v1/catalog/jobs/$JOB_ID/batches \
  -H "Authorization: Bearer $CATALOG_ADMIN_KEY" \
  -H "Idempotency-Key: $JOB_ID-0" \
  -H "Content-Type: application/json" \
  -d '{ "rows": [ { "externalId": "hr-1001", "productHandle": "ethiopia-guji", "sku": "HR-GUJI-250", "variant": { "priceMinor": 1850, "currency": "USD", "quantity": 40 } } ] }'

curl -X POST https://catalog.usethrottle.dev/v1/catalog/jobs/$JOB_ID/close \
  -H "Authorization: Bearer $CATALOG_ADMIN_KEY"

curl https://catalog.usethrottle.dev/v1/catalog/jobs/$JOB_ID \
  -H "Authorization: Bearer $CATALOG_ADMIN_KEY"
  • Nothing applies until close. A batch is validated when it arrives (the answer lists refused rows in issues) and stored. At close the batches apply in the order received, then a full sync runs if requested. The job is all-or-nothing in this sense: until the close, the catalog has not changed.
  • Open for 24 hours at most. A job not closed within 24 hours expires. Its staged batches are discarded, never applied, and the job ends as failed with JOB_EXPIRED (the webhook and the progress view say so). Resend the feed in a new job.
  • States. open (batches accepted), closed (applying or finished; read finishedAt and failureCode) and expired. Each batch is staged, applied or discarded.
  • Stock in a job applies at close, not on arrival. A staged set re-bases on-hand stock when the job closes. For live stock, use PATCH /v1/catalog/inventory (Stock).
Open, send, close and resume (SDK)
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);

Limits and guardrails

LimitValueWhat happens past it
Rows per batch1,000 rows400 INVALID_REQUEST
Bytes per batch5 MiB413 REQUEST_BODY_TOO_LARGE
Batches per jobAbout 2,000 (roughly 2 million rows)409 JOB_FULL
Job lifetime24 hours openThe job expires and is discarded (JOB_EXPIRED).
Open jobs per caller3409 TOO_MANY_OPEN_JOBS (retryable once one closes or expires).
Waiting batches per caller2,000 received but not yet applied, across open and running jobs429 PUSH_QUEUE_FULL with Retry-After: 60.
Request rate600 requests per 60 seconds per key429 RATE_LIMITED with Retry-After. A vendor key is limited on every route; the store key on jobs and products routes only.
Idempotency-Key1 to 200 characters400 INVALID_REQUEST

The store key is one caller: its jobs share one set of caps, and one lane. Send the next batch after a 429 only once Retry-After seconds have passed, with the same Idempotency-Key.

Idempotency

Send an Idempotency-Key (1 to 200 characters) with every batch. A retry with the same key answers the first answer again instead of staging the batch twice (the replay carries an Idempotent-Replayed header); the same key with a different body is 409 IDEMPOTENCY_CONFLICT. A job batch, a vendor single batch or an inventory request that contains an adjust row must carry one, or the answer is 400 IDEMPOTENCY_KEY_REQUIRED before anything is stored. The SDK makes a key for you when you pass none.

Full sync

fullSync: true (off by default, never implied) archives the caller's products that are missing from the whole job, once, at close. It never runs per batch.

  • The archive pass is skipped, and the job ends with FULL_SYNC_ARCHIVE_SKIPPED, when any row in the job was refused or failed, or when nothing was seen (an empty job). Nothing is archived on a job that is not clean. A job that expires unclosed is different: it ends failed with JOB_EXPIRED.
  • While a full-sync job is open, it refuses its own source's other writes: another job or a single batch from the same key (and for a vendor, any of that vendor's keys) answers 409 RUN_IN_PROGRESS. Other vendors are not affected.

Fair turns

Applying takes turns. Every vendor, and the store key, has its own lane, and within one store the lanes take turns, so a small update from one vendor is not stuck behind another vendor's multi-hundred-batch feed.

  • Only a closed job takes a turn. An idle open job never blocks anyone: its staged batches sit in storage, take no turn and apply nothing.
  • Within a lane, work applies one item at a time, oldest close first, so a vendor's single batch waits behind that same vendor's earlier-closed job.
  • A job finishes in the turn that applies its last batch.

Single batches

For a small update, POST /v1/catalog/variants sends one batch (up to 1,000 rows and 5 MiB) without a job and answers 202 with a run to follow. With a vendor key it has the full push behaviour (externalId, status, replace, adjust, and a mode in the body). With the store key it is today's endpoint, unchanged: no externalId, status, replace or adjust; use a job for those. A vendor single batch counts toward the same waiting-batch cap as job batches.

A single vendor batch, followed to the end
import { createCatalogAdmin } from '@usethrottle/catalog/admin';

// A vendor key, for a small update that needs no job.
const admin = createCatalogAdmin({ key: process.env.CATALOG_VENDOR_KEY ?? '' });

// One batch is applied as a run of its own. Rows are canonical: a mapping
// profile cannot set externalId, so a vendor batch never uses one.
const started = await admin.batches.send(
  [
    {
      externalId: 'hr-1001',
      productHandle: 'ethiopia-guji',
      sku: 'HR-GUJI-250',
      variant: { priceMinor: 1950, currency: 'USD' },
    },
  ],
  { mode: 'merge', idempotencyKey: 'price-change-2026-11-01' },
);

const run = await admin.runs.wait(started.runId);
// mode is the run kind (upsert or full_sync); pushMode is merge or replace.
console.log(run.status, run.mode, run.pushMode, run.rowsApplied, run.rowsFailed);
if (run.rowsFailed > 0) console.error(await admin.runs.errorsCsv(started.runId));

Runs and progress

GET /v1/catalog/runs/{runId} and GET /v1/catalog/jobs/{jobId} report counts (created, updated, unchanged, rowsFailed) and the end state. Poll every few seconds; an applied job is finished when finishedAt is set, and its failureCode says why it failed.

A vendor run answer keeps mode (the run kind, upsert or full_sync) and adds pushMode (merge or replace), with vendorId, fullSync and reportExpiresAt.

Error reports and stock results

  • GET …/errors.csv is a CSV of the refused and failed rows: row number, SKU and externalId, field and reason. Row numbers are job-wide, counted across all of the job's batches, not per batch.
  • Either of a vendor's keys, or any store key, can download a report, so rotating a key never locks one away. It is kept 30 days after the run finishes; then 410 REPORT_EXPIRED.
  • A job whose error CSV or stock CSV is too large, above 900 parts, answers 409 REPORT_TOO_LARGE. For stock results, read the paged GET …/stock instead. An error CSV has no paged form: a job of more than about 900 batches that has errors in most of them cannot download it, so split a feed that large into smaller jobs.
  • GET …/stock pages the quantity each stock-writing row left: sequence, rowNumber, sku, externalId, quantity, sourceWins.
Stock results, page by page
import { createCatalogAdmin } from '@usethrottle/catalog/admin';

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

declare const jobId: string;
declare const runId: string;

// Every row that wrote a quantity records what the quantity ended as.
const job = admin.jobs.resume(jobId);
for await (const result of job.stockResults()) {
  console.log(result.rowNumber, result.sku, result.quantity, result.sourceWins);
}

// A very large job answers 409 REPORT_TOO_LARGE on the CSV forms; the paged
// stockResults() above keeps working. A single run pages the same way.
for await (const result of admin.runs.stockResults(runId)) {
  console.log(result.sku, result.quantity);
}
A row warning is not a failure
Some issues, such as INVALID_GTIN_DROPPED, drop one value and still apply the row. They count in rowsWarned, not rowsFailed.