Import a BMECAT catalog
Your supplier drops a BMECAT catalog on your SFTP inbox, and the articles are products in your tenant by the next run. The moving parts are an import profile that says how the catalog maps onto your entities, an uploaded object, and a run that streams one into the other. This page builds that over the Import & Export API so you can run it from your own code, then puts the same thing on a schedule with the official Integration Studio template.
This is for developers and integrators connecting a PIM, ERP, or supplier feed. You need a tenant with Import & Export, your tenant slug, an API key (create an API key) carrying the io scope (plus products.list for the check in step 4, see which scope covers which app), and a BMECAT file. Nothing is installed locally. Every call goes through the API gateway with the same two headers (X-Revenexx-Tenant and X-Revenexx-Api-Key).
export REVENEXX_TENANT="<TENANT_SLUG>"
export REVENEXX_API_KEY="rvxk_..."
The examples capture ids with jq so each step can use what the previous one produced.
T_NEW_CATALOG, T_UPDATE_PRODUCTS and T_UPDATE_PRICES.1. Create the import profile
A profile is a saved, reusable mapping: a direction, a format, the entity it writes, and the natural keys it upserts on. BMECAT lives here and only here — format: "bmecat" is profile-only. The ad-hoc /v1/io/imports route does not accept it, so a catalog is always run through a profile.
If you do not already know the mapping your app's schema needs, take the shortcut first. The official template (step 6) provisions a working bmecat profile, and installing it is a Cockpit action you can do now. Install it, GET /v1/io/profiles, take that profile's id as $PROFILE_ID and carry on at step 2. The create call below then shows you the shape of what the template made.
PROFILE_ID=$(curl -s -X POST https://api.revenexx.com/v1/io/profiles \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Supplier BMECAT catalog",
"direction": "import",
"format": "bmecat",
"vendor": "revenexx",
"app": "products",
"entity": "products",
"apply_mode": "upsert",
"mapping": {
"keys": ["sku"],
"fields": [
{ "source": "SUPPLIER_PID", "target": "sku" }
]
}
}' | jq -r .id)
name, direction, vendor, app, entity and format are all required. vendor, app and entity together name the table the articles are written to, and app and entity are lower-case identifiers (^[a-z][a-z0-9_]{0,62}$).
mapping.keys is the one to get right first. It names the natural-key columns the upsert matches on, so it decides whether a second run updates products or creates duplicates. Map the supplier's SUPPLIER_PID onto whichever of your product columns actually holds it.
apply_mode is one of upsert, full-sync or append, and defaults to upsert.
A BMECAT profile carries its entity mapping under mapping.bmecat — the entities the catalog explodes into. Each key stands for one standard BMEcat structure, and its value is the name of an entity in the profile's own vendor and app:
| Key | BMEcat structure | Required |
|---|---|---|
product_entity | PRODUCT (ARTICLE in 1.2), one row per product | Yes |
feature_entity | FEATURE — FNAME, FVALUE, FUNIT, FORDER, the reference feature system and group | No |
price_entity | PRODUCT_PRICE — price_type, LOWER_BOUND, PRICE_AMOUNT, PRICE_CURRENCY, TAX | No |
image_entity | MIME — MIME_SOURCE, MIME_TYPE, MIME_PURPOSE, MIME_ORDER, MIME_DESCR. Read, but the products app creates nothing from it | No |
reference_entity | PRODUCT_REFERENCE — type, quantity, PROD_ID_TO | No |
category_entity | CATALOG_STRUCTURE — GROUP_ID, PARENT_ID, GROUP_NAME, GROUP_ORDER | No |
product_category_entity | PRODUCT_TO_CATALOGGROUP_MAP | No |
Four options go with them:
| Key | Default | Does |
|---|---|---|
pid_column | supplier_pid | The product column that holds SUPPLIER_PID — the natural key the other entities join on. |
language | the file header's language | The language of text without a lang attribute, as an ISO 639-2 code such as deu, when the file header names none. Every language of a file is imported, each under its own locale. |
locale | the tenant's default locale | The locale product names and descriptions, category labels and attribute group and attribute labels are written under, when the target is the products app. Any spelling works (de, de-DE, de_DE, deu). It is mapped to the tenant's write key, so a tenant that keeps text per language gets de and a regional tenant de-DE. Leave it out to use the default market's default locale. A value the tenant cannot resolve fails the import before anything is written, and the error lists the locales that would work. See Languages and locale codes. |
validate | false | Check the file against the matching BMEcat XSD before loading it. |
A structure whose key you leave out is skipped, and only the columns an entity actually has are filled. So the mapping is safe to widen one key at a time:
"bmecat": {
"product_entity": "products",
"feature_entity": "product_features",
"price_entity": "product_prices",
"pid_column": "sku",
"language": "deu"
}
The public API reference types mapping.bmecat as an open object and doesn't list these keys yet (as of 2026-09), and which entities exist depends on your app's schema. So the create call above is the shape of the request, not a mapping guaranteed to import your supplier's catalog. Every route below addresses the profile by its id, wherever it came from. Use it as $PROFILE_ID in steps 2 to 4 and widen it in step 5.
2. Upload the catalog
Bytes go straight to object storage, never through the API. Ask for a signed URL, then PUT the file to it:
UPLOAD=$(curl -s -X POST https://api.revenexx.com/v1/io/uploads \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "extension": "xml" }')
UPLOAD_URL=$(echo "$UPLOAD" | jq -r .upload_url)
OBJECT_KEY=$(echo "$UPLOAD" | jq -r .object_key)
The answer also carries expires_in and a headers object. object_key is what names the object from here on.
Every entry of that headers object has to go onto the PUT as its own -H flag, verbatim — they are part of what the URL was signed for:
HEADER_ARGS=()
while IFS= read -r h; do HEADER_ARGS+=(-H "$h"); done \
< <(echo "$UPLOAD" | jq -r '.headers | to_entries[] | "\(.key): \(.value)"')
curl -s -X PUT "$UPLOAD_URL" "${HEADER_ARGS[@]}" --upload-file catalog.xml
A field carrying several values arrives joined with , , which is the form HTTP expects, so passing each entry through unchanged is enough. The URL is short-lived — mint it when you are ready to upload, not ahead of time.
3. Run the profile
JOB_ID=$(curl -s -X POST https://api.revenexx.com/v1/io/profiles/$PROFILE_ID/run \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"object_key\": \"$OBJECT_KEY\"}" | jq -r .job_id)
object_key is required on an import run; omit it and the call answers 422 with RUN_NO_OBJECT. The optional markets array assigns the imported rows to one or more markets by code, overriding the profile's own setting for this run; an empty array means global, which is visible in every market rather than in none.
The call answers 202 with the job_id above and the run's direction and status. The import is asynchronous from here.
4. Read the result
curl -s https://api.revenexx.com/v1/io/bulk-jobs/$JOB_ID \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY"
status moves pending → running → completed, or ends at partial, failed or canceled. partial is a success with rejects — the job finished and counts.rejected is above zero — so a client that only checks for failed will call a half-imported catalog a clean run.
The row counts are nested under counts, not top-level: read, written, rejected and bytes. error_message carries the reason on a failure, and duration_ms the wall time.
Confirm the products landed by listing them:
curl -s https://api.revenexx.com/v1/products \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY"
5. Widen the mapping beyond products
The mapping in step 1 writes products only. That is the deliberate starting point, not the finished catalog. A BMECAT feed carries more than articles, and each part lands in a different place:
| In the feed | Where it lands |
|---|---|
ARTICLE / PRODUCT | one product, keyed on SUPPLIER_PID |
FEATURE (FNAME / FVALUE / FUNIT) | attribute values on the product. A feature name the tenant doesn't know yet creates a new attribute. |
CATALOG_GROUP_SYSTEM, and the article-to-group map | categories, and the product's links to them |
MIME (images, documents) | nothing in the products app; see below |
PRODUCT_PRICE_DETAILS | not on the product. Prices live on price lists; see below. |
Features converge instead of multiplying. A feature is matched to an existing attribute by the aliases a merchant curated on it, then by the profile mapping, and only then turned into a new attribute from its name. Re-importing the same feed updates values without duplicating attributes, and a curated attribute's definition is never overwritten. So let the first run discover attributes, curate them in Cockpit, and run again.
Prices are not product data. A product holds no price; a price lives on a price list, joined to the product by SKU. So after a run, check prices on the price lists, not on the products. If the feed's prices are not there, load them into a list with entries/bulk, keyed on the same SUPPLIER_PID you used as the SKU, as described in Set up B2B pricing.
Images do not arrive. The import reads MIME entries but creates no asset and no reference from them in the products app, and it never carries the files. Uploading them and attaching them to products is a separate job: Manage product images and documents.
Widening is a read-modify-write on the profile. Fetch what it holds now, extend the mapping, and PUT the whole profile back. PUT /v1/io/profiles/{id} replaces the mapping rather than merging into it, so send the complete object:
curl -s https://api.revenexx.com/v1/io/profiles/$PROFILE_ID \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY"
curl -s -X PUT https://api.revenexx.com/v1/io/profiles/$PROFILE_ID \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d @profile.json
This is also how you extend mapping.bmecat: read the keys the profile already carries, add the entity you want next, and send the whole object back.
The import is tolerant of columns it was not told about: it writes only the entities you name and ignores the rest of the feed. So widen one entity at a time and re-run against the same uploaded object, checking counts after each pass.
6. Put it on a schedule
Running it by hand is the development loop. In production a supplier drops a file and nobody watches for it, which is what the official template does: Integration Studio → Templates → BMECat catalogue import from SFTP (publisher revenexx, marked Official). Press Use template and you get a paused copy you can read before anything runs.
Installing a template has no public REST endpoint today, so this step happens in Cockpit. What it gives you is the surrounding machinery this page's API calls do not cover:
schedule trigger nightly pickup on 0 2 * * * (Europe/Berlin)
SFTP: List Directory finds new files in the inbox, optionally glob-filtered
Iterator runs the rest once per file
Stage From SFTP streams the file in and emits its object key
Import from profile the run from step 3
SFTP: Move File to the archive folder, or to failed
Every step carries an error branch, and a failed import moves its file to the failed folder rather than leaving it to be picked up again. The workflow's variables cover the same decisions you made by hand — inboxPath, archivePath, failedPath, pidColumn (the upsert key from step 1), productEntity, app, vendor, language and locale. It needs two credentials under Integration Studio → Credentials: a Revenexx API credential with the io scope, and an SFTP credential for the server the supplier delivers to.
The largest feed tested against this template is 469 MB (as of 2026-09), and the import step allows two hours for gigabyte catalogs, so a full supplier catalog is a normal case rather than something to work around.
Troubleshooting
POST /v1/io/importsrejects the format. It does not acceptbmecat. A catalog runs throughPOST /v1/io/profiles/{id}/run— the ad-hoc import route is for CSV, XML, JSON and XLSX.- The run answers
422withRUN_NO_OBJECT. An import run needsobject_keyin the body. Only an export run generates its own. - The upload
PUTis rejected. Theheadersfrom the/v1/io/uploadsanswer were not sent verbatim, or the signed URL expired. Mint a fresh one and retry. - The job says
partial. The import finished and rejected rows. Readcounts.rejectedagainstcounts.read— this is not a failure status, and code that only branches onfailedwill miss it. - A second run creates duplicate products.
mapping.keysis not naming the column that holds the supplier'sSUPPLIER_PID, so every article looks new. Fix the profile, not the feed. - Products import, categories do not. Expected until you widen the mapping. Add the entities in step 5.
- No prices on the products. Expected: products hold no prices. Look on the price lists, and load any missing prices by SKU, as in Set up B2B pricing.
- Rows are invisible in a storefront. The run assigned them to a market they are not read in. Passing an empty
marketsarray means global — visible everywhere — which is usually what an initial catalog load wants.
Where to go next
- API usage — headers, request ids, errors and pagination.
- API Explorer — the full Import & Export surface, with every field.
- Workflow Builder — execution modes, retries, and inspecting runs.
- Expression Syntax — referencing step outputs, variables and secrets.
- Set up B2B pricing — where the feed's prices go.
- Model a catalog with attributes, families and variants — decide the model before the feed lands, and repair a catalog that was imported flat.