API de Miralotu

Conecta tu sistema (un cotizador, un ERP, un CRM) con Miralotu: crea proyectos y propuestas, publica avances y recibe avisos cuando el cliente acepta, contesta o paga. Miralotu se encarga de la comunicación con el cliente: propuesta web, firma con Face ID o huella, portal con avances, horas y pagos.

URL base: https://miralotu.com/api/v1

Autenticación

Crea una llave en Integraciones (menú del panel, solo el dueño de la cuenta) y envíala en cada petición. Cada llave pertenece a una empresa: solo ve y modifica sus proyectos, con los permisos que elegiste.

curl https://miralotu.com/api/v1/projects \
  -H "Authorization: Bearer mlt_…"
projects:readLeer proyectos, etapas, avances, pagos, solicitudes y propuestas
projects:writeCrear y editar proyectos, etapas, conceptos e hitos
updates:writePublicar avances
requests:writeCrear solicitudes al cliente
payments:writeAgregar pagos al calendario
invitations:writeInvitar personas del cliente
proposals:writeEnviar propuestas
settings:writeSincronizar los datos de la empresa (nombre, datos fiscales, contacto y logo)
workspaces:writeDar de alta empresas integradas (solo plan Integrador)

Convenciones

  • Cuerpo y respuestas en JSON. Los errores devuelven { "error": "…" } con 400, 401, 403, 404, 409, 422 o 429.
  • Montos: los que envías van en pesos (p. ej. 85000); los que recibes, en centavos y con el sufijo Cents.
  • Precios unitarios y tarifas: sin IVA. El IVA y las retenciones se calculan con taxRate, applyRetentions, isrRetentionRate e ivaRetentionRate.
  • Fechas sin hora: AAAA-MM-DD. Fechas con hora: ISO 8601 (UTC).
  • Un proyecto se identifica por su slug o por tu referencia: /projects/ext:CP-2026-0001.
  • Límites: 120 peticiones por minuto por llave y, en la suscripción normal (un negocio), 2,000 llamadas al mes por cuenta. Para conectar a varios negocios existe el plan Integrador (sin límite mensual; cada negocio es una empresa integrada).

Proyectos

GET/projectsprojects:read

Listar proyectos

?externalRef= Filtra por la referencia del sistema externo (p. ej. el folio de tu cotización).

Respuesta
{
  "projects": [
    {
      "id": "3a87e7ad-95ba-465d-a820-2bebdd02f0fc",
      "slug": "aceros-del-norte-k3m9x",
      "externalRef": "CP-2026-0001",
      "name": "Nave industrial",
      "clientName": "Aceros del Norte",
      "status": "draft",
      "quoteType": "items",
      "paymentDays": 5,
      "currency": "MXN",
      "totalCents": 14036000,
      "portalUrl": "https://miralotu.com/p/aceros-del-norte-k3m9x",
      "progress": 0,
      "hoursUsed": 0,
      "paidCents": 0
    }
  ]
}
POST/projectsprojects:write

Crear proyecto

Por conceptos (`quoteType: "items"`) con `items` y `milestones`, o por horas (`quoteType: "hours"`) con `hoursPerMonth`, `months` y `hourlyRate`. `externalRef` es tu identificador (único por empresa): con él puedes consultar el proyecto como `/projects/ext:{externalRef}`. `managedBy` (p. ej. `"Picoi"`) indica que tu sistema administra avances, etapas y cobros: en el panel de Miralotu se ven pero no se editan.

Cuerpo
{
  "externalRef": "CP-2026-0001",
  "managedBy": "Picoi",
  "name": "Nave industrial",
  "clientName": "Aceros del Norte",
  "description": "Instalación eléctrica de la nave 2.",
  "quoteType": "items",
  "taxRate": 16,
  "paymentDays": 5,
  "startDate": "2026-10-05",
  "clientContactName": "Jorge Ramírez",
  "clientEmail": "compras@aceros.mx",
  "items": [
    {
      "description": "Instalación eléctrica tablero principal",
      "quantity": 1,
      "unit": "lote",
      "unitPrice": 85000
    },
    {
      "description": "Cableado y canalización",
      "quantity": 300,
      "unit": "m",
      "unitPrice": 120
    }
  ],
  "discounts": [
    {
      "label": "Descuento cliente fundador",
      "type": "fixed",
      "value": 5000
    }
  ],
  "milestones": [
    {
      "label": "Anticipo",
      "trigger": "acceptance",
      "amountType": "percent",
      "percent": 50
    },
    {
      "label": "Al 50 % de avance",
      "trigger": "progress",
      "triggerProgress": 50,
      "amountType": "percent",
      "percent": 30
    },
    {
      "label": "Contra entrega",
      "trigger": "delivery",
      "amountType": "percent",
      "percent": 20
    }
  ],
  "stages": [
    {
      "name": "Ingeniería",
      "description": "Planos y memoria de cálculo",
      "startDate": "2026-10-05",
      "endDate": "2026-10-20"
    }
  ]
}
Respuesta
{
  "project": {
    "id": "3a87e7ad-95ba-465d-a820-2bebdd02f0fc",
    "slug": "aceros-del-norte-k3m9x",
    "externalRef": "CP-2026-0001",
    "name": "Nave industrial",
    "clientName": "Aceros del Norte",
    "status": "draft",
    "quoteType": "items",
    "paymentDays": 5,
    "currency": "MXN",
    "totalCents": 14036000,
    "portalUrl": "https://miralotu.com/p/aceros-del-norte-k3m9x"
  }
}
GET/projects/{id}projects:read

Detalle del proyecto

`{id}` es el slug o `ext:{externalRef}`. Incluye etapas, conceptos, hitos (con su monto y si ya se cumplió), pagos, avances, propuestas y accesos del cliente.

Respuesta
{
  "project": {
    "id": "3a87e7ad-95ba-465d-a820-2bebdd02f0fc",
    "slug": "aceros-del-norte-k3m9x",
    "externalRef": "CP-2026-0001",
    "name": "Nave industrial",
    "clientName": "Aceros del Norte",
    "status": "draft",
    "quoteType": "items",
    "paymentDays": 5,
    "currency": "MXN",
    "totalCents": 14036000,
    "portalUrl": "https://miralotu.com/p/aceros-del-norte-k3m9x"
  },
  "stats": {
    "progress": 40,
    "hoursUsed": 12,
    "hoursRemaining": 0,
    "paidCents": 7018000,
    "totalCents": 14036000
  },
  "stages": [
    {
      "position": 1,
      "name": "Ingeniería",
      "status": "done",
      "progress": 100
    }
  ],
  "items": [
    {
      "position": 1,
      "description": "Instalación eléctrica tablero principal",
      "unit": "lote",
      "quantity": 1,
      "unitPriceCents": 8500000
    },
    {
      "position": 2,
      "description": "Tornillo 1/4",
      "unit": "pza",
      "quantity": 500,
      "unitPriceCents": 12.34
    }
  ],
  "milestones": [
    {
      "position": 1,
      "label": "Anticipo",
      "trigger": "acceptance",
      "condition": "Al aceptar la propuesta",
      "amountType": "percent",
      "percent": 50,
      "amountCents": 7018000,
      "reachedAt": "2026-10-02"
    }
  ],
  "payments": [
    {
      "id": "…",
      "concept": "Anticipo",
      "amountCents": 7018000,
      "dueDate": "2026-10-07",
      "condition": null,
      "automatic": true,
      "paidAt": null
    }
  ],
  "updates": [],
  "proposals": [
    {
      "folio": "P-2026-0001",
      "status": "accepted",
      "totalCents": 14036000,
      "acceptedName": "Jorge Ramírez"
    }
  ],
  "viewers": [],
  "pendingInvitations": []
}
PATCH/projects/{id}projects:write

Editar proyecto

Solo cambia los campos enviados. Si envías `items` o `milestones`, reemplazan los actuales. Para marcarlo entregado: `{ "status": "completed" }` (cumple los hitos «contra entrega»).

Cuerpo
{
  "status": "active",
  "items": [
    {
      "description": "Tablero principal",
      "quantity": 1,
      "unitPrice": 90000
    }
  ]
}
Respuesta
{
  "ok": true
}
PUT/projects/{id}/stagesprojects:write

Reemplazar etapas

Estimación de cada etapa: `estimate` + `unit` (`hours` · `days` · `weeks` · `months`), o `hoursEstimated` (horas). En cotización por horas se convierte a horas con las equivalencias de la empresa (8 h/día, 40 h/semana, 160 h/mes por defecto); por conceptos es solo la duración.

Cuerpo
{
  "stages": [
    {
      "name": "Ingeniería",
      "estimate": 2,
      "unit": "weeks",
      "startDate": "2026-10-05",
      "endDate": "2026-10-20",
      "status": "in_progress",
      "progress": 30
    }
  ]
}
Respuesta
{
  "ok": true,
  "count": 1
}
PATCH/projects/{id}/stages/{position}projects:write

Actualizar una etapa

Por su número (1, 2, 3…). El avance de las etapas cumple los hitos «al llegar a % de avance».

Cuerpo
{
  "status": "done"
}
Respuesta
{
  "ok": true,
  "stage": {
    "position": 1,
    "status": "done",
    "progress": 100
  }
}

Propuestas

POST/projects/{id}/proposalsproposals:write

Enviar propuesta

Genera la propuesta con folio y la manda por correo al contacto del cliente con un link personal. El cliente la acepta (nombre, correo verificado y firma con Face ID o huella) o la rechaza con motivos; te avisamos por webhook. Las anteriores sin responder quedan `superseded`. `cc` (opcional, hasta 10): correos que reciben su propio link solo para ver la propuesta; no la aceptan, rechazan ni descargan. `folio` (opcional): el folio de tu sistema, para que coincida en los dos lados; si no lo mandas, se asigna P-AAAA-NNNN. La propuesta sale con el logo y los datos de la empresa de la llave (Configuración o `PUT /settings`).

Cuerpo
{
  "to": "compras@aceros.mx",
  "cc": [
    "direccion@aceros.mx"
  ],
  "message": "Te comparto la propuesta que platicamos.",
  "folio": "PRY-2026-0002"
}
Respuesta
{
  "proposal": {
    "id": "…",
    "folio": "P-2026-0001",
    "status": "sent",
    "totalCents": 14036000
  },
  "url": "https://miralotu.com/propuesta/…",
  "emailed": true
}
GET/projects/{id}/proposalsprojects:read

Estado de las propuestas

`status`: sent · viewed · accepted · rejected · superseded.

Respuesta
{
  "proposals": [
    {
      "folio": "P-2026-0001",
      "status": "viewed",
      "viewedAt": "2026-10-02T21:53:00.000Z"
    }
  ]
}

Empresa

GET/settingsprojects:read

Datos de la empresa

Nombre, datos fiscales, contacto y logo con los que salen la propuesta, el portal y los correos.

Respuesta
{
  "settings": {
    "tradeName": "Aceros del Norte",
    "legalName": "Aceros del Norte SA de CV",
    "taxId": "ANO010101AB1",
    "logoUrl": "https://miralotu.com/api/brand/logo?w=…"
  }
}
PUT/settingssettings:write

Sincronizar datos de la empresa

Para que la propuesta, el portal y los correos salgan con los datos de tu sistema (p. ej. cada tenant). Solo se cambian los campos enviados. `logo`: `{ mime: image/png | image/jpeg | image/webp, data: base64 }` (máx. 5 MB; si es el mismo que la vez pasada no se vuelve a procesar) o `null` para quitarlo. No funciona con llaves de la empresa principal.

Cuerpo
{
  "tradeName": "Aceros del Norte",
  "legalName": "Aceros del Norte SA de CV",
  "taxId": "ANO010101AB1",
  "taxRegime": "601 - General de Ley Personas Morales",
  "taxZip": "64000",
  "address": "Av. Constitución 100, Monterrey, N.L.",
  "phone": "8112345678",
  "email": "ventas@aceros.mx",
  "logo": {
    "mime": "image/png",
    "data": "iVBORw0KGgo…"
  }
}
Respuesta
{
  "ok": true,
  "logoUpdated": true,
  "settings": {
    "tradeName": "Aceros del Norte",
    "logoUrl": "https://miralotu.com/api/brand/logo?w=…"
  }
}

Avances y solicitudes

POST/projects/{id}/updatesupdates:write

Publicar avance

El cliente lo ve al momento en su portal y recibe un aviso por correo y notificación (sin el detalle). `files`: hasta 10 adjuntos `{ name, mime, base64 }` — imágenes (10 MB, se ven dentro del avance), PDF, Word, Excel, PowerPoint, CSV, ZIP, DWG/DXF o video (25 MB); el tipo se toma de la extensión de `name`. (`images` sigue funcionando.) Con `externalRef` (el id del avance en tu sistema) es idempotente: si ya existe se actualiza y responde 200 con `created: false`; al actualizar no se manda correo ni se crean solicitudes, y las imágenes solo se reemplazan si mandas nuevas.

Cuerpo
{
  "externalRef": "picoi-update-019f…",
  "date": "2026-10-03",
  "hours": 6,
  "stage": 1,
  "stageProgress": 60,
  "title": "Levantamiento en sitio",
  "summary": "Se midió la nave y se ubicaron los puntos de carga.",
  "items": [
    {
      "text": "Levantamiento de cargas",
      "done": true
    }
  ],
  "next": [
    "Diagrama unifilar"
  ],
  "requests": [
    {
      "question": "¿Aprueban la ubicación del tablero?",
      "type": "approval"
    }
  ]
}
Respuesta
{
  "ok": true,
  "id": "…",
  "created": true
}
DELETE/projects/{id}/updates/{updateId}updates:write

Borrar avance

`{updateId}`: id del avance o `ext:{externalRef}`. Las solicitudes y lo que contestó el cliente se conservan.

Respuesta
{
  "ok": true
}
GET/projects/{id}/requestsprojects:read

Solicitudes y respuestas

Respuesta
{
  "requests": [
    {
      "id": "…",
      "question": "¿Aprueban la ubicación del tablero?",
      "type": "approval",
      "status": "answered"
    }
  ]
}
POST/projects/{id}/requestsrequests:write

Crear solicitud

`type`: choice · multiple · text · file · approval. `update` (id o `ext:{externalRef}` de un avance) la liga a ese avance. Con `externalRef` es idempotente: si ya existe y el cliente no ha contestado, se actualiza (200). Al crearse se avisa al cliente por correo y notificación (`notify: false` para no avisar). La respuesta del cliente llega por el webhook `request.answered` con tu `externalRef`.

Cuerpo
{
  "externalRef": "picoi-req-0042",
  "update": "ext:picoi-update-019f…",
  "question": "Envíanos el plano arquitectónico",
  "type": "file",
  "description": "PDF o DWG"
}
Respuesta
{
  "ok": true,
  "id": "…",
  "created": true
}
PATCH/projects/{id}/requests/{requestId}requests:write

Cerrar o reabrir solicitud

`{requestId}`: id o `ext:{externalRef}`. Cerrada, el cliente ya no puede cambiar su respuesta.

Cuerpo
{
  "status": "closed"
}
Respuesta
{
  "ok": true
}

Pagos y accesos

POST/projects/{id}/paymentspayments:write

Agregar pago al calendario

Pagos aparte de los automáticos (por periodo o por hito). Monto en pesos. Con `externalRef` es idempotente (si ya existe y no está pagado, se actualiza).

Cuerpo
{
  "externalRef": "picoi-cxc-0012",
  "concept": "Extra: tablero adicional",
  "amount": 18500,
  "dueDate": "2026-11-15"
}
Respuesta
{
  "ok": true,
  "id": "…"
}
POST/projects/{id}/payments/{paymentId}/recordpayments:write

Registrar pago recibido

`{paymentId}`: id del pago, `ext:{externalRef}`, `milestone:{n}` (pago del hito n) o `period:{n}` (pago del mes o etapa n). `amount` en pesos solo si difiere del monto. Volver a mandarlo actualiza el registro. El cliente ve el pago como «Pagado» y se dispara el webhook `payment.recorded`. Para deshacerlo: `DELETE` a la misma ruta.

Cuerpo
{
  "paidAt": "2026-10-06",
  "amount": 107276.8,
  "reference": "SPEI 0912"
}
Respuesta
{
  "ok": true,
  "id": "…",
  "paidAt": "2026-10-06",
  "paidAmountCents": 10727680
}
POST/projects/{id}/invitationsinvitations:write

Invitar al portal

Manda el link personal (de un solo uso) para que la persona active su acceso con Face ID o huella.

Cuerpo
{
  "name": "Ana López",
  "email": "ana@aceros.mx",
  "role": "Administración"
}
Respuesta
{
  "ok": true,
  "url": "https://miralotu.com/p/…?t=…",
  "emailed": true
}

Plataforma

POST/workspacesllave del servidor

Dar de alta una empresa

Con la llave del servidor, o con la de una cuenta con **plan Integrador** (permiso `workspaces:write`). Crea la empresa, invita a su responsable por correo y devuelve una llave de API para esa empresa (solo se muestra aquí). Un integrador da de alta así a cada negocio que conecta: cada empresa integrada tiene su marca, no tiene límite de API y se cobra al integrador por mes desde su alta. `freeMonths` (opcional): meses gratis de promoción.

Cuerpo
{
  "name": "Opsiris",
  "ownerName": "Yamili",
  "ownerEmail": "admin@opsiris.mx",
  "externalRef": "picoi-tenant-123",
  "freeMonths": 2
}
Respuesta
{
  "workspace": {
    "id": "…",
    "name": "Opsiris",
    "externalRef": "picoi-tenant-123"
  },
  "owner": {
    "email": "admin@opsiris.mx",
    "emailed": true
  },
  "apiKey": "mlt_…"
}
GET/workspacesllave del servidor

Listar empresas

Con la llave del servidor, todas; con la de un integrador, solo las empresas que integra.

Respuesta
{
  "workspaces": [
    {
      "id": "…",
      "name": "Opsiris",
      "externalRef": "picoi-tenant-123",
      "disabled": false,
      "projects": 3
    }
  ]
}

Webhooks

Registra tu URL en Integraciones y elige los eventos. Te avisamos con un POST en JSON; responde con cualquier código 2xx. Si falla, reintentamos a los 1, 5 y 30 minutos, 2 y 12 horas (6 intentos). El encabezado Miralotu-Delivery es único por envío: úsalo para no procesar dos veces el mismo aviso.

proposal.sentSe envió una propuesta al cliente
proposal.viewedEl cliente abrió la propuesta
proposal.acceptedEl cliente aceptó y firmó la propuesta
proposal.rejectedEl cliente rechazó la propuesta (con motivos)
project.authorizedEl proyecto quedó autorizado
update.publishedSe publicó un avance
request.answeredEl cliente contestó una solicitud
payment.recordedSe registró un pago recibido
viewer.registeredUna persona del cliente activó su acceso
Ejemplo
POST https://tu-sistema.com/webhooks/miralotu
Miralotu-Event: proposal.accepted
Miralotu-Delivery: 5b1f…
Miralotu-Signature: t=1790980000,v1=3c9a…

{
  "id": "5b1f…",
  "event": "proposal.accepted",
  "createdAt": "2026-10-02T22:00:00.000Z",
  "workspaceId": "…",
  "data": {
    "project": {
      "id": "…",
      "slug": "aceros-del-norte-k3m9x",
      "externalRef": "CP-2026-0001",
      "name": "Nave industrial",
      "status": "active",
      "totalCents": 14036000
    },
    "viewer": {
      "id": "…",
      "name": "Jorge Ramírez",
      "email": "compras@aceros.mx"
    },
    "proposal": {
      "folio": "P-2026-0001",
      "status": "accepted",
      "acceptedName": "Jorge Ramírez",
      "acceptedDevice": "Android · Chrome · Huella",
      "contentHash": "9f2c…"
    }
  }
}

Verifica la firma con el secreto del webhook (lo ves en Integraciones): es un HMAC-SHA256 de {t}.{cuerpo}.

import crypto from "node:crypto";

// En tu ruta del webhook, con el cuerpo SIN procesar (texto tal como llegó):
export function verifyMiralotu(rawBody, signatureHeader, secret) {
  const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // más de 5 min: se rechaza
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ""));
}

Flujo de una cotización

  1. POST /projects con quoteType: "items", tus conceptos, hitos de pago y externalRef = tu folio.
  2. POST /projects/ext:{folio}/proposals: el cliente recibe el correo con su propuesta.
  3. Recibes proposal.viewed y luego proposal.accepted (con nombre, dispositivo y huella SHA-256 del contenido) o proposal.rejected (con motivos).
  4. Al aceptar, el proyecto queda autorizado y el calendario de pagos se genera solo con tus hitos. El cliente ve todo en su portal.
  5. Publica avances con POST /projects/{id}/updates y actualiza etapas: el % de avance cumple los hitos de pago. Al terminar, PATCH { "status": "completed" }.