# Posdata API > API REST servidor a servidor y webhooks firmados de Posdata, el sistema POS para negocios en Colombia. Sirve para conectar el ERP, la tienda en línea, el BI o automatizaciones (n8n, Zapier, Make) de un negocio con su POS. Requiere el plan Pro (con o sin facturación electrónica). Documentación en español: https://developers.posdata.so Datos clave para un agente que integre esta API: - URL base de producción: `https://api.posdata.so/public/v1` (API keys `pdk_live_…`). Pruebas: `https://apidev.posdata.so/public/v1` (keys `pdk_test_…`, datos de prueba, puede reiniciarse). Una key del otro entorno responde `401 API_KEY_WRONG_ENVIRONMENT`. - Autenticación: solo `Authorization: Bearer `. La key se crea en https://cuenta.posdata.so → Desarrolladores (con código por correo) y se muestra una sola vez. Permisos (scopes): `products:read`, `products:write`, `customers:read`, `customers:write`, `inventory:read`, `inventory:write`, `inventory:costs`, `sales:read`, `invoices:read`, `quotes:read`, `cash:read`, `reports:read`, `events:read`. - Solo servidor a servidor: una petición con header `Origin` responde `403 BROWSER_REQUESTS_NOT_ALLOWED`. Nunca pongas la key en un navegador o una app. - Sin plan Pro, todo responde `403 PLAN_REQUIRED`; las keys no se borran y vuelven a funcionar con Pro. - Errores: `{ "error": { "code", "message", "param"?, "request_id" } }` (algunos traen campos extra, como `devices` en `DEVICES_OUTDATED` o `available` en `INSUFFICIENT_STOCK`). `VARIANT_STOCK_UNSUPPORTED` está obsoleto y ya no se emite. Ramifica por `code`. Header `Request-Id` en toda respuesta. - Listas: `{ object: "list", data, has_more, next_cursor }`, orden `created_at` descendente, `limit` 1-100 (25 por defecto), página siguiente con `starting_after=`. Filtros `created_since`, `created_until`, `updated_since` en ISO 8601 con zona horaria. La búsqueda `q` es literal (`%` y `_` no son comodines). Parámetros desconocidos se rechazan (`400 VALIDATION_ERROR`). - Todo POST exige `Idempotency-Key` (24 h por key). Mismo valor y misma petición: se repite la respuesta con `Idempotent-Replayed: true`. Otra petición: `422 IDEMPOTENCY_KEY_REUSED`. En curso o interrumpida: `409 IDEMPOTENCY_IN_PROGRESS` (verifica con un GET antes de usar un valor nuevo). Solo se guardan respuestas 2xx. - Límites: 300 peticiones/min por key, 600/min por cuenta, 60 escrituras/min por key. `429 RATE_LIMITED` con `Retry-After`. - Dinero en pesos colombianos (COP) enteros; tarifas de impuesto en porcentaje entero (19 = 19 %); fechas ISO 8601 UTC. - La API ve lo que las cajas ya subieron a la nube. `GET /me` → `data_freshness.devices[].last_sync_at` = último cambio que subió cada caja. En ventas, `occurred_at` es cuando se registró en la caja (reloj de la caja; en ventas sincronizadas antes del 28-sep-2026 es la hora de llegada) y `created_at` es cuando llegó a la nube (una caja sin internet la sube después). `GET /sales` filtra por `occurred_since`/`occurred_until`; el reporte diario agrupa por `occurred_at`, en hora de Bogotá. Para sincronización incremental usa `created_since`/`updated_since`, no `occurred_since`. - Escrituras llegan a las cajas por su sincronización normal (segundos si están en línea; al reconectarse si no). - Productos: se crean (sin variantes) con `POST /products` y se editan con `PATCH /products/{id}` (nombre, descripciones, SKU, categoría, precios, clase fiscal, `active`, `sellable`) y `PATCH /products/{id}/variants/{variantId}` (etiqueta, SKU, precios, `active`); nunca stock, unidad, venta por peso ni tipo. La edición viaja a las cajas sin stock (cada caja conserva el suyo) y solo funciona si todos los dispositivos del negocio tienen la versión de Posdata que la soporta; si no: `409 DEVICES_OUTDATED`, no se modifica nada y el error trae `devices[]` (`name`, `kind`, `platform`, `app_version`, `last_seen_at`). Solución: abrir esos dispositivos con internet para que se actualicen solos, o revocar los que no se usen en cuenta.posdata.so → Dispositivos. Crear productos y mover stock funcionan mientras tanto. - Stock: cada nivel (bodega, producto, variante) = último conteo físico + movimientos ocurridos después (las ventas de una caja sin internet se aplican al reconectarse; lo ocurrido antes de un conteo ya está en él). La nube lleva el stock oficial solo cuando la cuenta está en modo `baseline`: todas sus cajas principales usadas en los últimos 14 días tienen la versión de Posdata que lo soporta (el paso es automático; las cajas se actualizan solas al abrirlas con internet). Mientras la cuenta está en `legacy`: `stock` es `null` en productos y variantes; `GET /inventory/levels`, `POST /inventory/counts` y los movimientos de variantes responden `409 STOCK_MODE_LEGACY`; los movimientos de productos sin variantes funcionan sin verificar existencias. Para saber el modo: `GET /inventory/levels` con `limit=1` (200 = `baseline`, 409 `STOCK_MODE_LEGACY` = `legacy`); no lo deduzcas de un `stock: null`. En `baseline`: `stock` del producto = suma de sus niveles, cada variante trae el suyo (`null` en productos sin control de stock o con receta); puede tener decimales y ser negativo. Un cambio de stock no cambia `updated_at` del producto: sigue el stock con `GET /inventory/levels` y `updated_since`. - Niveles (`GET /inventory/levels`, `inventory:read`; filtros `product_id`, `variant_id`, `warehouse_id`): `quantity`, `available` = max(0, quantity − margen de la API), `baseline_quantity`/`baseline_at` (último conteo), `baseline_source`, `baseline_estimated` (true = estimación de la migración, conviene contar), `negative_alert_open`, `updated_at`. Margen de la API: entero 0-99, 2 por defecto, uno por cuenta; lo fija el negocio en cuenta.posdata.so → Inventario → Configuración. - Movimientos (`POST /inventory/movements`, `inventory:write`): `type` `in`/`out`, `quantity` > 0 entero (decimales solo en productos por peso), `variant_id` obligatorio si el producto tiene variantes, `warehouse_id` opcional (si no hay bodega clara: `409 NO_WAREHOUSE`). En `baseline` una salida `out` es atómica: si `quantity` supera lo disponible responde `409 INSUFFICIENT_STOCK` con `available` y no registra nada; con peticiones concurrentes por la última unidad gana exactamente una. No reintentes `INSUFFICIENT_STOCK` en bucle (vende como máximo `available`); ante error de red, 429 o 5xx reintenta con el mismo `Idempotency-Key` (nunca descuenta dos veces). No consultes `available` y luego decidas: la salida es la verificación. `in` no tiene tope. Productos con receta: `422 COMPOSITE_STOCK_UNSUPPORTED`. Sin reservas con vencimiento ni operaciones de varias líneas atómicas: si cancelas, devuelve con un `in`. - Conteos (`POST /inventory/counts`, `inventory:write`, solo en `baseline`): `counted_quantity` ≥ 0 (entero, decimales solo en productos por peso; si no, `400 VALIDATION_ERROR`) en el instante `counted_at` (cuándo se contó, ISO 8601 con zona, no en el futuro; por defecto ahora). El stock pasa a ser ese conteo y los movimientos posteriores se suman encima. Si ya hay un conteo físico más reciente, queda `status: historical` sin cambiar el stock (responde 201 igual). Trae `expected_quantity` y `difference`. Usa conteos solo para conteos reales; para sumar o restar, movimientos. - Costo y kardex: `POST /inventory/movements` acepta `unit_cost` (opcional, > 0, solo en entradas `in`; una salida con `unit_cost` es `400 VALIDATION_ERROR`) que alimenta el costo promedio ponderado móvil por producto de la cuenta; una entrada sin costo suma unidades y no mueve el promedio. Nunca envíes `0` como costo desconocido. `GET /inventory/kardex` (`inventory:read`; `product_id` obligatorio; filtros `warehouse_id`, `variant_id`, `from`, `to` ISO 8601 con zona, `type` `in`/`out`/`count`/`cost`, `order`; pagina con `page` y `page_size` máx. 200, no con cursor) devuelve el libro del producto con saldo corrido (`level_qty`, `total_qty`); los costos (`unit_cost`, `value_delta`, `avg_cost`, `total_value`) solo con el permiso `inventory:costs`: sin él vienen en `null` y `costs_hidden` es `true`. Un costo desconocido es `null` con `missing_cost: true`, nunca 0. `GET /inventory/valuation` (`inventory:costs`; `at` ISO 8601 con zona para una fecha de corte, `warehouse_id`, `page`, `page_size`) devuelve `total_value` en pesos enteros, `by_warehouse`, `lines`, y aparte `negatives` (niveles negativos: no suman) y `missing_cost` (sin costo: no suman, nunca valorados en 0); `computed_at` dice cuándo se calculó (información tardía puede cambiar un corte ya consultado). Ambos responden `409 STOCK_MODE_LEGACY` mientras la cuenta no lleve el stock en la nube. El movimiento trae además `reason_code` (motivo catalogado de un ajuste hecho en la app, o `null`). Las compras a proveedores (que la API no crea ni lista) llegan como movimientos con `origin_type: "purchase"` (entrada), `"purchase_return"` (salida devuelta al proveedor) o `"reversal"` (anulación de una compra). Los traslados entre bodegas (que la API no crea ni lista) llegan como movimientos con `origin_type: "transfer"` (salida en el origen, entrada en el destino; no mueven el costo); las filas del kardex traen `transfer` (`{ id, number, kind, counterpart }`, o `null`). La mercancía en tránsito no está en ningún nivel ni suma a `total_value`: `GET /inventory/valuation` la trae aparte en `in_transit_value` (pesos enteros; `0` si no hay nada en camino) y `in_transit` (detalle por línea; una línea sin costo conocido va con `unit_cost` y `value` en `null`, nunca en `0`). - Stock negativo: las ventas en las cajas nunca se rechazan. Si un nivel contado queda por debajo de cero se abre una alerta (`negative_alert_open`), se avisa al negocio para que cuente y se envía el webhook `inventory.stock.negative` (una vez por alerta). Una salida de la API nunca deja un nivel negativo. - No crea ventas ni facturas, no sube imágenes, no elimina registros. - Webhooks: se configuran solo en cuenta.posdata.so (https, puerto 443, hasta 5 endpoints). Cuerpo `{ id, type, created_at, api_version: "2026-09-28", data: { object } }`. Firma `Posdata-Signature: t=,v1=` = HMAC-SHA256(secreto `whsec_…` completo, "."); acepta cualquier `v1` (dos durante una rotación de 24 h), tolerancia de 5 minutos, comparación en tiempo constante. Responde 2xx en menos de 10 s. Reintentos: 8 intentos en ~28 h (1m, 5m, 30m, 2h, 5h, 10h, 10h). Deduplica por `id`. Reconcilia con `GET /events` (30 días). - Tipos de evento: `sale.created`, `sale.updated`, `sale.completed`, `sale.cancelled`, `product.created`, `product.updated`, `customer.created`, `customer.updated`, `inventory.movement.created`, `inventory.count.created` (solo conteos físicos; `data.object` = `stock_count`; revisa `status`), `inventory.stock.negative` (`data.object` = `stock_level`), `invoice.issued`, `invoice.rejected`, `cash_session.closed` (y `webhook.test`, solo al probar desde cuenta). - No confundir con Agent Connect (agentes de IA que operan Posdata en nombre de una persona, disponible en todos los planes): https://posdata.so ## Guías - [Inicio](https://developers.posdata.so/): qué es, entornos, requisito de plan - [Inicio rápido](https://developers.posdata.so/inicio-rapido/): crear una key, primera llamada, crear un cliente - [Autenticación](https://developers.posdata.so/autenticacion/): keys, permisos, entornos, revocación - [Errores](https://developers.posdata.so/errores/): todos los códigos y qué hacer - [Paginación y filtros](https://developers.posdata.so/paginacion/): cursores y sincronización incremental - [Idempotencia](https://developers.posdata.so/idempotencia/) - [Límites de uso](https://developers.posdata.so/limites/) - [Cómo se sincroniza con las cajas](https://developers.posdata.so/sincronizacion/): frescura de datos y limitaciones de la versión 1 - [Cómo funciona el stock](https://developers.posdata.so/stock/): niveles, conteos, margen de la API, salidas atómicas y stock negativo - [Webhooks](https://developers.posdata.so/webhooks/): eventos, firma (Node.js, Python, PHP), reintentos - [Herramientas](https://developers.posdata.so/herramientas/): colección de Postman/Insomnia, contrato OpenAPI, n8n, Make y Zapier - [Objetos](https://developers.posdata.so/referencia/objetos/): todos los esquemas campo por campo - [Changelog](https://developers.posdata.so/changelog/) - [Soporte](https://developers.posdata.so/soporte/): soporte@posdata.so (incluye el request_id) ## Contrato OpenAPI 3.1 - [openapi.json de producción](https://api.posdata.so/public/v1/openapi.json) ## Descargas y versiones en Markdown - Cada página de esta documentación tiene su versión en Markdown: agrega `.md` a la ruta (por ejemplo https://developers.posdata.so/errores.md, https://developers.posdata.so/referencia/operations/createstockmovement.md; la de inicio es https://developers.posdata.so/index.md). - [Colección de Postman v2.1](https://developers.posdata.so/descargas/posdata-api.postman_collection.json) (también la importa Insomnia), con ambientes de [pruebas](https://developers.posdata.so/descargas/posdata-pruebas.postman_environment.json) y [producción](https://developers.posdata.so/descargas/posdata-produccion.postman_environment.json). Generada del contrato en cada publicación. ## Endpoints - `GET /me`: cuenta, API key y frescura de los datos - `GET /products`, `POST /products`, `GET /products/{id}`, `PATCH /products/{id}`, `PATCH /products/{id}/variants/{variantId}` - `GET /categories`, `POST /categories`, `GET /categories/{id}`, `PATCH /categories/{id}` - `GET /customers`, `POST /customers`, `GET /customers/{id}`, `PATCH /customers/{id}` - `GET /warehouses`, `GET /warehouses/{id}` - `GET /inventory/movements`, `POST /inventory/movements`, `GET /inventory/movements/{id}` - `GET /inventory/levels`, `POST /inventory/counts` - `GET /inventory/kardex`, `GET /inventory/valuation` - `GET /sales`, `GET /sales/{id}` - `GET /invoices`, `GET /invoices/{id}` - `GET /quotes`, `GET /quotes/{id}` - `GET /cash-sessions`, `GET /cash-sessions/{id}` - `GET /reports/daily-sales` - `GET /events`, `GET /events/{id}`