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
Un administrador de la tienda entra al panel, Configuración › Integraciones, y crea una API key.
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.
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"}.
Estado
code
Cuándo
401
UNAUTHORIZED
Tres 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).
403
PLAN_SIN_INTEGRACION_ERP
La key es válida pero el plan de la tienda no incluye integraciones ERP (plan Empresa).
503
SERVICE_UNAVAILABLE
VirCat 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
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.
Productos: creá o actualizá con POST/PUT referenciando esos ids.
Stock: con PATCH /productos/:id/stock. Si tu ERP es la fuente de verdad, usá set.
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ámetro
En
Tipo
Requerido
Reglas
after
query
integer
No
Cursor: devuelve ids mayores a este valor. Por defecto 0 (primera página). Debe ser >= 0.
limit
query
integer
No
Tamaño de página. Por defecto 100, máximo 500 (valores mayores se acotan a 500). Debe ser >= 1.
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ámetro
En
Tipo
Requerido
Reglas
nombre
body
string
Al crear
1 a 200 caracteres (se recortan espacios).
descripcion
body
string
No
Hasta 2000 caracteres.
url_foto
body
string
No
Debe empezar con http:// o https://; hasta 2048 caracteres. Vacío se acepta.
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ámetro
En
Tipo
Requerido
Reglas
nombre
body
string
Al crear
1 a 200 caracteres (se recortan espacios).
descripcion
body
string
No
Hasta 2000 caracteres.
url_foto
body
string
No
Debe empezar con http:// o https://; hasta 2048 caracteres. Vacío se acepta.
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ámetro
En
Tipo
Requerido
Reglas
nombre
body
string
Al crear
1 a 200 caracteres (se recortan espacios).
id_categoria
body
integer
Al crear
Categoría padre; debe existir y ser de tu tienda (> 0).
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ámetro
En
Tipo
Requerido
Reglas
codigo
body
string
Al crear
1 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.
nombre
body
string
Al crear
1 a 200 caracteres.
precio
body
number
Al crear
Mayor a 0.
precio_promo
body
number
No
Entre 0 y precio (0 = sin promoción).
costo
body
number
No
0 o mayor.
stock
body
number
No
Solo al crear (0 o mayor). Para cambiarlo después usá PATCH /productos/:id/stock; en PUT se rechaza con 400.
stock_minimo
body
integer
No
0 o mayor. Si lo omitís al crear, el producto queda con 5.
descripcion
body
string
No
Hasta 10000 caracteres.
obs
body
string
No
Hasta 2000 caracteres.
url_foto
body
string
No
Empieza con http:// o https://; hasta 2048 caracteres.
estado
body
string
No
ACTIVO (por defecto al crear), INACTIVO o AGOTADO.
id_marca
body
integer
No
Debe ser una marca activa de tu tienda. En PUT, 0 la quita.
id_sub_categoria
body
integer
No
Debe ser una subcategoría activa de tu tienda. En PUT, 0 la quita.
es_destacado / es_nuevo / en_promocion
body
boolean
No
Banderas de visualización en la tienda.
controlar_stock
body
boolean
No
Si es true, los pedidos descuentan stock de este producto.
requiere_envio
body
boolean
No
Si el producto se envía (por defecto true al leer valores nulos).
solo_consulta
body
boolean
No
Producto de consulta, sin compra directa.
slug
body
string
No
Hasta 200 caracteres: minúsculas, dígitos y guiones simples (a-z, 0-9, -).
etiquetas
body
string[]
No
Hasta 20 etiquetas de 1 a 50 caracteres. Una lista vacía borra las etiquetas.
codigo_barras
body
string
No
Hasta 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"
Validación de algún campo, o id_marca / id_sub_categoria que no existe en tu tienda (el mensaje nombra el campo).
403
Se alcanzó el límite de productos del plan. Cuerpo: {"error","limite","count"}.
409
Ya existe un producto con ese codigo. Cuerpo: {"error","id_producto"} con el id del existente.
500
Error 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ámetro
En
Tipo
Requerido
Reglas
id
path
string
Sí
id_producto (número) o codigo. Un valor numérico siempre se interpreta como id; el codigo se compara sin distinguir mayúsculas.
codigo
body
string
No
1 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.
nombre
body
string
No
1 a 200 caracteres.
precio
body
number
No
Mayor a 0.
precio_promo
body
number
No
Entre 0 y precio (0 = sin promoción).
costo
body
number
No
0 o mayor.
stock
body
number
No
Solo al crear (0 o mayor). Para cambiarlo después usá PATCH /productos/:id/stock; en PUT se rechaza con 400.
stock_minimo
body
integer
No
0 o mayor. Si lo omitís al crear, el producto queda con 5.
descripcion
body
string
No
Hasta 10000 caracteres.
obs
body
string
No
Hasta 2000 caracteres.
url_foto
body
string
No
Empieza con http:// o https://; hasta 2048 caracteres.
estado
body
string
No
ACTIVO (por defecto al crear), INACTIVO o AGOTADO.
id_marca
body
integer
No
Debe ser una marca activa de tu tienda. En PUT, 0 la quita.
id_sub_categoria
body
integer
No
Debe ser una subcategoría activa de tu tienda. En PUT, 0 la quita.
es_destacado / es_nuevo / en_promocion
body
boolean
No
Banderas de visualización en la tienda.
controlar_stock
body
boolean
No
Si es true, los pedidos descuentan stock de este producto.
requiere_envio
body
boolean
No
Si el producto se envía (por defecto true al leer valores nulos).
solo_consulta
body
boolean
No
Producto de consulta, sin compra directa.
slug
body
string
No
Hasta 200 caracteres: minúsculas, dígitos y guiones simples (a-z, 0-9, -).
etiquetas
body
string[]
No
Hasta 20 etiquetas de 1 a 50 caracteres. Una lista vacía borra las etiquetas.
Falta cantidad, mode inválido, o stock resultante negativo (cuerpo: {"error","stock_antes"}).
404
Producto no encontrado.
500
Error 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ámetro
En
Tipo
Requerido
Reglas
Idempotency-Key
header
string
No
Hasta 200 caracteres, sin caracteres de control. Identifica al pedido, no al contenido.
items
body
object[]
Sí
Al menos un ítem.
items[].id_producto
body
integer
Sí
> 0.
items[].cantidad
body
number
Sí
> 0.
items[].precio_unit
body
number
Sí
> 0.
items[].precio_total
body
number
No
Si es 0 u omitido, se calcula.
items[].id_variante
body
integer
No
Variante del producto, si corresponde.
estado
body
string
No
PENDIENTE (por defecto), PREPARANDO, ENVIADO, ENTREGADO o CANCELADO.
obs
body
string
No
Observaciones.
id_medio_pago
body
integer
No
Medio de pago de la tienda. Si lo omitís, GET /pedidos/:id devuelve 0 (no null).
id_medio_entrega
body
integer
No
Medio de entrega de la tienda. Si lo omitís, GET /pedidos/:id devuelve 0 (no null).
Pedido creado. created_date trae microsegundos y es idéntico en la repetición con la misma Idempotency-Key.
200
Reintento: ya existía un pedido con esa Idempotency-Key; devuelve el original.
400
Sin í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.
500
Error 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ámetro
En
Tipo
Requerido
Reglas
after
query
integer
No
Cursor: devuelve pedidos con id_pedido menor a este valor. Por defecto: los más nuevos. Debe ser >= 0.
limit
query
integer
No
Por defecto 100, máximo 500 (se acota). Debe ser >= 1.
estado
query
string
No
PENDIENTE, PREPARANDO, ENVIADO, ENTREGADO o CANCELADO (no distingue mayúsculas).
fecha_desde
query
string
No
Inclusive. 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_hasta
query
string
No
YYYY-MM-DD incluye todo ese día (hora de Paraguay); con hora, el límite es inclusive. Mismos formatos que fecha_desde. Alias: hasta.
Página de pedidos. next_cursor es null en la última.
400
after, limit, estado o fechas inválidos; fecha_desde posterior a fecha_hasta; o se envió page.
500
Error 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á.
Pedido no encontrado, de otra tienda o con id menor o igual a 0.
500
Error 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ámetro
En
Tipo
Requerido
Reglas
id
path
integer
Sí
id_pedido (> 0).
estado
body
string
No
Nuevo estado.
numero_seguimiento
body
string
No
Hasta 200 caracteres. "" lo borra.
url_seguimiento
body
string
No
URL http(s) de hasta 2048 caracteres. "" la borra.
notas_internas
body
string
No
Hasta 5000 caracteres. "" las borra.
notificar_cliente
body
boolean
No
Por defecto false. Avisa por WhatsApp al cliente del cambio de estado.
Actualizado; devuelve el detalle (más cliente_notificado y motivo si enviaste notificar_cliente).
400
id inválido, cuerpo vacío (enviá al menos un campo) o campo inválido.
404
Pedido no encontrado.
409
Reapertura sin stock suficiente. Cuerpo: {"error":"stock_insuficiente","items":[...]}.
500
Error 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ámetro
En
Tipo
Requerido
Reglas
id
path
integer
Sí
id_pedido (> 0).
notificar_cliente
body
boolean
No
Por defecto false. Avisa al cliente de la cancelación.
Cancelado (o ya lo estaba); devuelve el detalle (más cliente_notificado y motivo si enviaste notificar_cliente).
400
id inválido.
404
Pedido no encontrado.
500
Error 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.
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
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.
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.
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.
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.
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étodo
Ruta
Qué hace
POST
/api/external/v1/productos/sync
Alta/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/productos
Hasta limit productos ACTIVOS (por defecto 100, máximo 500), más nuevos primero. Sin cursor. Respuesta: {"productos":[...]}.
GET
/api/external/v1/pedidos
Hasta 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 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.