Conecte su sistema de gestión con MiPlato
La API REST de MiPlato permite que cualquier ERP, punto de venta o sistema propio publique su catálogo en el menú online del comercio, reciba los pedidos en el momento y actualice su estado, sin intervención manual.
https://sandbox.miplato.com.py/api/v1
- REST + JSON sobre HTTPS
- Webhooks firmados con HMAC-SHA256
- Feed de eventos para sistemas sin IP pública
- Escrituras idempotentes y seguras ante reintentos
Qué puede hacer su sistema
Publicar el catálogo
Categorías, productos, precios, disponibilidad, fotos y adicionales, usando los códigos de su propio sistema.
Recibir los pedidos
Por webhook en el momento, o consultando el feed de eventos. Cada pedido llega completo, con cliente, dirección, pago e ítems.
Confirmar y registrar
Informe que el pedido se importó (o por qué no se pudo), con el número de venta de su sistema.
Actualizar el estado
Aceptado, en preparación, en camino, entregado. El cliente lo sigue en vivo desde su celular.
Inicio rápido
-
Obtenga una credencial
Para desarrollar, use el ambiente de homologación: allí recibe una tienda de prueba y genera su propio token de pruebas. En producción, la credencial la genera cada comercio:
El comercio ingresa a su panel en Panel → Integraciones → Conectar un sistema, elige «Sistema propio / otro ERP» (o el sistema que corresponda), define los permisos y el modo de catálogo, y copia la credencial. La credencial se muestra una sola vez y tiene este formato:
Credencialmpk_live_xxxxxxxxxxxxxxxxxxxxxxxx.mps_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxSu sistema debe guardarla cifrada y nunca mostrarla en pantallas ni registros. Si su sistema atiende a varios comercios, guarde una credencial por comercio.
-
Verifique la conexión
La primera llamada válida activa la integración y confirma con qué comercio está conectado.
cURLcurl https://sandbox.miplato.com.py/api/v1/me \ -H "Authorization: Bearer $MIPLATO_TOKEN"PHP<?php $ch = curl_init('https://sandbox.miplato.com.py/api/v1/me'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('MIPLATO_TOKEN')], CURLOPT_TIMEOUT => 20, ]); $resp = json_decode(curl_exec($ch), true); echo $resp['data']['store']['name'];Node.jsconst resp = await fetch('https://sandbox.miplato.com.py/api/v1/me', { headers: { Authorization: `Bearer ${process.env.MIPLATO_TOKEN}` }, }); const { data } = await resp.json(); console.log(data.store.name);Pythonimport os, requests r = requests.get('https://sandbox.miplato.com.py/api/v1/me', headers={'Authorization': f"Bearer {os.environ['MIPLATO_TOKEN']}"}, timeout=20) print(r.json()['data']['store']['name']) -
Publique el catálogo
Con la integración en modo
erp_managed, envíe primero las categorías y después los productos, identificados por los códigos de su sistema (external_id).cURLcurl -X PUT https://sandbox.miplato.com.py/api/v1/catalog/categories/G-12 \ -H "Authorization: Bearer $MIPLATO_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "Lomitos", "position": 1, "active": true}' curl -X PUT https://sandbox.miplato.com.py/api/v1/catalog/products/P-55 \ -H "Authorization: Bearer $MIPLATO_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "Lomito árabe", "category_external_id": "G-12", "price": "35000"}' -
Reciba los pedidos
Configure un webhook (ver Webhooks) o consulte periódicamente el feed de eventos. Use siempre el
iddel pedido (ord_…) como clave única en su sistema para no duplicarlo. -
Confirme la importación y actualice el estado
cURLcurl -X POST https://sandbox.miplato.com.py/api/v1/orders/ord_01JA9Z.../ack \ -H "Authorization: Bearer $MIPLATO_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: ack-ord_01JA9Z..." \ -d '{"external_id": "VENTA-88231", "external_number": "88231"}' curl -X POST https://sandbox.miplato.com.py/api/v1/orders/ord_01JA9Z.../status \ -H "Authorization: Bearer $MIPLATO_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: st-ord_01JA9Z...-accepted" \ -d '{"status": "accepted", "estimated_minutes": 30}'
Flujo de integración
El recorrido completo de un pedido entre MiPlato y su sistema:
PUT productos o POST /catalog/batchorder.createdPOST /orders/{id}/ack o /import-failurePOST /orders/{id}/status · evento order.status_changed¿Webhook o feed?
Webhook: MiPlato envía cada evento a una URL HTTPS pública de su sistema, al instante. Requiere que su servidor sea accesible desde internet.
Feed: su sistema consulta GET /v1/events cada 15 a 30 segundos. Ideal para sistemas instalados en una red local o detrás de NAT. Recomendamos usar el feed también como conciliación aunque tenga webhook (por ejemplo, cada 5 minutos).
Red de seguridad
GET /v1/orders?ack_status=pending devuelve los pedidos que su sistema todavía no confirmó. Consúltelo al iniciar su sistema y periódicamente: así ningún pedido queda sin importar, aunque se haya perdido un webhook.
Ambiente de homologación
Desarrolle y pruebe su integración en un ambiente separado del MiPlato oficial, con base de datos propia, una tienda de prueba y datos ficticios. Nada de lo que haga allí afecta a comercios reales.
| Ambiente | URL de la API | Token |
|---|---|---|
| Homologación | https://sandbox.miplato.com.py/api/v1 | mpk_test_…, generado por usted en la consola de homologación |
| Producción | https://app.miplato.com.py/api/v1 | mpk_live_…, generado por cada comercio en su panel |
- Solicite el acceso en https://sandbox.miplato.com.py/homologacion con los datos de su empresa y de su sistema.
- Con la solicitud aprobada, ingrese con su correo y su contraseña. En el menú Homologación encontrará la URL de la API, el botón para generar su token de pruebas y el simulador de pedidos (delivery, retiro con factura y producto sin código).
- El reporte de homologación se completa solo, con los registros reales de sus llamadas: conexión, catálogo, recepción de pedidos, rechazo de firmas inválidas, importación, estados y reintentos.
- Con todas las pruebas obligatorias aprobadas, emita el certificado de homologación, verificable por su código.
Convenciones
| URL base | https://sandbox.miplato.com.py/api/v1. Todas las rutas de esta documentación son relativas a /v1. |
|---|---|
| Transporte | Sólo HTTPS. |
| Formato | Content-Type: application/json; charset=utf-8 en solicitudes y respuestas. Textos en UTF-8. |
| Campos | snake_case. Su sistema debe ignorar los campos que no conozca en las respuestas (la v1 sólo agrega campos compatibles). En escritura, un campo desconocido se rechaza con 422 para evitar errores de tipeo silenciosos. |
| Montos | Texto decimal con punto y sin separador de miles: "15000" o "15000.50" (hasta 2 decimales). Nunca números de punto flotante. Moneda: PYG. |
| Fechas | ISO 8601 con desplazamiento explícito, por ejemplo 2026-10-12T21:30:00-03:00. Las respuestas usan el huso America/Asuncion. Lea siempre el desplazamiento informado; no asuma uno fijo. |
| Identificadores | Opacos, con prefijo y 26 caracteres: ord_01JA2B3C4D5E6F7G8H9J0KMNPQ. Prefijos: str_ comercio, int_ integración, cat_ categoría, prd_ producto, ogr_ grupo de adicionales, opt_ adicional, ord_ pedido, evt_ evento, dlv_ entrega de webhook, req_ solicitud. |
| Sus códigos | external_id: de 1 a 64 caracteres A-Z a-z 0-9 . _ : -, único por integración y sensible a mayúsculas. Es el código de su sistema; MiPlato guarda la relación con sus propios IDs. |
| Paginación | Por cursor: ?limit=50&cursor=…. La respuesta trae next_cursor (null en la última página). limit máximo: 200. |
| Límites | 120 solicitudes por minuto por integración (encabezados X-RateLimit-Limit y X-RateLimit-Remaining). Cuerpo máximo: 5 MB. |
| Rastreo | Cada respuesta incluye X-Request-Id: req_…. Indíquelo al contactar a soporte. |
Respuestas exitosas:
{ "data": { "id": "ord_01JA9Z…", "…": "…" } }{ "data": [ { "…": "…" } ], "next_cursor": "c_MTIz" }Autenticación
Envíe la credencial completa en el encabezado Authorization de cada solicitud:
Authorization: Bearer mpk_live_7Hq2…Zx.mps_live_9fK3…aQ- Las credenciales de producción empiezan con
mpk_live_/mps_live_. Las de un entorno de pruebas, conmpk_test_/mps_test_. Una credencial de pruebas nunca funciona en producción, ni al revés. - MiPlato guarda sólo un resumen criptográfico (SHA-256) del secreto: no puede volver a mostrarlo. Si se pierde, el comercio genera una nueva.
- Credencial ausente, inválida, vencida o revocada →
401 unauthenticated. Después de 30 fallas por minuto desde la misma IP, las solicitudes reciben429 rate_limitedhasta que termine el minuto. - Integración suspendida →
403 integration_suspended. Comercio suspendido →403 store_suspended.
Rotación de la credencial
El comercio puede rotar la credencial en el panel sin detener la integración: durante un período de superposición (24 horas de forma predeterminada, configurable de 0 a 72) la credencial anterior y la nueva funcionan al mismo tiempo. Mientras su sistema use una credencial que está por vencer, GET /v1/me devuelve credential.expires_at con la fecha de vencimiento: úselo para alertar al usuario. Procedimiento recomendado:
- El comercio rota la credencial en el panel.
- Carga la nueva credencial en su sistema.
- Su sistema la verifica con
GET /v1/me. - La anterior vence sola al terminar la superposición.
Permisos
Cada integración tiene permisos (scopes) que el comercio define al conectarla y puede cambiar en el panel. Si falta el permiso necesario, la respuesta es 403 insufficient_scope.
| Permiso | Habilita |
|---|---|
store:read | Leer los datos operativos del comercio y recibir el evento store.updated. |
store:write | Abrir o pausar la recepción de pedidos. |
catalog:read | Leer categorías y productos. |
catalog:write | Crear, actualizar y desactivar categorías, productos, precios, disponibilidad y adicionales. |
orders:read | Listar y consultar pedidos, leer el feed y recibir los eventos de pedidos. |
orders:write | Confirmar la importación, informar fallas y cambiar el estado de los pedidos. |
webhooks:manage | Configurar el webhook y reenviar entregas. |
Configuración de la integración
El comercio la define en el panel; su sistema la lee en GET /v1/me y recibe el evento integration.updated cuando cambia.
| Clave | Valores | Efecto |
|---|---|---|
catalog_mode | manual (predet.), erp_managed | En erp_managed su sistema es el dueño del catálogo y esos campos quedan bloqueados en el panel. En manual las escrituras de catálogo responden 403 field_not_managed. |
order_status_authority | both (predet.), erp, miplato | Quién puede cambiar el estado de los pedidos. Con miplato, POST /status responde 403 status_authority_denied. |
missing_items_policy | keep (predet.), deactivate | Qué pasa, en una sincronización completa, con los ítems que no vinieron (ver Lote). |
ack_expected | on_receipt (predet.), after_acceptance | Cuándo se espera la confirmación de importación: al recibir el pedido, o recién después de que el comercio lo acepta. |
ack_alert_minutes | 1 a 1440 (predet. 5) | Minutos sin confirmación antes de que el panel avise «pedido no registrado en el sistema». |
Errores
Todos los errores tienen el mismo formato. Use code para el tratamiento automático; message está en español y puede mostrarse al usuario.
{
"error": {
"code": "validation_failed",
"message": "Uno o más campos son inválidos.",
"details": [
{ "field": "price", "code": "invalid", "message": "Debe ser un decimal positivo en texto, p. ej. \"15000\"." }
],
"request_id": "req_01JA2B…"
}
}| HTTP | code | Cuándo |
|---|---|---|
| 400 | malformed_json | El cuerpo no es un objeto JSON válido. |
| 400 | idempotency_key_required, invalid_idempotency_key | Falta el encabezado Idempotency-Key donde es obligatorio, o su formato es inválido. |
| 400 | invalid_cursor | El cursor de paginación no es válido. |
| 401 | unauthenticated | Credencial ausente, inválida, vencida o revocada. |
| 403 | insufficient_scope, integration_suspended, store_suspended, status_authority_denied, field_not_managed | La credencial es válida, pero la operación no está permitida. |
| 404 | not_found | El recurso no existe o pertenece a otro comercio (nunca se revela cuál de los dos). |
| 409 | idempotency_conflict, request_in_progress | Misma Idempotency-Key con otro cuerpo, o la primera solicitud todavía se procesa. |
| 409 | invalid_status_transition | La transición de estado no está permitida (details trae current_status). |
| 409 | external_id_conflict, already_acknowledged | El pedido ya fue confirmado con otro código, o ya fue confirmado. |
| 409 | category_not_empty, already_linked | Categoría con productos activos; producto ya vinculado a otro código. |
| 409 | batch_in_progress, snapshot_closed, snapshot_out_of_order | Reglas del lote. |
| 409 | delivery_in_progress | La entrega de webhook se está enviando en este momento. |
| 413 | payload_too_large | Cuerpo mayor a 5 MB o lote con más de 500 operaciones. |
| 422 | validation_failed | Datos inválidos; details trae cada campo. |
| 429 | rate_limited | Se superó el límite. Espere los segundos de Retry-After. |
| 500 | internal_error | Error inesperado. Puede repetirse con seguridad si la solicitud es idempotente. |
| 503 | maintenance | No disponible temporalmente (ver Retry-After). |
429, 500, 502, 503, 504 o fallas de red, con espera exponencial (por ejemplo 1 s, 2 s, 4 s, 8 s…) y respetando Retry-After. Nunca repita un error 4xx sin corregir la causa.Idempotencia
Las redes fallan: su sistema debe poder repetir una solicitud sin duplicar el efecto.
PUTporexternal_ides idempotente por naturaleza: enviar el mismo producto dos veces deja el mismo resultado.- Encabezado
Idempotency-Key(8 a 100 caracteres: letras, números,-_.:; se recomienda un UUID). Es obligatorio en/ack,/import-failure,/statusy/catalog/batch, y opcional en los demásPOSTyPATCH.- Misma clave y mismo cuerpo, dentro de 7 días: devuelve la misma respuesta que la primera vez, con el encabezado
Idempotent-Replay: true. - Misma clave con otro cuerpo:
409 idempotency_conflict. - Misma clave mientras la primera solicitud todavía se procesa:
409 request_in_progressconRetry-After. - Las respuestas
5xxno se guardan: la misma clave puede reintentarse de inmediato.
- Misma clave y mismo cuerpo, dentro de 7 días: devuelve la misma respuesta que la primera vez, con el encabezado
- Cambios fuera de orden: categorías y productos aceptan
source_updated_at(fecha del último cambio en su sistema). Si llega una versión más antigua que la ya aplicada, no se aplica y la respuesta es200con"result": "ignored_stale". Así puede reenviar colas atrasadas con seguridad. - Pedidos: use el
iddel pedido (ord_…) como clave única en su sistema. El mismo pedido puede llegar más de una vez (webhook, feed, reintento) y nunca debe generar dos ventas.
Integración y comercio
Confirma el vínculo y devuelve la integración, sus permisos, su configuración y el comercio.
{
"data": {
"integration": {
"id": "int_01JA…",
"system": "custom",
"status": "active",
"scopes": ["store:read", "catalog:read", "catalog:write", "orders:read", "orders:write", "webhooks:manage"],
"settings": {
"catalog_mode": "erp_managed",
"order_status_authority": "both",
"missing_items_policy": "keep",
"ack_alert_minutes": 5,
"ack_expected": "on_receipt"
},
"environment": "live",
"credential": { "key_id": "mpk_live_7Hq2…", "expires_at": null }
},
"store": { "id": "str_01JA…", "slug": "lomiteria-nanduti", "name": "Lomitería Ñandutí" },
"api_version": "1.0"
}
}Datos operativos del comercio: si está recibiendo pedidos, modalidades de entrega y formas de pago.
{
"data": {
"id": "str_01JA…",
"slug": "lomiteria-nanduti",
"name": "Lomitería Ñandutí",
"timezone": "America/Asuncion",
"currency": "PYG",
"accepting_orders": true,
"fulfillment_types": ["delivery", "pickup"],
"payment_methods": [
{ "code": "cash", "name": "Efectivo" },
{ "code": "bank_transfer", "name": "Transferencia bancaria" },
{ "code": "card_on_delivery", "name": "Tarjeta (POS al entregar)" }
]
}
}Los code de pago son estables: relaciónelos con las formas de pago de su sistema. Códigos base: cash, bank_transfer, card_on_delivery, qr, other. Un comercio puede tener códigos propios con el prefijo custom_.
Abre o pausa la recepción de pedidos. Por ejemplo, su sistema puede pausar al cerrar la caja. Acepta sólo accepting_orders. Si el comercio no cumple los requisitos para abrir (por ejemplo, sin productos), responde 422 con el detalle.
{ "accepting_orders": false }Catálogo
Las escrituras de catálogo exigen que la integración esté en modo erp_managed. En ese modo su sistema es el dueño de estos campos, que quedan bloqueados en el panel:
- Categoría: nombre, posición y activa.
- Producto: nombre, descripción, SKU, precio, categoría, activo, disponible y adicionales.
El comercio sigue pudiendo cambiar las fotos en el panel y crear productos propios (por ejemplo, una promoción): esos productos tienen external_id: null y aparecen así en los pedidos. Si la integración se desconecta, los productos quedan en el menú y pasan a editarse manualmente.
Categorías
Crea o actualiza una categoría. Responde 201 si la creó y 200 en los demás casos.
| Campo | Tipo | Descripción |
|---|---|---|
name obligatorio | texto ≤ 120 | Nombre visible en el menú. |
position | entero 0–100000 | Orden en el menú (menor primero). |
active | booleano | Predeterminado true. |
source_updated_at | fecha ISO 8601 | Fecha del cambio en su sistema (protección contra envíos fuera de orden). |
{
"name": "Lomitos",
"position": 1,
"active": true,
"source_updated_at": "2026-10-12T20:00:00-03:00"
}{
"data": {
"id": "cat_01JA…",
"external_id": "G-12",
"result": "created"
}
}result puede ser created, updated, unchanged o ignored_stale.
Desactiva la categoría (no se borra). Si tiene productos activos, responde 409 category_not_empty: desactívelos antes.
Lista las categorías del comercio, con external_id cuando están vinculadas a su sistema. Filtro opcional: ?updated_since=.
Productos
Crea o actualiza un producto con reemplazo completo de los campos gestionados. Si envía option_groups, la lista reemplaza a los adicionales actuales: lo que no venga se desactiva. Si omite option_groups, los adicionales actuales no se tocan.
| Campo | Tipo | Descripción |
|---|---|---|
name obligatorio | texto ≤ 150 | Nombre del producto. |
category_external_id obligatorio | texto | Código de una categoría ya enviada. Si no existe → 422. |
price obligatorio | monto | Precio unitario, p. ej. "35000". |
description | texto ≤ 1000 | Descripción visible en el menú. |
sku | texto ≤ 64 | Código de barras o interno. Informativo; no reemplaza al external_id. |
active | booleano | false: el producto sale del menú. Predet. true. |
available | booleano | false: aparece como «Agotado» y no se puede pedir. Predet. true. |
position | entero 0–100000 | Orden dentro de la categoría. Si se omite, el comercio decide el orden. |
image_url | URL http(s) ≤ 500 | Foto del producto. Se descarga en segundo plano (JPEG, PNG o WebP, hasta 5 MB). Una imagen inválida no hace fallar al producto. Omitirla no borra la foto actual. |
image_mode | replace | if_empty | replace (predet.) reemplaza la foto actual; if_empty sólo la usa si el producto no tiene foto. Si el comercio cambia la foto en el panel, prevalece su decisión. |
option_groups | lista ≤ 20 | Grupos de adicionales (ver abajo). |
source_updated_at | fecha ISO 8601 | Protección contra envíos fuera de orden. |
Grupo de adicionales: external_id y name (obligatorios), min_select (0–50, predet. 0), max_select (1–50, predet. 1) y options (hasta 50). Opción: external_id y name (obligatorios), price (predet. "0") y available. Los códigos de grupos y opciones deben ser únicos y no pueden pertenecer a otro producto.
{
"name": "Lomito árabe",
"description": "Pan árabe, carne, lechuga, tomate y salsa de ajo.",
"sku": "7840000000123",
"category_external_id": "G-12",
"price": "35000",
"active": true,
"available": true,
"position": 1,
"image_url": "https://erp.ejemplo.com.py/img/lomito.jpg",
"image_mode": "if_empty",
"option_groups": [
{
"external_id": "ADIC-PAPAS",
"name": "Acompañamiento",
"min_select": 1,
"max_select": 1,
"options": [
{ "external_id": "AC-PAPAS", "name": "Papas fritas", "price": "0" },
{ "external_id": "AC-MANDI", "name": "Mandioca frita", "price": "3000" }
]
},
{
"external_id": "ADIC-EXTRA",
"name": "Extras",
"min_select": 0,
"max_select": 3,
"options": [
{ "external_id": "EX-HUEVO", "name": "Huevo", "price": "4000", "available": true },
{ "external_id": "EX-QUESO", "name": "Queso", "price": "5000", "available": true }
]
}
],
"source_updated_at": "2026-10-12T20:05:00-03:00"
}Cambio rápido de precio y disponibilidad, sin reenviar el producto. Acepta price, available, active y source_updated_at (al menos uno de los tres primeros).
{ "price": "37000", "available": false, "source_updated_at": "2026-10-12T21:00:00-03:00" }Cambia price y/o available de un adicional.
{ "available": false }Desactiva el producto (no se borra). Los pedidos anteriores no cambian: guardan una copia del nombre y del precio.
Lista paginada de productos con sus adicionales. Filtros: category_external_id, active (true/false), updated_since, cursor y limit. Los productos creados en el panel vienen con external_id: null.
Lote y sincronización completa
Hasta 500 operaciones por llamada, procesadas en el orden enviado. Cada operación tiene su propio resultado: si una falla, las demás se aplican igual.
Operaciones: upsert_category, deactivate_category, upsert_product, patch_product, deactivate_product, patch_option. El campo data lleva el mismo cuerpo que el endpoint individual.
{
"operations": [
{ "op": "upsert_category", "external_id": "G-12",
"data": { "name": "Lomitos", "position": 1 } },
{ "op": "upsert_product", "external_id": "P-55",
"data": { "name": "Lomito árabe",
"category_external_id": "G-12",
"price": "35000" } },
{ "op": "patch_product", "external_id": "P-56",
"data": { "available": false } },
{ "op": "deactivate_product", "external_id": "P-90" }
]
}{
"data": {
"summary": { "total": 4, "succeeded": 3, "failed": 1 },
"results": [
{ "index": 0, "external_id": "G-12", "result": "unchanged", "id": "cat_01JA…" },
{ "index": 1, "external_id": "P-55", "result": "created", "id": "prd_01JA…" },
{ "index": 2, "external_id": "P-56", "result": "updated", "id": "prd_01JB…" },
{ "index": 3, "external_id": "P-90", "result": "error",
"error": { "code": "not_found", "message": "Producto no encontrado." } }
],
"sync_run_id": "snc_01JA…"
}
}| Límite | Valor | Al superarlo |
|---|---|---|
| Operaciones por lote | 500 | 413 payload_too_large; no se procesa ninguna. |
| Lotes simultáneos por integración | 1 | 409 batch_in_progress con Retry-After. |
| Lotes por minuto | 10 | 429 rate_limited. |
| Adicionales por producto | 20 grupos × 50 opciones | Esa operación falla con error; el resto sigue. |
Sincronización completa (full_snapshot)
Con "full_snapshot": true, los ítems vinculados a su sistema que no vengan en el envío reciben el tratamiento de missing_items_policy: keep (no se hace nada) o deactivate. Para catálogos de más de 500 ítems, divida el envío en partes:
{ "full_snapshot": true, "snapshot_id": "sync-2026-10-12", "snapshot_part": 1, "operations": [ … ] }
{ "full_snapshot": true, "snapshot_id": "sync-2026-10-12", "snapshot_part": 2, "operations": [ … ] }
{ "full_snapshot": true, "snapshot_id": "sync-2026-10-12", "snapshot_part": 3, "snapshot_last": true, "operations": [ … ] }- Las partes deben llegar en orden (1, 2, 3…). Fuera de orden →
409 snapshot_out_of_ordercondetails.expected_part. Reenviar la misma parte es seguro. - La política se aplica sólo al llegar la última parte, y sólo si ninguna operación falló. Si hubo errores, no se desactiva nada (
"reason": "failed_operations"): corrija y envíe un snapshot nuevo con otrosnapshot_id. - Un snapshot incompleto vence a las 2 horas sin desactivar nada.
- Un ítem enviado con error de validación cuenta como presente: nunca se desactiva por un error.
Vincular productos ya existentes
Si el comercio ya tenía su menú cargado en MiPlato, vincule cada producto existente con su código en lugar de crear un duplicado: use link_to_id con el id del producto (obtenido en GET /catalog/products).
{ "op": "upsert_product", "external_id": "P-55", "link_to_id": "prd_01JA…", "data": { "name": "Lomito árabe", "category_external_id": "G-12", "price": "35000" } }Pedidos
Los pedidos nacen en MiPlato cuando el cliente confirma la compra. Su sistema recibe una copia completa, confirma la importación y, si la configuración lo permite, actualiza el estado.
El objeto Pedido
{
"id": "ord_01JA9Z…",
"number": 1042,
"store_id": "str_01JA…",
"status": "pending",
"status_reason": null,
"fulfillment_type": "delivery",
"estimated_minutes": null,
"created_at": "2026-10-12T21:40:12-03:00",
"updated_at": "2026-10-12T21:40:12-03:00",
"scheduled_for": null,
"customer": {
"name": "María González",
"phone": "+595981123456",
"billing": { "document_type": "ruc", "document": "80012345-6", "business_name": "María González" }
},
"delivery_address": {
"street": "Av. Mariscal López",
"number": "1234",
"neighborhood": "Villa Morra",
"city": "Asunción",
"reference": "Portón verde, frente a la farmacia",
"latitude": null,
"longitude": null
},
"payment": { "method_code": "cash", "method_name": "Efectivo", "change_for": "100000", "status": "unpaid" },
"items": [
{
"line": 1,
"product": { "id": "prd_01JA…", "external_id": "P-55", "sku": "7840000000123" },
"name": "Lomito árabe",
"unit_price": "35000",
"quantity": 2,
"options": [
{
"group_name": "Extras",
"group_external_id": "ADIC-EXTRA",
"option": { "id": "opt_01JA…", "external_id": "EX-HUEVO" },
"name": "Huevo",
"unit_price": "4000",
"quantity": 1
}
],
"notes": "Sin cebolla",
"line_total": "78000"
}
],
"notes": "Tocar el timbre",
"totals": { "subtotal": "78000", "delivery_fee": "10000", "discount": "0", "total": "88000", "currency": "PYG" },
"integration": {
"external_id": null,
"external_number": null,
"ack_status": "pending",
"acknowledged_at": null,
"last_import_error": null
},
"status_history": [
{ "status": "pending", "at": "2026-10-12T21:40:12-03:00", "by": "customer", "reason": null }
]
}numberes correlativo por comercio, para pantallas e impresión. La identificación técnica es siempreid.- Nombres y precios de
itemsson una copia del momento de la compra: los cambios posteriores del catálogo no afectan al pedido. option.quantityes la cantidad por unidad del ítem.line_total = (unit_price + Σ(option.unit_price × option.quantity)) × quantity;total = subtotal + delivery_fee − discount. Todos los montos los calcula MiPlato.fulfillment_type:deliveryopickup(retiro en el local, condelivery_address: null).customer.billing(opcional) trae los datos para la factura:document_typerucoci.payment.change_for: monto con el que el cliente paga en efectivo, para preparar el vuelto.payment.status:unpaidopaid.product.external_idesnullen productos creados en el panel: su sistema debe resolverlos (por ejemplo, con un producto genérico) o informar la falla.status_history[].by:customer,store_user,integrationosystem.
Lista paginada (del más antiguo al más nuevo). Filtros:
status | Uno o varios estados separados por coma: ?status=pending,accepted. |
ack_status | pending, acknowledged o failed. ?ack_status=pending devuelve los pedidos aún no importados. |
created_since, updated_since | Fecha ISO 8601 con desplazamiento. |
cursor, limit | Paginación (máx. 200). |
Devuelve el pedido completo.
Busca el pedido por el código de venta que su sistema informó en el ack.
Confirma que su sistema importó el pedido e informa su propio código. Responde 200 con el pedido (integration.ack_status = "acknowledged").
{ "external_id": "VENTA-88231", "external_number": "88231" }external_idobligatorio (1 a 64 caracteres, mismo formato que los códigos de catálogo);external_numberopcional (hasta 64), para mostrar en el panel.- Repetir con el mismo
external_id: éxito, sin efecto. Con otro:409 external_id_conflict. - El
ackno acepta el pedido: aceptar es un cambio de estado aparte.
Informa que su sistema no pudo importar el pedido. El comercio ve el aviso en el panel y el pedido sigue operable allí. Una vez resuelto, su sistema puede importarlo y enviar el ack normalmente.
{
"code": "unmapped_product",
"message": "El producto «Promo 2x1» no tiene código en el sistema.",
"retryable": false
}code: unmapped_product, invalid_customer, register_closed, validation_error u other. message: obligatorio, hasta 500 caracteres. Un pedido ya confirmado responde 409 already_acknowledged.
Cambia el estado del pedido (si order_status_authority es erp o both). Responde 200 con el pedido actualizado.
{
"status": "accepted",
"estimated_minutes": 30,
"occurred_at": "2026-10-12T21:42:00-03:00"
}reason: obligatorio (3 a 255 caracteres) pararejectedycancelled. El cliente lo ve en el seguimiento.estimated_minutes(1 a 600): sólo conaccepted; el cliente ve el tiempo estimado.occurred_at: momento real del cambio en su sistema (opcional).- Enviar el estado actual:
200sin efecto. Transición no permitida:409 invalid_status_transition.
Estados del pedido
| Estado | En el panel | Lo que ve el cliente |
|---|---|---|
pending | Nuevo | Recibido — esperando confirmación |
accepted | Aceptado | Confirmado |
preparing | En preparación | En preparación |
ready | Listo para entregar / retirar | Listo |
out_for_delivery | En camino (sólo delivery) | En camino |
completed | Entregado / Retirado | Entregado (final) |
rejected | Rechazado | Rechazado (final) |
cancelled | Cancelado | Cancelado (final) |
- Desde
pendingsólo se pasa aaccepted,rejectedocancelled. - Después de aceptado se puede avanzar salteando etapas (por ejemplo
accepted → completed). out_for_deliverysólo vale para pedidosdelivery.- No hay retroceso.
completed,rejectedycancelledson finales. rejectedsólo desdepending;cancelleddesde cualquier estado no final.
Cada cambio genera el evento order.status_changed, también cuando lo hizo su propio sistema: ignore los eventos cuyo data.changed_by.integration_id sea el de su integración.
Webhooks
Un webhook por integración. Puede configurarlo por la API o el comercio puede hacerlo en el panel.
Crea o actualiza el webhook. url HTTPS pública (hasta 500 caracteres) y events: lista de tipos o ["*"] (todos los que sus permisos permiten). active opcional. Al crearlo responde 201 e incluye signing_secret, que se muestra una sola vez.
{
"url": "https://erp.ejemplo.com.py/miplato/webhook",
"events": ["order.created", "order.status_changed", "order.updated"],
"active": true
}{
"data": {
"url": "https://erp.ejemplo.com.py/miplato/webhook",
"events": ["order.created", "order.status_changed", "order.updated"],
"active": true,
"paused": false,
"paused_reason": null,
"signing_secret": "whsec_…"
}
}| Endpoint | Descripción |
|---|---|
GET /v1/webhook | Configuración actual. |
POST /v1/webhook/rotate-secret | Nuevo secreto de firma. Durante 24 h MiPlato firma con el nuevo y con el anterior. |
POST /v1/webhook/test | Envía un evento webhook.test (responde 202). |
GET /v1/webhook/deliveries | Entregas recientes con sus intentos. Filtro ?status=pending|delivering|succeeded|failed. |
POST /v1/webhook/deliveries/{delivery_id}/retry | Reenvía una entrega ahora. |
Eventos
| Evento | Cuándo | Permiso |
|---|---|---|
order.created | Un cliente hizo un pedido. | orders:read |
order.status_changed | Cambió el estado (desde cualquier origen). Trae previous_status y changed_by. | orders:read |
order.updated | Cambiaron otros datos, por ejemplo el pago se marcó como paid. | orders:read |
store.updated | Cambió la recepción de pedidos, las formas de pago o las modalidades. | store:read |
integration.updated | El comercio cambió los permisos o la configuración. | — |
webhook.test | Prueba manual. | — |
{
"id": "evt_01JAA0…",
"type": "order.status_changed",
"api_version": "1.0",
"created_at": "2026-10-12T21:42:00-03:00",
"store_id": "str_01JA…",
"integration_id": "int_01JA…",
"data": {
"order": { "id": "ord_01JA9Z…", "status": "accepted", "…": "objeto Pedido completo" },
"previous_status": "pending",
"changed_by": { "type": "store_user", "integration_id": null }
}
}Los eventos de pedido traen siempre el pedido completo: no hace falta una segunda llamada. Pueden llegar desordenados: compare data.order.updated_at con lo que ya tiene y descarte la versión más antigua.
Solicitud de entrega
POST https://erp.ejemplo.com.py/miplato/webhook
Content-Type: application/json
User-Agent: MiPlato-Webhooks/1.0
X-MiPlato-Event-Id: evt_01JAA0…
X-MiPlato-Event-Type: order.status_changed
X-MiPlato-Delivery-Id: dlv_01JAA1…
X-MiPlato-Timestamp: 1760316120
X-MiPlato-Signature: v1=5d41402abc4b2a76b9719d911017c592…Responda 2xx en hasta 10 segundos; si el procesamiento es largo, guarde el evento en una cola y responda enseguida. Cualquier otra respuesta, un tiempo agotado o un error de conexión cuenta como falla y se reintenta después de 1 min, 5 min, 15 min, 1 h, 3 h, 6 h, 12 h y 24 h (9 intentos en total). Las entregas fallidas pueden reenviarse y los eventos siguen disponibles en el feed por 30 días. Si todas las entregas fallan durante 72 horas seguidas, el webhook se pausa y el comercio recibe un aviso en el panel.
Verificar la firma
firma = hex( HMAC_SHA256( signing_secret, X-MiPlato-Timestamp + "." + cuerpo_crudo ) )- Calcule la firma sobre el cuerpo crudo, antes de decodificar el JSON.
- Compare en tiempo constante. Durante una rotación el encabezado trae dos firmas (
v1=a,v1=b): alcanza con que coincida una. - Rechace si la diferencia entre la hora actual y
X-MiPlato-Timestampsupera 300 segundos. - Descarte duplicados por
X-MiPlato-Event-Id.
<?php
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_MIPLATO_TIMESTAMP'] ?? '';
$header = $_SERVER['HTTP_X_MIPLATO_SIGNATURE'] ?? '';
$secret = getenv('MIPLATO_WEBHOOK_SECRET');
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
http_response_code(400);
exit;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret);
$valid = false;
foreach (explode(',', $header) as $part) {
if (str_starts_with($part, 'v1=') && hash_equals($expected, substr($part, 3))) {
$valid = true;
break;
}
}
if (!$valid) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
// 1) Si $event['id'] ya fue procesado, responda 200 y termine.
// 2) Guarde el evento en una cola local y responda 200.
http_response_code(200);import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post('/miplato/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const ts = req.get('X-MiPlato-Timestamp') || '';
const header = req.get('X-MiPlato-Signature') || '';
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);
const expected = crypto.createHmac('sha256', process.env.MIPLATO_WEBHOOK_SECRET)
.update(ts + '.' + req.body.toString('utf8')).digest('hex');
const valid = header.split(',').some((p) => p.startsWith('v1=') &&
p.length === expected.length + 3 &&
crypto.timingSafeEqual(Buffer.from(p.slice(3)), Buffer.from(expected)));
if (!valid) return res.sendStatus(401);
const event = JSON.parse(req.body.toString('utf8'));
// Deduplicar por event.id, encolar y responder enseguida.
res.sendStatus(200);
});import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
@app.post('/miplato/webhook')
def miplato_webhook():
ts = request.headers.get('X-MiPlato-Timestamp', '')
header = request.headers.get('X-MiPlato-Signature', '')
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
abort(400)
body = request.get_data() # cuerpo crudo
expected = hmac.new(os.environ['MIPLATO_WEBHOOK_SECRET'].encode(),
ts.encode() + b'.' + body, hashlib.sha256).hexdigest()
if not any(p.startswith('v1=') and hmac.compare_digest(p[3:], expected)
for p in header.split(',')):
abort(401)
event = request.get_json()
# Deduplicar por event['id'], encolar y responder enseguida.
return '', 200Feed de eventos
Los mismos eventos de los webhooks, en orden estricto de creación. Es la opción para sistemas sin dirección pública y la conciliación recomendada para todos.
after | El último next_cursor procesado. Sin after, empieza por el evento más antiguo disponible. |
limit | 1 a 200 (predet. 100). |
types | Filtro opcional: ?types=order.created,order.status_changed. |
{
"data": [ { "id": "evt_01JAA0…", "type": "order.created", "…": "…" } ],
"next_cursor": "c_MTA0Mg",
"has_more": false,
"cursor_expired": false
}- Guarde el
next_cursordespués de procesar los eventos y continúe desde allí. Sihas_moreestrue, consulte de nuevo enseguida. - Los eventos se conservan 30 días. Si su cursor apunta a un evento ya eliminado, la respuesta empieza en el más antiguo disponible con
"cursor_expired": true: concilie conGET /v1/orders?updated_since=…. Los pedidos nunca se eliminan. - Frecuencia sugerida: cada 15 a 30 segundos sin webhook; cada 5 minutos como conciliación con webhook.
<?php
// Consulta periódica del feed (por ejemplo, desde una tarea programada).
$cursor = load_cursor(); // último next_cursor guardado, o null
do {
$url = 'https://sandbox.miplato.com.py/api/v1/events?limit=100' . ($cursor ? '&after=' . urlencode($cursor) : '');
$resp = miplato_get($url); // GET con Authorization: Bearer …
foreach ($resp['data'] as $event) {
process_once($event); // idempotente por $event['id']
}
$cursor = $resp['next_cursor'] ?? $cursor;
save_cursor($cursor);
} while ($resp['has_more']);Lista de verificación
Lo que su sistema debe cumplir para operar en producción. Cada ítem indica cómo se comprueba:
- Verificado el reporte del ambiente de homologación lo comprueba con sus llamadas reales; los obligatorios son condición para el certificado.
- Validado por la API la API rechaza la solicitud si no se cumple: no hay forma de saltearlo.
- Declaración no se puede comprobar desde afuera: su empresa lo declara al emitir el certificado.
- VerificadoSe conecta con
GET /v1/meusando la credencial del comercio. - DeclaraciónAvisa al usuario cuando
credential.expires_atno esnull(credencial por vencer). - DeclaraciónGuarda la credencial cifrada, una por comercio, y nunca la registra en archivos de log ni la muestra completa.
- Validado por la APIEnvía montos como texto (
"35000") y fechas con desplazamiento. - Validado por la APIEnvía las categorías antes que los productos (un producto con categoría desconocida se rechaza).
- VerificadoRecomendadoIncluye
source_updated_aten categorías y productos. - Validado por la APIUsa
Idempotency-Keyenack,status,import-failurey lotes. - VerificadoRecomendadoReutiliza la misma
Idempotency-Keyal reintentar. - VerificadoRecomendadoReintenta sólo ante
429/5xx/fallas de red, sin repetir errores4xx. - VerificadoRecibe los pedidos por webhook (respuesta 2xx) o por el feed de eventos.
- VerificadoVerifica la firma de los webhooks: rechaza firmas inválidas y firmas de más de 300 s.
- VerificadoResponde el webhook en menos de 10 segundos.
- VerificadoUsa el
iddel pedido como clave única: el mismo pedido recibido dos veces no genera dos ventas. - VerificadoConcilia con
GET /v1/orders?ack_status=pendingo con el feed. - VerificadoEnvía el
ackal registrar la venta, oimport-failuresi no pudo. - VerificadoResuelve los pedidos con productos
external_id: null(creados en el panel). - VerificadoLleva un pedido de «aceptado» a «entregado» con
POST /status(si la configuración le permite cambiar estados). - VerificadoRecomendadoCancela o rechaza informando
reason. - VerificadoRecomendadoIgnora los eventos
order.status_changedoriginados por su propia integración. - DeclaraciónIgnora los campos desconocidos de las respuestas y de los eventos.
Soporte
Para desarrollar y homologar su integración, solicite acceso al ambiente de homologación. Al reportar un problema, indique el X-Request-Id de la respuesta, la fecha y hora, y el endpoint utilizado. Nunca envíe la credencial ni el secreto del webhook por chat o correo.
Versiones
La versión va en la ruta (/v1). Dentro de la v1 sólo se agregan cambios compatibles: nuevos campos, endpoints o eventos. Un cambio incompatible tendría una ruta nueva (/v2) y un período de convivencia anunciado con anticipación.
| Versión | Cambios |
|---|---|
| 1.0 | Catálogo (individual y por lote, con sincronización completa), pedidos (consulta, confirmación de importación, fallas y estados), webhooks firmados y feed de eventos. |