Ir al contenido
docs

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:

HoraQué pasaStock
9:00Alguien cuenta 10 unidades.10
10:15La Caja 01 vende 2.8
11:00Tu tienda en línea registra una salida de 1.7
11:30La 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.
  • 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 → Dispositivos. Tu integración no tiene que hacer nada.

Cuenta en legacyCuenta en baseline (stock en la nube)
stock de productos y variantesnullEl número oficial.
GET /inventory/levels409 STOCK_MODE_LEGACYFunciona.
POST /inventory/counts409 STOCK_MODE_LEGACYFunciona.
POST /inventory/movements de un producto sin variantesFunciona 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 variante409 STOCK_MODE_LEGACYFunciona.
Productos con receta422 COMPOSITE_STOCK_UNSUPPORTED422 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 de todas las listas. No trae niveles de bodegas eliminadas, de productos sin control de stock ni de variantes eliminadas.

Terminal
curl "https://api.posdata.so/public/v1/inventory/levels?product_id=8d2c1f0a-3b4e-4c5d-9e6f-7a8b9c0d1e2f" \
  -H "Authorization: Bearer $POSDATA_API_KEY"
JavaScript
// 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
}
CampoQué es
quantityEl 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).
availableLo que la API puede comprometer: quantity menos 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_atEl ú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_sourceDe 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_estimatedtrue si ese punto de partida es una estimación de la migración. Conviene contar.
negative_alert_openHay una alerta de stock negativo abierta en este nivel.
updated_atCambia 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. 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 → 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) suma (type: "in") o resta (type: "out") stock de un nivel:

CampoQué es
product_idObligatorio.
variant_idObligatorio si el producto tiene variantes: el movimiento es de esa variante.
warehouse_idOpcional. 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.
typein o out.
quantityMayor 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_costOpcional y solo en entradas (type: "in"): lo que costó cada unidad base, mayor que 0. Alimenta el 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, referenceOpcionales. 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.

Terminal
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"
  }'
JavaScript
// 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.
  • 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.
  • 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.

Conteos

POST /inventory/counts (permiso inventory:write, con Idempotency-Key) 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.

CampoQué es
product_idObligatorio.
variant_idObligatorio si el producto tiene variantes.
warehouse_idOpcional; misma regla que en los movimientos (409 NO_WAREHOUSE si no hay una bodega clara).
counted_quantityObligatorio, 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_atCuá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).
noteOpcional. 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.

Terminal
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"
  }'
JavaScript
// 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ámetroQué es
product_idObligatorio.
warehouse_id, variant_idOpcionales: solo las filas de esa bodega o variante. El saldo total sigue siendo el del producto.
from, toOpcionales, instantes ISO 8601 con zona horaria. Los saldos ya incluyen lo anterior a from.
typeOpcional: in, out, count o cost.
orderasc (por defecto, cronológico) o desc.
page, page_sizePá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.
Terminal
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"
JavaScript
// 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:

CampoQué es
kindmovement (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).
typein, out, count o cost.
at, recorded_atCuá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_nameDe 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.
transferSolo 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_qtyLo 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, differenceSolo en conteos: cuánto se esperaba justo antes de contar y qué corrigió el conteo.
unit_cost, value_delta, avg_cost, total_valueEl 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_costtrue 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ámetroQué es
atOpcional: la fecha de corte, un instante ISO 8601 con zona horaria. Por defecto, ahora.
warehouse_idOpcional: solo esa bodega.
page, page_sizeComo en el kardex.
Terminal
curl "https://api.posdata.so/public/v1/inventory/valuation?at=2026-09-30T23:59:59-05:00" \
  -H "Authorization: Bearer $POSDATA_API_KEY"
JavaScript
// 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

EventoCuándo
inventory.movement.createdSe registró un movimiento, desde una caja o por la API. Trae unit_cost y, en los ajustes con motivo, reason_code.
inventory.count.createdSe 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.negativeUn nivel contado quedó por debajo de cero.

No hay un evento por cada cambio de stock. El detalle de cada evento está en Webhooks.

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.