Troubleshooting
One section per problem: the exact response, the cause, the fix with a corrected request, and how to confirm it is
fixed. Bodies come from real production answers (ids shortened) except where marked as the envelope only. Quote
requestId when you ask for help. The full code list is on
Errors.
Find your problem
- AUTHENTICATION_FAILED (HTTP 401)
- VENDOR_PAUSED (HTTP 403)
- VENDOR_KEY_NOT_ALLOWED (HTTP 403)
- NOT_FOUND for something that exists (HTTP 404)
- INVALID_REQUEST (HTTP 400)
- IDEMPOTENCY_KEY_REQUIRED (HTTP 400)
- IDEMPOTENCY_CONFLICT (HTTP 409)
- JOB_CLOSED (HTTP 409)
- JOB_FULL (HTTP 409)
- TOO_MANY_OPEN_JOBS (HTTP 409)
- RUN_IN_PROGRESS (HTTP 409)
- RATE_LIMITED (HTTP 429)
- PUSH_QUEUE_FULL (HTTP 429)
- REQUEST_BODY_TOO_LARGE (HTTP 413)
- REPORT_TOO_LARGE (HTTP 409)
- REPORT_EXPIRED (HTTP 410)
- HANDLE_TAKEN (HTTP 409)
- IDENTIFIER_LOCKED (HTTP 409)
- PROFILE_NOT_FOUND / VENDOR_NOT_FOUND (HTTP 404)
- MAPPING_INVALID (HTTP 422)
- INTERNAL_ERROR / INFRASTRUCTURE_UNAVAILABLE (HTTP 500 / 503)
- SCATTERED_PRODUCT_ROWS (rows) (Reported per row)
- FULL_SYNC_ARCHIVE_SKIPPED (job) (Shown on the job)
- Unknown currency or locale (FIELD_NOT_ALLOWED) (Reported per row)
- FIELD_NOT_ALLOWED: a field a vendor key may not set (rows) (Reported per row)
- SKU_CONFLICT (rows) (Reported per row)
- INVALID_ROW (rows) (Reported per row)
- held: a merchant hold (MERCHANT_HOLD) (HTTP 200)
- The webhook arrives late, twice, or not at all (Shown on the job)
- The storefront still shows an archived product (HTTP 200)
- Products exist but images are not copied (Shown on the job)
AUTHENTICATION_FAILED
Where you see it: HTTP 401.
{
"status": 401,
"body": {
"error": {
"code": "AUTHENTICATION_FAILED",
"message": "Authentication failed.",
"requestId": "…"
}
}
} Cause. The key is missing, misspelled, revoked or not a secret push key (a publishable pc_pk_ key never works here).
Fix. Send the full key as a bearer token. If it was revoked or lost, ask the merchant for a new one (a key is shown once).
curl "https://catalog.usethrottle.dev/v1/catalog/products?limit=1" -H "Authorization: Bearer $CATALOG_VENDOR_KEY" Confirm. The request answers 200.
VENDOR_PAUSED
Where you see it: HTTP 403.
{
"body": {
"error": {
"code": "VENDOR_PAUSED",
"message": "This vendor is paused.",
"requestId": "…"
}
}
} Cause. The merchant paused your vendor. Every key of the vendor is refused before the body is read; your products are untouched.
Fix. Ask the merchant to resume the vendor. Do not retry in a loop.
Confirm. A read such as GET /products?limit=1 answers 200.
VENDOR_KEY_NOT_ALLOWED
Where you see it: HTTP 403.
{
"status": 403,
"body": {
"error": {
"code": "VENDOR_KEY_NOT_ALLOWED",
"message": "A vendor key cannot call this route.",
"requestId": "…"
}
}
} Cause. A vendor key called a route only the store key may use (for example PUT /products/vendor, which assigns products to vendors).
Fix. Use the store key for that route, or ask the merchant. Vendor keys cover jobs, batches, stock, runs and their own products.
Confirm. The merchant or store-key caller gets 200 from the same route.
NOT_FOUND for something that exists
Where you see it: HTTP 404.
{
"status": 404,
"body": {
"error": {
"code": "NOT_FOUND",
"message": "Resource not found.",
"requestId": "…"
}
}
} Cause. A vendor key gets 404 (never 403) for another vendor's or the merchant's product, job or run, so it cannot tell it exists. It also answers for a wrong id, or a vendor product addressed by sku or handle you do not own.
Fix. Check the key and the id. Address a vendor product by externalId. The job id comes from the answer to POST /jobs.
curl "https://catalog.usethrottle.dev/v1/catalog/products?externalId=demo-1001" -H "Authorization: Bearer $CATALOG_VENDOR_KEY" Confirm. 200 with product.externalId equal to what you asked for.
INVALID_REQUEST
Where you see it: HTTP 400.
{
"status": 400,
"body": {
"error": {
"code": "INVALID_REQUEST",
"message": "The request is invalid.",
"requestId": "…",
"issues": [
{
"path": "rows",
"message": "Too small: expected array to have >=1 items"
}
]
}
}
} Cause. A malformed body, header or parameter: here an empty rows array. Also more than 1,000 rows, a bad Idempotency-Key length, or a field of the wrong type. issues names each problem by path.
Fix. Read issues[].path and fix that part. Send 1 to 1,000 rows; an Idempotency-Key is 1 to 200 characters.
curl -X POST "https://catalog.usethrottle.dev/v1/catalog/jobs/$JOB_ID/batches" -H "Authorization: Bearer $CATALOG_VENDOR_KEY" -H "Content-Type: application/json" -H "Idempotency-Key: $JOB_ID:1" -d '{ "rows": [ { "externalId": "demo-1001", "productHandle": "demo-ethiopia-guji", "sku": "DEMO-GUJI-250" } ] }' Confirm. 202 with rowsAccepted equal to the rows sent.
IDEMPOTENCY_KEY_REQUIRED
Where you see it: HTTP 400.
{
"status": 400,
"body": {
"error": {
"code": "IDEMPOTENCY_KEY_REQUIRED",
"message": "A request with an adjust row needs an Idempotency-Key.",
"requestId": "…"
}
}
} Cause. The request contains an adjust stock row (quantityMode adjust) and no Idempotency-Key. A delta must never apply twice, so the request is refused before anything is written.
Fix. Add an Idempotency-Key of 1 to 200 characters, one per delivery event (not per attempt). The SDK adds one for you.
curl -X PATCH "https://catalog.usethrottle.dev/v1/catalog/inventory" -H "Authorization: Bearer $CATALOG_VENDOR_KEY" -H "Content-Type: application/json" -H "Idempotency-Key: restock-2026-11-01-001" -d '{ "rows": [ { "sku": "DEMO-GUJI-250", "quantity": 3, "quantityMode": "adjust" } ] }' Confirm. 200 with results[0].status updated and the new quantity.
IDEMPOTENCY_CONFLICT
Where you see it: HTTP 409.
{
"status": 409,
"body": {
"error": {
"code": "IDEMPOTENCY_CONFLICT",
"message": "This Idempotency-Key was already used with a different body.",
"requestId": "…"
}
}
} Cause. The same Idempotency-Key arrived earlier with a different body. A retry must repeat the identical request.
Fix. Retry unchanged requests with their original key. When the content changed (you fixed a row), use a new key, for example the next batch number.
curl -X POST "https://catalog.usethrottle.dev/v1/catalog/jobs/$JOB_ID/batches" -H "Authorization: Bearer $CATALOG_VENDOR_KEY" -H "Content-Type: application/json" -H "Idempotency-Key: $JOB_ID:1-fixed" -d '{ "rows": [ ... ] }' Confirm. 202. A true retry (same key, same body) answers the first answer again with the Idempotent-Replayed header.
JOB_CLOSED
Where you see it: HTTP 409.
{
"status": 409,
"body": {
"error": {
"code": "JOB_CLOSED",
"message": "This job is closed or has expired.",
"requestId": "…"
}
}
} Cause. You sent a batch to a job that is closed or expired (24 hours open at most).
Fix. Open a new job and send the batch there. Do not retry in the closed job.
curl -X POST "https://catalog.usethrottle.dev/v1/catalog/jobs" -H "Authorization: Bearer $CATALOG_VENDOR_KEY" -H "Content-Type: application/json" -d '{ "mode": "merge" }' Confirm. 201 with state open, then the batch answers 202.
JOB_FULL
Where you see it: HTTP 409.
{
"body": {
"error": {
"code": "JOB_FULL",
"message": "This job already holds 2000 batches. Close it and open a new one.",
"requestId": "…"
}
}
} Cause. The job holds about 2,000 batches (roughly 2 million rows).
Fix. Close this job and open another for the rest. For a full sync that does not fit one job, split the catalog (the archive pass sees only one job).
Confirm. The new job accepts batches (202).
TOO_MANY_OPEN_JOBS
Where you see it: HTTP 409.
{
"status": 409,
"body": {
"error": {
"code": "TOO_MANY_OPEN_JOBS",
"message": "This caller already has 3 open jobs. Close one before opening another.",
"requestId": "…"
}
}
} Cause. You (or your vendor, or the store key) already have 3 open jobs. Idle open jobs count until they are closed or expire after 24 hours.
Fix. Close the jobs you do not need (a close of an empty job is fine), or wait for one to expire, then retry.
curl -X POST "https://catalog.usethrottle.dev/v1/catalog/jobs/$OLD_JOB_ID/close" -H "Authorization: Bearer $CATALOG_VENDOR_KEY" Confirm. POST /jobs answers 201.
RUN_IN_PROGRESS
Where you see it: HTTP 409.
{
"body": {
"error": {
"code": "RUN_IN_PROGRESS",
"message": "A full sync of this source is still open or running. Try again when it finishes.",
"requestId": "…"
}
}
} Cause. A full sync of the same source is still open or running. While it is, opening another job or sending a single batch is refused so the archive pass sees a consistent feed. Other vendors are not affected.
Fix. Close the full-sync job (or let it expire), wait for it to finish, then retry. The error is retryable.
Confirm. The write answers 202.
RATE_LIMITED
Where you see it: HTTP 429.
{
"body": {
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Try again in a minute.",
"requestId": "…"
}
}
} Cause. More than about 600 requests in 60 seconds on one key. The limiter is approximate.
Fix. Wait the Retry-After seconds, then resend the same request with the same Idempotency-Key. Send fewer, larger batches (up to 1,000 rows) and poll a job every 5 to 10 seconds, not continuously.
Confirm. The retried request answers 2xx.
PUSH_QUEUE_FULL
Where you see it: HTTP 429.
{
"body": {
"error": {
"code": "PUSH_QUEUE_FULL",
"message": "Too many batches are waiting to apply for this caller. Try again later.",
"requestId": "…"
}
}
} Cause. 2,000 of your batches are received but not yet applied, across open and running jobs. Retry-After is 60.
Fix. Wait 60 seconds and resend the same batch with the same key; the SDK does this up to 3 times. Close finished jobs so their batches can apply.
Confirm. The batch answers 202 and your jobs' batchesApplied rises.
REQUEST_BODY_TOO_LARGE
Where you see it: HTTP 413.
{
"body": {
"error": {
"code": "REQUEST_BODY_TOO_LARGE",
"message": "…",
"requestId": "…"
}
}
} Cause. A body over 5 MiB (5,242,880 bytes) on a batch, or over 1 MiB on PATCH /inventory.
Fix. Send fewer rows per batch (long descriptions make rows big). The SDK cuts batches under the byte limit for you.
Confirm. The smaller batch answers 202.
REPORT_TOO_LARGE
Where you see it: HTTP 409.
{
"body": {
"error": {
"code": "REPORT_TOO_LARGE",
"message": "…",
"requestId": "…"
}
}
} Cause. A job CSV (errors.csv or stock.csv) would be above 900 parts, which happens only on jobs of about 900 batches or more with problems in most of them.
Fix. For stock results, page GET /jobs/{jobId}/stock instead. For errors, split a feed that large into smaller jobs.
curl "https://catalog.usethrottle.dev/v1/catalog/jobs/$JOB_ID/stock" -H "Authorization: Bearer $CATALOG_VENDOR_KEY" Confirm. The paged stock answer is 200.
REPORT_EXPIRED
Where you see it: HTTP 410.
{
"body": {
"error": {
"code": "REPORT_EXPIRED",
"message": "This report has expired.",
"requestId": "…"
}
}
} Cause. Reports are kept 30 days after the run finishes (reportExpiresAt).
Fix. Download reports within 30 days. For an older run, send the feed again.
Confirm. A new run has a reportExpiresAt 30 days ahead.
HANDLE_TAKEN
Where you see it: HTTP 409.
{
"body": {
"error": {
"code": "HANDLE_TAKEN",
"message": "…",
"requestId": "…"
}
}
} Cause. A rename to a handle another product already uses.
Fix. Choose a free handle.
curl -X POST "https://catalog.usethrottle.dev/v1/catalog/products/rename" -H "Authorization: Bearer $CATALOG_VENDOR_KEY" -H "Content-Type: application/json" -d '{ "product": { "externalId": "demo-1001" }, "handle": "demo-ethiopia-guji-2" }' Confirm. 200 with the renamed product; the old URL redirects.
IDENTIFIER_LOCKED
Where you see it: HTTP 409.
{
"body": {
"error": {
"code": "IDENTIFIER_LOCKED",
"message": "…",
"requestId": "…"
}
}
} Cause. A rename of a product with no externalId that an import or the API supplies.
Fix. Rename by externalId: push a row with the new productHandle and the same externalId.
Confirm. The rename answers 200.
PROFILE_NOT_FOUND / VENDOR_NOT_FOUND
Where you see it: HTTP 404.
{
"body": {
"error": {
"code": "PROFILE_NOT_FOUND",
"message": "…",
"requestId": "…"
}
}
} Cause. The mapping profile (?profile=) or the vendor in the request does not exist. Vendor batches must use canonical rows: a mapping profile cannot set externalId.
Fix. Drop the profile parameter and send canonical rows, or fix the name or vendor id.
Confirm. The batch answers 202.
MAPPING_INVALID
Where you see it: HTTP 422.
{
"body": {
"error": {
"code": "MAPPING_INVALID",
"message": "…",
"requestId": "…"
}
}
} Cause. The mapping profile cannot map these rows.
Fix. Fix the profile, or send canonical rows.
Confirm. The batch answers 202.
SCATTERED_PRODUCT_ROWS (rows)
Where you see it: Reported per row.
{
"batch": {
"batchId": "12198461-bafe-4cb4-9f25-8d11b8e43ee3",
"sequence": 1,
"rowsAccepted": 1,
"rowsRefused": 2,
"issues": [
{
"index": 0,
"code": "SCATTERED_PRODUCT_ROWS"
},
{
"index": 2,
"code": "SCATTERED_PRODUCT_ROWS"
}
]
},
"errorsCsv": "row_number,sku,field,severity,code,message,external_id\n1,PUSHQA-DOCS-S-1,,error,SCATTERED_PRODUCT_ROWS,Rows of this product are not next to each other. In replace mode none of the product's rows were applied; in merge mode later rows win.,pushqa-docs-1006\n3,PUSHQA-DOCS-S-2,,error,SCATTERED_PRODUCT_ROWS,Rows of this product are not next to each other. In replace mode none of the product's rows were applied; in merge mode later rows win.,pushqa-docs-1006\n"
} Cause. In a replace job, rows of one product were not next to each other, or were split over two batches. Every row of that product is refused and nothing of it applies (here both rows of pushqa-docs-1006; the other product applied).
Fix. Keep each product's rows together, in one batch. Sort the feed by product before cutting batches; the SDK never splits a product.
Confirm. rowsRefused is 0 on the batch answer.
FULL_SYNC_ARCHIVE_SKIPPED (job)
Where you see it: Shown on the job.
{
"job": {
"state": "closed",
"finishedAt": "2026-11-01T19:54:02.415Z",
"failureCode": "FULL_SYNC_ARCHIVE_SKIPPED",
"rowsReceived": 0,
"rowsApplied": 0
}
} Cause. fullSync only archives when the whole job was clean and saw rows. A row was refused or failed, or the job was empty (shown here). Nothing was archived; this is a safety guard, not data loss.
Fix. Read errors.csv, fix the rows and send the whole feed again in a new full-sync job.
Confirm. finishedAt is set with failureCode null, and the products the feed no longer lists show status archived.
Unknown currency or locale (FIELD_NOT_ALLOWED)
Where you see it: Reported per row.
{
"batch": {
"batchId": "a2055bbe-7828-4f4a-a5b2-134752c36040",
"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,variant.prices.JPY.priceMinor,error,FIELD_NOT_ALLOWED,The store has no price list in this currency.,pushqa-docs-1001\n"
} Cause. A vendor row prices in a currency the store has no price list for (here JPY), or translates into a locale the store has not enabled. The row fails with "The store has no price list in this currency." and nothing of the row applies.
Fix. Ask the merchant to add the price list or enable the locale, or remove that price or translation from the row. The error report's field column names the part (variant.prices.JPY.priceMinor).
Confirm. The job finishes with rowsFailed 0.
FIELD_NOT_ALLOWED: a field a vendor key may not set (rows)
Where you see it: Reported per row.
{
"batch": {
"batchId": "bfd15121-…",
"sequence": 1,
"rowsAccepted": 0,
"rowsRefused": 1,
"issues": [
{
"index": 0,
"code": "FIELD_NOT_ALLOWED"
}
]
},
"job": {
"rowsApplied": 0,
"rowsFailed": 1,
"failureCode": null
},
"errorsCsv": "row_number,sku,field,severity,code,message,external_id\n1,PUSHQA-DOCS-GUJI-250,groupPrices,error,FIELD_NOT_ALLOWED,This field cannot be written with this key.,pushqa-docs-1001\n"
} Cause. A vendor row set something only the store may set, for example customer-group prices (groupPrices, shown here) or a raw_ attribute key. The batch answer gives only { index, code }; the error report's field column names the field. The row is refused and nothing of it applies.
Fix. Remove that field from the row. Customer-group prices, new attribute types and other store settings are set by the merchant.
Confirm. The corrected row is accepted (rowsRefused 0) and the job finishes with rowsFailed 0.
SKU_CONFLICT (rows)
Where you see it: Reported per row.
{
"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"
} Cause. The SKU already belongs to another vendor's or the merchant's product. SKUs are unique per store, and a push never takes one over. The batch is accepted; the row fails when applied.
Fix. Use a SKU of your own, or ask the merchant to assign the existing product to you (your first push then links it by SKU).
Confirm. The job finishes with rowsFailed 0 and the product is readable by externalId.
INVALID_ROW (rows)
Where you see it: Reported per row.
{
"batch": {
"batchId": "a43f104d-bcb0-4a41-93d0-e2316458d52a",
"sequence": 1,
"rowsAccepted": 1,
"rowsRefused": 1,
"issues": [
{
"index": 1,
"code": "INVALID_ROW"
}
]
},
"errorsCsv": "row_number,sku,field,severity,code,message,external_id\n2,,sku,error,INVALID_ROW,This row does not match the push row format.,pushqa-docs-bad\n"
} Cause. A row does not match the push row format (here, no sku). The batch answer gives only the index and code; the error report adds the field once the job has run.
Fix. Fix the row and send it again in the same job with a new Idempotency-Key. Rows count from 1 across the whole job in the report.
Confirm. rowsRefused is 0 on the corrected batch.
held: a merchant hold (MERCHANT_HOLD)
Where you see it: HTTP 200.
Cause. The merchant archived or drafted a vendor product in the dashboard. Your pushes update its data, but a restore or a status active row cannot republish it. Archive and restore answers give the product the result { index, status: "held" }, and a pushed row reports the warning MERCHANT_HOLD: the row applied, the product stays a draft.
Fix. Ask the merchant to lift the hold. Nothing is wrong on your side; held is a result, never an error.
Confirm. GET /products?externalId=… shows held false, and a restore answers restored.
The webhook arrives late, twice, or not at all
Where you see it: Shown on the job.
Cause. The first delivery attempt can fail while your endpoint is cold or unreachable, and a later retry delivers it (the schedule runs about 44.6 hours in all). A retry after a slow 2xx can deliver an event twice. Endpoints must be https, answer 2xx within 3 seconds, and not redirect.
Fix. Answer 2xx at once and work afterwards. Dedupe on the event id. If an outage was longer than the schedule, reconcile from GET /jobs/{jobId}.
curl "https://catalog.usethrottle.dev/v1/catalog/jobs/$JOB_ID" -H "Authorization: Bearer $CATALOG_VENDOR_KEY" Confirm. The job shows finishedAt, whether or not the event arrived.
The storefront still shows an archived product
Where you see it: HTTP 200.
{
"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"
}
]
}
} Cause. The storefront edge cache holds an answer for at most 60 seconds, so an archived product can show for up to a minute.
Fix. Nothing to fix. It is gone on the next uncached read.
Confirm. After a minute the storefront answers 404 for the handle.
Products exist but images are not copied
Where you see it: Shown on the job.
Cause. Image copies run alongside a job at about 200 to 285 per minute at peak (one production run, 2026-11-01, Worker e2f3c98a); copies still waiting when the job ends drain through a 5-minute sweep of up to 100 jobs per run, about 20 per minute. Until a copy lands, the storefront serves your own image URL. A catalog is also budgeted 20,000 image resizes per calendar month.
Fix. Nothing to fix: the storefront works meanwhile. Keep the image URLs public and stable; see Limits.
Confirm. The storefront product's images show the catalog URL after the copy.