Create or update product
POST /api/v1/productos
Creates or updates a product in the tenant’s inventory catalog — the same catalog used by POST /facturas when a line carries idProducto. It’s an upsert by codigo: if a product with that code already exists in the tenant, it’s updated; otherwise, it’s created.
It exists because, through v1.2, an integrator could only invoice lines already loaded in the catalog, or ad-hoc lines from the purchase catalog — there was no way to populate the sales catalog before issuing. With this endpoint you can preload the integrator’s own SKU and later invoice by codigoProducto without knowing LX CloudPos’s internal ids.
Authentication and permissions
| Attribute | Value |
|---|---|
| Scheme | API key (X-Api-Key) |
| Required scope | catalogo:write |
| Idempotent | Does not accept Idempotency-Key. The upsert by codigo is already retry-safe: resending the same body gives the same result, just as an update (200) instead of a creation (201). |
| Rate limit | 120 req/min per API key |
| Max body | 64 KB — smaller than the general 256 KB limit |
Headers
POST /api/v1/productos HTTP/1.1
Host: api.pos.lxsyscr.com
X-Api-Key: lxs_live_a1b2c3d4_001122...
Content-Type: application/json
Accept: application/jsonRequest body
See ProductoApiRequestDto for the full schema.
Example — create:
{
"codigo": "API-TEST-001",
"nombre": "Producto de prueba API",
"descripcion": "Alta desde la API de integradores",
"codigoCabys": "2342002000000",
"unidadMedida": "Unid",
"precioVenta": 1500.00,
"precioCosto": 1000.00,
"impuestos": [
{ "codigo": "01", "porcentaje": 13.00, "codigoTarifa": "08" }
]
}idMarca and idProveedor weren’t sent — see the note about that pair of fields in the business rules.
Example — update (same codigo, price changes):
{
"codigo": "API-TEST-001",
"nombre": "Producto de prueba API",
"descripcion": "Alta desde la API de integradores",
"codigoCabys": "2342002000000",
"unidadMedida": "Unid",
"precioVenta": 1650.00,
"precioCosto": 1000.00,
"impuestos": [
{ "codigo": "01", "porcentaje": 13.00, "codigoTarifa": "08" }
]
}Important: This update is not partial. The body above repeats every field, not just
precioVenta, because whatever is omitted gets overwritten with its default value. See the business rules.
Responses
201 Created — the product was created
HTTP/1.1 201 Created
Location: /api/v1/productos/API-TEST-001
Content-Type: application/json{
"idProducto": 919,
"codigo": "API-TEST-001",
"nombre": "Producto de prueba API",
"descripcion": "Alta desde la API de integradores",
"codigoCabys": "2342002000000",
"unidadMedida": "Unid",
"precioVenta": 1500.0000,
"precioCosto": 1000.0000,
"idCategoria": null,
"idSubcategoria": null,
"idMarca": 0,
"idProveedor": 0,
"estado": true,
"impuestos": [
{ "codigo": "01", "porcentaje": 13.0000, "codigoTarifa": "08" }
],
"advertencia": null
}200 OK — an existing product was updated
Same shape as the 201. The only way to tell a creation and an update apart is the status code: the body carries no indicator of its own.
400 Bad Request
Two possible sources, with two different formats.
a) Model validation — a required field is missing, or precioVenta/precioCosto are not greater than zero (for example if they are omitted from the body, since they default to 0). Detailed format, same as the rest of the API:
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Codigo": ["The Codigo field is required."],
"CodigoCabys": ["The CodigoCabys field is required."]
}
}b) Business rules — simple { "error": "..." } format, in the order they’re validated:
{ "error": "Debe indicar 'codigo'." }{ "error": "Debe indicar 'nombre'." }{ "error": "El código CABYS debe tener 13 dígitos." }{ "error": "Debe indicar 'unidadMedida'." }{ "error": "Debe indicar 'precioVenta'." }{ "error": "Debe indicar 'precioCosto'." }{ "error": "Debe indicar al menos un impuesto en 'impuestos'." }{ "error": "Cada línea de 'impuestos' debe indicar 'codigo'." }{ "error": "El campo 'estado' no se puede cambiar al actualizar un producto: activar o desactivar es una operación aparte. Enviá el producto sin 'estado' o con el valor que ya tiene." }{ "error": "La marca 7 no existe. Consultá GET /api/v1/catalogos/marcas." }{ "error": "El proveedor 3 no existe." }{ "error": "El tipo de código '05' no existe. Consultá GET /api/v1/catalogos/tipos-codigo." }And, after the save attempt, three infrequent edge cases — the second is usually a race with another request creating or editing the same codigo in parallel:
{ "error": "No se pudo guardar el producto." }{ "error": "El código 'API-TEST-001' ya pertenece a otro producto." }{ "error": "El producto 'API-TEST-001' se guardó pero no se pudo releer." }All three arrive as 400, not 500, even though conceptually they correspond more to an internal failure than to an error in the request sent.
401 Unauthorized
{ "error": "API key ausente, inválida o revocada." }403 Forbidden
Valid key but without catalogo:write:
{ "error": "La API key no tiene el scope requerido para esta operación." }429 Too Many Requests / 500 Internal Server Error
Same as the rest of the API — see Rate limiting and HTTP status codes. The 500 from this endpoint responds:
{ "error": "Error interno guardando el producto." }Business rules
-
Upsert by
codigo, not byidProducto. The request never includesidProducto— there’s no way to say “update product 919”; resolution always happens by looking up thecodigo. -
The update is NOT partial. Unlike customers, this endpoint rebuilds the entire product from the body on every call. An omitted field does not keep its previous value:
descripcionomitted → ends up""(empty string, notnull).idCategoria/idSubcategoriaomitted → end upnull.porcentajeGananciaomitted → ends up0.tipoCodigoomitted → falls back to the default"01".impuestosgets replaced entirely by whatever comes in the body — it is not merged with what it already had.
-
The two exceptions, the only fields that keep their previous value when omitted:
estado: only applies when creating (if omitted, the product is born active). On an update the product keeps the state it had, and sending a value different from the current one returns400— it’s not a silent no-op. Activating or deactivating is a separate operation: in the POS, deactivating a product that was never used deletes it, and an update request must not result in an unrequested deletion.idMarca/idProveedor: keep the brand and supplier the product already had.
-
idMarca/idProveedorare optional, with no arbitrary default. If omitted when creating, the product ends up with no brand and no supplier — the system does not assign one on its own. In the response this shows up asidMarca: 0/idProveedor: 0, notnull: the0means “unassigned,” not “brand number 0.” The tenant’s brands are listed withGET /catalogos/marcasand created withPOST /marcas. A nonexistent id returns400naming the field. -
tipoCodigois validated against Hacienda’s catalog (GET /catalogos/tipos-codigo). If omitted,"01"is used (seller’s product code), correct for the vast majority of integrations. -
codigoCabysandunidadMedidaare required because they are tax data: they go straight into the document’s XML when the product is invoiced. The CABYS code must have at least 13 digits — see CABYS codes. -
impuestosis required, with at least one line — the same minimum the POS’s product form requires. Each line needscodigo(for example"01"for VAT). -
advertenciadoesn’t block anything. It’s populated when thecodigoCabysclassifies as a service (first digit 6–9) and theunidadMedidasent isn’t a valid service unit: the system autocorrects it to"Os"and reports it in the response, instead of rejecting the request.El CABYS {codigoCabys} corresponde a un servicio; la unidad de medida se ajustó de "{anterior}" a "Os" (Otro tipo de servicio). Puede cambiarla por una unidad de servicio más específica si lo prefiere. -
Audit: the product is recorded with the user
"api:"followed by the API key’s name, so you can trace which integration modified each product. -
Stock is not managed through this endpoint. On creation, the product is created with quantity
0in every warehouse. On an edit, stock is not modified. To move it, use Entries and Transfers, not this endpoint.