API de integración para ERP

Documentación para el equipo de desarrollo que conecta un ERP o sistema de gestión con una tienda VirCat.

Descargar como .md

Introducción

La API ERP de VirCat permite sincronizar con tu sistema el catálogo (marcas, categorías, subcategorías y productos), el stock y los pedidos de tu tienda, y recibir avisos por webhooks.

  • Requiere que la tienda tenga el plan Empresa (integraciones ERP).
  • URL base: https://backend-vircat.fly.dev/api/erp
  • Todo se envía y se recibe en JSON (UTF-8). En POST, PUT y PATCH usá Content-Type: application/json.
  • Las fechas de respuesta van en formato ISO 8601 (RFC 3339). Los filtros de fecha sin zona horaria (por ejemplo 2026-10-01) se interpretan en hora de Paraguay (UTC-3).
  • La tienda sale siempre de la API key: nunca se envía un id de tienda.

Autenticación

  1. Un administrador de la tienda entra al panel, Configuración › Integraciones, y crea una API key.
  2. La key se muestra una sola vez: VirCat guarda solo su hash y no se puede recuperar. Si la perdés, revocala y generá otra.
  3. Una key revocada deja de funcionar de inmediato (401).
Header en cada request
Authorization: Bearer <tu_api_key>

Tratá la key como un secreto: identifica a tu tienda y permite leer y escribir su catálogo y pedidos.

Errores de autenticación

Los devuelve el middleware con cuerpo {"error","code"}.

EstadocodeCuándo
401UNAUTHORIZEDTres mensajes en "error", siempre con code UNAUTHORIZED: "missing authorization header" (falta el header Authorization), "invalid authorization header format" (no empieza con "Bearer "), "empty token" (el header es "Bearer " sin token) e "invalid or inactive API key" (la key no existe o está revocada).
403PLAN_SIN_INTEGRACION_ERPLa key es válida pero el plan de la tienda no incluye integraciones ERP (plan Empresa).
503SERVICE_UNAVAILABLEVirCat no pudo validar la key por un problema transitorio. La respuesta trae Retry-After: 2 (segundos). Reintentá con backoff.

Errores del resto de los endpoints

Los handlers devuelven solo {"error": "mensaje"}, sin code. El mensaje nombra el campo problemático y algunos casos agregan datos (por ejemplo id_producto en un 409). Decidí por el estado HTTP: el texto puede cambiar.

Conceptos

Paginación por cursor (keyset)

Los listados usan after y limit (por defecto 100, máximo 500) y devuelven next_cursor. Para la página siguiente pasá next_cursor como after; null indica la última página.

  • Marcas, categorías, subcategorías y productos: orden por id ascendente (after devuelve ids mayores).
  • Pedidos: del más nuevo al más antiguo (after devuelve id_pedido menores).
  • El parámetro page y el campo total ya no existen; enviar page a productos o pedidos devuelve 400.
Recorrer un listado
# 1. Primera página de productos
curl -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/productos?limit=500"

# 2. Siguiente página: pasá next_cursor como after
curl -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/productos?limit=500&after=319"

Identificar productos: id o codigo

Donde un producto va en la ruta (/productos/:id, /productos/:id/stock) podés usar el id_producto o el codigo. Un valor numérico siempre es un id, por eso el codigo debe tener al menos un carácter no numérico ("1234" se rechaza, "A1234" se acepta). El codigo se compara sin distinguir mayúsculas, también en /stock.

Baja lógica (soft delete)

DELETE no borra físicamente: el recurso deja de aparecer en listados y lecturas (404) y el codigo de un producto eliminado se puede reutilizar. Una marca o subcategoría con productos activos, o una categoría con subcategorías activas, no se puede eliminar (409).

Referencias a marca y subcategoría

id_marca e id_sub_categoria deben existir, estar activos y ser de tu tienda; si no, 400 con el campo en el mensaje. Por eso se sincroniza primero la taxonomía.

Idempotencia

  • POST /pedidos acepta el header Idempotency-Key (hasta 200 caracteres). Si tu tienda ya creó un pedido con esa clave, se devuelve ese pedido con 200 en lugar de crear otro (201). La clave identifica al pedido, no al contenido. Usá la misma clave en todos los reintentos del mismo pedido.
  • Stock: mode: "set" es idempotente (reenviar el mismo valor no acumula); mode: "delta" no lo es: un reintento a ciegas descuenta dos veces.

Flujo de sincronización recomendado

  1. Taxonomía: marcas, categorías y luego subcategorías. Necesitás sus ids para los productos; guardá el mapeo id de tu ERP ↔ id de VirCat.
  2. Productos: creá o actualizá con POST/PUT referenciando esos ids.
  3. Stock: con PATCH /productos/:id/stock. Si tu ERP es la fuente de verdad, usá set.
  4. Pedidos: recorré GET /pedidos con next_cursor hasta llegar a un id que ya procesaste, y devolvé estado y seguimiento con PATCH /pedidos/:id.
  • Ante 503 o un error 5xx, reintentá con backoff exponencial (respetá Retry-After cuando venga).
  • Al reintentar un POST /pedidos reenviá la misma Idempotency-Key.
  • Los 4xx son errores de datos: corregí la solicitud en lugar de reintentarla igual.
  • Combiná webhooks con una consulta periódica a GET /pedidos: los webhooks no se reintentan.

Sincronizar stock en ambos sentidos

ERP → VirCat

  • Usá PATCH /productos/:id/stock.
  • Preferí mode: "set" con el stock absoluto del ERP: es idempotente y se puede reintentar sin riesgo.
  • mode: "delta" sirve para ventas del ERP, pero no es idempotente: no lo reintentes a ciegas.

VirCat → ERP

  • Suscribite al webhook STOCK_ACTUALIZADO (se emite en cada movimiento de stock) y aplicá el campo stock como valor absoluto. Es lo recomendado.
  • Alternativa: PEDIDO_CREADO, que trae los ítems del pedido, para descontar stock.
  • Evitá bucles: si tu ERP escribe el stock de vuelta en VirCat al recibir un STOCK_ACTUALIZADO, VirCat emite otro evento con origen: "erp". Ignorá siempre los eventos con origen "erp".
  • Después de una importación masiva llegan como máximo 500 eventos por operación (los de stock primero): conciliá con GET /productos.

Conciliación

La suscripción es opcional: cada tienda elige los eventos al crear el webhook. Como los webhooks no se reintentan, conciliá periódicamente recorriendo GET /productos y comparando stock.

Referencia de endpoints

Las rutas son relativas a https://backend-vircat.fly.dev. En los ejemplos, $API_KEY es tu API key. Además de los códigos listados, todo endpoint puede responder 401, 403 (PLAN_SIN_INTEGRACION_ERP) o 503 (SERVICE_UNAVAILABLE) desde la autenticación; ver la sección Autenticación.

Marcas

Taxonomía de marcas de la tienda. Los productos las referencian con id_marca.

GET /api/erp/marcas

Lista las marcas de la tienda (activas e inactivas, sin las eliminadas), ordenadas por id ascendente, con paginación por cursor.

  • La página es la última cuando next_cursor es null. Se devuelven las ACTIVAS y las INACTIVAS; las eliminadas no aparecen.
  • El parámetro page no existe: enviarlo devuelve 400 (usá after / next_cursor). La respuesta incluye limit, el tamaño de página aplicado.
ParámetroEnTipoRequeridoReglas
afterqueryintegerNoCursor: devuelve ids mayores a este valor. Por defecto 0 (primera página). Debe ser >= 0.
limitqueryintegerNoTamaño de página. Por defecto 100, máximo 500 (valores mayores se acotan a 500). Debe ser >= 1.
Request (curl)
curl -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/marcas?limit=100"
Respuesta de ejemplo
{
  "items": [
    {
      "id": 12,
      "nombre": "Samsung",
      "descripcion": "",
      "url_foto": "https://cdn.ejemplo.com/samsung.png",
      "estado": "ACTIVO",
      "created_date": "2026-10-20T14:03:11.482Z",
      "updated_date": null
    }
  ],
  "next_cursor": null,
  "limit": 100
}
CódigoSignificado
200Página de resultados.
400after o limit inválidos, o se envió page.
500Error interno.

GET /api/erp/marcas/:id

Devuelve una marca por id.

ParámetroEnTipoRequeridoReglas
idpathintegerSíID de la marca (> 0).
Request (curl)
curl -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/marcas/12"
Respuesta de ejemplo
{
  "id": 12,
  "nombre": "Samsung",
  "descripcion": "",
  "url_foto": "https://cdn.ejemplo.com/samsung.png",
  "estado": "ACTIVO",
  "created_date": "2026-10-20T14:03:11.482Z",
  "updated_date": null
}
CódigoSignificado
200La entidad.
400id inválido.
404marca no encontrada (o de otra tienda).
500Error interno.

POST /api/erp/marcas

Crea una marca en la tienda de la API key.

  • Los nombres no son únicos: puede haber dos con el mismo nombre. Guardá en tu ERP el id que devuelve la creación y usalo como mapeo; no identifiques por nombre.
ParámetroEnTipoRequeridoReglas
nombrebodystringAl crear1 a 200 caracteres (se recortan espacios).
descripcionbodystringNoHasta 2000 caracteres.
url_fotobodystringNoDebe empezar con http:// o https://; hasta 2048 caracteres. Vacío se acepta.
estadobodystringNoACTIVO (por defecto) o INACTIVO.
Request (curl)
curl -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"nombre":"Samsung","url_foto":"https://cdn.ejemplo.com/samsung.png"}' \
  "https://backend-vircat.fly.dev/api/erp/marcas"
Respuesta de ejemplo
{
  "id": 12,
  "nombre": "Samsung",
  "descripcion": "",
  "url_foto": "https://cdn.ejemplo.com/samsung.png",
  "estado": "ACTIVO",
  "created_date": "2026-10-20T14:03:11.482Z",
  "updated_date": null
}
CódigoSignificado
201Creada; devuelve la entidad.
400Validación (campo faltante, largo o formato inválido).
500Error interno.

PUT /api/erp/marcas/:id

Actualiza parcialmente una marca: enviá solo los campos a cambiar (al menos uno).

  • Un campo ausente o null no se modifica. Para vaciar descripcion o url_foto enviá "" (cadena vacía); nombre no puede quedar vacío (400).
  • Los nombres no son únicos: identificá siempre por id.
ParámetroEnTipoRequeridoReglas
idpathintegerSíID de la marca (> 0).
nombrebodystringNo1 a 200 caracteres (se recortan espacios).
descripcionbodystringNoHasta 2000 caracteres.
url_fotobodystringNoDebe empezar con http:// o https://; hasta 2048 caracteres. Vacío se acepta.
estadobodystringNoACTIVO (por defecto) o INACTIVO.
Request (curl)
curl -X PUT -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"descripcion":"Electrónica de consumo"}' \
  "https://backend-vircat.fly.dev/api/erp/marcas/12"
Respuesta de ejemplo
{
  "id": 12,
  "nombre": "Samsung",
  "descripcion": "",
  "url_foto": "https://cdn.ejemplo.com/samsung.png",
  "estado": "ACTIVO",
  "created_date": "2026-10-20T14:03:11.482Z",
  "updated_date": null
}
CódigoSignificado
200Actualizada; devuelve la entidad.
400id inválido, validación, o ningún campo enviado.
404marca no encontrada (o de otra tienda).
500Error interno.

DELETE /api/erp/marcas/:id

Elimina (baja lógica) una marca.

ParámetroEnTipoRequeridoReglas
idpathintegerSíID de la marca (> 0).
Request (curl)
curl -X DELETE -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/marcas/12"
CódigoSignificado
204Eliminada (baja lógica). Sin cuerpo.
400id inválido.
404marca no encontrada (o de otra tienda).
409La marca la usan productos activos: reasignalos o eliminalos primero.
500Error interno.

Categorías

Categorías de primer nivel. Una categoría contiene subcategorías.

GET /api/erp/categorias

Lista las categorías de la tienda (activas e inactivas, sin las eliminadas), ordenadas por id ascendente, con paginación por cursor.

  • La página es la última cuando next_cursor es null. Se devuelven las ACTIVAS y las INACTIVAS; las eliminadas no aparecen.
  • El parámetro page no existe: enviarlo devuelve 400 (usá after / next_cursor). La respuesta incluye limit, el tamaño de página aplicado.
ParámetroEnTipoRequeridoReglas
afterqueryintegerNoCursor: devuelve ids mayores a este valor. Por defecto 0 (primera página). Debe ser >= 0.
limitqueryintegerNoTamaño de página. Por defecto 100, máximo 500 (valores mayores se acotan a 500). Debe ser >= 1.
Request (curl)
curl -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/categorias?limit=100"
Respuesta de ejemplo
{
  "items": [
    {
      "id": 4,
      "nombre": "Electrodomésticos",
      "descripcion": "Línea blanca y pequeños electrodomésticos",
      "url_foto": "",
      "estado": "ACTIVO",
      "created_date": "2026-10-20T14:03:11.482Z",
      "updated_date": null
    }
  ],
  "next_cursor": null,
  "limit": 100
}
CódigoSignificado
200Página de resultados.
400after o limit inválidos, o se envió page.
500Error interno.

GET /api/erp/categorias/:id

Devuelve una categoria por id.

ParámetroEnTipoRequeridoReglas
idpathintegerSíID de la categoria (> 0).
Request (curl)
curl -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/categorias/12"
Respuesta de ejemplo
{
  "id": 4,
  "nombre": "Electrodomésticos",
  "descripcion": "Línea blanca y pequeños electrodomésticos",
  "url_foto": "",
  "estado": "ACTIVO",
  "created_date": "2026-10-20T14:03:11.482Z",
  "updated_date": null
}
CódigoSignificado
200La entidad.
400id inválido.
404categoria no encontrada (o de otra tienda).
500Error interno.

POST /api/erp/categorias

Crea una categoria en la tienda de la API key.

  • Los nombres no son únicos: puede haber dos con el mismo nombre. Guardá en tu ERP el id que devuelve la creación y usalo como mapeo; no identifiques por nombre.
ParámetroEnTipoRequeridoReglas
nombrebodystringAl crear1 a 200 caracteres (se recortan espacios).
descripcionbodystringNoHasta 2000 caracteres.
url_fotobodystringNoDebe empezar con http:// o https://; hasta 2048 caracteres. Vacío se acepta.
estadobodystringNoACTIVO (por defecto) o INACTIVO.
Request (curl)
curl -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"nombre":"Electrodomésticos"}' \
  "https://backend-vircat.fly.dev/api/erp/categorias"
Respuesta de ejemplo
{
  "id": 4,
  "nombre": "Electrodomésticos",
  "descripcion": "Línea blanca y pequeños electrodomésticos",
  "url_foto": "",
  "estado": "ACTIVO",
  "created_date": "2026-10-20T14:03:11.482Z",
  "updated_date": null
}
CódigoSignificado
201Creada; devuelve la entidad.
400Validación (campo faltante, largo o formato inválido).
500Error interno.

PUT /api/erp/categorias/:id

Actualiza parcialmente una categoria: enviá solo los campos a cambiar (al menos uno).

  • Un campo ausente o null no se modifica. Para vaciar descripcion o url_foto enviá "" (cadena vacía); nombre no puede quedar vacío (400).
  • Los nombres no son únicos: identificá siempre por id.
ParámetroEnTipoRequeridoReglas
idpathintegerSíID de la categoria (> 0).
nombrebodystringNo1 a 200 caracteres (se recortan espacios).
descripcionbodystringNoHasta 2000 caracteres.
url_fotobodystringNoDebe empezar con http:// o https://; hasta 2048 caracteres. Vacío se acepta.
estadobodystringNoACTIVO (por defecto) o INACTIVO.
Request (curl)
curl -X PUT -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"estado":"INACTIVO"}' \
  "https://backend-vircat.fly.dev/api/erp/categorias/12"
Respuesta de ejemplo
{
  "id": 4,
  "nombre": "Electrodomésticos",
  "descripcion": "Línea blanca y pequeños electrodomésticos",
  "url_foto": "",
  "estado": "ACTIVO",
  "created_date": "2026-10-20T14:03:11.482Z",
  "updated_date": null
}
CódigoSignificado
200Actualizada; devuelve la entidad.
400id inválido, validación, o ningún campo enviado.
404categoria no encontrada (o de otra tienda).
500Error interno.

DELETE /api/erp/categorias/:id

Elimina (baja lógica) una categoria.

ParámetroEnTipoRequeridoReglas
idpathintegerSíID de la categoria (> 0).
Request (curl)
curl -X DELETE -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/categorias/12"
CódigoSignificado
204Eliminada (baja lógica). Sin cuerpo.
400id inválido.
404categoria no encontrada (o de otra tienda).
409La categoría tiene subcategorías activas: eliminalas primero.
500Error interno.

Subcategorías

Cada subcategoría pertenece a una categoría (id_categoria). Los productos la referencian con id_sub_categoria.

GET /api/erp/sub_categorias

Lista las subcategorías de la tienda (activas e inactivas, sin las eliminadas), ordenadas por id ascendente, con paginación por cursor.

  • La página es la última cuando next_cursor es null. Se devuelven las ACTIVAS y las INACTIVAS; las eliminadas no aparecen.
  • El parámetro page no existe: enviarlo devuelve 400 (usá after / next_cursor). La respuesta incluye limit, el tamaño de página aplicado.
ParámetroEnTipoRequeridoReglas
afterqueryintegerNoCursor: devuelve ids mayores a este valor. Por defecto 0 (primera página). Debe ser >= 0.
limitqueryintegerNoTamaño de página. Por defecto 100, máximo 500 (valores mayores se acotan a 500). Debe ser >= 1.
Request (curl)
curl -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/sub_categorias?limit=100"
Respuesta de ejemplo
{
  "items": [
    {
      "id": 31,
      "nombre": "Heladeras",
      "id_categoria": 4,
      "estado": "ACTIVO",
      "created_date": "2026-10-20T14:03:11.482Z",
      "updated_date": null
    }
  ],
  "next_cursor": null,
  "limit": 100
}
CódigoSignificado
200Página de resultados.
400after o limit inválidos, o se envió page.
500Error interno.

GET /api/erp/sub_categorias/:id

Devuelve una sub_categoria por id.

ParámetroEnTipoRequeridoReglas
idpathintegerSíID de la sub_categoria (> 0).
Request (curl)
curl -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/sub_categorias/12"
Respuesta de ejemplo
{
  "id": 31,
  "nombre": "Heladeras",
  "id_categoria": 4,
  "estado": "ACTIVO",
  "created_date": "2026-10-20T14:03:11.482Z",
  "updated_date": null
}
CódigoSignificado
200La entidad.
400id inválido.
404sub_categoria no encontrada (o de otra tienda).
500Error interno.

POST /api/erp/sub_categorias

Crea una sub_categoria en la tienda de la API key.

  • Los nombres no son únicos: puede haber dos con el mismo nombre. Guardá en tu ERP el id que devuelve la creación y usalo como mapeo; no identifiques por nombre.
ParámetroEnTipoRequeridoReglas
nombrebodystringAl crear1 a 200 caracteres (se recortan espacios).
id_categoriabodyintegerAl crearCategoría padre; debe existir y ser de tu tienda (> 0).
estadobodystringNoACTIVO (por defecto) o INACTIVO.
Request (curl)
curl -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"nombre":"Heladeras","id_categoria":4}' \
  "https://backend-vircat.fly.dev/api/erp/sub_categorias"
Respuesta de ejemplo
{
  "id": 31,
  "nombre": "Heladeras",
  "id_categoria": 4,
  "estado": "ACTIVO",
  "created_date": "2026-10-20T14:03:11.482Z",
  "updated_date": null
}
CódigoSignificado
201Creada; devuelve la entidad.
400Validación (campo faltante, largo o formato inválido, id_categoria inexistente).
500Error interno.

PUT /api/erp/sub_categorias/:id

Actualiza parcialmente una sub_categoria: enviá solo los campos a cambiar (al menos uno).

  • Un campo ausente o null no se modifica. nombre no puede quedar vacío (400).
  • Los nombres no son únicos: identificá siempre por id.
ParámetroEnTipoRequeridoReglas
idpathintegerSíID de la sub_categoria (> 0).
nombrebodystringNo1 a 200 caracteres (se recortan espacios).
id_categoriabodyintegerNoCategoría padre; debe existir y ser de tu tienda (> 0).
estadobodystringNoACTIVO (por defecto) o INACTIVO.
Request (curl)
curl -X PUT -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"nombre":"Heladeras y freezers"}' \
  "https://backend-vircat.fly.dev/api/erp/sub_categorias/12"
Respuesta de ejemplo
{
  "id": 31,
  "nombre": "Heladeras",
  "id_categoria": 4,
  "estado": "ACTIVO",
  "created_date": "2026-10-20T14:03:11.482Z",
  "updated_date": null
}
CódigoSignificado
200Actualizada; devuelve la entidad.
400id inválido, validación, o ningún campo enviado.
404sub_categoria no encontrada (o de otra tienda).
500Error interno.

DELETE /api/erp/sub_categorias/:id

Elimina (baja lógica) una sub_categoria.

ParámetroEnTipoRequeridoReglas
idpathintegerSíID de la sub_categoria (> 0).
Request (curl)
curl -X DELETE -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/sub_categorias/12"
CódigoSignificado
204Eliminada (baja lógica). Sin cuerpo.
400id inválido.
404sub_categoria no encontrada (o de otra tienda).
409La subcategoría la usan productos activos: reasignalos o eliminalos primero.
500Error interno.

Productos

Catálogo completo: alta, lectura, edición y baja.

GET /api/erp/productos

Lista los productos no eliminados de la tienda, ordenados por id_producto ascendente, con paginación por cursor.

  • El parámetro page ya no existe: enviarlo devuelve 400.
  • tiene_variantes es de solo lectura.
  • slug no lo genera la API: si no lo enviaste al crear o actualizar, vuelve como cadena vacía (""), como en el ejemplo.
ParámetroEnTipoRequeridoReglas
afterqueryintegerNoCursor: devuelve id_producto mayores. Por defecto 0. Debe ser >= 0.
limitqueryintegerNoPor defecto 100, máximo 500 (se acota). Debe ser >= 1.
estadoquerystringNoFiltra por ACTIVO, INACTIVO o AGOTADO (exacto, en mayúsculas).
Request (curl)
curl -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/productos?limit=100&after=0"
Respuesta de ejemplo
{
  "productos": [
    {
      "id_producto": 319,
      "codigo": "HEL-001",
      "nombre": "Heladera no frost 350 L",
      "descripcion": "Heladera con freezer superior",
      "obs": "",
      "url_foto": "https://cdn.ejemplo.com/hel-001.jpg",
      "precio": 3200000,
      "precio_promo": 2990000,
      "costo": 2400000,
      "stock": 14,
      "stock_minimo": 5,
      "estado": "ACTIVO",
      "id_marca": 12,
      "id_sub_categoria": 31,
      "es_destacado": false,
      "es_nuevo": true,
      "en_promocion": true,
      "controlar_stock": true,
      "requiere_envio": true,
      "solo_consulta": false,
      "tiene_variantes": false,
      "slug": "",
      "etiquetas": [
        "linea-blanca"
      ],
      "codigo_barras": "7891234567890",
      "created_date": "2026-10-20T14:03:11.482Z",
      "updated_date": null
    }
  ],
  "next_cursor": null,
  "limit": 100
}
CódigoSignificado
200Página de productos. next_cursor es null en la última.
400after, limit o estado inválidos, o se envió page.
500Error interno.

GET /api/erp/productos/:id

Devuelve un producto por id_producto o por codigo.

ParámetroEnTipoRequeridoReglas
idpathstringSíid_producto (número) o codigo. Un valor numérico siempre se interpreta como id; el codigo se compara sin distinguir mayúsculas.
Request (curl)
curl -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/productos/HEL-001"
Respuesta de ejemplo
{
  "id_producto": 319,
  "codigo": "HEL-001",
  "nombre": "Heladera no frost 350 L",
  "descripcion": "Heladera con freezer superior",
  "obs": "",
  "url_foto": "https://cdn.ejemplo.com/hel-001.jpg",
  "precio": 3200000,
  "precio_promo": 2990000,
  "costo": 2400000,
  "stock": 14,
  "stock_minimo": 5,
  "estado": "ACTIVO",
  "id_marca": 12,
  "id_sub_categoria": 31,
  "es_destacado": false,
  "es_nuevo": true,
  "en_promocion": true,
  "controlar_stock": true,
  "requiere_envio": true,
  "solo_consulta": false,
  "tiene_variantes": false,
  "slug": "",
  "etiquetas": [
    "linea-blanca"
  ],
  "codigo_barras": "7891234567890",
  "created_date": "2026-10-20T14:03:11.482Z",
  "updated_date": null
}
CódigoSignificado
200El producto.
404No existe, está eliminado o es de otra tienda.
500Error interno.

POST /api/erp/productos

Crea un producto en la tienda de la API key.

  • El codigo de un producto eliminado se puede reutilizar.
  • Si omitís stock_minimo, el producto se crea con 5 (valor por defecto de la tabla); enviá el que corresponda si necesitás otro umbral de STOCK_BAJO.
  • La creación respeta el límite de productos del plan de la tienda.
  • id_marca e id_sub_categoria deben pertenecer a tu tienda (ver Conceptos).
ParámetroEnTipoRequeridoReglas
codigobodystringAl crear1 a 100 caracteres. Debe contener al menos un carácter no numérico (un valor solo con dígitos se interpretaría como id_producto). Único entre los productos no eliminados de la tienda, sin distinguir mayúsculas.
nombrebodystringAl crear1 a 200 caracteres.
preciobodynumberAl crearMayor a 0.
precio_promobodynumberNoEntre 0 y precio (0 = sin promoción).
costobodynumberNo0 o mayor.
stockbodynumberNoSolo al crear (0 o mayor). Para cambiarlo después usá PATCH /productos/:id/stock; en PUT se rechaza con 400.
stock_minimobodyintegerNo0 o mayor. Si lo omitís al crear, el producto queda con 5.
descripcionbodystringNoHasta 10000 caracteres.
obsbodystringNoHasta 2000 caracteres.
url_fotobodystringNoEmpieza con http:// o https://; hasta 2048 caracteres.
estadobodystringNoACTIVO (por defecto al crear), INACTIVO o AGOTADO.
id_marcabodyintegerNoDebe ser una marca activa de tu tienda. En PUT, 0 la quita.
id_sub_categoriabodyintegerNoDebe ser una subcategoría activa de tu tienda. En PUT, 0 la quita.
es_destacado / es_nuevo / en_promocionbodybooleanNoBanderas de visualización en la tienda.
controlar_stockbodybooleanNoSi es true, los pedidos descuentan stock de este producto.
requiere_enviobodybooleanNoSi el producto se envía (por defecto true al leer valores nulos).
solo_consultabodybooleanNoProducto de consulta, sin compra directa.
slugbodystringNoHasta 200 caracteres: minúsculas, dígitos y guiones simples (a-z, 0-9, -).
etiquetasbodystring[]NoHasta 20 etiquetas de 1 a 50 caracteres. Una lista vacía borra las etiquetas.
codigo_barrasbodystringNoHasta 64 caracteres.
Request (curl)
curl -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"codigo":"HEL-001","nombre":"Heladera no frost 350 L","precio":3200000,"stock":14,"controlar_stock":true,"id_marca":12,"id_sub_categoria":31}' \
  "https://backend-vircat.fly.dev/api/erp/productos"
Respuesta de ejemplo
{
  "id_producto": 319,
  "codigo": "HEL-001",
  "nombre": "Heladera no frost 350 L",
  "descripcion": "Heladera con freezer superior",
  "obs": "",
  "url_foto": "https://cdn.ejemplo.com/hel-001.jpg",
  "precio": 3200000,
  "precio_promo": 2990000,
  "costo": 2400000,
  "stock": 14,
  "stock_minimo": 5,
  "estado": "ACTIVO",
  "id_marca": 12,
  "id_sub_categoria": 31,
  "es_destacado": false,
  "es_nuevo": true,
  "en_promocion": true,
  "controlar_stock": true,
  "requiere_envio": true,
  "solo_consulta": false,
  "tiene_variantes": false,
  "slug": "",
  "etiquetas": [
    "linea-blanca"
  ],
  "codigo_barras": "7891234567890",
  "created_date": "2026-10-20T14:03:11.482Z",
  "updated_date": null
}
CódigoSignificado
201Creado; devuelve el producto completo.
400Validación de algún campo, o id_marca / id_sub_categoria que no existe en tu tienda (el mensaje nombra el campo).
403Se alcanzó el límite de productos del plan. Cuerpo: {"error","limite","count"}.
409Ya existe un producto con ese codigo. Cuerpo: {"error","id_producto"} con el id del existente.
500Error interno.

PUT /api/erp/productos/:id

Actualización parcial: enviá solo los campos a cambiar (al menos uno). Devuelve el producto actualizado.

  • No acepta stock (400): usá PATCH /productos/:id/stock.
  • Si enviás precio sin precio_promo y la promo actual supera el nuevo precio, responde 400: enviá también precio_promo.
  • Un id_marca o id_sub_categoria en 0 quita la referencia.
ParámetroEnTipoRequeridoReglas
idpathstringSíid_producto (número) o codigo. Un valor numérico siempre se interpreta como id; el codigo se compara sin distinguir mayúsculas.
codigobodystringNo1 a 100 caracteres. Debe contener al menos un carácter no numérico (un valor solo con dígitos se interpretaría como id_producto). Único entre los productos no eliminados de la tienda, sin distinguir mayúsculas.
nombrebodystringNo1 a 200 caracteres.
preciobodynumberNoMayor a 0.
precio_promobodynumberNoEntre 0 y precio (0 = sin promoción).
costobodynumberNo0 o mayor.
stockbodynumberNoSolo al crear (0 o mayor). Para cambiarlo después usá PATCH /productos/:id/stock; en PUT se rechaza con 400.
stock_minimobodyintegerNo0 o mayor. Si lo omitís al crear, el producto queda con 5.
descripcionbodystringNoHasta 10000 caracteres.
obsbodystringNoHasta 2000 caracteres.
url_fotobodystringNoEmpieza con http:// o https://; hasta 2048 caracteres.
estadobodystringNoACTIVO (por defecto al crear), INACTIVO o AGOTADO.
id_marcabodyintegerNoDebe ser una marca activa de tu tienda. En PUT, 0 la quita.
id_sub_categoriabodyintegerNoDebe ser una subcategoría activa de tu tienda. En PUT, 0 la quita.
es_destacado / es_nuevo / en_promocionbodybooleanNoBanderas de visualización en la tienda.
controlar_stockbodybooleanNoSi es true, los pedidos descuentan stock de este producto.
requiere_enviobodybooleanNoSi el producto se envía (por defecto true al leer valores nulos).
solo_consultabodybooleanNoProducto de consulta, sin compra directa.
slugbodystringNoHasta 200 caracteres: minúsculas, dígitos y guiones simples (a-z, 0-9, -).
etiquetasbodystring[]NoHasta 20 etiquetas de 1 a 50 caracteres. Una lista vacía borra las etiquetas.
codigo_barrasbodystringNoHasta 64 caracteres.
Request (curl)
curl -X PUT -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"precio":3100000,"precio_promo":2900000}' \
  "https://backend-vircat.fly.dev/api/erp/productos/HEL-001"
Respuesta de ejemplo
{
  "id_producto": 319,
  "codigo": "HEL-001",
  "nombre": "Heladera no frost 350 L",
  "descripcion": "Heladera con freezer superior",
  "obs": "",
  "url_foto": "https://cdn.ejemplo.com/hel-001.jpg",
  "precio": 3200000,
  "precio_promo": 2990000,
  "costo": 2400000,
  "stock": 14,
  "stock_minimo": 5,
  "estado": "ACTIVO",
  "id_marca": 12,
  "id_sub_categoria": 31,
  "es_destacado": false,
  "es_nuevo": true,
  "en_promocion": true,
  "controlar_stock": true,
  "requiere_envio": true,
  "solo_consulta": false,
  "tiene_variantes": false,
  "slug": "",
  "etiquetas": [
    "linea-blanca"
  ],
  "codigo_barras": "7891234567890",
  "created_date": "2026-10-20T14:03:11.482Z",
  "updated_date": null
}
CódigoSignificado
200Actualizado.
400Validación, stock enviado, ningún campo, referencia fuera de tu tienda o precio_promo > precio.
404No existe, está eliminado o es de otra tienda.
409El codigo ya lo usa otro producto.
500Error interno.

DELETE /api/erp/productos/:id

Elimina (baja lógica) un producto: deja de verse en la tienda y en list/get, y su codigo queda libre.

ParámetroEnTipoRequeridoReglas
idpathstringSíid_producto (número) o codigo. Un valor numérico siempre se interpreta como id; el codigo se compara sin distinguir mayúsculas.
Request (curl)
curl -X DELETE -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/productos/HEL-001"
CódigoSignificado
204Eliminado. Sin cuerpo.
404No existe, ya estaba eliminado o es de otra tienda.
500Error interno.

Stock

Ajuste atómico del stock de un producto. El stock no se edita desde PUT /productos/:id.

PATCH /api/erp/productos/:id/stock

Fija o ajusta el stock de un producto de forma atómica y registra el movimiento en el kardex (origen ERP).

  • mode "set" reemplaza el stock por cantidad; "delta" suma cantidad al stock actual (negativo = descuenta).
  • cantidad es obligatoria: si la omitís la respuesta es 400 (antes se tomaba 0).
  • Un :id no numérico se busca por codigo sin distinguir mayúsculas.
  • mode "delta" no es idempotente: no lo reintentes a ciegas (ver Conceptos).
ParámetroEnTipoRequeridoReglas
idpathstringSíid_producto (número) o codigo.
cantidadbodynumberSíCon set: nuevo stock. Con delta: variación (puede ser negativa). El resultado no puede ser negativo.
modebodystringNo"set" (por defecto) o "delta".
Request (curl)
curl -X PATCH -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"cantidad":-2,"mode":"delta"}' \
  "https://backend-vircat.fly.dev/api/erp/productos/HEL-001/stock"
Respuesta de ejemplo
{
  "id_producto": 319,
  "stock_antes": 14,
  "stock_ahora": 12
}
CódigoSignificado
200Stock actualizado.
400Falta cantidad, mode inválido, o stock resultante negativo (cuerpo: {"error","stock_antes"}).
404Producto no encontrado.
500Error interno.

Pedidos

Alta de pedidos desde el ERP, lectura con cliente y vendedor, cambio de estado y cancelación.

POST /api/erp/pedidos

Registra un pedido: crea el pedido y sus ítems en una sola transacción, descuenta stock de los productos con control de stock y dispara el webhook PEDIDO_CREADO.

  • Enviá el header Idempotency-Key para que los reintentos sean seguros (ver Conceptos). Si ya existe un pedido de tu tienda con esa clave, se devuelve ese pedido con 200 aunque el cuerpo sea distinto.
  • cantidad se redondea al entero más cercano para descontar stock.
  • Un pedido creado con estado CANCELADO no descuenta stock.
  • Si omitís precio_total de un ítem se calcula como precio_unit x cantidad; el total del pedido es la suma de los precio_total.
  • estado se valida: debe ser PENDIENTE, PREPARANDO, ENVIADO, ENTREGADO o CANCELADO (no distingue mayúsculas); otro valor devuelve 400.
  • Cada id_producto e id_variante debe ser de tu tienda y no estar eliminado, y la variante debe pertenecer a ese producto; si no, 400 con los ids en el mensaje (por ejemplo "id_producto not found: 9001"). Un id_variante negativo también devuelve 400.
  • Avisa al dueño de la tienda en el panel solo si la tienda activó "Notificarme cuando el ERP crea o actualiza pedidos" en Configuración › Integraciones (desactivado por defecto).
  • Para poder avisar al cliente después (notificar_cliente en PATCH o cancelar) enviá cliente_nombre y cliente_telefono al crear el pedido. Con cualquiera de los dos, GET /pedidos/:id devuelve cliente como objeto (con id_cliente y codigo_erp en null si no hay ficha).
ParámetroEnTipoRequeridoReglas
Idempotency-KeyheaderstringNoHasta 200 caracteres, sin caracteres de control. Identifica al pedido, no al contenido.
itemsbodyobject[]SíAl menos un ítem.
items[].id_productobodyintegerSí> 0.
items[].cantidadbodynumberSí> 0.
items[].precio_unitbodynumberSí> 0.
items[].precio_totalbodynumberNoSi es 0 u omitido, se calcula.
items[].id_variantebodyintegerNoVariante del producto, si corresponde.
estadobodystringNoPENDIENTE (por defecto), PREPARANDO, ENVIADO, ENTREGADO o CANCELADO.
obsbodystringNoObservaciones.
id_medio_pagobodyintegerNoMedio de pago de la tienda. Si lo omitís, GET /pedidos/:id devuelve 0 (no null).
id_medio_entregabodyintegerNoMedio de entrega de la tienda. Si lo omitís, GET /pedidos/:id devuelve 0 (no null).
direccion_deliverybodystringNoDirección de entrega.
cliente_nombrebodystringNoNombre del cliente. Hasta 120 caracteres.
cliente_telefonobodystringNoTeléfono del cliente. Hasta 32 caracteres.
Request (curl)
curl -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: erp-ped-100045" \
  -d '{"items":[{"id_producto":319,"cantidad":2,"precio_unit":2990000}],"obs":"Pedido ERP 100045"}' \
  "https://backend-vircat.fly.dev/api/erp/pedidos"
Respuesta de ejemplo
{
  "id_pedido": 456,
  "total": 5980000,
  "estado": "PENDIENTE",
  "created_date": "2026-10-23T15:20:00.123456Z"
}
CódigoSignificado
201Pedido creado. created_date trae microsegundos y es idéntico en la repetición con la misma Idempotency-Key.
200Reintento: ya existía un pedido con esa Idempotency-Key; devuelve el original.
400Sin ítems, ítem inválido (el mensaje indica el número de ítem), producto o variante que no es de tu tienda, estado inválido, cliente_nombre / cliente_telefono demasiado largos o Idempotency-Key inválida.
500Error interno.

GET /api/erp/pedidos

Lista los pedidos de la tienda, del más nuevo al más antiguo (por id_pedido descendente), con paginación por cursor.

  • Para sincronizar, recorré con next_cursor y guardá el id_pedido más alto que ya procesaste (ver Flujo recomendado).
  • El parámetro page ya no existe: enviarlo devuelve 400.
  • No incluye los PRE_PEDIDO (pedidos que el cliente todavía no confirmó): solo aparecen pedidos ya confirmados. No existe un filtro estado=PRE_PEDIDO.
  • En una URL el + de un offset (2026-09-29T10:00:00+03:00) se lee como espacio: codificalo como %2B o usá Z.
ParámetroEnTipoRequeridoReglas
afterqueryintegerNoCursor: devuelve pedidos con id_pedido menor a este valor. Por defecto: los más nuevos. Debe ser >= 0.
limitqueryintegerNoPor defecto 100, máximo 500 (se acota). Debe ser >= 1.
estadoquerystringNoPENDIENTE, PREPARANDO, ENVIADO, ENTREGADO o CANCELADO (no distingue mayúsculas).
fecha_desdequerystringNoInclusive. YYYY-MM-DD (00:00 hora de Paraguay, UTC-3), YYYY-MM-DDTHH:MM:SS o YYYY-MM-DD HH:MM:SS (sin offset = hora de Paraguay), o RFC 3339 con offset. Alias: desde.
fecha_hastaquerystringNoYYYY-MM-DD incluye todo ese día (hora de Paraguay); con hora, el límite es inclusive. Mismos formatos que fecha_desde. Alias: hasta.
Request (curl)
curl -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/pedidos?estado=PENDIENTE&fecha_desde=2026-10-01&limit=100"
Respuesta de ejemplo
{
  "pedidos": [
    {
      "id_pedido": 456,
      "total": 5980000,
      "estado": "PENDIENTE",
      "obs": "",
      "cant_items": 1,
      "created_date": "2026-10-23T15:20:00Z",
      "cliente": {
        "id_cliente": 78,
        "nombre": "Ana Pérez",
        "telefono": "0981123456",
        "codigo_erp": "C-001"
      },
      "vendedor": null
    }
  ],
  "next_cursor": null,
  "limit": 100
}
CódigoSignificado
200Página de pedidos. next_cursor es null en la última.
400after, limit, estado o fechas inválidos; fecha_desde posterior a fecha_hasta; o se envió page.
500Error interno.

GET /api/erp/pedidos/:id

Detalle completo de un pedido con sus ítems, cliente y vendedor.

  • cliente es un objeto cuando el pedido tiene ficha de cliente o datos de contacto (cliente_nombre / cliente_telefono, por ejemplo los de un pedido creado por POST /pedidos); en ese caso id_cliente y codigo_erp pueden ser null. cliente es null solo si el pedido no tiene ni ficha ni contacto.
  • Cliente sin ficha (invitado o pedido de la API con contacto): {"id_cliente":null,"nombre":"Ana Gómez","telefono":"+595981123456","codigo_erp":null}; si solo se envió cliente_nombre: {"id_cliente":null,"nombre":"Ana Gómez","telefono":"","codigo_erp":null}.
  • vendedor es null en compras hechas por el cliente en la tienda online.
  • Si el pedido no tiene medio de pago o de entrega, id_medio_pago / id_medio_entrega vienen como 0, no como null.
  • Cuando el pedido solo tiene nombre de contacto (sin cliente_telefono), cliente.telefono es "" (cadena vacía), no null.
  • Si el pedido o sus ítems no se pueden leer completos, responde 500 (nunca datos parciales): reintentá.
ParámetroEnTipoRequeridoReglas
idpathintegerSíid_pedido (> 0).
Request (curl)
curl -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/pedidos/456"
Respuesta de ejemplo
{
  "id_pedido": 456,
  "total": 5980000,
  "estado": "ENVIADO",
  "obs": "Entregar por la tarde",
  "direccion_delivery": "Av. Principal 123, Minga Guazú",
  "id_medio_pago": 2,
  "id_medio_entrega": 1,
  "items": [
    {
      "id_pedido_item": 901,
      "id_producto": 319,
      "codigo": "HEL-001",
      "codigo_barras": "7891234567890",
      "nombre_producto": "Heladera no frost 350 L",
      "cantidad": 2,
      "precio_unit": 2990000,
      "precio_total": 5980000,
      "id_variante": null,
      "descuento": 0
    }
  ],
  "created_date": "2026-10-23T15:20:00Z",
  "cliente": {
    "id_cliente": 78,
    "nombre": "Ana Pérez",
    "telefono": "0981123456",
    "codigo_erp": "C-001"
  },
  "vendedor": null,
  "numero_seguimiento": "PY123456",
  "url_seguimiento": "https://transportista.ejemplo/track/PY123456",
  "notas_internas": "",
  "cupon_codigo": null,
  "cupon_descuento": 0,
  "id_lista_precios": null,
  "costo_envio": null,
  "distancia_km": null,
  "cliente_lat": null,
  "cliente_lng": null
}
CódigoSignificado
200El pedido.
400id no numérico.
404Pedido no encontrado, de otra tienda o con id menor o igual a 0.
500Error interno.

PATCH /api/erp/pedidos/:id

Cambia estado, número/URL de seguimiento y/o notas internas de un pedido. Solo cambian los campos enviados; una cadena vacía borra un campo de texto.

  • estado acepta PENDIENTE, PREPARANDO, ENVIADO, ENTREGADO y CANCELADO, en cualquier dirección (no distingue mayúsculas).
  • Pasar a CANCELADO devuelve el stock que tomó el pedido; reabrir un pedido cancelado lo vuelve a tomar y, si ya no alcanza, responde 409 stock_insuficiente.
  • Un cambio real de estado avisa al dueño de la tienda en el panel, solo si la tienda activó "Notificarme cuando el ERP crea o actualiza pedidos" en Configuración › Integraciones (desactivado por defecto).
  • Devuelve el detalle completo del pedido, igual que GET /pedidos/:id, con cliente_notificado y motivo al mismo nivel que las claves del detalle (respuesta plana, sin anidar).
  • notificar_cliente (boolean, por defecto false) pide a VirCat avisar por WhatsApp al cliente del nuevo estado, con el mismo mensaje que el panel. Si lo enviás en true, la respuesta agrega cliente_notificado (true / false) y, cuando es false, motivo. Si no lo enviás, esas claves no aparecen.
  • Un aviso que no se puede enviar nunca hace fallar la actualización: el pedido se cambia igual y cliente_notificado vale false.
  • motivo: whatsapp_inactivo (la tienda no tiene WhatsApp conectado), sin_cambio_de_estado (el estado no cambió, por ejemplo ya estaba en ese valor), sin_contacto_cliente (el pedido no tiene teléfono de cliente), pedido_no_encontrado, error_envio (falló el envío) o timeout_envio (el proveedor tardó demasiado; el mensaje todavía podría llegar).
  • El contacto del cliente se busca en este orden: cliente_telefono del pedido, su versión normalizada (cliente_telefono_norm), teléfono del cliente vinculado y, por último, el contacto del usuario que hizo el pedido (solo si no es el dueño ni parte del equipo de la tienda). Cada candidato cuenta solo si contiene al menos un dígito. Un pedido creado por API solo tiene contacto si enviaste cliente_telefono al crearlo.
  • notificar_cliente solo, sin estado ni otro campo, devuelve 400: enviá al menos uno de estado, numero_seguimiento, url_seguimiento o notas_internas.
ParámetroEnTipoRequeridoReglas
idpathintegerSíid_pedido (> 0).
estadobodystringNoNuevo estado.
numero_seguimientobodystringNoHasta 200 caracteres. "" lo borra.
url_seguimientobodystringNoURL http(s) de hasta 2048 caracteres. "" la borra.
notas_internasbodystringNoHasta 5000 caracteres. "" las borra.
notificar_clientebodybooleanNoPor defecto false. Avisa por WhatsApp al cliente del cambio de estado.
Request (curl)
curl -X PATCH -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"estado":"ENVIADO","numero_seguimiento":"PY123456","url_seguimiento":"https://transportista.ejemplo/track/PY123456","notificar_cliente":true}' \
  "https://backend-vircat.fly.dev/api/erp/pedidos/456"
Respuesta de ejemplo
{
  "id_pedido": 456,
  "total": 5980000,
  "estado": "ENVIADO",
  "obs": "Entregar por la tarde",
  "direccion_delivery": "Av. Principal 123, Minga Guazú",
  "id_medio_pago": 2,
  "id_medio_entrega": 1,
  "items": [
    {
      "id_pedido_item": 901,
      "id_producto": 319,
      "codigo": "HEL-001",
      "codigo_barras": "7891234567890",
      "nombre_producto": "Heladera no frost 350 L",
      "cantidad": 2,
      "precio_unit": 2990000,
      "precio_total": 5980000,
      "id_variante": null,
      "descuento": 0
    }
  ],
  "created_date": "2026-10-23T15:20:00Z",
  "cliente": {
    "id_cliente": 78,
    "nombre": "Ana Pérez",
    "telefono": "0981123456",
    "codigo_erp": "C-001"
  },
  "vendedor": null,
  "numero_seguimiento": "PY123456",
  "url_seguimiento": "https://transportista.ejemplo/track/PY123456",
  "notas_internas": "",
  "cupon_codigo": null,
  "cupon_descuento": 0,
  "id_lista_precios": null,
  "costo_envio": null,
  "distancia_km": null,
  "cliente_lat": null,
  "cliente_lng": null,
  "cliente_notificado": true
}
CódigoSignificado
200Actualizado; devuelve el detalle (más cliente_notificado y motivo si enviaste notificar_cliente).
400id inválido, cuerpo vacío (enviá al menos un campo) o campo inválido.
404Pedido no encontrado.
409Reapertura sin stock suficiente. Cuerpo: {"error":"stock_insuficiente","items":[...]}.
500Error interno.

POST /api/erp/pedidos/:id/cancelar

Equivale a PATCH /pedidos/:id con {"estado":"CANCELADO"}: el stock del pedido vuelve al inventario en la misma transacción. Cancelar un pedido ya cancelado no hace nada y responde 200.

  • El cuerpo es opcional: podés llamar sin cuerpo.
  • Acepta notificar_cliente (boolean, por defecto false) en el cuerpo; la respuesta agrega cliente_notificado y, cuando es false, motivo, con los mismos valores que en PATCH /pedidos/:id. Un aviso que falla nunca impide la cancelación.
  • Si el pedido ya estaba cancelado, no se devuelve el stock otra vez y no se avisa al cliente (motivo sin_cambio_de_estado).
ParámetroEnTipoRequeridoReglas
idpathintegerSíid_pedido (> 0).
notificar_clientebodybooleanNoPor defecto false. Avisa al cliente de la cancelación.
Request (curl)
curl -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"notificar_cliente":true}' \
  "https://backend-vircat.fly.dev/api/erp/pedidos/456/cancelar"
Respuesta de ejemplo
{
  "id_pedido": 456,
  "total": 5980000,
  "estado": "CANCELADO",
  "obs": "Entregar por la tarde",
  "direccion_delivery": "Av. Principal 123, Minga Guazú",
  "id_medio_pago": 2,
  "id_medio_entrega": 1,
  "items": [
    {
      "id_pedido_item": 901,
      "id_producto": 319,
      "codigo": "HEL-001",
      "codigo_barras": "7891234567890",
      "nombre_producto": "Heladera no frost 350 L",
      "cantidad": 2,
      "precio_unit": 2990000,
      "precio_total": 5980000,
      "id_variante": null,
      "descuento": 0
    }
  ],
  "created_date": "2026-10-23T15:20:00Z",
  "cliente": {
    "id_cliente": 78,
    "nombre": "Ana Pérez",
    "telefono": "0981123456",
    "codigo_erp": "C-001"
  },
  "vendedor": null,
  "numero_seguimiento": "PY123456",
  "url_seguimiento": "https://transportista.ejemplo/track/PY123456",
  "notas_internas": "",
  "cupon_codigo": null,
  "cupon_descuento": 0,
  "id_lista_precios": null,
  "costo_envio": null,
  "distancia_km": null,
  "cliente_lat": null,
  "cliente_lng": null,
  "cliente_notificado": false,
  "motivo": "whatsapp_inactivo"
}
CódigoSignificado
200Cancelado (o ya lo estaba); devuelve el detalle (más cliente_notificado y motivo si enviaste notificar_cliente).
400id inválido.
404Pedido no encontrado.
500Error interno.

Webhooks

VirCat envía un POST JSON a la URL que configures en Configuración › Integraciones (la crea un administrador de la tienda desde el panel; no se administran con la API key). Se registra un webhook por evento; solo se envía a webhooks activos y mientras el plan incluya integraciones ERP. Los eventos válidos son exactamente seis: PEDIDO_CREADO, PEDIDO_ACTUALIZADO, PRODUCTO_CREADO, PRODUCTO_ACTUALIZADO, STOCK_BAJO y STOCK_ACTUALIZADO.

Estructura común
{
  "event": "PEDIDO_CREADO",
  "id_tienda": 123,
  "origen": "checkout",
  "data": { ... },
  "timestamp": "2026-10-23T15:20:05Z"
}

origen indica quién provocó el cambio: "panel", "storefront", "erp", "checkout" o "import". Usalo para ignorar los ecos de tus propias escrituras.

Destino y seguridad

  • La URL debe ser https. Al crear el webhook se rechazan http, localhost, hosts .localhost, .internal y .local, direcciones IP privadas o reservadas y direcciones numéricas (decimal, hexadecimal u octal).
  • La entrega vuelve a bloquear cualquier destino que, tras resolver el DNS, apunte a una dirección interna o reservada.
  • VirCat no sigue redirecciones: un 301 / 302 / 307 se trata como respuesta final y el evento no llega al otro host. Registrá la URL definitiva.

Clave de firma

  • Cada webhook que se crea genera una clave de firma (secret, 64 caracteres hexadecimales). La respuesta de creación la devuelve una sola vez: guardala en ese momento. El listado de webhooks solo indica firmado: true | false.
  • POST /api/vendedor/integraciones/webhooks/:id/secret (desde el panel, con sesión de administrador) genera o regenera la clave y la devuelve una sola vez. La clave anterior deja de valer de inmediato: actualizá tu receptor antes o aceptá un breve período de rechazos.
  • Los webhooks creados antes, sin clave, siguen funcionando pero reciben el payload reducido hasta que generes su clave.

Firma

Toda entrega a un webhook con clave lleva el header X-VirCat-Signature: el HMAC-SHA256 en hexadecimal del cuerpo crudo usando esa clave. Calculá la firma sobre los bytes recibidos, sin volver a serializar el JSON, y compará en tiempo constante (los ejemplos usan timingSafeEqual, compare_digest y hash_equals). Rechazá con 401 lo que no coincida.

Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';

const app = express();
// Necesitás el cuerpo CRUDO: la firma se calcula sobre los bytes exactos que envía VirCat.
app.post('/vircat/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  // Comparamos bytes: timingSafeEqual lanza si los largos difieren (por ejemplo con un header no ASCII).
  const firma = Buffer.from(req.get('X-VirCat-Signature') ?? '', 'utf8');
  const esperada = Buffer.from(
    crypto.createHmac('sha256', process.env.VIRCAT_WEBHOOK_SECRET).update(req.body).digest('hex'),
    'utf8'
  );

  const ok = firma.length === esperada.length && crypto.timingSafeEqual(firma, esperada);
  if (!ok) return res.sendStatus(401);

  res.sendStatus(200); // respondé rápido; procesá en segundo plano
  const evento = JSON.parse(req.body.toString('utf8'));
  // encolar(evento)
});
Python (Flask)
import hmac, hashlib, os
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/vircat/webhook")
def vircat_webhook():
    cuerpo = request.get_data()  # bytes crudos, sin re-serializar
    esperada = hmac.new(
        os.environ["VIRCAT_WEBHOOK_SECRET"].encode(), cuerpo, hashlib.sha256
    ).hexdigest()
    # Bytes en ambos lados: compare_digest lanza TypeError con texto no ASCII.
    recibida = request.headers.get("X-VirCat-Signature", "").encode("utf-8")
    if not hmac.compare_digest(recibida, esperada.encode("utf-8")):
        abort(401)
    evento = request.get_json()
    # encolar(evento)
    return "", 200
PHP
<?php
$cuerpo = file_get_contents('php://input'); // bytes crudos
$firma = $_SERVER['HTTP_X_VIRCAT_SIGNATURE'] ?? '';
$esperada = hash_hmac('sha256', $cuerpo, getenv('VIRCAT_WEBHOOK_SECRET'));

if (!hash_equals($esperada, $firma)) {
    http_response_code(401);
    exit;
}
http_response_code(200);
$evento = json_decode($cuerpo, true);
// encolar($evento);

Entrega

  • El envío es asíncrono: no demora la operación que lo origina. Se emite después de confirmar la operación.
  • Entrega como máximo una vez, de mejor esfuerzo y sin reintentos: si tu endpoint estaba caído, tardó más de 5 segundos o respondió con error, el evento se pierde. Respondé 2xx rápido y procesá en segundo plano.
  • Reconciliá consultando GET /pedidos y GET /productos periódicamente con after / next_cursor.
  • Una operación masiva (importación, sincronización) emite como máximo 500 eventos, con los de stock primero; el resto se descarta. Después de una carga masiva, volvé a leer con GET.
  • Un webhook nuevo (o eliminado) puede tardar hasta 30 segundos en empezar (o dejar) de recibir eventos.
  • Payload completo o reducido: el pedido completo (cliente, teléfono, dirección, geolocalización, notas) se envía solo a webhooks con clave de firma y URL https. Un webhook sin clave (o con http) recibe el pedido reducido: cliente y vendedor con solo id y codigo_erp, y sin nombre ni teléfono del cliente, direccion_delivery, cliente_lat, cliente_lng, obs ni notas_internas.

Qué no emite webhooks

  • El alta genérica de pedidos desde el panel (no la de la API ni la del checkout).
  • Los cambios de stock de variantes hechos desde la edición genérica del panel.
  • La carga de productos del onboarding.
  • En ediciones genéricas del panel, stock_anterior (STOCK_ACTUALIZADO) y estado_anterior (PEDIDO_ACTUALIZADO) son de mejor esfuerzo.

Eventos

PEDIDO_CREADO

Se crea un pedido: compra online, pre-pedido confirmado o POST /api/erp/pedidos.

  • data es el detalle completo del pedido, con la misma forma que GET /api/erp/pedidos/:id: ítems con codigo y codigo_barras, cliente (id_cliente, nombre, telefono, codigo_erp), vendedor, dirección, geolocalización, cupón y envío.
  • cliente es un objeto cuando el pedido tiene ficha o datos de contacto (un invitado llega con "id_cliente": null y "codigo_erp": null); es null solo si no tiene ni ficha ni contacto. vendedor es null en compras online.
  • Los pedidos creados por POST /api/erp/pedidos traen además en data la clave heredada origen: "ERP" (en mayúsculas). El origen real del evento es el campo origen de primer nivel.
  • El payload completo, con datos personales, se envía solo a webhooks con clave de firma y URL https. Un webhook sin clave de firma (o con http) recibe una versión reducida: cliente y vendedor llevan solo id y codigo_erp, y no viajan nombre ni teléfono del cliente, direccion_delivery, cliente_lat, cliente_lng, obs ni notas_internas.
PEDIDO_CREADO
{
  "event": "PEDIDO_CREADO",
  "id_tienda": 123,
  "origen": "checkout",
  "data": {
    "id_pedido": 456,
    "total": 125000,
    "estado": "PENDIENTE",
    "obs": "",
    "direccion_delivery": "Av. Principal 123, Minga Guazú",
    "id_medio_pago": 2,
    "id_medio_entrega": 1,
    "items": [
      {
        "id_pedido_item": 901,
        "id_producto": 319,
        "codigo": "HEL-001",
        "codigo_barras": "7891234567890",
        "nombre_producto": "Heladera no frost 350 L",
        "cantidad": 2,
        "precio_unit": 62500,
        "precio_total": 125000,
        "id_variante": null,
        "descuento": 0
      }
    ],
    "created_date": "2026-10-23T15:20:00Z",
    "cliente": {
      "id_cliente": 78,
      "nombre": "Ana Gómez",
      "telefono": "+595981123456",
      "codigo_erp": "C-001"
    },
    "vendedor": null,
    "numero_seguimiento": "",
    "url_seguimiento": "",
    "notas_internas": "",
    "cupon_codigo": null,
    "cupon_descuento": 0,
    "id_lista_precios": null,
    "costo_envio": null,
    "distancia_km": null,
    "cliente_lat": null,
    "cliente_lng": null
  },
  "timestamp": "2026-10-23T15:20:05Z"
}

PEDIDO_ACTUALIZADO

Cambia el estado o el seguimiento de un pedido (solo cuando el valor realmente cambió).

  • data es el detalle del pedido, igual que en PEDIDO_CREADO (completo para webhooks firmados con https, reducido para los demás), más estado_anterior con el estado previo (de mejor esfuerzo cuando el cambio viene de una edición genérica del panel).
  • Se emite también cuando cancelar o reabrir un pedido devuelve o vuelve a tomar stock; ese movimiento además genera STOCK_ACTUALIZADO.
PEDIDO_ACTUALIZADO
{
  "event": "PEDIDO_ACTUALIZADO",
  "id_tienda": 123,
  "origen": "panel",
  "data": {
    "id_pedido": 456,
    "total": 125000,
    "estado": "ENVIADO",
    "obs": "",
    "direccion_delivery": "Av. Principal 123, Minga Guazú",
    "id_medio_pago": 2,
    "id_medio_entrega": 1,
    "items": [
      {
        "id_pedido_item": 901,
        "id_producto": 319,
        "codigo": "HEL-001",
        "codigo_barras": "7891234567890",
        "nombre_producto": "Heladera no frost 350 L",
        "cantidad": 2,
        "precio_unit": 62500,
        "precio_total": 125000,
        "id_variante": null,
        "descuento": 0
      }
    ],
    "created_date": "2026-10-23T15:20:00Z",
    "cliente": {
      "id_cliente": 78,
      "nombre": "Ana Gómez",
      "telefono": "+595981123456",
      "codigo_erp": "C-001"
    },
    "vendedor": null,
    "numero_seguimiento": "PY123456",
    "url_seguimiento": "",
    "notas_internas": "",
    "cupon_codigo": null,
    "cupon_descuento": 0,
    "id_lista_precios": null,
    "costo_envio": null,
    "distancia_km": null,
    "cliente_lat": null,
    "cliente_lng": null,
    "estado_anterior": "PREPARANDO"
  },
  "timestamp": "2026-10-23T15:20:05Z"
}

PRODUCTO_CREADO

Se crea un producto.

  • data tiene la misma forma que GET /api/erp/productos/:id.
  • Una carga masiva emite un evento por producto, hasta el tope por operación (ver Entrega).
PRODUCTO_CREADO
{
  "event": "PRODUCTO_CREADO",
  "id_tienda": 123,
  "origen": "panel",
  "data": {
    "id_producto": 319,
    "codigo": "HEL-001",
    "nombre": "Heladera no frost 350 L",
    "precio": 3200000,
    "stock": 14,
    "estado": "ACTIVO",
    "...": "resto de los campos de GET /api/erp/productos/:id"
  },
  "timestamp": "2026-10-23T15:20:05Z"
}

PRODUCTO_ACTUALIZADO

Se modifica o se elimina un producto.

  • data tiene la misma forma que GET /api/erp/productos/:id.
  • Un cambio solo de stock NO emite este evento en la API ERP, la importación de Excel ni la sincronización legacy: usá STOCK_ACTUALIZADO. Una edición desde el panel que envía junto con el stock otros campos sin cambios todavía puede emitirlo.
  • Al eliminar un producto se emite con "eliminado": true; si el producto se borró físicamente, data solo trae id_producto y eliminado: true.
  • En un evento con eliminado: true, data conserva el último estado del producto (por ejemplo "ACTIVO"): para saber si fue eliminado revisá eliminado, nunca estado.
PRODUCTO_ACTUALIZADO
{
  "event": "PRODUCTO_ACTUALIZADO",
  "id_tienda": 123,
  "origen": "import",
  "data": {
    "id_producto": 319,
    "codigo": "HEL-001",
    "precio": 3100000,
    "estado": "ACTIVO",
    "eliminado": true,
    "...": "resto de los campos"
  },
  "timestamp": "2026-10-23T15:20:05Z"
}

STOCK_BAJO

El stock de un producto con control de stock pasa de mayor a menor o igual que su stock_minimo.

  • Se emite solo en el cruce (de stock > stock_minimo a stock <= stock_minimo), no en cada descuento posterior.
  • El control de stock es el efectivo: el interruptor global de la tienda o controlar_stock del producto, salvo que la tienda venda sin stock (permitir venta sin stock).
  • id_variante aparece solo cuando el cruce ocurre en una variante.
  • En ediciones genéricas del panel, el valor previo de stock que se compara es de mejor esfuerzo.
STOCK_BAJO
{
  "event": "STOCK_BAJO",
  "id_tienda": 123,
  "origen": "checkout",
  "data": {
    "id_producto": 319,
    "codigo": "HEL-001",
    "nombre": "Heladera no frost 350 L",
    "stock": 4,
    "stock_minimo": 5
  },
  "timestamp": "2026-10-23T15:20:05Z"
}

STOCK_ACTUALIZADO

Cambia el stock de un producto o variante por cualquier movimiento.

  • Un evento por producto o variante en cada operación.
  • En ediciones genéricas del panel, stock_anterior es de mejor esfuerzo.
  • id_variante es null a nivel producto; id_pedido es null si el movimiento no viene de un pedido.
  • motivo: "venta", "cancelacion", "reapertura", "ajuste", "importacion" o "erp".
  • stock es el valor absoluto resultante; delta = stock - stock_anterior.
STOCK_ACTUALIZADO
{
  "event": "STOCK_ACTUALIZADO",
  "id_tienda": 123,
  "origen": "checkout",
  "data": {
    "id_producto": 319,
    "codigo": "HEL-001",
    "id_variante": null,
    "stock_anterior": 6,
    "stock": 4,
    "delta": -2,
    "motivo": "venta",
    "id_pedido": 456
  },
  "timestamp": "2026-10-23T15:20:05Z"
}

Buenas prácticas

  • Hacé tu consumidor idempotente, con clave (evento, id del recurso, timestamp), por si procesás dos veces el mismo evento (por ejemplo al reconciliar con GET).
  • Si tu ERP escribe de vuelta en VirCat, ignorá los eventos con origen "erp" para no entrar en bucles.
  • No asumas orden de llegada entre eventos.

API legacy (/api/external/v1)

Sigue funcionando con la misma API key y plan, pero está en desuso: sin cursor y con filtros limitados. Para integraciones nuevas usá /api/erp.

MétodoRutaQué hace
POST/api/external/v1/productos/syncAlta/actualización masiva por codigo. Cuerpo: {"productos":[{codigo, nombre, precio, stock, ...}]}. Respuesta: {"insertados","actualizados","errors"}. Rechaza todo el lote con 400 si algún id_marca / id_sub_categoria no es de tu tienda.
GET/api/external/v1/productosHasta limit productos ACTIVOS (por defecto 100, máximo 500), más nuevos primero. Sin cursor. Respuesta: {"productos":[...]}.
GET/api/external/v1/pedidosHasta limit pedidos (por defecto 50, máximo 200), más nuevos primero. Sin cursor ni filtros. Respuesta: {"pedidos":[...]} con cliente y vendedor.

Referencia interactiva

También podés explorar los endpoints en Swagger o importar la especificación OpenAPI en tu herramienta.

Cambios recientes

2026-10-23

  • Cambios que rompen (rechazan entradas que antes se aceptaban):
  • PATCH /productos/:id/stock: cantidad es obligatoria. Antes, si la omitías se tomaba 0 y con mode "set" el stock quedaba en 0.
  • POST /pedidos: estado se valida (PENDIENTE, PREPARANDO, ENVIADO, ENTREGADO o CANCELADO); antes se guardaba cualquier valor.
  • POST /pedidos: se rechazan (400) productos o variantes de otra tienda o eliminados, una variante que no es del producto y un id_variante negativo.
  • POST /pedidos: cliente_nombre (hasta 120) y cliente_telefono (hasta 32) se validan; son opcionales.
  • GET /marcas, /categorias y /sub_categorias: enviar page devuelve 400, y la respuesta incluye limit.
  • Webhooks: las URL nuevas deben ser https. Se rechazan http, localhost, .internal, .local, IP privadas o reservadas y direcciones numéricas (se bloquean también al entregar, tras resolver el DNS). No se siguen redirecciones.
  • Webhooks: cada webhook nuevo genera una clave de firma, que se muestra una sola vez. Los webhooks sin clave de firma o con http reciben el PEDIDO_CREADO / PEDIDO_ACTUALIZADO reducido, sin datos personales.
  • Novedades:
  • Mejora: PATCH /productos/:id/stock busca el codigo del producto sin distinguir mayúsculas.
  • PEDIDO_CREADO trae el pedido completo (ítems con codigo y codigo_barras, cliente, vendedor, dirección, geolocalización, cupón y envío) para webhooks firmados con https.
  • Nuevos eventos PEDIDO_ACTUALIZADO, PRODUCTO_CREADO, PRODUCTO_ACTUALIZADO, STOCK_BAJO y STOCK_ACTUALIZADO, y el campo origen en todos los eventos.
  • POST /api/vendedor/integraciones/webhooks/:id/secret genera o regenera la clave de firma; la anterior deja de valer de inmediato. El listado de webhooks devuelve firmado.
  • PATCH /pedidos/:id y POST /pedidos/:id/cancelar aceptan notificar_cliente; la respuesta agrega cliente_notificado y motivo. La cancelación acepta un cuerpo vacío.
  • POST /pedidos acepta cliente_nombre y cliente_telefono.
  • El aviso al dueño de la tienda por pedidos creados o actualizados desde el ERP es opcional (Configuración › Integraciones, desactivado por defecto).

2026-10

  • La paginación de GET /productos y GET /pedidos pasó de page/total a cursor (after, next_cursor, limit). Enviar page ahora devuelve 400.
  • Nuevos: CRUD de /marcas, /categorias y /sub_categorias.
  • Nuevos: GET y DELETE /productos/:id; GET /productos devuelve todos los campos; PUT acepta todos los campos editables.
  • id_marca e id_sub_categoria se validan contra tu tienda (también en la API legacy de sync).
  • Pedidos: PATCH /pedidos/:id, POST /pedidos/:id/cancelar, header Idempotency-Key en POST /pedidos y detalle ampliado (seguimiento, notas internas, cupón, envío).
  • codigo de producto: ya no se aceptan valores solo numéricos.