Product Catalog · Push API

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 key pc_vk_… or a store key pc_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 /inventory at most 1 MiB and 1,000 rows. Archive, restore and assign take at most 100 refs per call; GET /products takes limit 1 to 100 (default 50); externalId is at most 255 characters. An Idempotency-Key is remembered 24 hours.

Endpoints

CallUse
POST /jobsOpen a job. Body { mode: "merge" | "replace", fullSync?: boolean }. Answers 201 { job }.
POST /jobs/{jobId}/batchesStage up to 1,000 rows: { rows: [...] }. Answers 202 { batchId, sequence, rowsAccepted, rowsRefused, issues }.
POST /jobs/{jobId}/closeApply 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.csvRefused and failed rows, with job-wide row numbers.
POST /variantsOne small batch without a job (vendor key). Answers 202 { runId }; follow with GET /runs/{runId}.
PATCH /inventorySynchronous stock set/adjust, 1,000 rows. Use it for live stock.
GET /productsRead by exactly one of externalId, sku, handle; otherwise a page with nextCursor.
POST /products/archive | restore | renameProduct 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

  1. Open a job with mode: "replace", fullSync: true (or merge, if you only upsert). Keep the jobId.
  2. 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, productHandle and sku. Send at most 2,000 batches per job; keep at most 3 jobs open.
  3. Check each batch answer: if rowsRefused > 0, read issues. A refused row counts in rowsFailed for 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.
  4. Close the job exactly once. Nothing is applied before this.
  5. Wait: poll GET /jobs/{jobId} every 5 to 10 seconds, or use a webhook, until finishedAt is set. Do not hold requests open.
  6. Judge the result: success is failureCode === null and rowsFailed === 0. Otherwise download errors.csv and 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 is 409 IDEMPOTENCY_CONFLICT.
  • An adjust stock row requires a key (400 IDEMPOTENCY_KEY_REQUIRED). One key per delivery event, not per attempt.

Retry decision table

SituationRetry?WaitIdempotency-KeyBody
Network error or timeout, no answerYes, up to 5 timesBackoff 2, 4, 8, 16 sSame keyIdentical
429 RATE_LIMITED or PUSH_QUEUE_FULLYesRetry-After seconds (PUSH_QUEUE_FULL: 60)Same keyIdentical
500 INTERNAL_ERROR, 503 INFRASTRUCTURE_UNAVAILABLEYes, up to 5 timesBackoffSame keyIdentical
409 TOO_MANY_OPEN_JOBS or RUN_IN_PROGRESSYes, after the other job closes or finishes10 s, pollNew key if it is a new jobIdentical
409 IDEMPOTENCY_CONFLICTOnly with changed contentNoneA NEW keyThe 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 feedNoneA NEW key (the body changed)The fixed rows
409 JOB_CLOSED, JOB_FULLNo, in this jobNoneNew job, new keysContinue in a new job
400, 413, 422, 404, 401, 403NoNoneNoneFix the request first

Stop conditions

Stop and report to the user (do not retry) when:

  • The response is 401 or 403 (key invalid, vendor paused, route not allowed).
  • Five consecutive retries of one request have failed.
  • A batch answers 413 or 400 after you cut it to the limits.
  • The job finished with failureCode set, or rowsFailed > 0: report the code and the first lines of errors.csv.
  • You have waited more than 3 hours without finishedAt for a feed under 120,000 products (see the time guidance below), or the job state is expired.
  • 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.

CodeDo
429 RATE_LIMITED, 429 PUSH_QUEUE_FULLWait Retry-After seconds, resend the same request with the same key.
500, 503Retry with the same key, with backoff.
409 TOO_MANY_OPEN_JOBS, RUN_IN_PROGRESSWait for another job to close or finish, then retry.
409 JOB_CLOSED, JOB_FULLOpen a new job; do not retry in the same job.
401, 403 VENDOR_PAUSEDStop. Tell the user the key is invalid or the vendor is paused.
404The id is wrong or outside this key. Do not retry.
400, 413, 422Your request is wrong. Fix it using issues; do not retry unchanged.

All codes: Errors. Symptoms and fixes: Troubleshooting.

Never

  • Never set fullSync unless 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 /inventory for live stock.

Background: Concepts, first feed guide, API reference.