# API de integración para ERP — VirCat

Documentación para el equipo de desarrollo que conecta un ERP o sistema de gestión con una tienda VirCat. Versión en línea: https://vircat.shop/developers

## 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"}`.

| 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

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á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. |

Request:

```bash
curl -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/marcas?limit=100"
```

Respuesta de ejemplo:

```json
{
  "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ódigo | Significado |
| --- | --- |
| 200 | Página de resultados. |
| 400 | after o limit inválidos, o se envió page. |
| 500 | Error interno. |

#### `GET /api/erp/marcas/:id`

Devuelve una marca por id.

| Parámetro | En | Tipo | Requerido | Reglas |
| --- | --- | --- | --- | --- |
| `id` | path | integer | Sí | ID de la marca (> 0). |

Request:

```bash
curl -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/marcas/12"
```

Respuesta de ejemplo:

```json
{
  "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ódigo | Significado |
| --- | --- |
| 200 | La entidad. |
| 400 | id inválido. |
| 404 | marca no encontrada (o de otra tienda). |
| 500 | Error 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á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. |
| `estado` | body | string | No | ACTIVO (por defecto) o INACTIVO. |

Request:

```bash
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:

```json
{
  "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ódigo | Significado |
| --- | --- |
| 201 | Creada; devuelve la entidad. |
| 400 | Validación (campo faltante, largo o formato inválido). |
| 500 | Error 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ámetro | En | Tipo | Requerido | Reglas |
| --- | --- | --- | --- | --- |
| `id` | path | integer | Sí | ID de la marca (> 0). |
| `nombre` | body | string | No | 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. |
| `estado` | body | string | No | ACTIVO (por defecto) o INACTIVO. |

Request:

```bash
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:

```json
{
  "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ódigo | Significado |
| --- | --- |
| 200 | Actualizada; devuelve la entidad. |
| 400 | id inválido, validación, o ningún campo enviado. |
| 404 | marca no encontrada (o de otra tienda). |
| 500 | Error interno. |

#### `DELETE /api/erp/marcas/:id`

Elimina (baja lógica) una marca.

| Parámetro | En | Tipo | Requerido | Reglas |
| --- | --- | --- | --- | --- |
| `id` | path | integer | Sí | ID de la marca (> 0). |

Request:

```bash
curl -X DELETE -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/marcas/12"
```

| Código | Significado |
| --- | --- |
| 204 | Eliminada (baja lógica). Sin cuerpo. |
| 400 | id inválido. |
| 404 | marca no encontrada (o de otra tienda). |
| 409 | La marca la usan productos activos: reasignalos o eliminalos primero. |
| 500 | Error 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á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. |

Request:

```bash
curl -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/categorias?limit=100"
```

Respuesta de ejemplo:

```json
{
  "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ódigo | Significado |
| --- | --- |
| 200 | Página de resultados. |
| 400 | after o limit inválidos, o se envió page. |
| 500 | Error interno. |

#### `GET /api/erp/categorias/:id`

Devuelve una categoria por id.

| Parámetro | En | Tipo | Requerido | Reglas |
| --- | --- | --- | --- | --- |
| `id` | path | integer | Sí | ID de la categoria (> 0). |

Request:

```bash
curl -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/categorias/12"
```

Respuesta de ejemplo:

```json
{
  "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ódigo | Significado |
| --- | --- |
| 200 | La entidad. |
| 400 | id inválido. |
| 404 | categoria no encontrada (o de otra tienda). |
| 500 | Error 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á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. |
| `estado` | body | string | No | ACTIVO (por defecto) o INACTIVO. |

Request:

```bash
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:

```json
{
  "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ódigo | Significado |
| --- | --- |
| 201 | Creada; devuelve la entidad. |
| 400 | Validación (campo faltante, largo o formato inválido). |
| 500 | Error 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ámetro | En | Tipo | Requerido | Reglas |
| --- | --- | --- | --- | --- |
| `id` | path | integer | Sí | ID de la categoria (> 0). |
| `nombre` | body | string | No | 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. |
| `estado` | body | string | No | ACTIVO (por defecto) o INACTIVO. |

Request:

```bash
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:

```json
{
  "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ódigo | Significado |
| --- | --- |
| 200 | Actualizada; devuelve la entidad. |
| 400 | id inválido, validación, o ningún campo enviado. |
| 404 | categoria no encontrada (o de otra tienda). |
| 500 | Error interno. |

#### `DELETE /api/erp/categorias/:id`

Elimina (baja lógica) una categoria.

| Parámetro | En | Tipo | Requerido | Reglas |
| --- | --- | --- | --- | --- |
| `id` | path | integer | Sí | ID de la categoria (> 0). |

Request:

```bash
curl -X DELETE -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/categorias/12"
```

| Código | Significado |
| --- | --- |
| 204 | Eliminada (baja lógica). Sin cuerpo. |
| 400 | id inválido. |
| 404 | categoria no encontrada (o de otra tienda). |
| 409 | La categoría tiene subcategorías activas: eliminalas primero. |
| 500 | Error 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á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. |

Request:

```bash
curl -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/sub_categorias?limit=100"
```

Respuesta de ejemplo:

```json
{
  "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ódigo | Significado |
| --- | --- |
| 200 | Página de resultados. |
| 400 | after o limit inválidos, o se envió page. |
| 500 | Error interno. |

#### `GET /api/erp/sub_categorias/:id`

Devuelve una sub_categoria por id.

| Parámetro | En | Tipo | Requerido | Reglas |
| --- | --- | --- | --- | --- |
| `id` | path | integer | Sí | ID de la sub_categoria (> 0). |

Request:

```bash
curl -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/sub_categorias/12"
```

Respuesta de ejemplo:

```json
{
  "id": 31,
  "nombre": "Heladeras",
  "id_categoria": 4,
  "estado": "ACTIVO",
  "created_date": "2026-10-20T14:03:11.482Z",
  "updated_date": null
}
```

| Código | Significado |
| --- | --- |
| 200 | La entidad. |
| 400 | id inválido. |
| 404 | sub_categoria no encontrada (o de otra tienda). |
| 500 | Error 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á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). |
| `estado` | body | string | No | ACTIVO (por defecto) o INACTIVO. |

Request:

```bash
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:

```json
{
  "id": 31,
  "nombre": "Heladeras",
  "id_categoria": 4,
  "estado": "ACTIVO",
  "created_date": "2026-10-20T14:03:11.482Z",
  "updated_date": null
}
```

| Código | Significado |
| --- | --- |
| 201 | Creada; devuelve la entidad. |
| 400 | Validación (campo faltante, largo o formato inválido, id_categoria inexistente). |
| 500 | Error 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ámetro | En | Tipo | Requerido | Reglas |
| --- | --- | --- | --- | --- |
| `id` | path | integer | Sí | ID de la sub_categoria (> 0). |
| `nombre` | body | string | No | 1 a 200 caracteres (se recortan espacios). |
| `id_categoria` | body | integer | No | Categoría padre; debe existir y ser de tu tienda (> 0). |
| `estado` | body | string | No | ACTIVO (por defecto) o INACTIVO. |

Request:

```bash
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:

```json
{
  "id": 31,
  "nombre": "Heladeras",
  "id_categoria": 4,
  "estado": "ACTIVO",
  "created_date": "2026-10-20T14:03:11.482Z",
  "updated_date": null
}
```

| Código | Significado |
| --- | --- |
| 200 | Actualizada; devuelve la entidad. |
| 400 | id inválido, validación, o ningún campo enviado. |
| 404 | sub_categoria no encontrada (o de otra tienda). |
| 500 | Error interno. |

#### `DELETE /api/erp/sub_categorias/:id`

Elimina (baja lógica) una sub_categoria.

| Parámetro | En | Tipo | Requerido | Reglas |
| --- | --- | --- | --- | --- |
| `id` | path | integer | Sí | ID de la sub_categoria (> 0). |

Request:

```bash
curl -X DELETE -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/sub_categorias/12"
```

| Código | Significado |
| --- | --- |
| 204 | Eliminada (baja lógica). Sin cuerpo. |
| 400 | id inválido. |
| 404 | sub_categoria no encontrada (o de otra tienda). |
| 409 | La subcategoría la usan productos activos: reasignalos o eliminalos primero. |
| 500 | Error 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ámetro | En | Tipo | Requerido | Reglas |
| --- | --- | --- | --- | --- |
| `after` | query | integer | No | Cursor: devuelve id_producto mayores. Por defecto 0. Debe ser >= 0. |
| `limit` | query | integer | No | Por defecto 100, máximo 500 (se acota). Debe ser >= 1. |
| `estado` | query | string | No | Filtra por ACTIVO, INACTIVO o AGOTADO (exacto, en mayúsculas). |

Request:

```bash
curl -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/productos?limit=100&after=0"
```

Respuesta de ejemplo:

```json
{
  "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ódigo | Significado |
| --- | --- |
| 200 | Página de productos. next_cursor es null en la última. |
| 400 | after, limit o estado inválidos, o se envió page. |
| 500 | Error interno. |

#### `GET /api/erp/productos/:id`

Devuelve un producto por id_producto o por codigo.

| 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. |

Request:

```bash
curl -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/productos/HEL-001"
```

Respuesta de ejemplo:

```json
{
  "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ódigo | Significado |
| --- | --- |
| 200 | El producto. |
| 404 | No existe, está eliminado o es de otra tienda. |
| 500 | Error 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á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:

```bash
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:

```json
{
  "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ódigo | Significado |
| --- | --- |
| 201 | Creado; devuelve el producto completo. |
| 400 | 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. |
| `codigo_barras` | body | string | No | Hasta 64 caracteres. |

Request:

```bash
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:

```json
{
  "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ódigo | Significado |
| --- | --- |
| 200 | Actualizado. |
| 400 | Validación, stock enviado, ningún campo, referencia fuera de tu tienda o precio_promo > precio. |
| 404 | No existe, está eliminado o es de otra tienda. |
| 409 | El codigo ya lo usa otro producto. |
| 500 | Error 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á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. |

Request:

```bash
curl -X DELETE -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/productos/HEL-001"
```

| Código | Significado |
| --- | --- |
| 204 | Eliminado. Sin cuerpo. |
| 404 | No existe, ya estaba eliminado o es de otra tienda. |
| 500 | Error 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ámetro | En | Tipo | Requerido | Reglas |
| --- | --- | --- | --- | --- |
| `id` | path | string | Sí | id_producto (número) o codigo. |
| `cantidad` | body | number | Sí | Con set: nuevo stock. Con delta: variación (puede ser negativa). El resultado no puede ser negativo. |
| `mode` | body | string | No | "set" (por defecto) o "delta". |

Request:

```bash
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:

```json
{
  "id_producto": 319,
  "stock_antes": 14,
  "stock_ahora": 12
}
```

| Código | Significado |
| --- | --- |
| 200 | Stock actualizado. |
| 400 | 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). |
| `direccion_delivery` | body | string | No | Dirección de entrega. |
| `cliente_nombre` | body | string | No | Nombre del cliente. Hasta 120 caracteres. |
| `cliente_telefono` | body | string | No | Teléfono del cliente. Hasta 32 caracteres. |

Request:

```bash
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:

```json
{
  "id_pedido": 456,
  "total": 5980000,
  "estado": "PENDIENTE",
  "created_date": "2026-10-23T15:20:00.123456Z"
}
```

| Código | Significado |
| --- | --- |
| 201 | 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. |

Request:

```bash
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:

```json
{
  "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ódigo | Significado |
| --- | --- |
| 200 | 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á.

| Parámetro | En | Tipo | Requerido | Reglas |
| --- | --- | --- | --- | --- |
| `id` | path | integer | Sí | id_pedido (> 0). |

Request:

```bash
curl -H "Authorization: Bearer $API_KEY" \
  "https://backend-vircat.fly.dev/api/erp/pedidos/456"
```

Respuesta de ejemplo:

```json
{
  "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ódigo | Significado |
| --- | --- |
| 200 | El pedido. |
| 400 | id no numérico. |
| 404 | 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. |

Request:

```bash
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:

```json
{
  "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ódigo | Significado |
| --- | --- |
| 200 | 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. |

Request:

```bash
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:

```json
{
  "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ódigo | Significado |
| --- | --- |
| 200 | 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`.

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.

```json
{
  "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.

```json
{
  "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).

```json
{
  "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.

```json
{
  "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.

```json
{
  "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.

```json
{
  "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é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.

- [https://backend-vircat.fly.dev/swagger/public/docs](https://backend-vircat.fly.dev/swagger/public/docs)
- [https://backend-vircat.fly.dev/swagger/public/public_swagger.json](https://backend-vircat.fly.dev/swagger/public/public_swagger.json)

## 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.
