Catalog Push API: reference for AI agents
Follow this page literally to run a full sync of a vendor's catalog. Machine-readable contract: https://catalog.usethrottle.dev/v1/catalog/openapi.json (public, no key).
Base URL and auth
- Base URL:
https://catalog.usethrottle.dev/v1/catalog - Header on every call:
Authorization: Bearer <key>, with a vendor keypc_vk_…or a store keypc_sk_…. Never send either from a browser, and never print or log it. - Bodies are JSON (
Content-Type: application/json), at most 5 MiB and 1,000 rows per batch;PATCH /inventoryat most 1 MiB and 1,000 rows. Archive, restore and assign take at most 100 refs per call;GET /productstakeslimit1 to 100 (default 50);externalIdis at most 255 characters. AnIdempotency-Keyis remembered 24 hours.
Endpoints
| Call | Use |
|---|---|
POST /jobs | Open a job. Body { mode: "merge" | "replace", fullSync?: boolean }. Answers 201 { job }. |
POST /jobs/{jobId}/batches | Stage up to 1,000 rows: { rows: [...] }. Answers 202 { batchId, sequence, rowsAccepted, rowsRefused, issues }. |
POST /jobs/{jobId}/close | Apply everything staged. Answers 202 { job }; a repeated close answers 200 { job }. |
GET /jobs/{jobId} | Progress. Finished when job.finishedAt is not null; then check job.failureCode and job.rowsFailed. |
GET /jobs/{jobId}/errors.csv | Refused and failed rows, with job-wide row numbers. |
POST /variants | One small batch without a job (vendor key). Answers 202 { runId }; follow with GET /runs/{runId}. |
PATCH /inventory | Synchronous stock set/adjust, 1,000 rows. Use it for live stock. |
GET /products | Read by exactly one of externalId, sku, handle; otherwise a page with nextCursor. |
POST /products/archive | restore | rename | Product operations by ref { externalId } | { sku } | { handle }. |
Complete call sequence with exact JSON
Every request and response of a full sync, in order. Replace <key>,
<jobId> and <batchIndex>. Bodies are real
answers from production (ids replaced); the base URL is https://catalog.usethrottle.dev/v1/catalog.
Steps 1 to 5: open, send, close, poll, judge
[
{
"step": 1,
"name": "open",
"request": {
"method": "POST",
"path": "/jobs",
"headers": {
"Authorization": "Bearer <key>",
"Content-Type": "application/json"
},
"body": {
"mode": "replace",
"fullSync": true
}
},
"response": {
"status": 201,
"body": {
"job": {
"jobId": "<jobId>",
"state": "open",
"mode": "replace",
"fullSync": true,
"vendorId": "<vendorId>",
"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
}
}
},
"next": "Store job.jobId. Go to step 2."
},
{
"step": 2,
"name": "send batch (repeat for every batch, in order)",
"request": {
"method": "POST",
"path": "/jobs/<jobId>/batches",
"headers": {
"Authorization": "Bearer <key>",
"Content-Type": "application/json",
"Idempotency-Key": "<jobId>:<batchIndex>"
},
"body": {
"rows": [
{
"externalId": "hr-1001",
"productHandle": "ethiopia-guji",
"sku": "HR-GUJI-250",
"status": "active",
"product": {
"title": "Ethiopia Guji"
},
"variant": {
"priceMinor": 1850,
"currency": "USD",
"quantity": 40
}
}
]
}
},
"response": {
"status": 202,
"body": {
"batchId": "<batchId>",
"sequence": 1,
"rowsAccepted": 1,
"rowsRefused": 0,
"issues": []
}
},
"next": "If rowsRefused > 0, record issues (index is the row position in this batch). With fullSync true a refused row makes the archive pass skip. Otherwise send the next batch, then step 3."
},
{
"step": 3,
"name": "close",
"request": {
"method": "POST",
"path": "/jobs/<jobId>/close",
"headers": {
"Authorization": "Bearer <key>"
},
"body": null
},
"response": {
"status": 202,
"body": {
"job": {
"jobId": "<jobId>",
"state": "closed",
"mode": "replace",
"fullSync": true,
"vendorId": "<vendorId>",
"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": 1,
"rowsValidated": 1,
"rowsRefused": 0,
"batchesApplied": 0,
"rowsApplied": 0,
"rowsFailed": 0,
"created": 0,
"updated": 0,
"unchanged": 0,
"reportExpiresAt": null
}
}
},
"next": "A repeated close answers 200 with the job. Go to step 4."
},
{
"step": 4,
"name": "poll until finished",
"request": {
"method": "GET",
"path": "/jobs/<jobId>",
"headers": {
"Authorization": "Bearer <key>"
},
"body": null
},
"response": {
"status": 200,
"body": {
"job": {
"jobId": "<jobId>",
"state": "closed",
"mode": "replace",
"fullSync": true,
"vendorId": "<vendorId>",
"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": 1,
"rowsValidated": 1,
"rowsRefused": 0,
"batchesApplied": 1,
"rowsApplied": 1,
"rowsFailed": 0,
"created": 1,
"updated": 0,
"unchanged": 0,
"reportExpiresAt": "2026-12-01T19:24:21.409Z"
}
}
},
"next": "While job.finishedAt is null, wait 5 to 10 seconds and repeat. When set, go to step 5."
},
{
"step": 5,
"name": "judge and report",
"request": {
"method": "GET",
"path": "/jobs/<jobId>/errors.csv",
"headers": {
"Authorization": "Bearer <key>"
},
"body": null
},
"response": {
"status": 200,
"body": "row_number,sku,field,severity,code,message,external_id\n2,,sku,error,INVALID_ROW,This row does not match the push row format.,hr-bad\n"
},
"next": "Only fetch it when job.failureCode is not null or job.rowsFailed > 0 (a refused row counts in rowsFailed). Success means failureCode null and rowsFailed 0."
}
] Required call order for a full sync
- Open a job with
mode: "replace", fullSync: true(or merge, if you only upsert). Keep thejobId. - Send batches: split the feed into batches of at most 1,000 rows and under 5 MiB. Keep all rows of one product adjacent in the same batch (replace refuses scattered rows). Every row of a vendor needs
externalId,productHandleandsku. Send at most 2,000 batches per job; keep at most 3 jobs open. - Check each batch answer: if
rowsRefused > 0, readissues. A refused row counts inrowsFailedfor the whole job and skips the archive pass, even if you resend a fixed row. To get the archive, open a new full-sync job with the whole corrected feed. - Close the job exactly once. Nothing is applied before this.
- Wait: poll
GET /jobs/{jobId}every 5 to 10 seconds, or use a webhook, untilfinishedAtis set. Do not hold requests open. - Judge the result: success is
failureCode === nullandrowsFailed === 0. Otherwise downloaderrors.csvand report it.
Time and size
Measured on 2026-11-01 in two single runs on one store (Workers e844ed62 and e2f3c98a): 120,000 products (372,242 rows) took 55 and 62 minutes end to end, an apply rate of 7,538 and 6,713 rows per minute (end-to-end rates: about 6,700 and 6,045). Plan on about 1 hour per 120,000 products with images. Image copies run alongside the job at about 200 to 285 per minute at peak, and leftovers drain at about 20 per minute afterwards; the storefront serves the vendor's image URL until a copy lands. Updates with no image changes run faster. Split very large catalogs across jobs. Details: Limits and throughput.
A full sync with plain fetch
// A full sync with plain fetch: the call order an agent must follow.
const BASE = 'https://catalog.usethrottle.dev/v1/catalog';
const headers = { Authorization: `Bearer ${process.env.CATALOG_VENDOR_KEY ?? ''}`, 'Content-Type': 'application/json' };
interface Row { externalId: string; productHandle: string; sku: string }
async function call(path: string, init: RequestInit & { idempotencyKey?: string } = {}) {
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: { ...headers, ...(init.idempotencyKey ? { 'Idempotency-Key': init.idempotencyKey } : {}) },
});
// 429 and 5xx: wait, then resend the SAME request with the SAME key.
if (res.status === 429 || res.status >= 500) {
await new Promise((r) => setTimeout(r, Number(res.headers.get('Retry-After') ?? 5) * 1000));
continue;
}
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code} ${body.error.requestId}`);
return body;
}
throw new Error('GAVE_UP');
}
// batches: arrays of at most 1,000 rows; one product's rows stay together.
export async function fullSync(batches: Row[][]) {
const { job } = await call('/jobs', { method: 'POST', body: JSON.stringify({ mode: 'replace', fullSync: true }) });
for (const [index, rows] of batches.entries()) {
await call(`/jobs/${job.jobId}/batches`, {
method: 'POST',
body: JSON.stringify({ rows }),
idempotencyKey: `${job.jobId}:${index}`,
});
}
await call(`/jobs/${job.jobId}/close`, { method: 'POST' });
for (;;) {
const { job: view } = await call(`/jobs/${job.jobId}`);
if (view.finishedAt !== null) return view; // read failureCode and rowsFailed
await new Promise((r) => setTimeout(r, 10_000));
}
}
Idempotency rules
- Send
Idempotency-Key(1 to 200 characters) on every batch. Use a stable key such as{jobId}:{batchIndex}. - A retry must repeat the same body with the same key; the replayed answer carries
Idempotent-Replayed. A different body with a used key is409 IDEMPOTENCY_CONFLICT. - An
adjuststock row requires a key (400 IDEMPOTENCY_KEY_REQUIRED). One key per delivery event, not per attempt.
Retry decision table
| Situation | Retry? | Wait | Idempotency-Key | Body |
|---|---|---|---|---|
| Network error or timeout, no answer | Yes, up to 5 times | Backoff 2, 4, 8, 16 s | Same key | Identical |
| 429 RATE_LIMITED or PUSH_QUEUE_FULL | Yes | Retry-After seconds (PUSH_QUEUE_FULL: 60) | Same key | Identical |
| 500 INTERNAL_ERROR, 503 INFRASTRUCTURE_UNAVAILABLE | Yes, up to 5 times | Backoff | Same key | Identical |
| 409 TOO_MANY_OPEN_JOBS or RUN_IN_PROGRESS | Yes, after the other job closes or finishes | 10 s, poll | New key if it is a new job | Identical |
| 409 IDEMPOTENCY_CONFLICT | Only with changed content | None | A NEW key | The corrected body |
| A row was refused (rowsRefused > 0) | Resend only the fixed rows (merge); with fullSync the archive is already skipped, so run a new full-sync job with the whole corrected feed | None | A NEW key (the body changed) | The fixed rows |
| 409 JOB_CLOSED, JOB_FULL | No, in this job | None | New job, new keys | Continue in a new job |
| 400, 413, 422, 404, 401, 403 | No | None | None | Fix the request first |
Stop conditions
Stop and report to the user (do not retry) when:
- The response is
401or403(key invalid, vendor paused, route not allowed). - Five consecutive retries of one request have failed.
- A batch answers
413or400after you cut it to the limits. - The job finished with
failureCodeset, orrowsFailed > 0: report the code and the first lines oferrors.csv. - You have waited more than 3 hours without
finishedAtfor a feed under 120,000 products (see the time guidance below), or the job state isexpired. - The feed would archive far more products than usual: stop before close and ask the user (a full sync archives everything the job did not send).
The loop in pseudo-code
Full sync control flow (type-checked pseudo-code)
// Pseudo-code that type-checks: the control flow of a full sync.
type Row = { externalId: string; productHandle: string; sku: string };
type Job = { jobId: string; finishedAt: string | null; failureCode: string | null; rowsFailed: number };
type Outcome = { ok: true; job: Job } | { ok: false; reason: string };
declare function openJob(body: { mode: 'replace'; fullSync: boolean }): Promise<{ jobId: string }>;
declare function sendBatch(jobId: string, rows: Row[], idempotencyKey: string): Promise<{ rowsRefused: number }>;
declare function closeJob(jobId: string): Promise<void>;
declare function getJob(jobId: string): Promise<Job>;
declare function sleep(seconds: number): Promise<void>;
declare function chunkByProduct(rows: Row[], max: number): Row[][];
export async function fullSync(feed: Row[]): Promise<Outcome> {
const job = await openJob({ mode: 'replace', fullSync: true });
const batches = chunkByProduct(feed, 1000); // never split a product's rows
let refused = 0;
for (const [index, rows] of batches.entries()) {
// On 429/5xx the HTTP layer waits Retry-After and resends the same key.
const answer = await sendBatch(job.jobId, rows, `${job.jobId}:${index}`);
refused += answer.rowsRefused;
}
if (refused > 0) return { ok: false, reason: 'rows refused: a refused row skips the archive for this whole job; start a new full-sync job with the corrected feed' };
await closeJob(job.jobId);
for (;;) {
const view = await getJob(job.jobId);
if (view.finishedAt !== null) {
return view.failureCode === null && view.rowsFailed === 0
? { ok: true, job: view }
: { ok: false, reason: `failureCode=${view.failureCode} rowsFailed=${view.rowsFailed}` };
}
await sleep(10);
}
}
Error handling
Every error is { "error": { "code", "message", "requestId", "issues"? } }. Branch on code, never on the message.
| Code | Do |
|---|---|
429 RATE_LIMITED, 429 PUSH_QUEUE_FULL | Wait Retry-After seconds, resend the same request with the same key. |
500, 503 | Retry with the same key, with backoff. |
409 TOO_MANY_OPEN_JOBS, RUN_IN_PROGRESS | Wait for another job to close or finish, then retry. |
409 JOB_CLOSED, JOB_FULL | Open a new job; do not retry in the same job. |
401, 403 VENDOR_PAUSED | Stop. Tell the user the key is invalid or the vendor is paused. |
404 | The id is wrong or outside this key. Do not retry. |
400, 413, 422 | Your request is wrong. Fix it using issues; do not retry unchanged. |
All codes: Errors. Symptoms and fixes: Troubleshooting.
Never
- Never set
fullSyncunless the feed is the vendor's complete catalog: it archives everything the job did not send. - Never split a product's rows across batches in replace mode.
- Never put a key in a browser, URL, log or repository.
- Never assume stock in a job is live: it applies at close. Use
PATCH /inventoryfor live stock.
Background: Concepts, first feed guide, API reference.