# Cómo funciona el stock

Niveles por bodega, conteos, el margen de la API, salidas atómicas y stock negativo. Léelo antes de vender desde otro canal.

Posdata lleva el stock de cada producto (o variante) **por bodega**. Esta página explica de dónde sale el número que te da la API, qué pasa cuando tu tienda en línea vende y qué hace Posdata cuando una caja sin internet vende lo mismo.

## El modelo: último conteo + movimientos posteriores

Cada combinación de (bodega, producto, variante) es un **nivel** de stock, y su cantidad es:

> **stock = último conteo físico + movimientos ocurridos después de ese conteo**

- Un **conteo** dice «había N unidades en este instante». Lo registra alguien del negocio en la app, con una hoja de conteo, o tu integración con `POST /inventory/counts`.
- Un **movimiento** es una diferencia: una venta resta, una entrada suma. Las cajas suben sus ventas como movimientos; tú registras los tuyos con `POST /inventory/movements`.
- Lo que ocurrió **antes** del conteo ya está reflejado en él: no se vuelve a sumar ni a restar, aunque llegue después (por ejemplo, las ventas de una caja que estaba sin internet). Lo que ocurrió **después** se aplica encima. Una venta nunca se pierde ni se descuenta dos veces.

Un ejemplo con la camiseta talla M en la Bodega principal:

| Hora  | Qué pasa                                                                            | Stock                                                                    |
| ----- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| 9:00  | Alguien cuenta 10 unidades.                                                         | 10                                                                       |
| 10:15 | La Caja 01 vende 2.                                                                 | 8                                                                        |
| 11:00 | Tu tienda en línea registra una salida de 1.                                        | 7                                                                        |
| 11:30 | La Caja 02, sin internet desde las 8:00, se reconecta y sube una venta de las 8:40. | 7: la venta ocurrió antes del conteo, así que el conteo ya la reflejaba. |

La hora de una venta es la del reloj de la caja que la hizo (nunca posterior a cuando llegó a la nube). Si un reloj está mal, el error se corrige con el siguiente conteo.

Dónde ves el stock:

- **`GET /inventory/levels`**: un objeto `stock_level` por nivel, con la cantidad, lo que la API puede comprometer y el último conteo. [Detalle abajo](https://developers.posdata.so/stock/#consultar-el-stock).
- **El campo `stock` de los productos** (`GET /products`, `GET /products/{id}`): la suma de todos sus niveles (todas las bodegas y variantes). Cada variante trae su propio `stock`, la suma de sus niveles. Es `null` en los productos que no llevan control de stock y en los que se arman con receta (el stock lo tienen sus insumos).

## Dos modos: las cajas o la nube

Cada cuenta pasa del modo en el que cada caja lleva su propio stock (**`legacy`**) al modo en el que Posdata lleva el stock oficial en la nube (**`baseline`**). Mientras la cuenta está en `legacy`, la API no puede darte un número confiable, así que no te lo da.

El paso es **automático**: ocurre cuando todas las cajas principales del negocio que se han usado en los últimos 14 días (las de `kind: "primary"` en `GET /me`, sin contar las revocadas) tienen la versión de Posdata que lo soporta y subieron su stock de partida. Las cajas se actualizan solas al abrirlas con internet. Si una caja que ya no se usa frena el cambio, el negocio puede revocarla en [cuenta.posdata.so](https://cuenta.posdata.so) → **Dispositivos**. Tu integración no tiene que hacer nada.

|                                                          | Cuenta en `legacy`                                                                                          | Cuenta en `baseline` (stock en la nube)                        |
| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| `stock` de productos y variantes                         | `null`                                                                                                      | El número oficial.                                             |
| `GET /inventory/levels`                                  | `409 STOCK_MODE_LEGACY`                                                                                     | Funciona.                                                      |
| `POST /inventory/counts`                                 | `409 STOCK_MODE_LEGACY`                                                                                     | Funciona.                                                      |
| `POST /inventory/movements` de un producto sin variantes | Funciona como siempre: suma o resta **sin verificar existencias** (la API no conoce el stock de cada caja). | Funciona. Una salida es atómica y respeta el margen de la API. |
| `POST /inventory/movements` de una variante              | `409 STOCK_MODE_LEGACY`                                                                                     | Funciona.                                                      |
| Productos con receta                                     | `422 COMPOSITE_STOCK_UNSUPPORTED`                                                                           | `422 COMPOSITE_STOCK_UNSUPPORTED`                              |

**Cómo saber en qué modo está la cuenta:** pide `GET /inventory/levels?limit=1`. Si responde `200`, el stock está en la nube; si responde `409 STOCK_MODE_LEGACY`, todavía lo llevan las cajas. No lo deduzcas de un `stock: null` en un producto: también es `null` en productos sin control de stock o con receta.

Al pasar a `baseline`, el punto de partida de cada nivel es el stock que tenían las cajas. Si un nivel trae `baseline_estimated: true`, ese punto de partida es una **estimación** (el ítem ya tenía movimientos en la nube que esa caja quizá no había visto): conviene que el negocio lo cuente.

> **Atención**
>
> Tu integración debe manejar `stock: null` y `409 STOCK_MODE_LEGACY` en cualquier momento, no solo al principio: el soporte de Posdata puede devolver una cuenta al modo anterior mientras resuelve un problema.

## Consultar el stock

`GET /inventory/levels` (permiso `inventory:read`) lista los niveles, con los filtros `product_id`, `variant_id` y `warehouse_id`, más la [paginación y los filtros por fecha](https://developers.posdata.so/paginacion/) de todas las listas. No trae niveles de bodegas eliminadas, de productos sin control de stock ni de variantes eliminadas.

**curl**

```sh
curl "https://api.posdata.so/public/v1/inventory/levels?product_id=8d2c1f0a-3b4e-4c5d-9e6f-7a8b9c0d1e2f" \
  -H "Authorization: Bearer $POSDATA_API_KEY"
```

**Node.js**

```js
// Node.js 20+ — what the API can commit of a product, per warehouse (sketch)
const BASE = 'https://api.posdata.so/public/v1';
const headers = { Authorization: `Bearer ${process.env.POSDATA_API_KEY}` };

async function availableByWarehouse(productId) {
  const url = new URL(`${BASE}/inventory/levels`);
  url.searchParams.set('product_id', productId);
  url.searchParams.set('limit', '100');
  const res = await fetch(url, { headers });
  const body = await res.json();
  // The tills still own the stock: there is no official number yet.
  if (res.status === 409 && body.error.code === 'STOCK_MODE_LEGACY') return null;
  if (!res.ok) throw new Error(`Posdata ${res.status} ${body.error.code} ${body.error.request_id}`);
  // One level per (warehouse, variant). Follow next_cursor if has_more is true.
  return body.data.map((level) => ({
    warehouseId: level.warehouse_id,
    variantId: level.variant_id,
    available: level.available,
  }));
}
```

```json
{
  "object": "list",
  "data": [
    {
      "object": "stock_level",
      "id": "2f6e0c1a-9b8d-4e7f-a1c2-3d4e5f6a7b8c",
      "warehouse_id": "7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f",
      "product_id": "8d2c1f0a-3b4e-4c5d-9e6f-7a8b9c0d1e2f",
      "variant_id": "b3a4c5d6-e7f8-4a9b-8c0d-1e2f3a4b5c6d",
      "quantity": 7,
      "available": 5,
      "baseline_quantity": 10,
      "baseline_at": "2026-09-29T14:00:00.000Z",
      "baseline_estimated": false,
      "baseline_source": "count",
      "negative_alert_open": false,
      "updated_at": "2026-09-29T16:30:05.000Z"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

| Campo                              | Qué es                                                                                                                                                                                                                                                                                                                                          |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `quantity`                         | El stock oficial del nivel, en la unidad base del producto: el último conteo más los movimientos posteriores que ya llegaron a la nube. Puede tener decimales (productos por peso) y ser **negativo** (mira [Stock negativo](https://developers.posdata.so/stock/#stock-negativo)).                                                             |
| `available`                        | Lo que la API puede comprometer: `quantity` menos el [margen de la API](https://developers.posdata.so/stock/#el-margen-de-la-api), nunca menor que `0`. Es lo máximo que una salida por la API puede sacar de ese nivel.                                                                                                                        |
| `baseline_quantity`, `baseline_at` | El último conteo: cuánto se contó y cuándo. `null` si el nivel nunca se ha contado; entonces `quantity` es solo la suma de sus movimientos y conviene contarlo.                                                                                                                                                                                 |
| `baseline_source`                  | De dónde salió ese conteo: `count` (conteo en la app), `count_sheet` (hoja de conteo importada), `initial` (la primera carga del ítem, por ejemplo al crearlo con stock), `api` (`POST /inventory/counts`), `migration` o `upgrade` (el stock que tenía una caja al estrenar el stock en la nube o al pasar de un plan gratuito a uno de pago). |
| `baseline_estimated`               | `true` si ese punto de partida es una estimación de la migración. Conviene contar.                                                                                                                                                                                                                                                              |
| `negative_alert_open`              | Hay una alerta de stock negativo abierta en este nivel.                                                                                                                                                                                                                                                                                         |
| `updated_at`                       | Cambia cada vez que cambia el nivel: su cantidad o su último conteo.                                                                                                                                                                                                                                                                            |

**Sincronización incremental del stock:** usa `GET /inventory/levels?updated_since=…` con la [receta de siempre](https://developers.posdata.so/paginacion/#receta-sincronizaci%C3%B3n-incremental). Un cambio de stock **no** cambia el `updated_at` del producto, así que `GET /products?updated_since=…` no te trae los productos cuyo stock cambió.

`available` es una foto del momento: entre tu consulta y tu siguiente petición, una caja puede vender. Úsalo para mostrar disponibilidad; para comprometer unidades, registra la salida y deja que la API decida (abajo).

## El margen de la API

Las cajas pueden vender sin internet, y esas ventas llegan a la nube cuando se reconectan. Para que tu canal en línea no venda las últimas unidades que quizá una caja ya vendió, **la API nunca compromete las últimas N unidades de cada nivel**: `available = quantity − margen`, nunca menor que `0`.

- El margen lo fija el dueño del negocio en [cuenta.posdata.so](https://cuenta.posdata.so) → **Inventario** → **Configuración**. Es un entero de 0 a 99 y por defecto es **2**.
- Es uno por cuenta: aplica igual a todas las bodegas y todos los productos.
- Solo limita las **salidas** por la API. Las entradas no tienen tope y los conteos registran lo que se contó.
- Con margen `0`, la API puede comprometer hasta la última unidad.

## Salidas atómicas

`POST /inventory/movements` (permiso `inventory:write`, con [`Idempotency-Key`](https://developers.posdata.so/idempotencia/)) suma (`type: "in"`) o resta (`type: "out"`) stock de un nivel:

| Campo                 | Qué es                                                                                                                                                                                                                                                                                                                                                                                                                |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `product_id`          | Obligatorio.                                                                                                                                                                                                                                                                                                                                                                                                          |
| `variant_id`          | Obligatorio si el producto tiene variantes: el movimiento es de esa variante.                                                                                                                                                                                                                                                                                                                                         |
| `warehouse_id`        | Opcional. Si no lo envías, se usa la bodega predeterminada (o la única activa); si no hay una clara, la respuesta es `409 NO_WAREHOUSE`.                                                                                                                                                                                                                                                                              |
| `type`                | `in` o `out`.                                                                                                                                                                                                                                                                                                                                                                                                         |
| `quantity`            | Mayor que `0`, en la unidad base del producto. Entero, salvo en productos que se venden por peso (`sold_by_weight`), que aceptan decimales (`0.375` kg).                                                                                                                                                                                                                                                              |
| `unit_cost`           | Opcional y solo en entradas (`type: "in"`): lo que costó **cada unidad base**, mayor que `0`. Alimenta el [costo promedio](https://developers.posdata.so/stock/#kardex-y-costo-promedio) de la cuenta. Si no lo conoces, no lo envíes: nunca envíes `0` (un costo desconocido se muestra como «Sin costo», no como gratis). En una salida responde `400 VALIDATION_ERROR`: una salida sale siempre al costo promedio. |
| `reason`, `reference` | Opcionales. El motivo que se ve en el historial (por defecto «Movimiento vía API») y tu referencia (orden de compra, pedido…).                                                                                                                                                                                                                                                                                        |

Con la cuenta en `baseline`, una salida `out` es **atómica**: Posdata bloquea el producto, verifica que `quantity − cantidad ≥ margen` y descuenta en la misma operación. Si no alcanza, responde `409 INSUFFICIENT_STOCK` con `available` (lo que sí puedes sacar) y **no registra nada**. Si varias peticiones piden a la vez la última unidad disponible, **exactamente una** gana; las demás reciben el `409`.

**curl**

```sh
curl https://api.posdata.so/public/v1/inventory/movements \
  -H "Authorization: Bearer $POSDATA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: pedido-1234-linea-1-out" \
  -d '{
    "product_id": "8d2c1f0a-3b4e-4c5d-9e6f-7a8b9c0d1e2f",
    "variant_id": "b3a4c5d6-e7f8-4a9b-8c0d-1e2f3a4b5c6d",
    "type": "out",
    "quantity": 2,
    "reference": "pedido-1234"
  }'
```

**Node.js**

```js
// Node.js 20+ — take stock for one line of an online order (sketch)
const BASE = 'https://api.posdata.so/public/v1';

async function takeStock({ orderId, line, productId, variantId, quantity }) {
  // One Idempotency-Key per order line: retrying it never discounts twice.
  const idempotencyKey = `pedido-${orderId}-linea-${line}-out`;
  const body = JSON.stringify({
    product_id: productId,
    ...(variantId ? { variant_id: variantId } : {}),
    type: 'out',
    quantity,
    reference: `pedido-${orderId}`,
  });

  for (let attempt = 0; attempt < 5; attempt++) {
    let res;
    try {
      res = await fetch(`${BASE}/inventory/movements`, {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.POSDATA_API_KEY}`,
          'Content-Type': 'application/json',
          'Idempotency-Key': idempotencyKey,
        },
        body,
      });
    } catch {
      await sleep(backoff(attempt)); // network error: same key, safe to retry
      continue;
    }
    if (res.status === 201) return { ok: true, movement: await res.json() };

    const { error } = await res.json();
    // Final answers: retrying the same request gives the same result.
    if (error.code === 'INSUFFICIENT_STOCK') return { ok: false, available: error.available };
    if (error.code === 'STOCK_MODE_LEGACY') return { ok: false, reason: 'stock-not-in-cloud-yet' };
    // Transient: wait and retry with the SAME key.
    if (res.status === 429) {
      await sleep(Number(res.headers.get('Retry-After')) * 1000);
      continue;
    }
    if (error.code === 'IDEMPOTENCY_IN_PROGRESS' || res.status >= 500) {
      await sleep(backoff(attempt));
      continue;
    }
    throw new Error(`Posdata ${res.status} ${error.code} ${error.request_id}`);
  }
  throw new Error(`Posdata: no answer after 5 attempts (Idempotency-Key ${idempotencyKey})`);
}

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const backoff = (attempt) => 1000 * 2 ** attempt * (0.8 + Math.random() * 0.4);
```

Si no alcanza:

```json
{
  "error": {
    "code": "INSUFFICIENT_STOCK",
    "message": "No hay stock suficiente para esa salida: hay 1 disponible(s) para la API en esa bodega (se reservan las últimas 2 unidades para las cajas; el margen se ajusta en cuenta.posdata.so → Inventario → Configuración). No se registró nada.",
    "param": "quantity",
    "available": 1,
    "request_id": "req_…"
  }
}
```

Qué hacer con cada respuesta:

- **`201`**: la salida quedó registrada y el stock ya bajó. Si es un reintento de algo que ya se hizo, recibes la respuesta original con el header `Idempotent-Replayed: true`.
- **`409 INSUFFICIENT_STOCK`**: es una respuesta final para ese momento. **No la reintentes en bucle**: vende como máximo `available` o marca el producto como agotado. Como los errores no se guardan en la idempotencia, si más tarde entra stock puedes reintentar la misma operación con el mismo `Idempotency-Key`.
- **Error de red, tiempo de espera, `429` o `5xx`**: reintenta con el **mismo** `Idempotency-Key`. Si la primera petición alcanzó a descontar, recibes su respuesta y nada se descuenta dos veces.
- **`409 IDEMPOTENCY_IN_PROGRESS`**: espera unos segundos y reintenta con el mismo valor. Mira [Idempotencia](https://developers.posdata.so/idempotencia/#si-recibes-idempotency_in_progress).
- **`409 STOCK_MODE_LEGACY`** (solo con variantes): la cuenta todavía no lleva el stock en la nube.

No consultes `available` para decidir y luego registres la salida: entre las dos peticiones otra caja u otro canal puede llevarse el stock. La salida misma es la verificación.

**Pedidos de varias líneas:** cada línea es un movimiento aparte; no hay una operación que descuente varias líneas a la vez o ninguna. Si una línea responde `INSUFFICIENT_STOCK` y decides cancelar el pedido, devuelve lo que ya descontaste con un `in` (con su propio `Idempotency-Key` y la misma `reference`).

**No hay reservas:** la API no aparta stock con vencimiento. Una salida descuenta de inmediato. Si tu tienda necesita apartar mientras el cliente paga, registra la salida al iniciar el pago y devuélvela con un `in` si el pago falla.

Más sobre los movimientos:

- Una **entrada** `in` no tiene tope y puede declarar su costo con `unit_cost`.
- Con la cuenta en `legacy`, una salida de un producto sin variantes se registra **sin verificar existencias**: la API no conoce el stock de cada caja.
- Con la cuenta en `baseline`, en un producto que no lleva control de stock el movimiento queda en el historial, pero no cambia ningún nivel ni se verifica.
- Los movimientos de la API aparecen en `GET /inventory/movements` con `origin_type: "api"`, junto con los de las ventas que las cajas ya subieron. Cada uno emite `inventory.movement.created`. Su `unit_cost` es el costo que declaraste (o `null`) y su `reason_code` es el motivo catalogado cuando el movimiento viene de un ajuste hecho en la app (`shrinkage`, `damage`, `expired`, `theft`, `internal_use`, `supplier_return`, `found`, `correction`, `other` o `transfer_shortage`); es `null` en los demás.
- Las **compras a proveedores** las registra el negocio desde la app y llegan como movimientos: `origin_type: "purchase"` (entrada de la compra recibida), `origin_type: "purchase_return"` (salida de lo devuelto al proveedor) y `origin_type: "reversal"` (la anulación de una compra, que deshace su entrada). La API no crea ni lee proveedores ni compras como documento.
- Los **traslados entre bodegas** los registra el negocio desde la app y llegan como movimientos con `origin_type: "transfer"`: una salida (`out`) en la bodega de origen cuando se despacha y una entrada (`in`) en la de destino cuando se recibe (o en la de origen, si se devuelve). **Un traslado no mueve el costo promedio.** La API no crea ni lee traslados como documento: solo ves sus movimientos. Mientras la mercancía va en camino no está en ningún nivel (no cuenta en `stock` ni en `total_value`): su valor va aparte, en `in_transit_value` del [valor del inventario](https://developers.posdata.so/stock/#valor-del-inventario).
- Crear un producto con `initial_stock` (`POST /products`) registra además un movimiento de entrada y, con la cuenta en `baseline`, ese stock inicial es el primer conteo del nivel (`baseline_source: "initial"`).
- Los movimientos llegan a las cajas por su [sincronización normal](https://developers.posdata.so/sincronizacion/#tus-cambios-llegan-a-las-cajas-por-su-sincronizaci%C3%B3n-normal).

## Conteos

`POST /inventory/counts` (permiso `inventory:write`, con [`Idempotency-Key`](https://developers.posdata.so/idempotencia/)) registra un **conteo físico**: «en esta bodega había N unidades en este instante». Solo funciona con la cuenta en `baseline`; antes responde `409 STOCK_MODE_LEGACY`.

| Campo              | Qué es                                                                                                                                                                              |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `product_id`       | Obligatorio.                                                                                                                                                                        |
| `variant_id`       | Obligatorio si el producto tiene variantes.                                                                                                                                         |
| `warehouse_id`     | Opcional; misma regla que en los movimientos (`409 NO_WAREHOUSE` si no hay una bodega clara).                                                                                       |
| `counted_quantity` | Obligatorio, `0` o más, en la unidad base del producto. Entero, salvo en productos que se venden por peso (`sold_by_weight`), que aceptan decimales; si no, `400 VALIDATION_ERROR`. |
| `counted_at`       | **Cuándo se contó**, no cuándo lo envías: ISO 8601 con zona horaria. Por defecto, ahora. No puede estar en el futuro (`400 VALIDATION_ERROR`).                                      |
| `note`             | Opcional. Se ve en el historial del conteo.                                                                                                                                         |

Qué hace:

- El stock del nivel pasa a ser `counted_quantity` en el instante `counted_at`, y los movimientos ocurridos **después** se suman encima, también los que lleguen más tarde. Los ocurridos antes se consideran ya reflejados en el conteo.
- Si el nivel ya tiene un conteo físico **más reciente**, el tuyo queda como historial (`status: "historical"`) y no cambia el stock. La respuesta es `201` en los dos casos: revisa `status`.
- `expected_quantity` es lo que Posdata creía que había en ese instante y `difference`, la diferencia (`counted_quantity − expected_quantity`): tus faltantes o sobrantes.
- Si el conteo queda como base de un producto que no llevaba control de stock, Posdata le vuelve a activar el control.
- Emite el webhook `inventory.count.created`.

Por ejemplo, tu bodega contó 40 unidades a las 7:00 (antes de abrir) y lo envías a las 11:00, cuando las cajas ya vendieron 3. El stock queda en 37: el conteo más las ventas de después.

**curl**

```sh
curl https://api.posdata.so/public/v1/inventory/counts \
  -H "Authorization: Bearer $POSDATA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: conteo-2026-09-29-bodega-principal-8d2c" \
  -d '{
    "product_id": "8d2c1f0a-3b4e-4c5d-9e6f-7a8b9c0d1e2f",
    "warehouse_id": "7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f",
    "counted_quantity": 40,
    "counted_at": "2026-09-29T07:00:00-05:00",
    "note": "Inventario mensual"
  }'
```

**Node.js**

```js
// Node.js 20+ — send a physical count from your warehouse system (sketch)
const BASE = 'https://api.posdata.so/public/v1';

async function recordCount({ countId, productId, variantId, warehouseId, counted, countedAt }) {
  const res = await fetch(`${BASE}/inventory/counts`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.POSDATA_API_KEY}`,
      'Content-Type': 'application/json',
      // Your own count id: retrying the same count never records it twice.
      'Idempotency-Key': `conteo-${countId}`,
    },
    body: JSON.stringify({
      product_id: productId,
      ...(variantId ? { variant_id: variantId } : {}),
      warehouse_id: warehouseId,
      counted_quantity: counted,
      counted_at: countedAt.toISOString(), // when it was counted, not now
    }),
  });
  const body = await res.json();
  if (!res.ok) throw new Error(`Posdata ${res.status} ${body.error.code} ${body.error.request_id}`);
  if (body.status === 'historical') console.warn('A newer count already existed: the stock did not change.');
  return body; // body.difference = counted − what Posdata expected at that instant
}
```

```json
{
  "object": "stock_count",
  "id": "c0a1b2c3-d4e5-4f60-8a7b-9c0d1e2f3a4b",
  "warehouse_id": "7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f",
  "product_id": "8d2c1f0a-3b4e-4c5d-9e6f-7a8b9c0d1e2f",
  "variant_id": null,
  "counted_quantity": 40,
  "counted_at": "2026-09-29T12:00:00.000Z",
  "source": "api",
  "status": "applied",
  "estimated": false,
  "expected_quantity": 43,
  "difference": -3,
  "note": "Inventario mensual",
  "created_at": "2026-09-29T16:05:12.000Z"
}
```

> **Nota**
>
> Usa un conteo solo cuando alguien contó de verdad. Para sumar o restar (una compra que llegó, una devolución, un pedido en línea) usa movimientos: un conteo afirma cuánto había en ese instante y reemplaza todo lo anterior a él.

## Kardex y costo promedio

El **kardex** es el libro de un producto: cada entrada, salida, conteo y cambio de costo, en orden, con el saldo que dejó cada fila. Posdata lo **reproduce** con las mismas reglas que usan las cajas y la cuenta, así que el último saldo siempre coincide con el `quantity` de sus niveles.

`GET /inventory/kardex` (permiso `inventory:read`; los costos, con `inventory:costs`) responde el libro de **un** producto:

| Parámetro                    | Qué es                                                                                                                                                                    |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `product_id`                 | Obligatorio.                                                                                                                                                              |
| `warehouse_id`, `variant_id` | Opcionales: solo las filas de esa bodega o variante. El saldo total sigue siendo el del producto.                                                                         |
| `from`, `to`                 | Opcionales, instantes ISO 8601 **con zona horaria**. Los saldos ya incluyen lo anterior a `from`.                                                                         |
| `type`                       | Opcional: `in`, `out`, `count` o `cost`.                                                                                                                                  |
| `order`                      | `asc` (por defecto, cronológico) o `desc`.                                                                                                                                |
| `page`, `page_size`          | Página (desde 1) y filas por página (máximo 200, por defecto 50). **Estos dos endpoints paginan por página, no con `next_cursor`**: la respuesta trae `meta.total_pages`. |

**curl**

```sh
curl "https://api.posdata.so/public/v1/inventory/kardex?product_id=8d2c1f0a-3b4e-4c5d-9e6f-7a8b9c0d1e2f&order=desc&page_size=50" \
  -H "Authorization: Bearer $POSDATA_API_KEY"
```

**Node.js**

```js
// Node.js 20+ — read the whole book of a product, page by page (sketch)
const BASE = 'https://api.posdata.so/public/v1';
const headers = { Authorization: `Bearer ${process.env.POSDATA_API_KEY}` };

async function* kardexRows(productId) {
  for (let page = 1; ; page++) {
    const url = new URL(`${BASE}/inventory/kardex`);
    url.searchParams.set('product_id', productId);
    url.searchParams.set('page', String(page));
    url.searchParams.set('page_size', '200');
    const res = await fetch(url, { headers });
    const body = await res.json();
    // The tills still own the stock: there is no book to read yet.
    if (res.status === 409 && body.error.code === 'STOCK_MODE_LEGACY') return;
    if (!res.ok) throw new Error(`Posdata ${res.status} ${body.error.code} ${body.error.request_id}`);
    yield* body.rows;
    if (page >= body.meta.total_pages) return;
  }
}
```

```json
{
  "object": "kardex",
  "product": { "id": "8d2c1f0a-3b4e-4c5d-9e6f-7a8b9c0d1e2f", "local_id": "p_8f3a", "name": "Harina de trigo" },
  "rows": [
    {
      "key": "movement:3f1c…",
      "kind": "movement",
      "at": "2026-10-06T15:00:00.000Z",
      "type": "out",
      "origin_type": "sale",
      "document_number": "V-12",
      "user_name": "Ana Ruiz",
      "quantity_in": 0,
      "quantity_out": 3,
      "level_qty": 7,
      "total_qty": 7,
      "unit_cost": 1000,
      "avg_cost": 1000,
      "total_value": 7000,
      "missing_cost": false
    }
  ],
  "meta": { "page": 1, "page_size": 50, "total": 1, "total_pages": 1 },
  "levels": [{ "warehouse_id": "7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f", "variant_id": null, "quantity": 7 }],
  "total_qty": 7,
  "avg_cost": 1000,
  "warehouses": [{ "id": "7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f", "name": "Bodega principal" }],
  "costs_hidden": false
}
```

Cada fila de `rows`:

| Campo                                                        | Qué es                                                                                                                                                                                                                                                                                                                                                                                  |
| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `kind`                                                       | `movement` (entrada o salida), `count` (un conteo físico), `cost_change` (el negocio cambió el costo a mano) o `cost_adjustment` (Posdata ajustó el costo, por ejemplo cuando entra mercancía y había ventas hechas en negativo).                                                                                                                                                       |
| `type`                                                       | `in`, `out`, `count` o `cost`.                                                                                                                                                                                                                                                                                                                                                          |
| `at`, `recorded_at`                                          | Cuándo **ocurrió** (el instante con el que se ordena) y cuándo se **registró** de verdad, si difiere (`null` si no se conoce).                                                                                                                                                                                                                                                          |
| `origin_type`, `document_number`, `reason_code`, `user_name` | De dónde viene (`sale`, `purchase`, `purchase_return`, `adjustment`, `reversal`, `transfer`, `api`…), el número legible del documento, el motivo catalogado de un ajuste y quién lo hizo.                                                                                                                                                                                               |
| `transfer`                                                   | Solo en los movimientos de un traslado entre bodegas (`null` en los demás): `{ id, number, kind, counterpart }`. `number` es el número del traslado (el mismo `document_number`), `kind` es `two_step` (con tránsito) o `direct`, y `counterpart` es la bodega del **otro extremo** de esta fila (`warehouse_id`, `local_id`, `name`): hacia dónde salió la mercancía o de dónde llegó. |
| `quantity_in`, `quantity_out`, `level_qty`, `total_qty`      | Lo que entró y salió en esta fila, el saldo de **este nivel** (`warehouse_id`, `variant_id`) y el saldo de **todo el producto** después de la fila.                                                                                                                                                                                                                                     |
| `expected_qty`, `difference`                                 | Solo en conteos: cuánto se esperaba justo antes de contar y qué corrigió el conteo.                                                                                                                                                                                                                                                                                                     |
| `unit_cost`, `value_delta`, `avg_cost`, `total_value`        | El costo de la fila, su efecto en el valor, el costo promedio corrido y `total_qty × avg_cost`. `null` si se desconocen o si tu llave no tiene `inventory:costs`.                                                                                                                                                                                                                       |
| `missing_cost`                                               | `true` si el costo de esa fila se desconoce. **Nunca lo tomes como `0`.**                                                                                                                                                                                                                                                                                                               |

Cosas que conviene saber:

- **Sin `inventory:costs` no hay costos.** Con solo `inventory:read` obtienes cantidades y saldos; todo costo y valor viene en `null` y `costs_hidden` es `true`. El permiso se pide aparte a propósito: un costo es un dato comercial sensible.
- **Un costo desconocido es `null`, no `0`.** Una entrada sin `unit_cost` suma las unidades pero no mueve el costo promedio; si el producto todavía no tiene ningún costo, sigue sin costo hasta que llegue una entrada con `unit_cost` o el negocio lo registre.
- **Costo promedio ponderado móvil, por producto.** Cada entrada con costo recalcula el promedio; las salidas salen al promedio vigente y no lo mueven. Una venta hecha en negativo se costea de forma provisional y, al entrar mercancía, el kardex agrega una fila `cost_adjustment` por la diferencia; la línea de la venta nunca se reescribe.
- **El saldo del kardex es el stock oficial.** El último `total_qty` coincide con la suma de los `quantity` de los niveles del producto.
- Con la cuenta en `legacy` responde `409 STOCK_MODE_LEGACY`, igual que los niveles.

## Valor del inventario

`GET /inventory/valuation` (permiso `inventory:costs`) responde cuánto vale el inventario **hoy** o a una **fecha de corte**: para cada nivel, su existencia por el costo promedio del producto en ese instante.

| Parámetro           | Qué es                                                                                  |
| ------------------- | --------------------------------------------------------------------------------------- |
| `at`                | Opcional: la fecha de corte, un instante ISO 8601 con zona horaria. Por defecto, ahora. |
| `warehouse_id`      | Opcional: solo esa bodega.                                                              |
| `page`, `page_size` | Como en el kardex.                                                                      |

**curl**

```sh
curl "https://api.posdata.so/public/v1/inventory/valuation?at=2026-09-30T23:59:59-05:00" \
  -H "Authorization: Bearer $POSDATA_API_KEY"
```

**Node.js**

```js
// Node.js 20+ — the value of the inventory at the end of September (sketch)
const BASE = 'https://api.posdata.so/public/v1';

const url = new URL(`${BASE}/inventory/valuation`);
url.searchParams.set('at', '2026-09-30T23:59:59-05:00');
const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.POSDATA_API_KEY}` } });
const body = await res.json();
if (!res.ok) throw new Error(`Posdata ${res.status} ${body.error.code} ${body.error.request_id}`);

console.log(`Valor al corte: $${body.total_value} (calculado ${body.computed_at})`);
if (body.missing_cost.length > 0) console.warn(`${body.missing_cost.length} niveles sin costo: no suman al total.`);
```

```json
{
  "object": "inventory_valuation",
  "at": "2026-10-01T04:59:59.000Z",
  "computed_at": "2026-10-20T15:00:00.000Z",
  "total_value": 25000,
  "by_warehouse": [{ "warehouse_id": "7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f", "name": "Bodega principal", "value": 25000 }],
  "lines": [
    {
      "product_id": "8d2c1f0a-3b4e-4c5d-9e6f-7a8b9c0d1e2f",
      "product_name": "Harina de trigo",
      "warehouse_id": "7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f",
      "warehouse_name": "Bodega principal",
      "variant_id": null,
      "quantity": 25,
      "avg_cost": 1000,
      "value": 25000
    }
  ],
  "meta": { "page": 1, "page_size": 50, "total": 1, "total_pages": 1 },
  "negatives": [],
  "missing_cost": [],
  "in_transit_value": 0,
  "in_transit": { "transfers": 0, "missing_cost_lines": 0, "lines": [] },
  "costs_hidden": false
}
```

- **`total_value`** son pesos enteros y suma solo las líneas de `lines`. Cada línea es positiva y tiene costo.
- **Los niveles negativos no suman** (no son stock): van aparte en `negatives`, para que el negocio los cuente.
- **Un producto sin costo no se valora en `0`**: va aparte en `missing_cost`, con su cantidad y sin valor. No suma al total; el negocio debe registrar su costo.
- **`computed_at` dice cuándo se calculó.** Información que llega tarde (una caja que estuvo sin internet, un conteo con fecha pasada) puede cambiar un corte que ya consultaste: si necesitas un número estable para un cierre, guarda `computed_at` junto a él.
- **`in_transit_value`** es el valor de la mercancía **en tránsito** entre bodegas: la que se despachó en un traslado y todavía no se recibió. No está en ningún nivel, así que **no suma a `total_value`**: va aparte, a pesos enteros, valorada al costo que quedó congelado en cada línea del traslado al despacharla. Es `0` si no hay nada en camino. Con `warehouse_id` cuenta lo que viene **hacia** esa bodega; con `at`, lo que estaba en camino en ese instante.
- **`in_transit`** detalla lo anterior: `transfers` (cuántos traslados tienen algo pendiente), `missing_cost_lines` y `lines` (hasta 500, cada una con `transfer_id`, `number`, `product_id`, `product_name`, `quantity` pendiente, `unit_cost`, `value`, y las bodegas `origin` y `destination`). **Una línea cuyo costo se desconoce no se valora en `0`**: viene con `unit_cost` y `value` en `null`, se cuenta en `missing_cost_lines` y no suma a `in_transit_value`.
- Un `at` en el futuro se toma como «ahora». Con la cuenta en `legacy` responde `409 STOCK_MODE_LEGACY`.

## Stock negativo

Las ventas en las cajas **nunca se rechazan**: al cliente que está en el mostrador se le vende. Si una caja sin internet vendió unidades que ya se habían comprometido por otro canal, al subir esas ventas el nivel puede quedar **por debajo de cero**. Entonces:

- El nivel muestra `quantity` negativa, `available: 0` y `negative_alert_open: true`.
- Posdata avisa al negocio para que haga un conteo.
- Tu servidor recibe el webhook `inventory.stock.negative` con el `stock_level` justo después del cambio.

Hay **una alerta por nivel**: si el nivel sigue negativo con más ventas, no se repite. Se cierra cuando el nivel vuelve a `0` o más (con un conteo o una entrada) y, si vuelve a bajar, se abre otra. Solo abren alerta los niveles que tienen un conteo (`baseline_at` distinto de `null`).

Una salida `out` de la API nunca deja un nivel negativo: no baja de lo que reserva el margen. Si recibes `inventory.stock.negative`, deja de ofrecer ese ítem (su `available` ya es `0`) hasta que el negocio lo cuente.

## Webhooks de inventario

| Evento                       | Cuándo                                                                                                                                                                                                     |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `inventory.movement.created` | Se registró un movimiento, desde una caja o por la API. Trae `unit_cost` y, en los ajustes con motivo, `reason_code`.                                                                                      |
| `inventory.count.created`    | Se registró un conteo físico, desde una caja o por la API. No se envía por el stock de partida que suben las cajas al estrenar el stock en la nube (`migration`) o al pasar a un plan de pago (`upgrade`). |
| `inventory.stock.negative`   | Un nivel contado quedó por debajo de cero.                                                                                                                                                                 |

No hay un evento por cada cambio de stock. El detalle de cada evento está en [Webhooks](https://developers.posdata.so/webhooks/#tipos-de-evento).

Lo que esta versión todavía no hace (reservas, recetas, márgenes por producto…) está en [Lo que la API no hace en la versión 1](https://developers.posdata.so/sincronizacion/#lo-que-la-api-no-hace-en-la-versi%C3%B3n-1).

---

Fuente: https://developers.posdata.so/stock/ · Índice para agentes: https://developers.posdata.so/llms.txt
