API pública de Vendty para integraciones de lectura: ERPs, sistemas contables, dashboards externos y aplicaciones a medida que necesitan consultar datos del negocio. REST · JSON · OAuth2 (Personal Access Tokens con scopes).
https://api-pub.vendty.com/v1tipo_negocio='restaurante'): pisos, mesas con estado computado en vivo, items pedidos con adiciones y variations resueltas a nombres, kitchen tracking opt-in vía Comanda Virtual.catalogs:read): almacenes, impuestos, vendedores, usuarios y listas de precio.purchases:read): proveedores, facturas de compra (con líneas y pagos), documentos soporte DIAN.reports:read): listado y descarga de reports generados desde systempos (vía S3).quotes:read): proformas (en cafeteriavendty se usa más como borrador de gastos).cash-registers:read): cajas físicas + cierres diarios con totales.webhooks:manage): suscripción a 3 eventos disparados por scanner cada minuto: sale.created, sale.voided, table_order.created. Firma HMAC-SHA256 estilo Stripe./v1/me) y health check público (/v1/health).Nota importante. La API pública actualmente no acepta escrituras (POST/PATCH/DELETE) por decisión de dirección. Si tu integración necesita crear, actualizar o anular datos en Vendty, hablamos directamente para evaluar tu caso de uso. El código y los endpoints están preservados para una reactivación futura controlada.
Una vez publicada una versión bajo /v1/, el contrato se preserva: no se eliminan endpoints, no se quitan campos, no se cambian tipos. Los cambios incompatibles van a /v2/.
per_page hasta 200, links.next para iterar)./v1/sales (escala a tenants con millones de ventas).?updated_since en /v1/products.Si tienes problemas con tu integración: incluye siempre el header X-Request-Id de la respuesta cuando contactes a tu representante de Vendty. Con ese ID ubicamos la llamada exacta en menos de un minuto.
En 5 minutos haces tu primera llamada autenticada a la API y confirmas que tus credenciales funcionan.
ApiClient para tu negocio y te entrega un token. Los tokens se muestran una sola vez al emitirlos — guárdalo en un lugar seguro (gestor de contraseñas, vault, no en el repositorio de tu aplicación).curl o cualquier cliente HTTP.curl -s https://api-pub.vendty.com/v1/me \
-H "Authorization: Bearer TU_TOKEN_AQUI"
Respuesta esperada (HTTP 200):
{
"data": {
"id": "8d3b1bff-…",
"name": "Mi aplicación",
"rate_tier": "basic",
"scopes": [],
"allowed_origins": [],
"created_at": "2026-05-11T18:35:00+00:00"
},
"meta": { "request_id": "f7ad…" }
}
Si recibes 401 unauthenticated → el token está mal, expiró o fue revocado. Contacta a Vendty.
Si recibes 403 tenant_not_enabled → tu negocio aún no ha completado el alta para la API. Contacta a Vendty.
El campo data.scopes te dice qué puede hacer tu token. Catálogo completo de lectura activos:
| Scope | Permite |
|---|---|
products:read |
Productos + categorías |
customers:read |
Clientes + grupos + historial de ventas por cliente |
sales:read |
Ventas (lista + detalle) + métodos de pago |
credit-notes:read |
Notas crédito asociadas a una venta |
restaurant:read |
Pisos, mesas y órdenes de mesa + Quick Service |
catalogs:read |
Almacenes, impuestos, vendedores, usuarios, listas de precio |
purchases:read |
Proveedores, facturas de compra, documentos soporte DIAN |
quotes:read |
Cotizaciones / Proformas |
cash-registers:read |
Cajas registradoras + cierres diarios con totales |
reports:read |
Reportes async generados desde systempos (descarga vía S3) |
webhooks:manage |
CRUD de webhooks + test + auditoría de deliveries |
Si el arreglo data.scopes viene vacío, tu token solo puede llamar a /v1/me y /v1/health. Para usar endpoints de negocio pídele a Vendty un token con los scopes que necesitas.
Todas las respuestas incluyen:
| Header | Significado |
|---|---|
X-Request-Id |
Identificador único de esta petición — úsalo al reportar incidencias |
X-RateLimit-Limit |
Límite por minuto de tu plan |
X-RateLimit-Remaining |
Cuántas llamadas te quedan en la ventana actual |
X-RateLimit-Reset |
Marca de tiempo (Unix) en que se reinicia el contador |
Por decisión de dirección, la API pública actualmente solo expone endpoints GET. Cualquier POST/PATCH/DELETE retorna 404 route_not_found.
Si tu integración necesita crear o actualizar datos en Vendty (clientes, ventas, anulaciones, etc.), coordina con tu representante de Vendty — esa funcionalidad existe en el código y se puede habilitar por tenant cuando se requiera, bajo evaluación caso por caso.
Las colecciones acotadas (/v1/products, /v1/categories, /v1/customers, /v1/customer-groups, /v1/payment-methods) usan paginación offset clásica: el meta incluye current_page, last_page, per_page, from, to y total. Salta páginas con ?page=N. Cap máximo per_page=200.
Las colecciones que pueden tener millones de filas (/v1/sales, /v1/customers/{id}/sales) usan paginación por cursor — diseñada para escalar sin recalcular COUNT(*) en cada request:
meta incluye next_cursor (opaco) y prev_cursor. NO incluye total ni last_page.links.next hasta que sea null.per_page=100 (no 200 — más conservador).# Primera página
curl "https://api-pub.vendty.com/v1/sales?per_page=50&date_from=2026-01-01&date_to=2026-03-31" \
-H "Authorization: Bearer $TOKEN"
# El response trae: { "data": [...], "meta": { "next_cursor": "eyJmZWNoY...", ... }, "links": { "next": "https://..." } }
# Página siguiente — usa la URL completa de links.next, NO armes la query manualmente
curl "https://api-pub.vendty.com/v1/sales?per_page=50&date_from=...&cursor=eyJmZWNoY..." \
-H "Authorization: Bearer $TOKEN"
# Cuando links.next sea null, terminaste.
Por qué cursor: un tenant con 6 millones de ventas haría
SELECT COUNT(*)cada request en paginación offset — segundos perdidos por cada página. Cursor solo haceWHERE (fecha, id) < (?, ?) LIMIT 50que aprovecha índice y escala a cualquier tamaño.
Todos los errores siguen el mismo formato:
{
"error": {
"code": "validation_failed",
"message": "La información proporcionada no es válida.",
"details": { "fields": { "email": ["El campo email debe ser una dirección válida."] } },
"request_id": "f7ad…"
}
}
Programa tu lógica contra error.code (estable, legible por máquina). El campo error.message es para humanos y puede cambiar.
Toda llamada a /v1/* (excepto /v1/health) debe llevar un token Bearer de Passport en el header Authorization:
Authorization: Bearer tu_token
Los tokens los emite Vendty — no hay endpoint público de registro ni de login. Para obtener un token, contacta a tu representante de Vendty.
Un token tiene:
ApiClient) a la que pertenece."App móvil prod 2026" o "Sync contabilidad 2026") para identificarlo en los logs de auditoría.401 unauthenticated.Puedes inspeccionar tu token activo con GET /v1/me.
Los tokens llevan cero o más scopes del catálogo. Por defecto un token no trae ningún scope — cada endpoint exige al menos uno. Pedir un scope que no tienes devuelve 403 scope_missing.
| Scope | Endpoints |
|---|---|
products:read |
GET /v1/products, GET /v1/products/{id}, GET /v1/categories |
customers:read |
GET /v1/customers, GET /v1/customers/{id}, GET /v1/customer-groups, GET /v1/customers/{id}/sales |
sales:read |
GET /v1/sales, GET /v1/sales/{id}, GET /v1/payment-methods |
credit-notes:read |
GET /v1/sales/{id}/credit-notes |
restaurant:read |
GET /v1/restaurant/sections, GET /v1/restaurant/tables, GET /v1/restaurant/tables/{id}/order, GET /v1/quick-service/orders |
catalogs:read |
GET /v1/warehouses, GET /v1/taxes, GET /v1/sellers, GET /v1/users, GET /v1/price-lists |
purchases:read |
GET /v1/suppliers, GET /v1/purchase-orders, GET /v1/purchase-orders/{id}, GET /v1/support-documents, GET /v1/support-documents/{id} |
quotes:read |
GET /v1/quotes, GET /v1/quotes/{id} |
cash-registers:read |
GET /v1/cash-registers, GET /v1/cash-register-closures, GET /v1/cash-register-closures/{id} |
webhooks:manage |
CRUD /v1/webhooks*, POST /v1/webhooks/{id}/test, GET /v1/webhooks/{id}/deliveries |
reports:read |
GET /v1/reports, GET /v1/reports/{id} |
Solo lectura. Los scopes de escritura (
*:write,voids:write,webhooks:manage,inventory:*) están definidos en el código pero no se emiten mientras la API sea solo lectura por decisión de dirección. Cuando se reactive el modo escritura, este catálogo se ampliará.
| Operación | Quién | Cómo |
|---|---|---|
| Crear cliente | Operador Vendty | php artisan api:client:create (una vez por aplicación) |
| Emitir token | Operador Vendty | php artisan api:token:issue — el bearer se muestra una sola vez |
| Inspeccionar | Tú | GET /v1/me |
| Revocar | Operador Vendty | php artisan api:token:revoke |
| Rotar | Tú vía Vendty | Pides un token nuevo; el viejo se revoca al entregarte el nuevo |
request_id.Si llamas a la API desde un navegador, el origen de tu aplicación debe estar en la lista permitida (allowed_origins) de tu cliente. No se permiten comodines (*). Envía a Vendty los dominios desde los que vas a llamar cuando pidas tu token.
| HTTP | code | Significado |
|---|---|---|
| 401 | unauthenticated |
Token ausente, mal formado, expirado o revocado |
| 403 | scope_missing |
El token no tiene el scope que ese endpoint requiere |
| 403 | client_suspended |
Tu ApiClient está suspendido — contacta a Vendty |
| 403 | tenant_not_enabled |
Tu negocio aún no está habilitado para la API pública |
| 429 | rate_limit_exceeded |
Bajá la tasa de llamadas — ver header Retry-After |
Todas las respuestas no-2xx siguen este formato:
{
"error": {
"code": "<string_estable_legible_por_maquina>",
"message": "<texto_para_humanos>",
"details": { ... opcional ... },
"request_id": "<uuid>"
}
}
error.code es el contrato. Es estable entre versiones. error.message es para humanos y puede cambiar en cualquier momento.
| HTTP | code | Cuándo |
|---|---|---|
| 400 | bad_request |
JSON mal formado o query string inválido |
| 401 | unauthenticated |
Token ausente, mal formado, expirado o revocado |
| 403 | scope_missing |
El token no tiene el scope que el endpoint requiere |
| 403 | client_suspended |
Tu ApiClient está suspendido |
| 403 | tenant_not_enabled |
Tu negocio aún no está habilitado para la API pública |
| 403 | cors_origin_blocked |
El Origin del request no está en tu lista permitida |
| 404 | not_found |
El recurso no existe o no pertenece a tu negocio. (También retornado si intentas POST/PATCH/DELETE: la API es solo lectura.) |
| 409 | conflict |
Conflicto de estado (raro en API de solo lectura) |
| 422 | validation_failed |
Query string no pasó validación. details.fields lista los errores por campo |
| 422 | warehouse_required |
include=stock sin warehouse_id |
| 422 | invalid_category_id / invalid_warehouse_id / invalid_customer_id / invalid_seller_id |
Un ID público no se pudo resolver a una entidad del negocio |
| 422 | date_range_too_wide |
Una ventana date_from–date_to excede el límite (90 días en ventas) |
| 422 | invalid_status |
Valor de status no reconocido (válidos: valid, voided, all) |
| 426 | https_required |
La petición llegó por HTTP plano y la API requiere HTTPS |
| 429 | rate_limit_exceeded |
Excediste tu cuota. Mira el header Retry-After |
| 500 | internal_error |
Error inesperado del lado del servidor. Reporta tu request_id |
| 503 | tenant_db_unavailable |
La base de datos del negocio no respondió. Reintentable después de un rato |
error.code como única fuente de verdad para tu lógica de cliente. Compara contra el string; no parsees error.message.request_id. Cuando contactes a soporte de Vendty, es el dato más útil.Lista exhaustiva de los 41 endpoints disponibles, con scope requerido, parámetros principales y forma del response. Para schemas detallados (todos los campos de cada recurso), revisa la sección API Reference generada desde el OpenAPI más abajo en este mismo documento.
Convención. Todos los endpoints que toman un
{id}lo reciben como ID público opaco (p_…,c_…,s_…, etc.). Nunca uses IDs internos. Detalle de los prefijos en la guía para implementadores.
| Método | Path | Auth | Descripción |
|---|---|---|---|
| GET | /v1/health |
público | Health check |
| GET | /v1/me |
bearer | Identidad del cliente API: scopes, rate tier, allowed_origins |
curl https://api-pub.vendty.com/v1/health
curl https://api-pub.vendty.com/v1/me -H "Authorization: Bearer $TOKEN"
products:read| Método | Path | Descripción |
|---|---|---|
| GET | /v1/products |
Lista paginada (offset, per_page ≤ 200) |
| GET | /v1/products/{id} |
Detalle por p_… |
| GET | /v1/categories |
Lista de categorías activas |
Filtros de /v1/products: search, category_id, warehouse_id (con include=stock), active, include (stock,category), updated_since, sort (name/-name/price/-price/created_at/-created_at).
curl 'https://api-pub.vendty.com/v1/products?search=americano&include=stock,category&warehouse_id=wh_…' \
-H "Authorization: Bearer $TOKEN"
customers:read| Método | Path | Descripción |
|---|---|---|
| GET | /v1/customers |
Lista paginada offset |
| GET | /v1/customers/{id} |
Detalle de un cliente |
| GET | /v1/customer-groups |
Grupos de clientes del negocio |
| GET | /v1/customers/{id}/sales |
Doble scope customers:read + sales:read. Historial de ventas del cliente (cursor pagination) |
Filtros de /v1/customers: search (nombre, NIF, email, móvil), group_id, online_store (bool), updated_since, sort.
sales:read| Método | Path | Descripción |
|---|---|---|
| GET | /v1/sales |
Lista con cursor pagination (per_page ≤ 100) |
| GET | /v1/sales/{id} |
Venta + líneas (details) + pagos (payments) |
| GET | /v1/payment-methods |
Catálogo de métodos de pago del negocio |
Filtros de /v1/sales: date_from, date_to (max 90 días), customer_id, seller_id, warehouse_id, status (valid/voided/all), invoice_number, cursor, per_page.
curl 'https://api-pub.vendty.com/v1/sales?date_from=2026-04-01&date_to=2026-05-01&status=valid' \
-H "Authorization: Bearer $TOKEN"
credit-notes:read| Método | Path | Descripción |
|---|---|---|
| GET | /v1/sales/{id}/credit-notes |
Lista de NCs asociadas a una venta |
restaurant:readSolo disponible para tenants con
opciones.tipo_negocio='restaurante'. Tenants no-restaurante reciben403 not_a_restaurant.
| Método | Path | Descripción |
|---|---|---|
| GET | /v1/restaurant/sections |
Pisos / áreas |
| GET | /v1/restaurant/tables |
Mesas con status calculado en vivo (free/occupied/bill_requested/reserved) |
| GET | /v1/restaurant/tables/{id}/order |
Orden activa: items, adiciones (con nombres), variations resueltos, kitchen tracking opcional |
| GET | /v1/quick-service/orders |
Órdenes Quick Service (zona = -1) agrupadas por mesa virtual |
Filtros de /v1/restaurant/tables: section_id, status, warehouse_id.
catalogs:read| Método | Path | Descripción |
|---|---|---|
| GET | /v1/warehouses |
Almacenes / sucursales |
| GET | /v1/taxes |
Impuestos configurados |
| GET | /v1/sellers |
Vendedores |
| GET | /v1/users |
Usuarios del POS (cross-tenant safe: filtrado por db_config_id en BD central) |
| GET | /v1/price-lists |
Listas de precio |
purchases:read| Método | Path | Descripción |
|---|---|---|
| GET | /v1/suppliers |
Proveedores |
| GET | /v1/purchase-orders |
Facturas de compra (cursor pagination) |
| GET | /v1/purchase-orders/{id} |
Detalle: líneas + pagos |
| GET | /v1/support-documents |
Documentos soporte DIAN (a no obligados a facturar) |
| GET | /v1/support-documents/{id} |
Detalle de documento soporte |
Filtros de /v1/purchase-orders: date_from, date_to, supplier_id, warehouse_id, status, cursor, per_page.
quotes:read| Método | Path | Descripción |
|---|---|---|
| GET | /v1/quotes |
Proformas (en cafeteriavendty se usan como borrador de gastos) |
| GET | /v1/quotes/{id} |
Detalle de una proforma |
Filtros: date_from, date_to, supplier_id, warehouse_id.
cash-registers:read| Método | Path | Descripción |
|---|---|---|
| GET | /v1/cash-registers |
Cajas físicas del negocio |
| GET | /v1/cash-register-closures |
Cierres diarios (incluye cajas abiertas con is_open=true) |
| GET | /v1/cash-register-closures/{id} |
Detalle de un cierre con totales (income, expenses, closing, count) |
Filtros de /v1/cash-register-closures: cash_register_id, warehouse_id, date_from, date_to, is_open (bool).
reports:read| Método | Path | Descripción |
|---|---|---|
| GET | /v1/reports |
Lista de jobs de reporte generados desde systempos |
| GET | /v1/reports/{id} |
Detalle: estado, progreso, download_url (S3 presigned, expira a 7 días) |
Filtros: status (pending/processing/completed/failed), report_type, date_from, date_to.
Solo expone reportes generados por el sistema async (
vendty_services.report_jobs). Disparar reportes nuevos vía API requiere endpoint write — está deshabilitado.
webhooks:manage| Método | Path | Descripción |
|---|---|---|
| GET | /v1/webhooks |
Lista los webhooks de tu cliente |
| POST | /v1/webhooks |
Crea un webhook (devuelve secret UNA vez). Idempotency-Key requerido |
| GET | /v1/webhooks/{id} |
Detalle (sin secret) |
| PATCH | /v1/webhooks/{id} |
Actualiza url, events, status. Idempotency-Key requerido |
| DELETE | /v1/webhooks/{id} |
Elimina (soft delete) |
| POST | /v1/webhooks/{id}/test |
Encola un delivery de prueba (webhook.test) |
| GET | /v1/webhooks/{id}/deliveries |
Auditoría de deliveries (cursor pagination, filtros status/after) |
Webhooks es el único dominio con verbos POST/PATCH/DELETE habilitados en modo solo-lectura. Se aceptaron porque son meta-operaciones sobre el cliente, no escrituras al negocio.
Eventos disparados por scanner cada minuto: sale.created, sale.voided, table_order.created. Detalle de payload, firma y retries en la página Webhooks.
| Grupo | Endpoints | Scope |
|---|---|---|
| Sistema | 2 | (sin scope) |
| Productos | 3 | products:read |
| Clientes | 4 | customers:read (+ sales:read en uno) |
| Ventas | 3 | sales:read |
| Notas crédito | 1 | credit-notes:read |
| Restaurante | 4 | restaurant:read |
| Catálogos | 5 | catalogs:read |
| Compras | 5 | purchases:read |
| Cotizaciones | 2 | quotes:read |
| Cajas | 3 | cash-registers:read |
| Reportes | 2 | reports:read |
| Webhooks | 7 | webhooks:manage |
| Total | 41 | 11 scopes |
| Header | Descripción |
|---|---|
X-Request-Id |
UUID único por request (úsalo al reportar issues) |
X-RateLimit-Limit |
Límite por minuto del plan |
X-RateLimit-Remaining |
Llamadas restantes en la ventana |
X-RateLimit-Reset |
Unix timestamp del reset |
Retry-After |
Solo en 429, segundos a esperar |
Endpoints POST/PATCH/DELETE (hoy solo bajo /v1/webhooks*) requieren header Idempotency-Key (UUID v4). Llamadas con el mismo key dentro de 24h devuelven la respuesta cacheada. Sin el header → 400 idempotency_key_required.
Vendty empuja eventos a tu URL vía webhooks firmados con HMAC-SHA256. Patrón estilo Stripe / Shopify / GitHub: tú expones un endpoint HTTPS, lo registras vía API y nosotros te avisamos cuando algo importante pasa en el negocio del partner.
Estado actual: activos. Aunque la API es de solo lectura para el partner, los eventos sí se generan: un scanner interno corre cada minuto sobre la BD del tenant (
venta,ventas_anuladas,orden_producto_restaurant) y dispara los eventos suscritos. Latencia esperada del evento al delivery: 0–60 s + tiempo de retry si tu endpoint falla.
| Evento | Cuándo se dispara | Tabla origen |
|---|---|---|
sale.created |
Se creó una venta nueva en el POS | venta |
sale.voided |
Se anuló una venta | ventas_anuladas |
table_order.created |
Se abrió una orden en una mesa de restaurante (excluye Quick Service) | orden_producto_restaurant WHERE zona <> -1 |
webhook.test |
Disparado manualmente con POST /v1/webhooks/{id}/test. No depende del scanner. |
— |
Eventos congelados.
credit_note.created,customer.createdycustomer.updatedestán definidos en el código pero no se emiten mientras la API sea solo lectura — dependen de POSTs del partner que hoy no existen. Si los necesitas, contacta a Vendty.
Cada minuto:
Scheduler → webhooks:scan → Por cada (tenant, evento) suscrito:
│
├─ Lee cursor (último id procesado)
├─ SELECT id FROM <tabla> WHERE id > cursor LIMIT 50
├─ Para cada fila → DeliverWebhookJob (cola async)
└─ UPDATE cursor = MAX(id)
DeliverWebhookJob:
POST a tu URL → Tu 2xx → succeeded
→ 4xx (≠ 408/429) → failed_permanently
→ 5xx / timeout / 408 / 429 → retry con backoff [1m, 5m, 30m, 2h, 12h]
Bootstrap del cursor: la primera vez que un partner se suscribe a un evento, el cursor se inicializa con MAX(id) actual de la tabla — no recibirás histórico. Solo lo que pase de ese minuto en adelante.
curl -X POST https://api-pub.vendty.com/v1/webhooks \
-H "Authorization: Bearer $TOKEN_CON_WEBHOOKS_MANAGE" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"url": "https://partner.example.com/vendty-webhook",
"events": ["sale.created", "sale.voided", "table_order.created"],
"description": "Producción principal"
}'
Response 201:
{
"data": {
"id": "whk_a3b8c7d9e1f2",
"url": "https://partner.example.com/vendty-webhook",
"events": ["sale.created", "sale.voided", "table_order.created"],
"status": "active",
"secret": "7f3b9d8c2401...64-hex-chars-total...",
"secret_warning": "Store this secret now — it will not be shown again.",
"consecutive_failures": 0,
"created_at": "2026-05-13T18:30:00Z"
}
}
Guarda el
secretAHORA. No se mostrará otra vez. Si lo pierdes, elimina el webhook y crea uno nuevo.
Validaciones:
url debe ser HTTPS y un host alcanzable público (no IPs privadas, no localhost).events[] debe contener al menos un evento del catálogo.POST https://partner.example.com/vendty-webhook
User-Agent: Vendty-Webhook/1.0
Content-Type: application/json
X-Vendty-Event: sale.created
X-Vendty-Delivery-Id: whd_a3b8c7d9e1f2
X-Vendty-Webhook-Id: whk_a3b8c7d9e1f2
X-Vendty-Signature: t=1715789432,v1=fc3a2b1e4d9f...64-hex...
{
"id": "evt_d3c7a8...",
"type": "sale.created",
"occurred_at": "2026-05-13T18:30:32Z",
"api_version": "v1",
"api_client": "<tu_api_client_public_id>",
"data": { ...el mismo shape que GET /v1/sales/{id}... }
}
El campo data siempre coincide con la representación pública del recurso correspondiente (/v1/sales/{id}, /v1/restaurant/tables/{id}/order, etc.) — para que tu código de webhook reuse el mismo parser que tu integración pull.
Sin verificar la firma, un atacante puede forge eventos y disparar lógica de tu sistema. Verifica siempre.
function verifyVendtySignature(string $rawBody, string $sigHeader, string $secret, int $tolerance = 300): bool {
if (! preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $sigHeader, $m)) {
return false;
}
[$_, $t, $v1] = $m;
if (abs(time() - (int) $t) > $tolerance) {
return false; // replay protection
}
$expected = hash_hmac('sha256', $t.'.'.$rawBody, $secret);
return hash_equals($expected, $v1);
}
// En tu endpoint:
$rawBody = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_VENDTY_SIGNATURE'] ?? '';
if (! verifyVendtySignature($rawBody, $sig, $YOUR_WEBHOOK_SECRET)) {
http_response_code(401);
exit;
}
const crypto = require('crypto');
function verifyVendtySignature(rawBody, sigHeader, secret, toleranceSec = 300) {
const m = sigHeader.match(/^t=(\d+),v1=([a-f0-9]{64})$/);
if (!m) return false;
const [, t, v1] = m;
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
app.post('/vendty-webhook', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyVendtySignature(req.body, req.headers['x-vendty-signature'], YOUR_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(req.body);
res.status(200).end();
// ...procesa async
});
import hmac, hashlib, re, time
def verify(raw_body: bytes, sig_header: str, secret: str, tolerance: int = 300) -> bool:
m = re.match(r"^t=(\d+),v1=([a-f0-9]{64})$", sig_header)
if not m: return False
t, v1 = m.group(1), m.group(2)
if abs(time.time() - int(t)) > tolerance: return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
| Tu response | Nuestro comportamiento |
|---|---|
| 2xx (200, 201, 204) | succeeded. Resetea consecutive_failures a 0. |
| 5xx, timeout, conn refused | Retry hasta 5 veces con delay [1m, 5m, 30m, 2h, 12h]. Tras los 5 → failed_permanently. |
| 408, 429 | Transient. Mismo retry que 5xx. |
| Otro 4xx (400, 401, 403, …) | failed_permanently SIN retry. Incrementa consecutive_failures. |
Auto-pause: si consecutive_failures llega a 5, el webhook pasa a status="paused". Reactívalo con PATCH /v1/webhooks/{id} {"status":"active"} (también resetea el contador).
Total ventana de retry: ~14.5 h. Si tu servicio está caído más, considera reconciliar con GET /v1/sales?date_from=... cuando vuelva.
Implementa el handler como idempotente. Usa X-Vendty-Delivery-Id:
$deliveryId = $_SERVER['HTTP_X_VENDTY_DELIVERY_ID'];
if ($redis->exists("processed:{$deliveryId}")) {
http_response_code(200);
exit; // ya procesado, responde 200 igual para que dejemos de reintentar
}
// ...procesar...
$redis->setex("processed:{$deliveryId}", 86400 * 7, '1'); // 7 días
http_response_code(200);
curl -X POST https://api-pub.vendty.com/v1/webhooks/$WHK_ID/test \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
Encola UN delivery de webhook.test SOLO a ese webhook. No requiere que tu webhook esté suscrito a webhook.test. Confirma que recibes el POST + valida la firma + responde 200.
# Las últimas 25 entregas
curl "https://api-pub.vendty.com/v1/webhooks/$WHK_ID/deliveries" -H "Authorization: Bearer $TOKEN"
# Solo las fallidas
curl "https://api-pub.vendty.com/v1/webhooks/$WHK_ID/deliveries?status=failed" -H "Authorization: Bearer $TOKEN"
# De cierta fecha en adelante
curl "https://api-pub.vendty.com/v1/webhooks/$WHK_ID/deliveries?after=2026-05-01T00:00:00Z" -H "Authorization: Bearer $TOKEN"
# Cursor pagination — sigue links.next hasta null
curl "https://api-pub.vendty.com/v1/webhooks/$WHK_ID/deliveries?cursor=$CURSOR_FROM_PREVIOUS_PAGE"
X-Vendty-Delivery-Id para deduplicar.data.updated_at o event.occurred_at para resolver.consecutive_failures de tus webhooks. Si sube, hay algo mal en tu lado.GET /v1/sales?date_from=<last_seen> y reconcilia.secret ni siquiera en logs.sale.created, sale.voided, table_order.created). Otros eventos del catálogo están definidos pero no se emiten en modo solo-lectura.Catálogo de clientes con paginación. Requiere customers:read.
El response NUNCA expone password, remember_token, numero_cuenta ni entidad_bancaria.
| page | integer >= 1 Default: 1 |
| per_page | integer [ 1 .. 200 ] Default: 25 |
| search | string <= 80 characters Substring sobre razón social, nombre comercial y NIT (case-insensitive). |
| identification | string <= 30 characters NIT/identificación exacta. Útil para deduplicación. |
| group_id | string^cg_[1-9A-HJ-NP-Za-km-z]{8,16}$ |
| updated_since | string <date-time> |
| sort | string Default: "-id" Enum: "business_name" "-business_name" "identification" "-identification" "id" "-id" |
{- "data": [
- {
- "id": "string",
- "identification_type": "NIT",
- "identification": "900123456",
- "check_digit": "1",
- "business_name": "Distribuidora ACME SAS",
- "trade_name": "string",
- "email": "user@example.com",
- "phone": "string",
- "mobile": "string",
- "country": "string",
- "state": "string",
- "city": "string",
- "address": "string",
- "notes": "string",
- "online_store": true,
- "group": {
- "id": "string",
- "name": "string"
}
}
], - "meta": {
- "current_page": 1,
- "last_page": 42,
- "per_page": 25,
- "from": 1,
- "to": 25,
- "total": 1042,
}, - "links": {
}
}| id required | string^c_[1-9A-HJ-NP-Za-km-z]{8,16}$ |
{- "data": {
- "id": "string",
- "identification_type": "NIT",
- "identification": "900123456",
- "check_digit": "1",
- "business_name": "Distribuidora ACME SAS",
- "trade_name": "string",
- "email": "user@example.com",
- "phone": "string",
- "mobile": "string",
- "country": "string",
- "state": "string",
- "city": "string",
- "address": "string",
- "notes": "string",
- "online_store": true,
- "group": {
- "id": "string",
- "name": "string"
}
}
}Devuelve las ventas del cliente con paginación por cursor (escala a clientes con miles de ventas).
Requiere AMBOS scopes customers:read y sales:read. Iterar siguiendo links.next hasta que sea null.
| id required | string^c_[1-9A-HJ-NP-Za-km-z]{8,16}$ |
| cursor | string |
| per_page | integer [ 1 .. 100 ] Default: 25 |
{- "data": [
- {
- "id": "string",
- "invoice_number": "No45824",
- "status": "valid",
- "invoice_type": "estandar",
- "date": "2019-08-24T14:15:22Z",
- "due_date": "2019-08-24T14:15:22Z",
- "warehouse": {
- "id": "string",
- "name": "string"
}, - "customer": {
- "id": "string",
- "identification_type": "NIT",
- "identification": "900123456",
- "check_digit": "1",
- "business_name": "Distribuidora ACME SAS",
- "trade_name": "string",
- "email": "user@example.com",
- "phone": "string",
- "mobile": "string",
- "country": "string",
- "state": "string",
- "city": "string",
- "address": "string",
- "notes": "string",
- "online_store": true,
- "group": {
- "id": "string",
- "name": "string"
}
}, - "seller": {
- "id": "string",
- "name": "string",
- "identification": "string",
- "email": "user@example.com",
- "phone": "string",
- "commission": "string",
- "code": "string",
- "warehouse": {
- "id": "string",
- "name": "string"
}
}, - "subtotal": "18000.00",
- "discount_total": "18000.00",
- "tax_total": "18000.00",
- "total": "18000.00",
- "currency": "COP",
- "change": "18000.00",
- "note": "string",
- "details": [
- {
- "id": "string",
- "product_id": "string",
- "product_code": "string",
- "product_name": "string",
- "quantity": 2,
- "unit_price": "18000.00",
- "discount_percent": "0.00",
- "tax_rate": "0.00",
- "subtotal": "18000.00",
- "total": "18000.00"
}
], - "payments": [
- {
- "method": {
- "id": "string",
- "code": "string",
- "name": "string"
}, - "code": "string",
- "amount": "18000.00",
- "change": "18000.00"
}
]
}
], - "meta": {
- "per_page": 25,
- "next_cursor": "eyJmZWNoYSI6IjIwMjYtMDUtMTIgMTQ6MjklMDg...",
- "prev_cursor": "string"
},
}Paginación por cursor — diseñada para escalar a tenants con millones de ventas.
Por defecto solo retorna ventas válidas (status=valid). Pasa ?status=voided para ver anuladas
o ?status=all para ambas. El rango date_from–date_to no puede exceder 90 días.
Cómo iterar: primer request sin cursor, luego sigue links.next (o usa ?cursor=<meta.next_cursor>)
hasta que sea null. per_page por defecto 25, máximo 100. NO se devuelve total ni last_page
— ver schema CursorMeta.
| cursor | string Token de cursor opaco devuelto en |
| per_page | integer [ 1 .. 100 ] Default: 25 |
| date_from | string <date> |
| date_to | string <date> |
| status | string Default: "valid" Enum: "valid" "voided" "all" |
| customer_id | string^c_[1-9A-HJ-NP-Za-km-z]{8,16}$ |
| warehouse_id | string^wh_[1-9A-HJ-NP-Za-km-z]{8,16}$ |
| seller_id | string^sl_[1-9A-HJ-NP-Za-km-z]{8,16}$ |
| invoice_number | string <= 60 characters |
| include | string Example: include=details,payments Subconjunto de |
| sort | string Default: "-date" Enum: "date" "-date" "total" "-total" "id" "-id" Orden. El servidor agrega |
{- "data": [
- {
- "id": "string",
- "invoice_number": "No45824",
- "status": "valid",
- "invoice_type": "estandar",
- "date": "2019-08-24T14:15:22Z",
- "due_date": "2019-08-24T14:15:22Z",
- "warehouse": {
- "id": "string",
- "name": "string"
}, - "customer": {
- "id": "string",
- "identification_type": "NIT",
- "identification": "900123456",
- "check_digit": "1",
- "business_name": "Distribuidora ACME SAS",
- "trade_name": "string",
- "email": "user@example.com",
- "phone": "string",
- "mobile": "string",
- "country": "string",
- "state": "string",
- "city": "string",
- "address": "string",
- "notes": "string",
- "online_store": true,
- "group": {
- "id": "string",
- "name": "string"
}
}, - "seller": {
- "id": "string",
- "name": "string",
- "identification": "string",
- "email": "user@example.com",
- "phone": "string",
- "commission": "string",
- "code": "string",
- "warehouse": {
- "id": "string",
- "name": "string"
}
}, - "subtotal": "18000.00",
- "discount_total": "18000.00",
- "tax_total": "18000.00",
- "total": "18000.00",
- "currency": "COP",
- "change": "18000.00",
- "note": "string",
- "details": [
- {
- "id": "string",
- "product_id": "string",
- "product_code": "string",
- "product_name": "string",
- "quantity": 2,
- "unit_price": "18000.00",
- "discount_percent": "0.00",
- "tax_rate": "0.00",
- "subtotal": "18000.00",
- "total": "18000.00"
}
], - "payments": [
- {
- "method": {
- "id": "string",
- "code": "string",
- "name": "string"
}, - "code": "string",
- "amount": "18000.00",
- "change": "18000.00"
}
]
}
], - "meta": {
- "per_page": 25,
- "next_cursor": "eyJmZWNoYSI6IjIwMjYtMDUtMTIgMTQ6MjklMDg...",
- "prev_cursor": "string"
},
}Devuelve todas las NCs registradas para una venta (incluyendo las creadas desde el POS, desde devoluciones, o desde esta API).
Paginación offset clásica — una venta rara vez tiene más de 1-2 NCs. Requiere scope credit-notes:read.
| id required | string^s_[1-9A-HJ-NP-Za-km-z]{8,16}$ |
| page | integer >= 1 Default: 1 |
| per_page | integer [ 1 .. 200 ] Default: 25 |
{- "data": [
- {
- "id": "string",
- "consecutivo": "NOCT0034",
- "type": "NC",
- "amount": "18000.00",
- "date": "2019-08-24T14:15:22Z",
- "status": "active",
- "electronic_invoice": true,
- "dian_status": "not_applicable",
- "note": "string",
- "sale": {
- "id": "string",
- "invoice_number": "string"
}, - "customer": {
- "id": "string",
- "identification_type": "NIT",
- "identification": "900123456",
- "check_digit": "1",
- "business_name": "Distribuidora ACME SAS",
- "trade_name": "string",
- "email": "user@example.com",
- "phone": "string",
- "mobile": "string",
- "country": "string",
- "state": "string",
- "city": "string",
- "address": "string",
- "notes": "string",
- "online_store": true,
- "group": {
- "id": "string",
- "name": "string"
}
}
}
], - "meta": {
- "current_page": 1,
- "last_page": 42,
- "per_page": 25,
- "from": 1,
- "to": 25,
- "total": 1042,
}, - "links": {
}
}Eager-load automático de customer, seller, warehouse, details, payments (con sus métodos).
| id required | string^s_[1-9A-HJ-NP-Za-km-z]{8,16}$ |
{- "data": {
- "id": "string",
- "invoice_number": "No45824",
- "status": "valid",
- "invoice_type": "estandar",
- "date": "2019-08-24T14:15:22Z",
- "due_date": "2019-08-24T14:15:22Z",
- "warehouse": {
- "id": "string",
- "name": "string"
}, - "customer": {
- "id": "string",
- "identification_type": "NIT",
- "identification": "900123456",
- "check_digit": "1",
- "business_name": "Distribuidora ACME SAS",
- "trade_name": "string",
- "email": "user@example.com",
- "phone": "string",
- "mobile": "string",
- "country": "string",
- "state": "string",
- "city": "string",
- "address": "string",
- "notes": "string",
- "online_store": true,
- "group": {
- "id": "string",
- "name": "string"
}
}, - "seller": {
- "id": "string",
- "name": "string",
- "identification": "string",
- "email": "user@example.com",
- "phone": "string",
- "commission": "string",
- "code": "string",
- "warehouse": {
- "id": "string",
- "name": "string"
}
}, - "subtotal": "18000.00",
- "discount_total": "18000.00",
- "tax_total": "18000.00",
- "total": "18000.00",
- "currency": "COP",
- "change": "18000.00",
- "note": "string",
- "details": [
- {
- "id": "string",
- "product_id": "string",
- "product_code": "string",
- "product_name": "string",
- "quantity": 2,
- "unit_price": "18000.00",
- "discount_percent": "0.00",
- "tax_rate": "0.00",
- "subtotal": "18000.00",
- "total": "18000.00"
}
], - "payments": [
- {
- "method": {
- "id": "string",
- "code": "string",
- "name": "string"
}, - "code": "string",
- "amount": "18000.00",
- "change": "18000.00"
}
]
}
}Pisos/áreas, mesas con estado y órdenes activas en mesas (solo tenants con opciones.tipo_negocio='restaurante')
Solo para tenants con opciones.tipo_negocio='restaurante'. Si el tenant no es restaurante,
retorna 422 tenant_not_restaurant.
Excluye automáticamente la sección sintética id=-1 (Quick Service) y, por defecto, las inactivas.
Cada sección incluye tables_count (mesas activas asociadas) y occupied_tables_count
(mesas con al menos un ítem en orden_producto_restaurant), calculados en una sola query.
| warehouse_id | string^wh_[1-9A-HJ-NP-Za-km-z]{8,16}$ |
| active | boolean Default: true |
| page | integer >= 1 Default: 1 |
| per_page | integer [ 1 .. 200 ] Default: 25 |
{- "data": [
- {
- "id": "string",
- "name": "1er Piso",
- "code": "1",
- "description": "string",
- "active": true,
- "warehouse": {
- "id": "string",
- "name": "string"
}, - "tables_count": 0,
- "occupied_tables_count": 0
}
], - "meta": {
- "current_page": 1,
- "last_page": 42,
- "per_page": 25,
- "from": 1,
- "to": 25,
- "total": 1042,
}, - "links": {
}
}Solo para tenants restaurante. El campo status se computa en server (occupied si hay al menos
un item en orden_producto_restaurant.mesa_id, sino free). kitchen_summary agrega por estado.
vendedor_estacion=-1 se traduce a seller: null.
| warehouse_id | string^wh_[1-9A-HJ-NP-Za-km-z]{8,16}$ |
| section_id | string^rs_[1-9A-HJ-NP-Za-km-z]{8,16}$ |
| status | string Default: "all" Enum: "occupied" "free" "all" |
| active | boolean Default: true |
| page | integer >= 1 Default: 1 |
| per_page | integer [ 1 .. 200 ] Default: 25 |
{- "data": [
- {
- "id": "string",
- "name": "Mesa Esquina",
- "code": "string",
- "active": true,
- "section": {
- "id": "string",
- "name": "string"
}, - "warehouse": {
- "id": "string",
- "name": "string"
}, - "status": "occupied",
- "diners": 0,
- "table_note": "string",
- "consecutive_number": 0,
- "occupied_since": "2019-08-24T14:15:22Z",
- "active_items_count": 0,
- "kitchen_summary": {
- "new": 0,
- "sent": 0,
- "in_kitchen": 0,
- "splitting": 0
}, - "seller": {
- "id": "string",
- "name": "string"
}
}
], - "meta": {
- "current_page": 1,
- "last_page": 42,
- "per_page": 25,
- "from": 1,
- "to": 25,
- "total": 1042,
}, - "links": {
}
}Devuelve los items pre-venta actualmente en la mesa, con resolución completa de:
{product, quantity}; IDs duplicados se cuentan como cantidad).producto_variation → JOIN con variation → {id, name, code}).comanda_virtual_productos (si el tenant tiene opciones.comanda_virtual='si').| id required | string^rt_[1-9A-HJ-NP-Za-km-z]{8,16}$ |
{- "data": {
- "table": {
- "id": "string",
- "name": "string",
- "code": "string",
- "section": {
- "id": "string",
- "name": "string"
}
}, - "occupied_since": "2019-08-24T14:15:22Z",
- "diners": 0,
- "table_note": "string",
- "consecutive_number": 0,
- "seller": {
- "id": "string",
- "name": "string"
}, - "items": [
- {
- "id": "string",
- "product": {
- "id": "string",
- "code": "string",
- "name": "string"
}, - "quantity": 2,
- "note": "string",
- "kitchen_status": "new",
- "created_at": "2019-08-24T14:15:22Z",
- "modifications": [
- "string"
], - "additions": [
- {
- "product": {
- "id": "string",
- "code": "string",
- "name": "string"
}, - "quantity": 0
}
], - "variations": [
- {
- "id": 0,
- "name": "string",
- "code": "string"
}
], - "kitchen_tracking": {
- "available": true,
- "status": "pending",
- "started_at": "2019-08-24T14:15:22Z",
- "finished_at": "2019-08-24T14:15:22Z"
}
}
]
}
}Items con zona=-1, agrupados por su mesa_id virtual (timestamp Unix generado on-the-fly por systempos).
Cada grupo representa un "ticket" de Quick Service. Ordenados por actividad reciente desc.
| warehouse_id | string^wh_[1-9A-HJ-NP-Za-km-z]{8,16}$ |
{- "data": [
- {
- "virtual_table_id": "qs_1778080533",
- "opened_at": "2019-08-24T14:15:22Z",
- "items": [
- {
- "id": "string",
- "product": {
- "id": "string",
- "code": "string",
- "name": "string"
}, - "quantity": 2,
- "note": "string",
- "kitchen_status": "new",
- "created_at": "2019-08-24T14:15:22Z",
- "modifications": [
- "string"
], - "additions": [
- {
- "product": {
- "id": "string",
- "code": "string",
- "name": "string"
}, - "quantity": 0
}
], - "variations": [
- {
- "id": 0,
- "name": "string",
- "code": "string"
}
], - "kitchen_tracking": {
- "available": true,
- "status": "pending",
- "started_at": "2019-08-24T14:15:22Z",
- "finished_at": "2019-08-24T14:15:22Z"
}
}
]
}
], - "meta": {
- "total": 0,
- "request_id": "string"
}
}Catálogo de almacenes. Scope catalogs:read. Por defecto retorna los activos (cuando la columna activo existe).
| active | boolean Default: true |
| page | integer >= 1 Default: 1 |
| per_page | integer [ 1 .. 200 ] Default: 25 |
{- "data": [
- {
- "id": "string",
- "name": "Principal",
- "code": "string",
- "address": "string",
- "phone": "string",
- "active": true
}
], - "meta": {
- "current_page": 1,
- "last_page": 42,
- "per_page": 25,
- "from": 1,
- "to": 25,
- "total": 1042,
}, - "links": {
}
}Catálogo de impuestos. Scope catalogs:read.
Cada impuesto incluye external_code (mapeo a códigos DIAN o sistemas externos del partner; puede ser null).
| page | integer >= 1 Default: 1 |
| per_page | integer [ 1 .. 200 ] Default: 25 |
{- "data": [
- {
- "id": "string",
- "name": "IVA",
- "rate": "19.00",
- "is_default": true,
- "external_code": "string"
}
], - "meta": {
- "current_page": 1,
- "last_page": 42,
- "per_page": 25,
- "from": 1,
- "to": 25,
- "total": 1042,
}, - "links": {
}
}Catálogo de vendedores con sus datos (cédula, email, teléfono, comisión, almacén). Scope catalogs:read. Filtro ?warehouse_id opcional.
| warehouse_id | string^wh_[1-9A-HJ-NP-Za-km-z]{8,16}$ |
| page | integer >= 1 Default: 1 |
| per_page | integer [ 1 .. 200 ] Default: 25 |
{- "data": [
- {
- "id": "string",
- "name": "string",
- "identification": "string",
- "email": "user@example.com",
- "phone": "string",
- "commission": "string",
- "code": "string",
- "warehouse": {
- "id": "string",
- "name": "string"
}
}
], - "meta": {
- "current_page": 1,
- "last_page": 42,
- "per_page": 25,
- "from": 1,
- "to": 25,
- "total": 1042,
}, - "links": {
}
}Catálogo de usuarios (cajeros, cocineros, admins) del negocio. Scope catalogs:read.
Aislamiento cross-tenant garantizado: el endpoint filtra WHERE db_config_id = <tu tenant> antes de retornar nada. Nunca puedes ver usuarios de otro tenant.
Campos sensibles BLOQUEADOS (jamás presentes en la respuesta): password, salt, remember_token, api_token, forgotten_password_code, activation_code, ip_address.
| is_admin | boolean Filtrar solo admins (true) o solo no-admins (false) |
| page | integer >= 1 Default: 1 |
| per_page | integer [ 1 .. 200 ] Default: 25 |
{- "data": [
- {
- "id": "string",
- "username": "string",
- "first_name": "string",
- "last_name": "string",
- "realname": "string",
- "email": "user@example.com",
- "is_admin": true,
- "role_id": 0,
- "active": true,
- "last_login": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
], - "meta": {
- "current_page": 1,
- "last_page": 42,
- "per_page": 25,
- "from": 1,
- "to": 25,
- "total": 1042,
}, - "links": {
}
}Catálogo de listas de precio (lista_precios). Scope catalogs:read.
Solo el header de la lista (nombre, vigencia, almacén, grupo cliente).
Las reglas detalladas por producto (specific_price_rule_*) son un caso aparte
que se entregará en una fase futura si emerge demanda.
| warehouse_id | string^wh_[1-9A-HJ-NP-Za-km-z]{8,16}$ |
| page | integer >= 1 Default: 1 |
| per_page | integer [ 1 .. 200 ] Default: 25 |
{- "data": [
- {
- "id": "string",
- "name": "Mayorista",
- "warehouse": {
- "id": "string",
- "name": "string"
}, - "customer_group": {
- "id": "string",
- "name": "string"
}, - "valid_from": "2019-08-24",
- "valid_to": "2019-08-24"
}
], - "meta": {
- "current_page": 1,
- "last_page": 42,
- "per_page": 25,
- "from": 1,
- "to": 25,
- "total": 1042,
}, - "links": {
}
}Catálogo de proveedores. Scope purchases:read. Filtros: ?search, ?identification. NUNCA expone entidad_bancaria ni numero_cuenta.
| search | string <= 80 characters |
| identification | string <= 30 characters |
| page | integer >= 1 Default: 1 |
| per_page | integer [ 1 .. 200 ] Default: 25 |
{- "data": [
- {
- "id": "string",
- "business_name": "Distribuidora ACME SAS",
- "trade_name": "string",
- "identification": "string",
- "identification_type": "string",
- "check_digit": "string",
- "document_type": "string",
- "company_type": "string",
- "email": "user@example.com",
- "phone": "string",
- "mobile": "string",
- "contact": "string",
- "website": "string",
- "country": "string",
- "state": "string",
- "city": "string",
- "address": "string",
- "postal_code": "string",
- "notes": "string"
}
], - "meta": {
- "current_page": 1,
- "last_page": 42,
- "per_page": 25,
- "from": 1,
- "to": 25,
- "total": 1042,
}, - "links": {
}
}Listado de órdenes de compra a proveedores. Scope purchases:read. Cursor pagination porque el histórico crece (cafeteriavendty tiene 361 filas).
Filtros: ?status=valid|voided|all (default valid), ?date_from, ?date_to, ?warehouse_id, ?supplier_id.
| cursor | string |
| per_page | integer [ 1 .. 100 ] Default: 25 |
| status | string Default: "valid" Enum: "valid" "voided" "all" |
| date_from | string <date> |
| date_to | string <date> |
| warehouse_id | string^wh_[1-9A-HJ-NP-Za-km-z]{8,16}$ |
| supplier_id | string^sup_[1-9A-HJ-NP-Za-km-z]{8,16}$ |
{- "data": [
- {
- "id": "string",
- "invoice_number": "string",
- "status": "valid",
- "document_type": "string",
- "date": "2019-08-24T14:15:22Z",
- "due_date": "2019-08-24T14:15:22Z",
- "voided_at": "2019-08-24T14:15:22Z",
- "void_reason": "string",
- "total": "18000.00",
- "currency": "COP",
- "note": "string",
- "supplier": {
- "id": "string",
- "business_name": "string",
- "trade_name": "string",
- "identification": "string"
}, - "warehouse": {
- "id": "string",
- "name": "string"
}, - "details": [
- {
- "id": "string",
- "product_id": "string",
- "product_code": "string",
- "product_name": "string",
- "description": "string",
- "quantity": 0,
- "purchase_price": "18000.00",
- "discount_percent": 0,
- "tax_rate": 0
}
], - "payments": [
- {
- "id": "string",
- "date": "2019-08-24",
- "amount": "18000.00",
- "method": "string",
- "note": "string",
- "withholding": "18000.00"
}
]
}
], - "meta": {
- "per_page": 25,
- "next_cursor": "eyJmZWNoYSI6IjIwMjYtMDUtMTIgMTQ6MjklMDg...",
- "prev_cursor": "string"
},
}Eager-load de proveedor, almacén, líneas y pagos.
| id required | string^po_[1-9A-HJ-NP-Za-km-z]{8,16}$ |
{- "data": {
- "id": "string",
- "invoice_number": "string",
- "status": "valid",
- "document_type": "string",
- "date": "2019-08-24T14:15:22Z",
- "due_date": "2019-08-24T14:15:22Z",
- "voided_at": "2019-08-24T14:15:22Z",
- "void_reason": "string",
- "total": "18000.00",
- "currency": "COP",
- "note": "string",
- "supplier": {
- "id": "string",
- "business_name": "string",
- "trade_name": "string",
- "identification": "string"
}, - "warehouse": {
- "id": "string",
- "name": "string"
}, - "details": [
- {
- "id": "string",
- "product_id": "string",
- "product_code": "string",
- "product_name": "string",
- "description": "string",
- "quantity": 0,
- "purchase_price": "18000.00",
- "discount_percent": 0,
- "tax_rate": 0
}
], - "payments": [
- {
- "id": "string",
- "date": "2019-08-24",
- "amount": "18000.00",
- "method": "string",
- "note": "string",
- "withholding": "18000.00"
}
]
}
}Listado de documentos soporte (DIAN: comprobantes a no-obligados). Scope purchases:read. Cursor pagination.
| cursor | string |
| per_page | integer [ 1 .. 100 ] Default: 25 |
| date_from | string <date> |
| date_to | string <date> |
| warehouse_id | string^wh_[1-9A-HJ-NP-Za-km-z]{8,16}$ |
| supplier_id | string^sup_[1-9A-HJ-NP-Za-km-z]{8,16}$ |
{- "data": [
- {
- "id": "string",
- "voucher_number": "string",
- "status": "draft",
- "date": "2019-08-24T14:15:22Z",
- "due_date": "2019-08-24",
- "identification": "string",
- "phone": "string",
- "payment_method": "string",
- "payment_term": "string",
- "discount": "18000.00",
- "total": "18000.00",
- "currency": "COP",
- "notes": "string",
- "authorization_text": "string",
- "supplier": {
- "id": "string",
- "business_name": "string",
- "trade_name": "string",
- "identification": "string"
}, - "warehouse": {
- "id": "string",
- "name": "string"
}, - "details": [
- {
- "id": "string",
- "product_id": "string",
- "description": "string",
- "quantity": 0,
- "price": "18000.00",
- "discount": 0,
- "discount_type": "string",
- "tax_rate": 0
}
], - "created_at": "2019-08-24T14:15:22Z"
}
], - "meta": {
- "per_page": 25,
- "next_cursor": "eyJmZWNoYSI6IjIwMjYtMDUtMTIgMTQ6MjklMDg...",
- "prev_cursor": "string"
},
}| id required | string^supdoc_[1-9A-HJ-NP-Za-km-z]{8,16}$ |
{- "data": {
- "id": "string",
- "voucher_number": "string",
- "status": "draft",
- "date": "2019-08-24T14:15:22Z",
- "due_date": "2019-08-24",
- "identification": "string",
- "phone": "string",
- "payment_method": "string",
- "payment_term": "string",
- "discount": "18000.00",
- "total": "18000.00",
- "currency": "COP",
- "notes": "string",
- "authorization_text": "string",
- "supplier": {
- "id": "string",
- "business_name": "string",
- "trade_name": "string",
- "identification": "string"
}, - "warehouse": {
- "id": "string",
- "name": "string"
}, - "details": [
- {
- "id": "string",
- "product_id": "string",
- "description": "string",
- "quantity": 0,
- "price": "18000.00",
- "discount": 0,
- "discount_type": "string",
- "tax_rate": 0
}
], - "created_at": "2019-08-24T14:15:22Z"
}
}Lista los jobs de reportes async creados desde systempos. Scope reports:read.
Tenant gating: si el tenant no está inscrito en async_reports_enabled_clients con enabled=1, retorna 422 tenant_not_async_enabled.
Cursor pagination — los jobs históricos crecen (cafeteriavendty tiene 911 jobs).
Filtros: ?status=pending|processing|completed|failed, ?report_type=stock_minimo_maximo|....
El campo parameters_summary está sanitizado — solo expone keys "safe" (almacen, fecha_inicio, fecha_fin, etc.). Internal keys (db_config_id, is_admin) se omiten.
| cursor | string |
| per_page | integer [ 1 .. 100 ] Default: 25 |
| status | string |
| report_type | string |
{- "data": [
- {
- "id": "string",
- "job_uuid": "de25c4df-6d4e-439f-9d5f-a4c55d30f26e",
- "report_type": "stock_minimo_maximo",
- "status": "pending",
- "progress": 100,
- "requested_by": {
- "username": "string",
- "email": "user@example.com"
}, - "parameters_summary": { },
- "file_name": "string",
- "file_expires_at": "2019-08-24T14:15:22Z",
- "error_message": "string",
- "started_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "email_sent": true
}
], - "meta": {
- "per_page": 25,
- "next_cursor": "eyJmZWNoYSI6IjIwMjYtMDUtMTIgMTQ6MjklMDg...",
- "prev_cursor": "string"
},
}| id required | string^rj_[1-9A-HJ-NP-Za-km-z]{8,16}$ |
{- "data": {
- "id": "string",
- "job_uuid": "de25c4df-6d4e-439f-9d5f-a4c55d30f26e",
- "report_type": "stock_minimo_maximo",
- "status": "pending",
- "progress": 100,
- "requested_by": {
- "username": "string",
- "email": "user@example.com"
}, - "parameters_summary": { },
- "file_name": "string",
- "file_expires_at": "2019-08-24T14:15:22Z",
- "error_message": "string",
- "started_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "email_sent": true
}
}Paginación offset clásica. Cross-ApiClient isolation — solo ves los webhooks que tu propio token creó.
| page | integer >= 1 Default: 1 |
| per_page | integer [ 1 .. 200 ] Default: 25 |
{- "data": [
- {
- "id": "whk_a3b8c7d9e1f2034a5b6c7d8e9f0a1b2c",
- "events": [
- "sale.created"
], - "status": "active",
- "consecutive_failures": 0,
- "last_succeeded_at": "2019-08-24T14:15:22Z",
- "last_failed_at": "2019-08-24T14:15:22Z",
- "description": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
], - "meta": {
- "current_page": 1,
- "last_page": 42,
- "per_page": 25,
- "from": 1,
- "to": 25,
- "total": 1042,
}, - "links": {
}
}Crea una suscripción webhook. Requiere scope webhooks:manage y Idempotency-Key.
El secret se devuelve UNA SOLA VEZ en esta respuesta. Almacénalo de forma segura — todo GET posterior lo omite.
URL: HTTPS obligatorio en producción. HTTP solo para localhost, 127.0.0.1 y rangos RFC1918 (10.x, 172.16-31.x, 192.168.x) para desarrollo local del partner.
Eventos válidos: ver schema WebhookCreate.events para el catálogo actual.
Firma: cada delivery incluye X-Vendty-Signature: t=<unix>,v1=<hex_hmac>, donde hmac = HMAC-SHA256(secret, "<t>.<raw_body>"). Rechaza si now - t > 5 min.
| Idempotency-Key required | string <uuid> |
| url required | string <uri> <= 500 characters Endpoint del partner. HTTPS obligatorio en producción. HTTP solo para localhost / RFC1918 (10.x, 172.16-31.x, 192.168.x). |
| events required | Array of strings [ 1 .. 20 ] items Items Enum: "sale.created" "sale.voided" "table_order.created" "credit_note.created" "customer.created" "customer.updated" "webhook.test" |
| description | string or null <= 200 characters |
{- "events": [
- "sale.created"
], - "description": "string"
}{- "data": {
- "id": "whk_a3b8c7d9e1f2034a5b6c7d8e9f0a1b2c",
- "events": [
- "sale.created"
], - "status": "active",
- "consecutive_failures": 0,
- "last_succeeded_at": "2019-08-24T14:15:22Z",
- "last_failed_at": "2019-08-24T14:15:22Z",
- "description": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "secret": "7f3b9d8c2401a5b6e1f203...truncated...",
- "secret_warning": "string"
}
}| id required | string^whk_[a-f0-9]{32}$ |
{- "data": {
- "id": "whk_a3b8c7d9e1f2034a5b6c7d8e9f0a1b2c",
- "events": [
- "sale.created"
], - "status": "active",
- "consecutive_failures": 0,
- "last_succeeded_at": "2019-08-24T14:15:22Z",
- "last_failed_at": "2019-08-24T14:15:22Z",
- "description": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
}Actualización parcial. Solo se pueden enviar url, events, description o status (active ↔ paused).
DELETE (soft-delete) se hace con el endpoint dedicado, NO con status: deleted (rechazado con 422).
Cuando se reactiva un webhook pausado (status: active), el contador consecutive_failures se resetea a 0.
| id required | string^whk_[a-f0-9]{32}$ |
| Idempotency-Key required | string <uuid> |
| url | string <uri> <= 500 characters |
| events | Array of strings [ 1 .. 20 ] items Items Enum: "sale.created" "sale.voided" "table_order.created" "credit_note.created" "customer.created" "customer.updated" "webhook.test" |
| description | string or null <= 200 characters |
| status | string Enum: "active" "paused" |
{- "events": [
- "sale.created"
], - "description": "string",
- "status": "active"
}{- "data": {
- "id": "whk_a3b8c7d9e1f2034a5b6c7d8e9f0a1b2c",
- "events": [
- "sale.created"
], - "status": "active",
- "consecutive_failures": 0,
- "last_succeeded_at": "2019-08-24T14:15:22Z",
- "last_failed_at": "2019-08-24T14:15:22Z",
- "description": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
}Marca el webhook como deleted. Deliveries pendientes que aún estén en queue NO se envían.
| id required | string^whk_[a-f0-9]{32}$ |
| Idempotency-Key required | string <uuid> |
{- "error": {
- "code": "string",
- "message": "string",
- "details": { },
- "request_id": "string",
}
}Lista las entregas del webhook con paginación por cursor. Soporta filtros ?status= y ?event_type=.
El status acepta el alias failed que combina failed_retryable + failed_permanently.
| id required | string^whk_[a-f0-9]{32}$ |
| cursor | string |
| per_page | integer [ 1 .. 100 ] Default: 25 |
| status | string Enum: "pending" "in_flight" "succeeded" "failed" "failed_retryable" "failed_permanently" |
| event_type | string |
| after | string <date-time> |
{- "data": [
- {
- "id": "string",
- "event_id": "string",
- "event_type": "sale.created",
- "status": "pending",
- "attempt": 1,
- "max_attempts": 5,
- "response_status": 0,
- "response_body": "string",
- "error": "string",
- "payload_size": 0,
- "payload_preview": { },
- "scheduled_at": "2019-08-24T14:15:22Z",
- "delivered_at": "2019-08-24T14:15:22Z",
- "next_retry_at": "2019-08-24T14:15:22Z"
}
], - "meta": {
- "per_page": 25,
- "next_cursor": "eyJmZWNoYSI6IjIwMjYtMDUtMTIgMTQ6MjklMDg...",
- "prev_cursor": "string"
},
}Útil para validar tu integración antes de empezar a recibir eventos reales. Dispara UN WebhookDelivery
con event_type=webhook.test SOLO contra este webhook. No requiere que el webhook esté suscrito a webhook.test.
Devuelve 409 si el webhook está pausado o eliminado.
| id required | string^whk_[a-f0-9]{32}$ |
| Idempotency-Key required | string <uuid> |
{- "data": {
- "id": "string",
- "event_id": "string",
- "event_type": "sale.created",
- "status": "pending",
- "attempt": 1,
- "max_attempts": 5,
- "response_status": 0,
- "response_body": "string",
- "error": "string",
- "payload_size": 0,
- "payload_preview": { },
- "scheduled_at": "2019-08-24T14:15:22Z",
- "delivered_at": "2019-08-24T14:15:22Z",
- "next_retry_at": "2019-08-24T14:15:22Z"
}
}Returns the tenant's product catalog. Requires products:read scope.
Default returns only active products; pass active=false for inactive.
Use include=stock (with warehouse_id) to embed stock for one warehouse.
| page | integer >= 1 Default: 1 |
| per_page | integer [ 1 .. 200 ] Default: 25 |
| search | string <= 80 characters Case-insensitive substring search across name and code. |
| category_id | string^cat_[1-9A-HJ-NP-Za-km-z]{8,16}$ Filter by category (opaque public ID). |
| warehouse_id | string^wh_[1-9A-HJ-NP-Za-km-z]{8,16}$ Required when |
| active | boolean Default: true |
| include | string Example: include=stock,category Comma-separated. Supported tokens: |
| updated_since | string <date-time> Returns products created/updated at or after this timestamp. Legacy schemas use |
| sort | string Default: "-created_at" Enum: "name" "-name" "price" "-price" "created_at" "-created_at" |
{- "data": [
- {
- "id": "p_nQsdd4SDgjLM",
- "code": "BC001",
- "name": "Café Americano",
- "description": "string",
- "price": "18000.00",
- "currency": "COP",
- "tax_rate": "19.00",
- "active": true,
- "category": {
- "id": "string",
- "name": "string",
- "code": "string",
- "active": true
}, - "stock": [
- {
- "warehouse_id": "string",
- "warehouse_name": "string",
- "quantity": 0
}
], - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "meta": {
- "current_page": 1,
- "last_page": 42,
- "per_page": 25,
- "from": 1,
- "to": 25,
- "total": 1042,
}, - "links": {
}
}Returns a product the authenticated tenant owns. Returns 404 for IDs from other tenants — never 403 — to avoid cross-tenant existence disclosure.
| id required | string^p_[1-9A-HJ-NP-Za-km-z]{8,16}$ |
{- "data": {
- "id": "p_nQsdd4SDgjLM",
- "code": "BC001",
- "name": "Café Americano",
- "description": "string",
- "price": "18000.00",
- "currency": "COP",
- "tax_rate": "19.00",
- "active": true,
- "category": {
- "id": "string",
- "name": "string",
- "code": "string",
- "active": true
}, - "stock": [
- {
- "warehouse_id": "string",
- "warehouse_name": "string",
- "quantity": 0
}
], - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}