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 it | The merchant, or the merchant’s own system. | One supplier (a vendor). The merchant creates it, at most 2 active per vendor. |
| Reach | Every 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 by | SKU or handle (its own id space). | externalId, the vendor’s own product id, unique per vendor. |
| Rate limit | 600 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:
# 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.
| State | Meaning | What you read |
|---|---|---|
open | Accepts batches. Up to 24 hours. | state: open |
closed | Close was called; batches apply in order. Finished when finishedAt is set. | finishedAt null, then a time |
| completed | finishedAt 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 |
| failed | finishedAt set and a hard failureCode, for example JOB_EXPIRED. | failureCode, errors.csv |
expired | Never 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
| State | What moves the job out | POST …/batches | POST …/close | GET job and reports |
|---|---|---|---|---|
open | You call close (to closed), or 24 hours pass (to expired). | 202 (up to the batch limits) | 202 | 200 |
closed | The last batch applies (finishedAt is set: completed or failed). | 409 JOB_CLOSED | 200, the same job | 200 |
expired | Nothing. The job is final and failed with JOB_EXPIRED. | 409 JOB_CLOSED | refused | 200 |
# 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.
{
"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.
{
"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 (
heldin 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.
{
"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": "…"
}
}
}
} 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": {
"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:
{
"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."
}