Product Catalog · Push API

Concepts

Seven ideas explain the whole push API. Each section is short and links to the page with the details.

Store key and vendor key

Store key (pc_sk_)Vendor key (pc_vk_)
Who holds itThe merchant, or the merchant’s own system.One supplier (a vendor). The merchant creates it, at most 2 active per vendor.
ReachEvery product, job and run in the store.Only that vendor’s products, jobs and runs. Another vendor’s or the store’s resource answers 404; a store-only route answers 403 VENDOR_KEY_NOT_ALLOWED.
Identifies a product bySKU or handle (its own id space).externalId, the vendor’s own product id, unique per vendor.
Rate limit600 per 60 s on jobs and products routes.600 per 60 s on every route.

Example. The same header name, a different reach and a different way to name a product:

Store key and vendor key requests
# The store key: reaches every product. Its products have no externalId; address them by sku or handle.
curl "https://catalog.usethrottle.dev/v1/catalog/products?sku=DEMO-GUJI-250" \
  -H "Authorization: Bearer $CATALOG_STORE_KEY"

# A vendor key: only that vendor's products. Address them by externalId.
curl "https://catalog.usethrottle.dev/v1/catalog/products?externalId=demo-1001" \
  -H "Authorization: Bearer $CATALOG_VENDOR_KEY"

Both are secret and server-side only. Details: Vendors and keys.

The staged job lifecycle

A job is a staged, all-or-nothing push. Batches are checked on arrival and stored; nothing changes in the catalog until the job closes.

open closed completed failed expired close 24 h, never closed
StateMeaningWhat you read
openAccepts batches. Up to 24 hours.state: open
closedClose was called; batches apply in order. Finished when finishedAt is set.finishedAt null, then a time
completedfinishedAt set. failureCode is null, or FULL_SYNC_ARCHIVE_SKIPPED (the job is still completed; the archive pass was skipped). Some rows can still have failed: read rowsFailed.rowsApplied, created, updated, unchanged, failureCode
failedfinishedAt set and a hard failureCode, for example JOB_EXPIRED.failureCode, errors.csv
expiredNever closed within 24 hours: staged batches are discarded and never applied. It ends failed with JOB_EXPIRED.failureCode JOB_EXPIRED

Which calls work in which state

StateWhat moves the job outPOST …/batchesPOST …/closeGET job and reports
openYou call close (to closed), or 24 hours pass (to expired).202 (up to the batch limits)202200
closedThe last batch applies (finishedAt is set: completed or failed).409 JOB_CLOSED200, the same job200
expiredNothing. The job is final and failed with JOB_EXPIRED.409 JOB_CLOSEDrefused200
The same job in each state (real answers)
# open: accepts batches and a close.
curl -X POST "$BASE/jobs/$JOB_ID/batches" -H "$AUTH" -H "Idempotency-Key: $JOB_ID:0" -d '{ "rows": [ ... ] }'   # 202
curl -X POST "$BASE/jobs/$JOB_ID/close" -H "$AUTH"                                                              # 202, state closed

# closed: batches are refused, a repeated close answers the job again, reads always work.
curl -X POST "$BASE/jobs/$JOB_ID/batches" -H "$AUTH" -H "Idempotency-Key: late" -d '{ "rows": [ ... ] }'       # 409 JOB_CLOSED
curl -X POST "$BASE/jobs/$JOB_ID/close" -H "$AUTH"                                                              # 200, the same job
curl "$BASE/jobs/$JOB_ID" -H "$AUTH"                                                                            # 200, poll finishedAt

The job view has the states open, closed and expired; completed and failed are read from finishedAt and failureCode. A completed job can carry FULL_SYNC_ARCHIVE_SKIPPED, so consumers must check failureCode (webhook: data.failureCode) on a catalog.run.completed event. Webhooks say catalog.run.completed or catalog.run.failed.

Merge and replace

Merge (default) changes only what you send. Replace treats the job as the full truth for your own data: values you did not send are dropped, and variants you supplied earlier and did not send are archived. Replace needs a product's rows together in one batch. Full sync is a separate switch: after applying, it archives your products that the whole job never mentioned, and only if the job was clean. See Merge and replace.

Example, run on production. A product has two variants, a brand and a stock of 5. The replace job sends only the 250 g variant, with a title and a price. Afterwards the brand is gone (the value you no longer send), the stock you did not resend is dropped to 0, and the 1 kg variant you stopped sending is archived. A merge job with the same row would have changed only the price.

The product before and after a replace job
{
 "before": {
  "title": "Replace demo",
  "brand": "Highland Roasters",
  "draft": false,
  "externalId": "pushqa-docs-1005",
  "handle": "pushqa-docs-1005",
  "status": "active",
  "held": false,
  "apiArchived": false,
  "purgeAfter": null,
  "vendorId": "b2bab2db-5f77-47b4-9b23-d4ab1d4d2ef4",
  "attributes": {},
  "variants": [
   {
    "priceMinor": 1000,
    "currency": "USD",
    "quantity": 5,
    "sku": "PUSHQA-DOCS-R-250",
    "status": "active"
   },
   {
    "priceMinor": 3000,
    "currency": "USD",
    "quantity": 0,
    "sku": "PUSHQA-DOCS-R-1KG",
    "status": "active"
   }
  ]
 },
 "after": {
  "title": "Replace demo",
  "draft": false,
  "externalId": "pushqa-docs-1005",
  "handle": "pushqa-docs-1005",
  "status": "active",
  "held": false,
  "apiArchived": false,
  "purgeAfter": null,
  "vendorId": "b2bab2db-5f77-47b4-9b23-d4ab1d4d2ef4",
  "attributes": {},
  "variants": [
   {
    "priceMinor": 1000,
    "currency": "USD",
    "quantity": 0,
    "sku": "PUSHQA-DOCS-R-250",
    "status": "active"
   },
   {
    "quantity": 0,
    "sku": "PUSHQA-DOCS-R-1KG",
    "status": "archived"
   }
  ]
 }
}

Fair turns

Each vendor, and the store key, has its own lane. Closed jobs take turns across lanes, so a small update is not stuck behind another vendor's huge feed. An idle open job holds no turn. Within a lane, work goes oldest close first.

Two vendors, one store
{
  "note": "An illustrative timeline, not a measurement. Two vendors, one store. Vendor A closes a 300-batch feed at 10:00:00; Vendor B closes one 1-batch price update at 10:00:30.",
  "lanes": {
    "vendorA": "300 batches, applied one batch per turn",
    "vendorB": "1 batch"
  },
  "timeline": [
    { "at": "10:00:00", "event": "A closes its job. Lane A starts applying its batches." },
    { "at": "10:00:30", "event": "B closes its job. Lane B now has work." },
    { "at": "10:00:30+", "event": "The next turn goes to lane B, not to A's remaining batches. B's job finishes in that turn." },
    { "at": "after", "event": "Turns return to lane A, which keeps going until its last batch." }
  ],
  "notEffective": "A job that is still open holds no turn: its batches wait in storage until it is closed."
}

Ownership, adoption and holds

  • Ownership. A product has one owner: a vendor or the merchant. A vendor key never changes ownership, and a SKU clash with another owner fails with SKU_CONFLICT.
  • Adoption. Products already in the catalog stay the merchant's until the merchant assigns them to a vendor; the vendor's first push then links each one by SKU and records its externalId. A push never claims an unassigned product.
  • The vendor wins. On a vendor-owned product, what the vendor sends outranks the merchant's dashboard edit.
  • Holds. If the merchant archives or drafts a vendor product in the dashboard, the vendor can still update its data but cannot republish or restore it until the merchant lifts the hold (held in the answer).

A SKU clash, walked through

Vendor A owns DEMO-GUJI-250. Vendor B pushes a different product with the same SKU. The batch is accepted (a SKU clash is found when the row is applied), the row fails with SKU_CONFLICT in the error report, and nothing of vendor A's product changes. Vendor B cannot see A's product to find out why: it answers 404, and a store-only route answers 403 VENDOR_KEY_NOT_ALLOWED. Resolve it by choosing a SKU of your own, or ask the merchant to assign the product to the right vendor.

The clash as vendor B sees it (real answers)
{
 "vendor_b_pushes_vendor_a_sku": {
  "batch": {
   "batchId": "8508c7b4-25b0-4277-9b10-afd9083398f8",
   "sequence": 1,
   "rowsAccepted": 1,
   "rowsRefused": 0,
   "issues": []
  },
  "job": {
   "rowsApplied": 0,
   "rowsFailed": 1,
   "failureCode": null
  },
  "errorsCsv": "row_number,sku,field,severity,code,message,external_id\n1,PUSHQA-DOCS-GUJI-250,sku,error,SKU_CONFLICT,This SKU belongs to another product in this catalog.,pushqa-docs-b1\n"
 },
 "vendor_b_reads_vendor_a_product": {
  "status": 404,
  "body": {
   "error": {
    "code": "NOT_FOUND",
    "message": "Resource not found.",
    "requestId": "…"
   }
  }
 },
 "vendor_key_on_store_only_route": {
  "status": 403,
  "body": {
   "error": {
    "code": "VENDOR_KEY_NOT_ALLOWED",
    "message": "A vendor key cannot call this route.",
    "requestId": "…"
   }
  }
 }
}

Holds in detail.

API archive and the 30-day purge

Deleting through the API archives: the product leaves the storefront and can be restored. An API archive is purged 30 days later (the whole product, unless another source still supplies it). A merchant's dashboard archive never purges. An archived product stays archived when later pushes resend it, until an explicit restore or a row that sends status: "active".

Archive can take up to 60 seconds to show
The storefront edge cache holds an answer for at most 60 seconds, so an archived product can still appear for up to a minute. It is gone on the next uncached read.
Archive, read the purge date, restore (real answers)
{
 "archive": {
  "results": [
   {
    "index": 0,
    "status": "archived"
   }
  ]
 },
 "read": {
  "externalId": "pushqa-docs-1002",
  "status": "archived",
  "apiArchived": true,
  "held": false,
  "purgeAfter": "2026-12-01T19:53:19.039Z"
 },
 "restore": {
  "results": [
   {
    "index": 0,
    "status": "restored"
   }
  ]
 }
}

The purge in practice:

What happens on day 0, between, and on day 30
{
  "day0": "POST /v1/catalog/products/archive: status archived, apiArchived true, purgeAfter = day 30. The storefront answers 404 (within 60 s).",
  "day0to30": "A push that resends the product updates its data but keeps it archived. POST /products/restore, or a row with status active, brings it back and clears purgeAfter.",
  "day30": "The purge removes the whole product (variants, images, data) if no other source supplies it and no hold exists; otherwise only your own values, and the product stays archived.",
  "never": "A merchant's dashboard archive never purges."
}

The purge in detail.