Demo: los cambios se borran cada noche

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

  1. 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 llave kmd_test_… todo pasa ahí, nunca en tus datos reales. Copia la llave: solo se muestra una vez.
  2. Pruébala:
    export KMAND_LLAVE=kmd_test_...
    curl https://demo.kmand.mx/api/v1/yo -H "Authorization: Bearer $KMAND_LLAVE"
    Responde el modo, tus alcances, los restaurantes que alcanza la llave y tu cuota del minuto.
  3. 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"
  4. 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 de GET /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}'
  5. Suscribe un webhook en el panel y toca Enviar prueba; verifica la firma con el ejemplo de la sección Webhooks.
  6. Cuando todo funcione, activa el módulo API y webhooks en /configuracion/plan y 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.

AlcanceQué permite
ventas:leerÓrdenes con partidas, pagos y descuentos; pedidos en línea.
catalogo:leerCategorías y productos con variantes, modificadores, precios por sucursal e IVA.
catalogo:escribirCrear productos, cambiar precios y agotar o reactivar.
inventario:leerInsumos, existencias y movimientos de inventario.
inventario:escribirRegistrar entradas de inventario (compras).
clientes:leerFicha de clientes SIN teléfono ni correo.
clientes:contactoAdemás, el WhatsApp y el correo de los clientes (y los datos de entrega de los pedidos).
clientes:escribirDar de alta clientes.
pedidos:crearCrear pedidos para llevar o a domicilio (mismo camino que la tienda en línea).
reservas:leerReservas y su estado.
reservas:escribirCrear, modificar y cancelar reservas.
caja:leerTurnos de caja y cortes del día.
personal:leerEmpleados y jornadas, SIN salarios.
personal:salariosAdemás, el salario diario de los empleados.
contabilidad:leerGastos (con su CFDI si lo hay).
webhooks:gestionarCrear, 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 su tasa_iva.
  • Fechas: instantes ISO 8601 con el desfase de la zona del restaurante (2026-09-29T14:05:00.000-06:00) y fechas de negocio AAAA-MM-DD (el día de negocio del restaurante).
  • Listas: { "datos": [...], "siguiente_cursor": "…", "tiene_mas": true }. Pide la siguiente página con ?cursor=; limite de 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_at más reciente que viste y vuelve con updated_since; puede repetir filas (son idempotentes por id).
    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 con Idempotent-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-Remaining y X-RateLimit-Reset; al pasarte, 429 con Retry-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ódigoHTTPSignifica
falta_llave401Falta la llave de API. Mándala en la cabecera Authorization: Bearer kmd_…
llave_invalida401La llave de API no es válida.
llave_revocada401Esa llave se revocó. Crea otra en /configuracion/api.
llave_vencida401Esa llave ya venció. Crea otra en /configuracion/api.
alcance_insuficiente403La llave no tiene el alcance que pide esta operación.
restaurante_no_permitido403La llave no puede operar sobre ese restaurante (o no tiene el módulo API activo).
modulo_inactivo403El restaurante no tiene activo el módulo que necesita esta operación.
heredado_del_maestro403Ese campo lo manda el menú maestro: cámbialo en el maestro.
no_encontrado404No encontramos ese recurso.
ruta_desconocida404Esa ruta no existe en la API v1.
metodo_no_permitido405Ese método no está permitido en esta ruta.
peticion_invalida400La petición no es válida. Revisa los detalles.
json_invalido400El cuerpo no es JSON válido.
cursor_invalido400El cursor no es válido. Usa el que regresó la página anterior.
falta_restaurante400Esta llave es de varias sucursales: indica restaurante_id.
falta_idempotency_key400Las escrituras piden la cabecera Idempotency-Key (1 a 255 caracteres).
idempotency_key_reutilizada422Esa Idempotency-Key ya se usó con otra petición en las últimas 24 h.
idempotency_en_proceso409Una petición con esa Idempotency-Key sigue en proceso. Reintenta en unos segundos.
sin_disponibilidad409Ese horario no está disponible.
deposito_requerido409Para ese tamaño de grupo el restaurante pide depósito: la reserva va por su página de reservas o por teléfono.
cliente_bloqueado409No podemos tomar esa reserva por la API. Que el cliente llame al restaurante.
estado_invalido409El recurso ya no admite ese cambio por su estado.
cliente_existente409Ya existe un cliente con ese WhatsApp.
pedido_rechazado409El restaurante no puede recibir ese pedido. Revisa el motivo.
limite_suscripciones409Llegaste al máximo de 10 suscripciones de webhooks en este restaurante.
url_no_permitida400La URL del webhook debe ser https y pública (nada de IP privadas ni localhost).
limite_excedido429Superaste la cuota de peticiones por minuto de esta llave. Espera al reinicio.
error_interno500Algo salió mal de nuestro lado. Reintenta; si sigue, avísanos con el solicitud_id.

5. Referencia de endpoints

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.

EventoAlcanceCuándo
orden.cerradaventas:leerUna orden se cobró completa y se cerró.
orden.canceladaventas:leerUna orden se canceló (con su motivo).
pago.registradoventas:leerEntró un pago a una orden (efectivo, tarjeta, Mercado Pago, monedero…).
devolucion.registradaventas:leerSe devolvió dinero o producto de una orden.
producto.agotadocatalogo:leerUn producto se marcó agotado (el 86).
producto.disponiblecatalogo:leerUn producto agotado volvió a estar disponible.
pedido_linea.creadoventas:leerLlegó un pedido en línea (tienda, teléfono o API).
pedido_linea.estadoventas:leerUn pedido en línea cambió de estado (aceptado, en cocina, listo, en camino, entregado, rechazado…).
reserva.creadareservas:leerSe creó una reserva.
reserva.estadoreservas:leerUna reserva cambió de estado (confirmada, llegó, sentada, no-show, cancelada…).
turno_caja.cerradocaja:leerSe cerró un turno de caja (con su corte).
inventario.bajo_minimoinventario:leerUn insumo quedó por debajo de su mínimo (una vez por insumo y día).
cliente.creadoclientes:leerSe dio de alta un cliente (sin teléfono salvo con clientes:contacto).
factura.emitidacontabilidad:leerSe 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 id de la entrega es único: úsalo para ignorar duplicados (un reenvío trae id nuevo y reenvio_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.