API REST v1 · webhooks
Conecta tu restaurante con cualquier sistema
Lee ventas, catálogo, inventario, clientes, reservas, pedidos, caja, personal y contabilidad; crea pedidos, reservas y productos; recibe webhooks firmados cuando algo pasa. Todo en JSON, con paginación por cursor, sincronización incremental y una llave de prueba que nunca toca tus datos reales.
Base: https://demo.kmand.mx/api/v1
1. Primeros pasos con la llave de prueba
- Entra como dueño a Configuración → API y webhooks (
/configuracion/api) y crea una llave en modo Prueba. La primera prepara un restaurante de prueba para tu organización (catálogo, ventas de varias semanas, clientes, reservas, pedidos e inventario): con una llavekmd_test_…todo pasa ahí, nunca en tus datos reales. Copia la llave: solo se muestra una vez. - Pruébala:
Responde el modo, tus alcances, los restaurantes que alcanza la llave y tu cuota del minuto.export KMAND_LLAVE=kmd_test_... curl https://demo.kmand.mx/api/v1/yo -H "Authorization: Bearer $KMAND_LLAVE" - Lee las órdenes cerradas de la semana:
curl "https://demo.kmand.mx/api/v1/ordenes?estado=cerrada&desde=2026-09-22&hasta=2026-09-28" \ -H "Authorization: Bearer $KMAND_LLAVE" - Crea un pedido para llevar (toda escritura pide
Idempotency-Key: si repites la petición con la misma clave no se duplica). Los ids de producto y opciones salen deGET /productos.curl -X POST "https://demo.kmand.mx/api/v1/pedidos" \ -H "Authorization: Bearer $KMAND_LLAVE" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"tipo":"domicilio","partidas":[{"producto_id":"b6152f21-3c67-4896-a4a5-ea72ec44601a","cantidad":3,"opciones":["a0d861cf-afba-4cc2-a9a4-697eced718ac","b70a07e5-bb4b-483c-be84-2797f7790197"]}],"cliente":{"nombre":"Carmen","whatsapp":"4441234567"},"entrega":{"direccion":"Av. Carranza 1200, Col. Centro","lat":22.1523,"lng":-100.9781},"pago":"efectivo","paga_con":200}' - Suscribe un webhook en el panel y toca Enviar prueba; verifica la firma con el ejemplo de la sección Webhooks.
- Cuando todo funcione, activa el módulo API y webhooks en
/configuracion/plany crea una llave En vivo con solo los alcances que tu integración necesita. Guárdala en tu servidor (nunca en una app o sitio público).
2. Llaves y alcances
Manda la llave en cada petición: Authorization: Bearer kmd_live_…. Hay llaves de una sucursal, de toda la organización (dueño o director; en las escrituras indicas restaurante_id) y de prueba. Revocar una llave la apaga al instante; puedes ponerle vencimiento. En la base solo guardamos su huella (hash): si la pierdes, revócala y crea otra.
Cada llave es una identidad de servicio propia: todas sus consultas pasan por las mismas reglas de seguridad de la base (el restaurante y el alcance se revisan ahí, no solo en el servidor). Sus escrituras quedan firmadas por el dispositivo API con la llave como autor, en la misma bitácora de eventos que el POS.
| Alcance | Qué permite |
|---|---|
| ventas:leer | Órdenes con partidas, pagos y descuentos; pedidos en línea. |
| catalogo:leer | Categorías y productos con variantes, modificadores, precios por sucursal e IVA. |
| catalogo:escribir | Crear productos, cambiar precios y agotar o reactivar. |
| inventario:leer | Insumos, existencias y movimientos de inventario. |
| inventario:escribir | Registrar entradas de inventario (compras). |
| clientes:leer | Ficha de clientes SIN teléfono ni correo. |
| clientes:contacto | Además, el WhatsApp y el correo de los clientes (y los datos de entrega de los pedidos). |
| clientes:escribir | Dar de alta clientes. |
| pedidos:crear | Crear pedidos para llevar o a domicilio (mismo camino que la tienda en línea). |
| reservas:leer | Reservas y su estado. |
| reservas:escribir | Crear, modificar y cancelar reservas. |
| caja:leer | Turnos de caja y cortes del día. |
| personal:leer | Empleados y jornadas, SIN salarios. |
| personal:salarios | Además, el salario diario de los empleados. |
| contabilidad:leer | Gastos (con su CFDI si lo hay). |
| webhooks:gestionar | Crear, cambiar y probar suscripciones de webhooks; leer su bitácora. |
Una llave puede leer de vuelta lo que acaba de escribir aunque no tenga el alcance de lectura (p. ej. un pedido que creó con pedidos:crear).
3. Convenciones
- Versión en la URL:
/api/v1. La v1 solo crece (campos, endpoints y eventos nuevos); tu código debe ignorar campos que no conozca. Ver cambios. - Montos: texto decimal con dos decimales y además en centavos enteros:
"total": "123.50", "total_centavos": 12350. Los precios ya incluyen IVA; cada producto trae sutasa_iva. - Fechas: instantes ISO 8601 con el desfase de la zona del restaurante (
2026-09-29T14:05:00.000-06:00) y fechas de negocioAAAA-MM-DD(el día de negocio del restaurante). - Listas:
{ "datos": [...], "siguiente_cursor": "…", "tiene_mas": true }. Pide la siguiente página con?cursor=;limitede 1 a 100 (50 por omisión). Un recurso suelto viene en{ "datos": {...} }. - Sincronizar: las listas se ordenan por su último cambio. Guarda el
actualizado_atmás reciente que viste y vuelve conupdated_since; puede repetir filas (son idempotentes porid).let cursor = null; const todas = []; do { const url = new URL("https://demo.kmand.mx/api/v1/ordenes"); url.searchParams.set("updated_since", ultimaSincronizacion); // ISO con zona url.searchParams.set("limite", "100"); if (cursor) url.searchParams.set("cursor", cursor); const r = await fetch(url, { headers: { Authorization: `Bearer ${process.env.KMAND_LLAVE}` } }); const pagina = await r.json(); todas.push(...pagina.datos); cursor = pagina.siguiente_cursor; } while (cursor); - Idempotencia: toda escritura (POST, PATCH, DELETE) exige la cabecera
Idempotency-Key(1–255 caracteres, un UUID por operación). Repetirla en 24 h con el mismo cuerpo devuelve la misma respuesta conIdempotent-Replayed: true; con otro cuerpo,422 idempotency_key_reutilizada. - Cuota: por llave y por minuto (120 por omisión). Cada respuesta trae
X-RateLimit-Limit,X-RateLimit-RemainingyX-RateLimit-Reset; al pasarte,429conRetry-After. - Sin datos de prueba: las órdenes marcadas como prueba en el POS nunca salen en la API ni en los webhooks.
4. Errores
Siempre { "error": { "codigo", "mensaje", "message", "estado", "solicitud_id", "detalles"? } }: el codigo es estable, mensaje viene en español y message en inglés. Con el solicitud_id te ayudamos a rastrear un problema.
| Código | HTTP | Significa |
|---|---|---|
| falta_llave | 401 | Falta la llave de API. Mándala en la cabecera Authorization: Bearer kmd_… |
| llave_invalida | 401 | La llave de API no es válida. |
| llave_revocada | 401 | Esa llave se revocó. Crea otra en /configuracion/api. |
| llave_vencida | 401 | Esa llave ya venció. Crea otra en /configuracion/api. |
| alcance_insuficiente | 403 | La llave no tiene el alcance que pide esta operación. |
| restaurante_no_permitido | 403 | La llave no puede operar sobre ese restaurante (o no tiene el módulo API activo). |
| modulo_inactivo | 403 | El restaurante no tiene activo el módulo que necesita esta operación. |
| heredado_del_maestro | 403 | Ese campo lo manda el menú maestro: cámbialo en el maestro. |
| no_encontrado | 404 | No encontramos ese recurso. |
| ruta_desconocida | 404 | Esa ruta no existe en la API v1. |
| metodo_no_permitido | 405 | Ese método no está permitido en esta ruta. |
| peticion_invalida | 400 | La petición no es válida. Revisa los detalles. |
| json_invalido | 400 | El cuerpo no es JSON válido. |
| cursor_invalido | 400 | El cursor no es válido. Usa el que regresó la página anterior. |
| falta_restaurante | 400 | Esta llave es de varias sucursales: indica restaurante_id. |
| falta_idempotency_key | 400 | Las escrituras piden la cabecera Idempotency-Key (1 a 255 caracteres). |
| idempotency_key_reutilizada | 422 | Esa Idempotency-Key ya se usó con otra petición en las últimas 24 h. |
| idempotency_en_proceso | 409 | Una petición con esa Idempotency-Key sigue en proceso. Reintenta en unos segundos. |
| sin_disponibilidad | 409 | Ese horario no está disponible. |
| deposito_requerido | 409 | Para ese tamaño de grupo el restaurante pide depósito: la reserva va por su página de reservas o por teléfono. |
| cliente_bloqueado | 409 | No podemos tomar esa reserva por la API. Que el cliente llame al restaurante. |
| estado_invalido | 409 | El recurso ya no admite ese cambio por su estado. |
| cliente_existente | 409 | Ya existe un cliente con ese WhatsApp. |
| pedido_rechazado | 409 | El restaurante no puede recibir ese pedido. Revisa el motivo. |
| limite_suscripciones | 409 | Llegaste al máximo de 10 suscripciones de webhooks en este restaurante. |
| url_no_permitida | 400 | La URL del webhook debe ser https y pública (nada de IP privadas ni localhost). |
| limite_excedido | 429 | Superaste la cuota de peticiones por minuto de esta llave. Espera al reinicio. |
| error_interno | 500 | Algo salió mal de nuestro lado. Reintenta; si sigue, avísanos con el solicitud_id. |
5. Referencia de endpoints
Cada endpoint con sus parámetros, alcance, ejemplos en curl y JavaScript y la forma de la respuesta está en la referencia completa.
6. Webhooks
Suscribe una URL https pública desde el panel o con POST /webhooks y elige eventos. Cada evento sale de lo que ya quedó confirmado en el servidor (no de un celular que todavía no sincroniza) y trae el mismo recurso que devuelve la API, con los alcances de la suscripción: sin teléfonos salvo que pida clientes:contacto.
| Evento | Alcance | Cuándo |
|---|---|---|
| orden.cerrada | ventas:leer | Una orden se cobró completa y se cerró. |
| orden.cancelada | ventas:leer | Una orden se canceló (con su motivo). |
| pago.registrado | ventas:leer | Entró un pago a una orden (efectivo, tarjeta, Mercado Pago, monedero…). |
| devolucion.registrada | ventas:leer | Se devolvió dinero o producto de una orden. |
| producto.agotado | catalogo:leer | Un producto se marcó agotado (el 86). |
| producto.disponible | catalogo:leer | Un producto agotado volvió a estar disponible. |
| pedido_linea.creado | ventas:leer | Llegó un pedido en línea (tienda, teléfono o API). |
| pedido_linea.estado | ventas:leer | Un pedido en línea cambió de estado (aceptado, en cocina, listo, en camino, entregado, rechazado…). |
| reserva.creada | reservas:leer | Se creó una reserva. |
| reserva.estado | reservas:leer | Una reserva cambió de estado (confirmada, llegó, sentada, no-show, cancelada…). |
| turno_caja.cerrado | caja:leer | Se cerró un turno de caja (con su corte). |
| inventario.bajo_minimo | inventario:leer | Un insumo quedó por debajo de su mínimo (una vez por insumo y día). |
| cliente.creado | clientes:leer | Se dio de alta un cliente (sin teléfono salvo con clientes:contacto). |
| factura.emitida | contabilidad:leer | Se emitió un CFDI (factura, factura global o nota de crédito) con el módulo de facturación: folio fiscal, serie y folio, receptor, totales, forma de pago y orden. |
Cuerpo de una entrega (cabeceras KMAND-Firma, KMAND-Entrega y KMAND-Evento):
{
"id": "b6152f21-3c67-4896-a4a5-ea72ec44601a",
"evento": "pago.registrado",
"api_version": "v1",
"restaurante_id": "b6152f21-3c67-4896-a4a5-ea72ec44601a",
"modo": "live",
"creado_at": "2026-09-29T14:05:00.000-06:00",
"reenvio_de": "3f2a9c1e-7b1d-4c55-9e0a-2d3b4c5d6e7f",
"datos": {
"id": "b6152f21-3c67-4896-a4a5-ea72ec44601a",
"orden_id": "b6152f21-3c67-4896-a4a5-ea72ec44601a",
"restaurante_id": "b6152f21-3c67-4896-a4a5-ea72ec44601a",
"metodo": "<metodo>",
"monto": "123.50",
"monto_centavos": 12350,
"recibido": "123.50",
"recibido_centavos": 12350,
"cambio": "123.50",
"cambio_centavos": 12350,
"referencia": "<referencia>",
"etiqueta": "<etiqueta>",
"pagado_at": "2026-09-29T14:05:00.000-06:00",
"fecha_negocio": "2026-09-29",
"orden": {
"numero": 1,
"estado": "<estado>",
"total": "123.50",
"total_centavos": 12350,
"saldo": "123.50",
"saldo_centavos": 12350
}
}
}Verifica la firma
KMAND-Firma: t=<segundos unix>,v1=<hex> donde v1 = HMAC-SHA256(secreto, t + "." + cuerpo crudo). Rechaza si no coincide o si t tiene más de 5 minutos (evita repeticiones). Usa el cuerpo tal cual llegó: no lo vuelvas a serializar.
import { createHmac, timingSafeEqual } from "node:crypto";
// cuerpo = el texto CRUDO que llegó (no lo vuelvas a serializar)
export function firmaValida(cabecera, cuerpo, secreto, toleranciaSeg = 300) {
const partes = Object.fromEntries(cabecera.split(",").map((p) => p.split("=")));
const t = Number(partes.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranciaSeg) return false; // vieja: posible repetición
const esperada = createHmac("sha256", secreto).update(`${t}.${cuerpo}`).digest();
const recibida = Buffer.from(partes.v1 ?? "", "hex");
return recibida.length === esperada.length && timingSafeEqual(recibida, esperada);
}
// Express: app.post("/kmand", express.raw({ type: "application/json" }), (req, res) => {
// if (!firmaValida(req.get("KMAND-Firma"), req.body.toString("utf8"), process.env.KMAND_WEBHOOK_SECRETO)) return res.sendStatus(400);
// const evento = JSON.parse(req.body); // evento.id es único: ignora los que ya procesaste
// res.sendStatus(200); // responde rápido (antes de 10 s) y procesa después
// });Entrega, reintentos y desactivación
- Responde 2xx en menos de 10 s y procesa después. Otra respuesta o más tiempo cuenta como fallo.
- Reintentamos con espera exponencial: 1 min, 5 min, 30 min, 2 h, 5 h y 10 h, siempre dentro de 24 h; después la entrega queda agotada (la puedes reenviar desde la bitácora).
- El
idde la entrega es único: úsalo para ignorar duplicados (un reenvío trae id nuevo yreenvio_de). - Si una suscripción falla sin parar durante 24 h se apaga sola y avisamos al dueño en el tablero; al encenderla se retoman las pendientes.
- El panel guarda la bitácora de cada entrega con su cuerpo, la respuesta de tu servidor y la duración, y trae Enviar prueba y Reenviar.
7. Límites y datos
- 120 peticiones por minuto por llave (se puede subir por llave), 25 llaves activas por organización, 10 suscripciones de webhooks por restaurante.
- Páginas de hasta 100 filas; los cortes del día, rangos de hasta 93 días.
- ¿Quieres TODO de una vez? El dueño descarga sus datos completos en un ZIP (CSV por entidad, eventos crudos, fotos y documentos) desde Configuración → Tus datos. Es gratis y no necesita el módulo.