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
| Mode | What a push does |
|---|---|
merge | Partial update (the default). A field, variant or value you do not send is left alone. |
replace | Full 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
nullblanks 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.
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; readfinishedAtandfailureCode) andexpired. Each batch isstaged,appliedordiscarded. - Stock in a job applies at close, not on arrival. A staged
setre-bases on-hand stock when the job closes. For live stock, usePATCH /v1/catalog/inventory(Stock).
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
| Limit | Value | What happens past it |
|---|---|---|
| Rows per batch | 1,000 rows | 400 INVALID_REQUEST |
| Bytes per batch | 5 MiB | 413 REQUEST_BODY_TOO_LARGE |
| Batches per job | About 2,000 (roughly 2 million rows) | 409 JOB_FULL |
| Job lifetime | 24 hours open | The job expires and is discarded (JOB_EXPIRED). |
| Open jobs per caller | 3 | 409 TOO_MANY_OPEN_JOBS (retryable once one closes or expires). |
| Waiting batches per caller | 2,000 received but not yet applied, across open and running jobs | 429 PUSH_QUEUE_FULL with Retry-After: 60. |
| Request rate | 600 requests per 60 seconds per key | 429 RATE_LIMITED with Retry-After. A vendor key is limited on every route; the store key on jobs and products routes only. |
| Idempotency-Key | 1 to 200 characters | 400 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 withJOB_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.
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.csvis 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 pagedGET …/stockinstead. 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 …/stockpages the quantity each stock-writing row left:sequence,rowNumber,sku,externalId,quantity,sourceWins.
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);
}
INVALID_GTIN_DROPPED, drop one value and still apply the row.
They count in rowsWarned, not rowsFailed.