Create or update a product
POST/v1/products
scope products.write
Upsert keyed on your external_id: the same call twice updates rather than duplicating, which makes a retried push safe. Required category attributes are enforced exactly as they are for a seller using the dashboard. Send images in the same call — one request per product, images included.
Request body#
| Field | Type | Required | Description |
|---|---|---|---|
external_id | string | required | Your own product id, and the key the upsert matches on — the same call twice updates rather than duplicating. Required: it is the only thing tying a listing here back to a row in your system, and without it a retry after a timeout would create a second listing you have no way to find. Unique per seller. |
title | string | required | Required on create; on update, omit it to leave the current title. Held to the same length limits as the seller dashboard. |
description | string | required | Required on create; omit on update to keep the current text. Same length limits as the dashboard. |
category_id | integer | required | From GET /v1/categories. Required on create. The category decides which attributes are mandatory, so read GET /v1/categories/{id}/attributes before your first push. A translated category id resolves to its Romanian master, so you may send the id you got in any language. |
price | object | required | The asking price. |
price. | integer | required | Minor units — 249900 is 2 499,00. Required on create. Never send a decimal. |
price. | string | optional | Shown for readability only. Not supported Ignored on write. A listing is priced in its country's currency, which you cannot choose here — |
price. | boolean | optional | Whether you accept price offers on this listing. Defaults to false. Only has an effect if your company has offers switched on. |
stock | integer | required | Units available. Required on create — send 0 for something you are out of, which is a real answer and is accepted. Must be a non-negative integer (invalid_stock, 422). Afterwards, PATCH /v1/products/{id}/stock is the cheaper way to move it. |
sku | string | optional | Your stock-keeping unit. Stored and returned as-is; not used for matching — that is external_id. |
ean | string | optional | EAN barcode. Stored and returned as-is. |
gtin | string | optional | GTIN. Stored and returned as-is. |
mpn | string | optional | Manufacturer part number. Stored and returned as-is. |
attributes | array | optional | Category attributes — brand, condition, frame size. Whether this is optional depends entirely on the category: a category with required attributes rejects the create with missing_required_attribute and names each one. Send the array and it REPLACES what the listing had; omit the key entirely and the existing values are kept. |
attributes[]. | integer | required | The attribute, from GET /v1/categories/{id}/attributes. An entry with no field_id is skipped silently. Translated ids resolve to their Romanian master. |
attributes[]. | integer | optional | The chosen option, for attributes that have a fixed list. Translated ids resolve to their master. |
attributes[]. | string | optional | A free-text value, for attributes with no option list. Send option_id or value depending on the attribute — GET /v1/categories/{id}/attributes says which. |
city_id | integer | optional | Where the item is. Almost never needed, and best left out: with no city_id the listing takes the city, county and street of your default shipping address, which is where the courier collects from. Send it only when this item really does ship from somewhere else — an override sets the city but no street, so the listing shows no address at all. From GET /v1/geo/cities. |
county_id | integer | optional | Defaults to the county the city belongs to. Only send it to override that. |
shipping | object | optional | Parcel dimensions and weight. Omit the whole object and the listing is still shipped by us, but with zero weight and dimensions — which quotes the courier wrongly. Send real numbers. |
shipping. | boolean | optional | Always true, and not yours to set.Not supported Ignored on write. Every listing created through this API ships with Biklo — we quote the courier, the buyer pays delivery at checkout and we book the waybill. A listing with this off cannot be ordered at all, so there is nothing useful to switch off. Sending |
shipping. | number | optional | Kilograms, decimals allowed. Defaults to 0. Drives the shipping class and the courier quote, so a zero weight quotes the item wrongly. |
shipping. | number | optional | Centimetres. Defaults to 0. |
shipping. | number | optional | Centimetres. Defaults to 0. |
shipping. | number | optional | Centimetres. Defaults to 0. |
images | array | optional | Image URLs, first is the cover — plain strings or {"url": "…"} objects. Referenced, not downloaded, exactly like POST /v1/products/{id}/images. Send the array and it REPLACES the listing's images (an identical list changes nothing); omit the key and they are left alone; [] removes them all. Every entry must be an http(s) URL (invalid_image_url, naming the position), at most 20 (too_many_images). |
Example
{
"external_id": "ERP-1001",
"title": "Bicicleta MTB Hardtail 29 inch",
"description": "A hardtail mountain bike in very good condition, barely used.",
"category_id": 4291,
"price": {
"amount": 249900,
"currency": "RON",
"negotiable": true
},
"stock": 4,
"sku": "MTB-29-001",
"attributes": [
{
"field_id": 32,
"option_id": 226
},
{
"field_id": 47,
"option_id": 422
}
],
"shipping": {
"weight_kg": 14.5,
"width_cm": 80,
"height_cm": 120,
"length_cm": 180
},
"images": [
"https://cdn.example.com/erp/mtb-front.jpg",
"https://cdn.example.com/erp/mtb-side.jpg"
]
}Response#
Example
{
"id": 237224,
"external_id": "ERP-1001",
"sku": "MTB-29-001",
"gtin": null,
"mpn": null,
"ean": null,
"title": "Bicicleta MTB Hardtail 29 inch",
"description": "A hardtail mountain bike in very good condition, barely used.",
"category_id": 4291,
"status": "active",
"is_reviewed": true,
"price": {
"amount": 249900,
"currency": "RON",
"negotiable": true
},
"stock": 4,
"shipping": {
"ship_with_us": true,
"free_shipping": false,
"shipping_class": "bike",
"parcel_tier": "large",
"weight_kg": 14.5,
"width_cm": 80,
"height_cm": 120,
"length_cm": 180
},
"group_id": null,
"images": [
{
"id": 108876841,
"position": 0,
"path": "files/237224/mtb-front.jpg",
"source_url": null
}
],
"attributes": [
{
"field_id": 32,
"option_id": 226,
"value": null
}
],
"created_at": "2026-09-01T08:14:22Z",
"updated_at": "2026-09-11T16:02:51Z"
}