Last updated: 26 August 2026
Get an API key
Sign in with a merchant account, open Settings → Integrations, select the API Keys tab, and create a key. Copy it immediately: the complete key is shown only once. You can revoke keys from the same page.
Base URL and authentication
https://a.ivo.md/v1/merchant-api
Send the key with every request using either header:
X-API-Key: <YOUR_API_KEY>
Authorization: Bearer <YOUR_API_KEY>
Always send Accept: application/json, and Content-Type: application/json except for file uploads. Without the Accept header, authentication failures are returned as an HTML error page instead of JSON.
Response envelope
| Case | HTTP | Body |
|---|---|---|
| Success | 200 | The endpoint's fields plus "status": "success". |
| Handled error | 202, 403, 404, 409, 422, 500 | "status": "error", a machine-readable message (the error code), and any extra context fields. |
| Authentication failure | 403 | Raised before the controller runs, so it has no status field — only {"message": "api_key_invalid"}. Codes: api_key_missing, api_key_invalid, merchant_not_found, integration_disabled. |
| Rate limited | 429 | 120 requests per minute per IP address. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining. |
Important: on a 200 response the top-level status field is always the literal string success — it says the call was accepted, nothing more. Endpoints that also have work in flight report it under a key of their own: sync_status on the sync endpoints, and import_status on the import endpoints.
A 202 response is an error envelope whose message says why the answer is not ready yet: queued, processing, or pending_approval.
Check the connection
GET /check
Validates the API key and returns the merchant identity. This is the endpoint to call first when wiring up an integration.
Input: none beyond the API key header.
{
"merchant_id": "<MERCHANT_ID>",
"merchant_name": "My Shop",
"status": "success"
}
| Output field | Type | Meaning |
|---|---|---|
merchant_id | string | The IVO identifier of the merchant this key belongs to. Store it: it never changes and it identifies the account in support requests. |
merchant_name | string | The merchant's display name in IVO. |
status | string | Always success. It confirms the key is valid and not revoked; it is not the merchant's account status. |
Merchant points
Every offer belongs to a merchant point (a shop, warehouse or pickup location). Use the point ID returned by these endpoints as merchant_point_id. If the account has exactly one point, product sync selects it automatically and you can omit the field.
GET /merchant-points
Returns all points belonging to the authenticated merchant.
{
"points": [
{
"_id": "<POINT_ID>",
"name": "Main store",
"contact_person": "Ion Popescu",
"email": "[email protected]",
"phone": "+37360000000",
"street_id": "<STREET_ID>",
"number": "12",
"city_id": "<CITY_ID>",
"state_id": "<STATE_ID>",
"country": "MD",
"zip_code": "MD-2001",
"status": "approved",
"is_active": true
}
],
"status": "success"
}
| Output field | Type | Meaning |
|---|---|---|
points[]._id | string | The point ID. This is the value to send as merchant_point_id. |
points[].name | string | The point name. It can also be sent as merchant_point instead of the ID; the match is exact and case-insensitive. |
points[].contact_person, email, phone | string | Who the courier and IVO support contact at this location. |
points[].street_id, number, block, entrance, floor, apartment, intercom, zip_code, city_id, state_id, country | string | The pickup address. The location IDs are IVO's own street/city/state identifiers. |
points[].status | string | pending (waiting for IVO approval), approved, or rejected. Offers at a point that is not approved stay invisible on the site. |
points[].is_active | boolean | The merchant's own on/off switch. Offers at an inactive point stay invisible. |
points[].rejection_reason | string, null | Why IVO rejected the point, when status is rejected. |
POST /merchant-points/store
Creates a point. It is always created with status: pending and is_active: true, and stays invisible to buyers until IVO approves it.
| Input field | Type | Meaning |
|---|---|---|
name | string | How the location is named in your system and in the merchant panel. |
contact_person | string | The person the courier asks for at pickup. |
email, phone | string | Contact details for this location. |
street_id, city_id, state_id | string | IVO location identifiers for the address. |
number, block, entrance, floor, apartment, intercom | string | The rest of the street address, so the courier reaches the right door. |
zip_code, country | string | Postal code and country code. |
Any field outside this list is ignored. The response is {"point": { … }, "status": "success"}, where point is the created record including its new _id.
POST /merchant-points/update/{id}
Updates a point owned by the merchant. {id} is the point ID. Accepts every creation field plus is_active (boolean — set it to false to take the location's whole stock offline without deleting anything). Returns the updated point. Unknown ID: point_not_found (404).
POST /merchant-points/delete/{id}
Removes a point owned by the merchant. Returns {"deleted": true, "status": "success"}. The record is soft-deleted, so its history is kept, but the point can no longer be used for offers.
Update an existing offer
POST /product-offer/update
Updates price or stock on an offer that already exists, without running the product import. This is the cheapest and fastest endpoint — use it for regular price and stock refreshes.
{
"merchant_internal_id": "PRODUCT-123",
"merchant_point_id": "<POINT_ID>",
"price": 1999,
"currency": "MDL",
"availability": 5,
"availability_on_order": 0
}
| Input field | Type | Meaning |
|---|---|---|
offer_id | string | The IVO offer ID. Send this or merchant_internal_id. The offer must belong to your merchant account. |
merchant_internal_id | string | Your own product identifier (SKU), as sent when the offer was created. Searched within your account only. |
merchant_point_id | string | Only used together with merchant_internal_id: it narrows the search to one point. Required when the same SKU exists at several points, otherwise the call fails with merchant_internal_id_not_unique. |
price | number | The new selling price, in currency. An offer with a price of 0 or empty is never shown on the site. |
currency | string | ISO 4217 code. Accepted: MDL (default), EUR, USD, RON, UAH, RUB, GBP. Prices in another currency are converted for display at IVO's stored rate. |
availability | integer | Units physically in stock at this point and shippable immediately. |
availability_on_order | integer | Units you can supply on order (supplier stock). Keeps the product buyable when availability is 0, with a longer delivery time. |
At least one of availability, availability_on_order, price or currency must be present; sending only an identifier fails with no_fields_to_update. Fields you omit keep their current value. Every change is written to the offer's price/stock history.
{
"offer": {
"id": "<OFFER_ID>",
"merchant_internal_id": "PRODUCT-123",
"product_id": "<PRODUCT_ID>",
"merchant_point_id": "<POINT_ID>",
"condition": "new",
"quality": null,
"price": 1999,
"currency": "MDL",
"availability": 5,
"availability_on_order": 0
},
"status": "success"
}
| Output field | Type | Meaning |
|---|---|---|
offer.id | string | The offer ID. Reuse it as offer_id to skip the SKU lookup on later calls. |
offer.merchant_internal_id | string, null | Your SKU as stored on the offer. |
offer.product_id | string | The IVO product (the variant) this offer is attached to. |
offer.merchant_point_id | string | The point the offer is stocked at. |
offer.condition | string | new, used or refurbished. |
offer.quality | string, null | Free-text grade used to tell apart several non-new offers of the same product. |
offer.price, offer.currency, offer.availability, offer.availability_on_order | number / string / integer | The stored values after the update. |
Errors: missing_identifier (422) — neither offer_id nor merchant_internal_id was sent; no_fields_to_update (422); offer_not_found (404) — no such offer, or it belongs to another merchant; merchant_internal_id_not_unique (409) — the SKU matches several offers, and the body carries count. Send merchant_point_id to disambiguate.
Product and offer information
GET /product/{id}/info
Use the offer ID as {id}, not a product ID. The offer must belong to the authenticated merchant.
{
"product": {
"id": "<OFFER_PRODUCT_ID>",
"root_product_id": "<ROOT_PRODUCT_ID>",
"type": "variant",
"status": "active",
"name": {"ro": "Nume", "en": "Name", "ru": "Название"},
"slug": {"ro": "nume", "en": "name", "ru": "nazvanie"},
"url": "https://ivo.md/nume/p",
"urls": {"ro": "https://ivo.md/nume/p", "en": "https://ivo.md/en/name/p", "ru": "https://ivo.md/ru/nazvanie/p"}
},
"offer": {
"id": "<OFFER_ID>",
"price": 1999,
"currency": "MDL",
"availability": 5,
"availability_on_order": 0
},
"history": [
{
"price": 2099,
"currency": "MDL",
"availability": 3,
"availability_on_order": 0,
"changed_at": "2026-08-20T11:04:35+03:00",
"source": "merchant_api"
}
],
"status": "success"
}
| Output field | Type | Meaning |
|---|---|---|
product.id | string | The product the offer is attached to. For a product with variants this is the variant, not the parent. |
product.root_product_id | string | The main (parent) product. Several variants share it, and the public page belongs to it. |
product.type | string | main or variant. |
product.status | string | The catalogue status of the offer's product, e.g. active or draft. |
product.name | object | The catalogue name per language (ro, en, ru). IVO writes it, so it can differ from the name you sent. |
product.slug | object | The URL segment per language. |
product.url | string | The Romanian public URL — the shortest link to give a buyer. |
product.urls | object | The public URL per language. |
offer.id, offer.price, offer.currency, offer.availability, offer.availability_on_order | — | The offer's current values, as IVO stores them right now. |
history[] | array | Up to 100 price/stock changes, most recent first. Each entry carries price, currency, availability, availability_on_order, changed_at (ISO 8601) and source — which system made the change (merchant_api, api_sync, and so on). |
Errors: offer_not_found (404); product_not_accessible (403) — the offer belongs to another merchant; product_not_found (404); pending_approval (202) — the main product is not published yet, and the body carries product_id and product_status; product_slug_missing (422).
POST /product/info-by-sku
The same detailed answer, found by your own SKU instead of an IVO ID. Use it when you do not store IVO identifiers on your side.
{
"import_name": "daily-sync",
"merchant_internal_id": "PRODUCT-123"
}
| Input field | Type | Meaning |
|---|---|---|
merchant_internal_id | string, required | Your SKU. The lookup searches the imported rows of your merchant account. |
import_name | string, required | Validated as required, but the lookup does not filter by it: a SKU is searched across all of your imports. Send the same name you used on /product/sync. |
On success the body is identical to GET /product/{id}/info. While the product is still travelling through the pipeline, the answer is an error envelope:
| message | HTTP | Meaning |
|---|---|---|
not_imported | 404 | No imported row carries this SKU. Send the product with /product/sync first. |
queued | 202 | The row is waiting to be processed. Poll again. |
processing | 202 | The row is being processed right now. Poll again. |
pending_approval | 202 | The product exists but is not published yet. Nothing to do on your side. |
| the row's error code | 500 | Processing failed. The body carries import_row_id, import_id, ai_product_id, ai_error and error. |
| the row's offer status | 404 | The row completed without producing an offer (for example unauthorized_category). |
offer_deleted | 404 | The row points at an offer that no longer exists. |
Create or synchronize one product
POST /product/sync
Creates a product or updates an existing offer. Reusing the same import_name with a stable merchant_internal_id updates the same row instead of creating duplicates, so this endpoint is safe to call repeatedly for the same catalogue.
{
"mode": "async",
"import_name": "daily-sync",
"merchant_internal_id": "PRODUCT-123",
"name": "Example Phone 256 GB Black",
"price": 1999,
"currency": "MDL",
"availability": 5,
"availability_on_order": 0,
"merchant_point_id": "<POINT_ID>",
"brand": "Example",
"ean": "5940000000000",
"description": "Product description",
"images": ["https://example.com/product.jpg"]
}
| Input field | Type | Meaning |
|---|---|---|
price | number, required | Selling price, greater than 0 (minimum 0.01). Must be a plain number — 1999 or 1999.00, not "1 999,00". |
name | string, required without product_id | The full product name. IVO matches it against the catalogue and creates the product from it, so include the brand, model and the distinguishing attributes (capacity, colour). A name that is too vague is rejected with the row error too_generic. |
product_id | string | An existing IVO product ID. Bypasses catalogue matching completely and only writes the offer — the fastest path when you already know the product. If that product already has variants, you must also send offer_product_id. |
offer_product_id | string | The variant of product_id the offer attaches to. Required when the main product has variants, because IVO never guesses which variant you are stocking. |
merchant_internal_id | string | Your own stable product identifier (SKU). It is the key that links your catalogue to IVO on every later call. Strongly recommended: without it, rows are matched by a hash of the whole payload. |
legacy_merchant_internal_id / legacy_merchant_internal_ids | string / array | Identifiers this product used to be sent under. Use them when your SKU scheme changes, so the existing row and offer are reused instead of a duplicate product being created. |
import_name | string | The name of the import list this product belongs to, and the key you poll status with. It is slugified: spaces and punctuation become _ or are dropped, and it is cut to 64 characters ("My Daily Sync" → my_daily_sync). Empty means default. Use one stable name per source. |
merchant_point_id / merchant_point | string | The point ID, or the exact point name. If the account has a single point, it is selected automatically. |
availability | integer or string | Immediate stock. Text is parsed leniently: "10 pcs" → 10, "2-3" → 3, "in stock"/"da" → 1, "out of stock" and anything unrecognised → 0. If neither availability nor availability_on_order is sent, immediate stock defaults to 1. |
availability_on_order | integer or string | Stock available on order, parsed the same way. |
currency | string | ISO 4217 code, MDL by default. |
condition | string | new (default), used or refurbished. One new offer exists per product and point; non-new offers may be several. |
quality | string | Free-text grade that tells apart several non-new offers of the same product at the same point. |
brand | string | Brand name. Helps matching and is used when the product has to be created. |
ean, asin, jan | string | Global product barcodes/identifiers. The strongest matching signal there is — send them when you have them. |
description | string | Product description used for matching and, for a new product, as source material. |
images (or image) | array or string | Image URLs, first one first. IVO downloads them; they must be publicly reachable. |
weight, volume | number | Shipping weight (grams) and volume, used for delivery pricing. |
item_group_id, item_group_title, variant_option, variant_options | string / object | Variant grouping hints: products sharing an item_group_id are treated as variants of one parent, and the option values say what distinguishes them (colour, size). |
force_reprocess | boolean | true restarts the row from zero, dropping previous matching results. Use it to recover a row that was matched wrongly — not on every sync, because it re-runs the whole matching pipeline. |
mode | string | async (default) returns immediately. sync holds the connection until the row finishes, up to a server-side limit (300 seconds by default), and returns anyway if the work is still running. |
Any other field is stored with the row but is only understood if its name matches an import column.
Responses
All three are HTTP 200 with "status": "success". Which fields are present tells you what happened.
| Fields returned | What happened |
|---|---|
sync_status: "completed", offer_id, offer_product_id, message: "Offer saved (AI bypassed)" | You sent product_id: the offer was written directly, with no matching and no approval step. |
sync_status: "completed", offer_id, offer_product_id, import_id, row_id, and matched_by when a legacy identifier was used | An existing offer with this merchant_internal_id was found and updated immediately. This is the normal answer for a product already in the catalogue. |
sync_status: "processing", import_id, message: "Product creation started" | A new — or materially renamed — product entered the import and approval flow. Poll /product-import/status or /product/info-by-sku for the outcome. |
| Output field | Type | Meaning |
|---|---|---|
sync_status | string | completed — the offer exists and nothing is pending. processing — the product was queued and you must poll for the outcome. This is the field to branch on, not status. |
offer_id | string | The created or updated offer. Store it: it makes later price/stock updates a single fast call. |
offer_product_id | string | The IVO product (variant) the offer hangs on. |
import_id | string | The import list this product was filed under — the one named by import_name. |
row_id | string | The import row for this product. |
matched_by | string | merchant_internal_id, or legacy_merchant_internal_id when the offer was found through one of the old identifiers you supplied. |
row_status | string | Only in mode: sync when the wait expired: the row's state at that moment. |
Errors: invalid_product_id (422), product_not_found (404), offer_product_not_found (404), offer_product_invalid (422) — the variant does not belong to that main product, missing_offer_product_id (422), missing_merchant_point (422) — no point could be resolved on the product_id path, invalid_price (422), unauthorized_category (403) — your account is not authorized to sell in that category, integration_not_found / integration_disabled (403) — the import_name names an integration that was deleted or switched off, and processing_failed (422) in mode: sync with error and import_id.
Synchronize multiple products
POST /product/sync-multiple
Between 1 and 100 products per request. Each entry of products takes exactly the fields of /product/sync, plus the extras below. This is the endpoint to build a full catalogue sync on.
{
"mode": "async",
"import_name": "daily-sync",
"image_base_url": "https://shop.example.com/images",
"products": [
{
"merchant_internal_id": "PRODUCT-123",
"name": "Example Phone 256 GB Black",
"price": 1999,
"availability_by_point": {
"<POINT_ID_1>": 5,
"<POINT_ID_2>": 2
}
}
]
}
| Batch field | Type | Meaning |
|---|---|---|
products | array, required | 1 to 100 product objects. Split a larger catalogue into consecutive requests with the same import_name. |
import_name | string | Same rules as on /product/sync, and the same list: the two endpoints share an import when the name matches. |
mode | string | async (default) or sync. |
image_base_url | string (URL) | Prefix prepended to relative image paths, so a feed can send /catalog/x.jpg. Stored on the import and reused on retries. |
image_split_spaces | boolean | Treat spaces inside an image value as separators between several paths. |
image_split_commas | boolean | Treat commas inside an image value as separators between several paths. |
integration_run_id | string, max 64 | Marks every row of one full catalogue run. It lets IVO measure progress against the run's real total instead of the size of one batch, and lets a new run discard stock left by the previous configuration. |
| Per-product extra | Type | Meaning |
|---|---|---|
availability_by_point | object | Stock per point: {"<POINT_ID>": 5}. Send the complete picture for that SKU in one entry — it replaces the previous snapshot. The product then lands on a single import row instead of one row per point. |
availability_only | boolean | true makes the entry a pure stock update: only merchant_internal_id and availability are required, name and price are not. Unknown identifiers are skipped instead of creating products — this is a refresh stream, not a product source. |
force_reprocess | boolean | Skips the fast existing-offer path and restarts the row's matching. |
Response: sync_status (completed when every product was already up to date, processing when rows were queued) and import_id, plus message when work was queued. Nothing is returned per product — read individual outcomes from /product-import/status or /product/info-by-sku. A payload that fails validation is rejected as a whole with validation_failed (422) plus the index of the offending product and its errors; also invalid_product_payload (422) and products_required (422).
Upload a product file
POST /product-import/upload-and-import
Sends a whole catalogue file in one request, exactly like uploading it in the merchant panel. Use multipart/form-data.
| Input field | Type | Meaning |
|---|---|---|
file | file, required | The catalogue file. Accepted extensions: XLSX, XLS, CSV, TSV, ODS, JSON and XML. |
mode | string | async (default) returns as soon as the file is queued. sync waits for the import to finish, up to 300 seconds by default. |
import_name | string | Optional name to file this upload under, slugified like everywhere else. Pass one when you intend to poll the import: GET /product-import/status then finds it by that name. Without it the import keeps the uploaded file's own name, and you poll by import_id (or by that exact file name). |
columns | array | Column mapping, one entry per column of the file in order. Use the canonical names: name, description, brand, ean, asin, jan, weight, volume, image, availability, availability_on_order, price, price_and_currency, currency, merchant_internal_id, and ignore for columns to skip. Map several columns to image to import several images. When columns is omitted, IVO detects the mapping from the header row. |
| Output field | Type | Meaning |
|---|---|---|
import_id | string | The created import. Keep it: it is what you poll the status endpoint with. |
import_name | string | The name the import was filed under — your import_name after slugification, or the uploaded file's name. Poll with exactly this value. |
import_status | string | The import's real state: pending, ready_to_process, processing, completed, completed_with_errors or error. |
message | string | Import started successfully, or a note that the work is still running in mode: sync. |
import | object | Only in mode: sync when the import reached a final state: the full import record. |
For recurring synchronization prefer /product/sync-multiple: it updates the same rows in place, reports per-product outcomes, and needs no file.
Check import status
GET /product-import/status
Reports the progress of any import you created — through /product/sync, /product/sync-multiple or /product-import/upload-and-import — and the outcome for one product in it.
GET /product-import/status?import_name=daily-sync&merchant_internal_id=PRODUCT-123
GET /product-import/status?import_id=<IMPORT_ID>
| Input field | Type | Meaning |
|---|---|---|
import_id | string | The import to report on, as returned by any sync or upload call. Send this or import_name; sending neither fails with missing_identifier (422). |
import_name | string | The import list name. Matched both slugified (as sync files it) and exactly as written (as an upload files it), so the name you were handed back always resolves. Must belong to your merchant account. |
merchant_internal_id | string | Which product's row to report. Can be omitted only when the import holds exactly one row; on a multi-row import, omitting it fails with merchant_internal_id_required (422). |
{
"import_id": "<IMPORT_ID>",
"import_name": "daily-sync",
"import_status": "processing",
"error_type": null,
"processed_rows": 87,
"total_rows": 120,
"row_count": 120,
"row": {
"id": "<ROW_ID>",
"row_number": 12,
"status": "completed",
"error": null,
"merchant_internal_id": "PRODUCT-123",
"ai_product_id": "<AI_PRODUCT_ID>",
"offer_product_id": "<VARIANT_ID>",
"product_id": "<PRODUCT_ID>",
"offer_id": "<OFFER_ID>",
"product": {
"id": "<VARIANT_ID>",
"root_product_id": "<ROOT_PRODUCT_ID>",
"type": "variant",
"root_type": "main",
"status": "active",
"name": {"ro": "Nume", "en": "Name", "ru": "Название"},
"slug": {"ro": "nume", "en": "name", "ru": "nazvanie"},
"url": "https://ivo.md/nume/p",
"urls": {"ro": "https://ivo.md/nume/p"},
"has_public_url": true
},
"started_at": "2026-08-26T09:12:00+03:00",
"ended_at": "2026-08-26T09:12:41+03:00"
},
"status": "success"
}
| Output field | Type | Meaning |
|---|---|---|
import_id | string | The import this request resolves to. |
import_name | string | The name it is filed under. |
import_status | string | The state of the whole import: pending, ready_to_process, processing, completed, completed_with_errors or error. This is the import's state — the top-level status only says the request itself succeeded. |
processed_rows / total_rows | integer | Progress of the whole import: rows that reached completed or error, out of the expected total. Equal values mean the import is finished. |
row_count | integer | How many rows the import currently holds. |
error_type | string, null | Set when the import itself failed (an unreadable file, for instance), rather than an individual row. |
row.status | string | The state of the selected product: ready (queued), processing, completed, or error. This is the field to poll for one SKU. |
row.error | string, null | Why this row failed, as a machine-readable code — see the row-level codes below. |
row.merchant_internal_id | string, null | Your SKU as stored on the row. |
row.ai_product_id | string, null | The intermediate record produced while the product was being built. Useful only for support requests. |
row.product_id / row.offer_product_id | string, null | The matched product, and the variant the offer attaches to. |
row.offer_id | string, null | The resulting offer. Once it is present, later updates can go through /product-offer/update. |
row.product | object, null | Public product information: name and slug per language, url and urls, catalogue status, and has_public_url — false while the product has no live page yet. |
row.started_at / row.ended_at | datetime, null | When processing of this row began and ended. |
status | string | Always success — it reports that the request itself succeeded. The import's own state is import_status. |
Errors: missing_identifier (422) — neither import_id nor import_name was sent, import_not_found (404), import_row_not_found (404), merchant_internal_id_required (422).
When an offer becomes visible
A successful call is not the same as a live product. An offer appears on ivo.md only when all of the following hold:
- the merchant account is active;
- its merchant point is
approvedandis_active; - the product has passed IVO's approval and has a public page;
priceis present and greater than zero;availabilityoravailability_on_orderis greater than zero.
If a synced product does not appear, check these five in order before opening a support request.
Common error codes
| Code | HTTP | Meaning |
|---|---|---|
api_key_missing | 403 | No X-API-Key and no Authorization: Bearer header. |
api_key_invalid | 403 | Unknown or revoked key. |
merchant_not_found | 403 | The key is valid but its merchant account no longer exists. |
integration_disabled / integration_not_found | 403 | The import_name names an integration that the merchant switched off or deleted. |
unauthorized_category | 403 | Your account is not authorized to sell in that category. |
product_not_accessible | 403 | The offer belongs to another merchant. |
offer_not_found, product_not_found, offer_product_not_found, point_not_found, import_not_found, import_row_not_found, not_imported, offer_deleted | 404 | The named record does not exist, or does not belong to your account. |
merchant_internal_id_not_unique | 409 | The SKU matches several offers. Add merchant_point_id. |
missing_identifier, no_fields_to_update, invalid_price, invalid_product_id, missing_merchant_point, missing_offer_product_id, offer_product_invalid, merchant_internal_id_required, products_required, invalid_product_payload, validation_failed, product_slug_missing, processing_failed | 422 | The payload is incomplete or inconsistent. validation_failed carries index and errors. |
queued, processing, pending_approval | 202 | Not an error: the answer is not ready yet. Poll again. |
Row-level import errors appear in row.error rather than as an HTTP status: too_generic (the name does not identify a specific product), missing_name_value, missing_category, unauthorized_category, variant_not_found, duplicate, no_availability, and missing_merchant_point.