Ritual ERP Ritual ERP

Ritual ERP · Plataforma

Manual para desarrolladores

Cómo conectar apps y webs a Ritual ERP y cómo construir módulos dentro del sistema — manteniendo el aislamiento entre clientes y la seguridad a nivel senior que el ERP ya garantiza. Todo lo de aquí está vivo en producción.

Base URL  https://ritualerp.ch  ·  JSON  ·  TLS obligatorio

01 Fundamentos

El vocabulario y los formatos que se repiten en toda la API.

Multi-tenantCada centro (tenant) es un mundo aislado. Toda petición se resuelve a un centro y nunca ve datos de otro — lo garantiza la base de datos (RLS), no un where.
DineroEnteros en la unidad menor (Rappen). Los campos terminan en Minor: grossMinor: 12500 = CHF 125.00. Sin coma flotante.
FechasISO 8601 en UTC (2026-07-25T09:30:00.000Z). Zona de negocio: Europe/Zurich.
TextoUTF-8 completo (ñ, tildes, acentos).
MonedaCHF.
ErroresCódigo HTTP + cuerpo { "error": "…" }. 400 validación · 401 sin autenticar · 403 sin permiso/scope · 404 no existe/ajeno · 409 conflicto de idempotencia · 422 regla de negocio · 429 rate-limit.
Contrato vivo

La especificación OpenAPI 3.1 se genera desde el código (no se desincroniza) y es importable en Postman / Insomnia / Swagger:

GET /v1/developer/openapi.json      # requiere una sesión ADMIN

02 Los cimientos de seguridad

No son opcionales ni "buenas prácticas": es cómo está construido el sistema. Si escribes código o consumes la API, respétalos.

Aislamiento por centro — Row-Level Security (RLS)

Cada tabla con tenantId tiene RLS forzada en Postgres. El runtime corre con un rol sin privilegios (ritual_app) y toda operación pasa por withTenant(tenantId, tx => …), que fija app.current_tenant para esa transacción. La política de la tabla hace el resto:

ALTER TABLE "Mitabla" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Mitabla" FORCE  ROW LEVEL SECURITY;
CREATE POLICY "Mitabla_tenant_isolation" ON "Mitabla"
  USING      ("tenantId" = current_setting('app.current_tenant', true))
  WITH CHECK ("tenantId" = current_setting('app.current_tenant', true));
GRANT SELECT, INSERT, UPDATE, DELETE ON "Mitabla" TO ritual_app;
Regla · no negociable

Toda tabla nueva con tenantId DEBE registrarse en TENANT_TABLES y llevar RLS + GRANT en una migración. Una tabla de centro sin RLS es una fuga de datos entre clientes. El test rls-coverage.spec falla si te la saltas.

Secretos, dinero e integridad

Cadena de auditoría

Cada hecho con consecuencias (cobros, facturas, notas de crédito, cancelaciones, nóminas, cierres, movimientos de paquetería…) se añade a una cadena append-only por centro: cada evento encadena el hash del anterior desde un génesis, y el append se serializa con un cerrojo de Postgres para que dos operaciones simultáneas no puedan bifurcarla. Alterar o borrar un registro por detrás rompe la cadena y se detecta.

GET/v1/auditleer el registro (type, area=money|sales|parcels|shop|rental|jobs|stock|data|support, from/to en ISO 8601, take, before=seq) — ADMIN/ACCOUNTANT
GET/v1/audit/verifyintegridad → { ok, count, brokenAt? }

Va del más reciente hacia atrás y se pagina con before (el seq del último leído). Los hashes no se devuelven: son el mecanismo de integridad, no información de pantalla.

Los accesos de soporte también se auditan

Cuando el operador de la plataforma entra en el panel de un centro, queda un evento SUPPORT_ACCESS en la cadena de ese centro, con quién entró y como qué usuario — visible para el propio centro en Ajustes → Registro de actividad. Si no se puede dejar constancia, no se entra: un acceso silencioso es justo lo que esto impide.

03 Las cinco credenciales

Elige la correcta según quién llama y a qué necesita acceder. No mezcles: cada una tiene un alcance distinto a propósito.

CredencialFormato / cabeceraParaAlcance
Sesión de usuario JWT
Authorization: Bearer <jwt>
El panel (personas). El rol del usuario (ADMIN, RECEPTION…). Si a la cuenta le cambian el rol o la desactivan, su sesión deja de valer (401, en unos segundos) y sus renovaciones quedan revocadas: se vuelve a entrar. Todo JWT requiere una cuenta activa existente y su versión de sesión vigente. Cambiar la contraseña revoca accesos y renovaciones anteriores; el cambio propio devuelve una sesión nueva. La invalidación local descarta consultas anteriores en curso; en otras instancias la caché caduca a los 30 segundos.
Clave de API (M2M) rk_<tenant>_<keyId>_<secret>
Authorization: Bearer rk_…
Apps y módulos externos (servidor). Scopes por módulo. Nunca toca administración.
Clave de integración trk_live_<hex>
X-Api-Key: trk_live_…
Server-to-server: seguimiento, calculadora de envíos y emitir sesiones de portal. Solo /public/tracking*, /public/paqueteria/* y /public/portal/session.
Token de portal cst_<tenant>_<id>_<secret>
Authorization: Bearer cst_…
El navegador del cliente final. Solo los datos de ESE cliente. Corto (60 min), revocable.
Conexión de IA (MCP) rma_<tenant>_<id>_<secret>
Authorization: Bearer rma_…
El ChatGPT o Claude de UNA persona, con su permiso (OAuth 2.1). Solo /mcp, con el puesto de esa persona y solo lectura. 1 h, renovable y revocable. Ver 06h.

04 Conectar una app o web (API M2M)

Para que tu backend hable con el ERP: crea una clave de API, dale scopes por módulo y llama a los recursos.

1 · Crea la clave y dale scopes

En el panel → Desarrolladores (o por API, rol ADMIN). Un scope es <modulo>:read o <modulo>:write (write incluye read), o * para todo. El método HTTP decide la acción: GET/HEAD = read; el resto = write.

GET/v1/developer/api-keyslistar
POST/v1/developer/api-keyscrear (devuelve el token 1 vez)
DEL/v1/developer/api-keys/:idrevocar

Una clave creada por una cuenta con acceso que caduca (consultor externo, desde el 29-09-2026) caduca, como tarde, el mismo día que esa cuenta: expiresAt se recorta al crearla y también si después se le acorta el plazo. Pasada esa fecha la clave responde 401 como cualquier clave caducada. Si la integración debe seguir, que la cree una cuenta propia del centro.

Desde el 07-10-2026, las claves de una persona se anulan en el momento en que esa cuenta se desactiva, deja de ser administradora o se le acaba el acceso: la clave responde 401 y el cambio queda en la auditoría del centro (USER_CHANGED con apiKeysRevoked). Una integración que deba sobrevivir a cambios de personal se crea con una cuenta de administración estable del centro. Además, pasaporte y cédula de destinatarios y remitentes se guardan cifrados: el parámetro q de /v1/destinatarios y /v1/remitentes los encuentra solo por el número completo, ya no por un trozo.

Módulos con scope: agenda, clientes, ventas, servicios, vales, avisos, chat, terapeutas, inventario, marketing, contactos, formularios, facturas, contabilidad, b2b, comercial, recepcion (paquetería), cursos, membresias, eventos, trabajos, archivos, tienda, renovaciones, flota, alquiler. Renovaciones (/v1/recurring-revenues) tiene scope propio renovaciones:read / renovaciones:write desde el 14-09-2026; antes iba con facturas:*. Membresías, Renovaciones y Alquiler de vehículos (/v1/alquiler/*) son complementos que el centro suma a su plan: sin tenerlos contratados, sus rutas no responden. Alquiler necesita además flota, clientes y facturas en el plan del centro.

Renovaciones a fondo (2026-10-03). Además de GET/POST/PATCH/DELETE /v1/recurring-revenues y GET …/due (cada periodo trae ahora netMinor con los cambios de precio aplicados, lineCount, priceChanged, autoIssue, autoSend y customerHasEmail):

GET/v1/recurring-revenues/summarycifras: mrrNetMinor, arrNetMinor, dueCount/dueNetMinor, next90NetMinor, openGrossMinor, cuántas activas, en pausa, con baja y automáticas
GET/v1/recurring-revenues/:idficha: líneas, cambios de precio, periodos saltados, avisos enviados, facturas (con lo cobrado), próximos periodos y totales
POST/v1/recurring-revenues/:id/post{ period, send? } (Idempotency-Key): emite la factura del periodo; con send: true la manda también por correo y la respuesta lleva mail: { sent, to | error }. Un periodo saltado, en pausa o tras la baja responde 400; un cliente sin código postal y población, 400 (el QR lleva sus datos)
POST/v1/recurring-revenues/post-all{ items: [{ id, period }], send? } (Idempotency-Key): cada periodo en su propia transacción; devuelve results[] con ok, number o error
POST/v1/recurring-revenues/:id/skip · DELETE …/skip/:periodmarcar un periodo como no facturable desde la renovación ({ period, reason?, invoiceNumber? }: la factura hecha a mano debe ser del mismo cliente) y deshacerlo
POST/v1/recurring-revenues/:id/pause{ from, until } (fechas incluidas; until: null = sin fin); { from: null, until: null } la reanuda
POST/v1/recurring-revenues/:id/cancel{ endDate, reason? }: baja; 400 si endDate es anterior a hoy + cancelNoticeDays. { reactivate: true } la deshace
PUT/v1/recurring-revenues/:id/lines{ lines: [{ description, qty, unitNetMinor, serviceId?, treatment? }] } (hasta 30; lista vacía = una sola línea)
POST/v1/recurring-revenues/:id/price-changes · DELETE …/price-changes/:changeId{ effectiveFrom, bps | netMinor, note? }: cambio de precio programado (porcentaje en puntos básicos sobre todas las líneas, o importe nuevo si es de una línea)

Campos nuevos de la renovación: autoIssue y autoSend (emitir y enviar solas; una clave rk_ no puede cambiarlos: 403, se encienden desde el panel), customerNotice y customerNoticeDays (aviso previo al cliente), cancelNoticeDays (preaviso), pausedFrom/pausedUntil, cancelledAt/cancelReason. La emisión automática solo emite periodos de los últimos 7 días.

Barrera dura

Una clave de API solo alcanza rutas de un módulo de negocio. Toda la administración (usuarios, ajustes, plataforma, developer, copias de seguridad, suscripción) no está mapeada a ningún módulo y devuelve 403 siempre — una clave nunca llega ahí.

Desde el 01-10-2026, tampoco con *, una clave no alcanza la configuración que vive dentro de un módulo ni lo que puede desviar dinero: /v1/payments/* (pasarelas, datáfonos y cobros al datáfono), /v1/automations*, /v1/public-config, /v1/recepciones/tracking-key y /v1/recepciones/portal-session-key*, /v1/shop-shipments/post-config*, toda la nómina (/v1/payroll*; el scope nomina ya no se ofrece), la lista de domiciliaciones de socios (/v1/socios/domiciliacion*), los lotes de pago a proveedores (/v1/payment-runs*, el fichero pain.001 manda dinero a los IBAN que lleve), el saldo cruzado de un cliente que también es proveedor (/v1/customers/:id/counterparty y /supplier) y el alta o edición de fichas con datos bancarios: empleados (POST /v1/therapists, PATCH /v1/therapists/:id), proveedores (POST /v1/suppliers, PATCH /v1/suppliers/:id) y cuentas de tesorería (POST/PATCH /v1/accounting/cash-accounts). Además, una clave no hace nada que en el panel solo haga la administración (tarifas y categorías, contratos B2B, liquidaciones, borrados en bloque, catálogos…). Responden 403 con el motivo, también por su dirección en inglés (p. ej. /v1/members/direct-debit, /v1/receptions/tracking-key). Todo eso se hace desde el panel.

Desde el 15-10-2026: la contabilidad (/v1/accounting/*, /v1/journal/*) y la configuración de la tienda (/v1/shop-pricing*, /v1/shop-storefront*, /v1/shop-zones*, /v1/shop-zone-areas*, /v1/shop-delivery-methods*) son de solo lectura para una clave: cerrar el año, la apertura, el Storno, traspasos, pagos o borrar una declaración de IVA reciben 403. El registro de auditoría (/v1/audit) no está disponible para claves. En el Diario, una clave ve las nóminas agrupadas por día («Nómina · N personas»), sin nombres ni sueldo por persona. Las acciones delicadas (ajustar puntos a mano, cambiar o dar de baja renovaciones, perdonar recargos) y anular un pedido de la tienda ya cobrado responden 403 a una clave.

Dinero, desde el 15-10-2026: DELETE /v1/vouchers/:id ya no borra el vale: lo anula (Storno de su emisión, estado CANCELLED, el código queda libre). POST /v1/vouchers/:id/redeem acepta Idempotency-Key. POST /v1/pos/sales/:id/void responde 422 si la venta es el cobro de una cita: se deshace con «Deshacer cobro» en la cita (que revierte también la comisión de la plataforma). Cerrar una devolución de la tienda (POST /v1/shop-returns/:id/close) es de dirección o gerencia (403 a recepción y a claves). El Diario no revierte a solas los canjes y cobros de vales, las liquidaciones de plataformas, la comisión de una cita de plataforma ni un anticipo aplicado.

Datos que una clave no recibe aunque tenga el scope: las notas del cliente, sus notas de bitácora y los comentarios de opinión (pueden ser datos de salud) salen null o vacíos; el IBAN y la referencia del mandato de domiciliación (sepaIban, sepaMandateRef) salen null; las respuestas de formularios (answers) salen null; las fotos del documento de identidad de un destinatario salen null; el IBAN de los proveedores sale null (y el libro de gastos solo trae su nombre); y GET /v1/therapists devuelve solo nombre, puesto, horario, servicios y fechas de contrato, sin AVS, IBAN de sueldo, sueldo ni datos personales. Los webhooks customer.created y customer.updated tampoco llevan notas ni mandato, los dispare el panel o una clave.

Desde el 02-10-2026, en Personal (/v1/absences, /v1/shifts, /v1/fichaje/*, /v1/team-activities) el motivo de una ausencia (reason, puede ser un dato de salud), la nota de la decisión (decisionNote) y las note de turnos, fichajes y actividades salen null con una clave rk_, y si la clave los manda al crear o editar, se ignoran (al editar se conserva lo que había).

2 · Autentica y llama

# Listar clientes del centro dueño de la clave
curl https://ritualerp.ch/v1/customers?limit=50&offset=0 \
  -H "Authorization: Bearer rk_<tenant>_<keyId>_<secret>"

# Crear un cliente (write). En operaciones de dinero, añade Idempotency-Key.
curl -X POST https://ritualerp.ch/v1/customers \
  -H "Authorization: Bearer rk_…" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Juan Pérez", "email": "juan@ejemplo.ch" }'

Recursos documentados en el OpenAPI: /v1/bookings, /v1/customers, /v1/services, /v1/products, /v1/invoices, /v1/sales, /v1/vouchers, /v1/jobs. Otros módulos son alcanzables con su scope (p. ej. paquetería: /v1/recepciones, /v1/bultos, /v1/tarifas… con recepcion:*).

Retroceder una cita (desde el 13-10-2026). POST /v1/bookings/:id/undo-charge con { reason } e Idempotency-Key: anula el cobro de una cita COMPLETED (la venta, la sesión del bono o, con permiso de dirección o contabilidad, la factura con nota de crédito) y la deja CONFIRMED, lista para cobrarse otra vez. Recepción solo deshace cobros del mismo día (si no, 422). POST /v1/bookings/:id/unconfirm pasa una cita CONFIRMED sin cobro a PENDING. Ninguna de las dos avisa a la clienta.

Pagada por adelantado (desde el 13-10-2026). Una cita cobrada o facturada antes de su hora sigue CONFIRMED y pasa sola a COMPLETED al terminar. GET /v1/bookings devuelve en cada cita paid (true si tiene un cobro vivo): CONFIRMED + paid es una cita pagada por adelantado. No se cancela ni se marca «no vino» con el dinero dentro (409): primero undo-charge, que también la acepta.

Citas de pareja (desde el 06-10-2026). Un servicio con staffRequired: 2 lo atienden dos profesionales a la vez. Al crearla, POST /v1/bookings exige therapistId y secondTherapistId, distintos (si falta uno: 400). La respuesta es la cita principal, con el precio. El segundo profesional ocupa su agenda con una segunda reserva que lleva partnerOfId (el id de la principal) y priceMinor: 0. Cobrar, facturar, cambiar el estado o cancelar cualquiera de las dos actúa sobre la principal y arrastra a la otra. Mover la hora se hace en cualquiera de las dos y mueve las dos. Para contar citas o visitas, filtra las que tienen partnerOfId.

Direcciones en inglés (preferidas desde el 04-10-2026)

Toda ruta con nombre en español tiene ahora su equivalente en inglés, que es la dirección preferida para integraciones nuevas. Las españolas siguen funcionando igual y sin fecha de retirada, pero quedan obsoletas (en el OpenAPI llevan deprecated). Las dos llegan al mismo manejador: mismos datos, mismos scopes, mismas barreras de plan y de rol, mismos webhooks. Se traduce cada trozo fijo de la ruta, nunca los identificadores: /v1/flota/vehiculos/:id/documentos → /v1/fleet/vehicles/:id/documents. La tabla completa, trozo a trozo, viene en el OpenAPI (x-route-aliases).

ÁreaPreferida (inglés)Obsoleta (español)Notas
Flota/v1/fleet/v1/flotavehicles, deadlines (vencimientos), drivers, damages, repairs, inspections (revisiones), refuels, fines, tires, campaigns, costs, workshops, private-use, summary; lsva y panel se quedan
Alquiler/v1/rentals/v1/alquilercontracts (…/void, /handover, /return, /deposit/refund, /charges, /drivers, /invoice), classes, seasons, vehicles/:id/listing, figures, calendar, settings
Paquetería/v1/receptions
/v1/parcels
/v1/manifests
/v1/recepciones
/v1/bultos
/v1/manifiestos
hbl se queda; en manifiestos, …/parcels y …/close
Paquetería/v1/trips
/v1/containers
/v1/senders
/v1/recipients
/v1/customs-regimes
/v1/viajes
/v1/contenedores
/v1/remitentes
/v1/destinatarios
/v1/regimenes-aduana
…/favorite, …/archive, …/convert-to-customer, …/applicable
Paquetería/v1/warehouse
/v1/rates
/v1/pickups
/v1/shipping
/v1/almacen
/v1/tarifas
/v1/recogidas
/v1/paqueteria
Escuelas/v1/courses
/v1/enrollments
/v1/sessions
/v1/cursos
/v1/inscripciones
/v1/sesiones
…/attendance, …/drop-ins, …/stats. Un curso lleva taxTreatment (null = el de su servicio) y priceIncludesVat (por defecto true); el priceMinor de una inscripción es lo que se paga, ya con el IVA
Membresías/v1/members
/v1/membership-plans
/v1/socios
/v1/membresias
los planes van en membership-plans porque /v1/memberships ya existía para otra cosa. Planes y bonos (/v1/packs, que devuelve además grossMinor, lo que se cobra) llevan taxTreatment y priceIncludesVat: caja y factura cobran la misma cifra
Eventos/v1/events
/v1/tickets
/v1/eventos
/v1/entradas
Equipo/v1/time-clock
/v1/staff
/v1/incidents
/v1/files
/v1/rooms/occupancy
/v1/fichaje
/v1/personal
/v1/incidencias
/v1/archivos
/v1/salas/ocupacion
fichaje: clock-in, clock-out, team
Perfil y otros/v1/me/vehicles
/v1/me/notices
/v1/me/time-clock/clock-in
/v1/jobs/:id/photos
/v1/import/template
/v1/me/vehiculos
/v1/me/avisos
/v1/me/fichaje/entrada
/v1/jobs/:id/fotos
/v1/import/plantilla
también /v1/me/pickups, /v1/me/time, /v1/me/notices/read y /preferences
Portal (cst_)/portal/invoices
/portal/receipts
/portal/statement
/portal/senders
/portal/recipients
/portal/addresses
/portal/renewals
/portal/school
/portal/shipments/:ref/hbl.pdf
/portal/facturas
/portal/recibos
/portal/estado
/portal/remitentes
/portal/destinatarios
/portal/direcciones
/portal/renovaciones
/portal/escuela
/portal/envios/:ref/hbl.pdf

Lo que ya estaba en inglés (/v1/customers, /v1/invoices…) no cambia. Los webhooks, los scopes (flota:read, recepcion:write…) y los nombres de campo tampoco: solo cambia la dirección.

Destinatarios de paquetería (/v1/recipients). Desde el 21-10-2026 el alta (POST) exige la ficha completa: primerNombre, primerApellido, calle, numero, provincia, municipio y cedula (No. de carné de identidad: 11 cifras si pais es CUB, que es el valor por defecto; se aceptan espacios y guiones y se guarda solo con las cifras). Si falta algo, 400 que nombra el campo. Al editar (PATCH) el carné se comprueba con el mismo formato. Con una clave rk_, cedula y pasaporte llegan siempre como null (en la lista y en la ficha): un documento de identidad no sale a una integración.

Documentos de identidad: nunca por la API pública. El carné (cédula), el pasaporte y las fotos del documento de remitentes y destinatarios no salen nunca a una clave rk_ ni en un webhook: en JSON el campo llega como null (cedula, pasaporte, recipientCedula, fotos…), y las etiquetas, el HBL, la hoja de ruta, el manifiesto y la lista de empaque que pide una clave salen sin esos números. Los datos de identidad que una web declare dentro de una lista de empaque se guardan, pero no se devuelven ni viajan en packinglist.updated.

Vales de venta directa. POST /v1/vouchers acepta paymentMethod (CASH, CARD, TWINT o TRANSFER): decide si el importe entra en la caja o en el banco (o en la cuenta que el centro asignó a esa forma de cobro en Tesorería). Sin él, banco, como hasta ahora. Un vale de plataforma no lo lleva.

Trabajos (Aufträge). Un trabajo es un encargo con principio y fin: se presupuesta, se ejecuta en varias citas y se factura al final. Scope trabajos:read / trabajos:write. Las etapas del pipeline las define cada centro en /v1/job-stages; mover un trabajo a una etapa terminal lo cierra (closedAt + outcome) y volver a una activa lo reabre.

GET/v1/jobsabiertos (?open=false = cerrados)
GET/v1/jobs/:idficha + citas, horas, material, gastos, facturas y margen
POST/v1/jobsabrir (Idempotency-Key)
POST/v1/jobs/:id/stagemover de etapa
POST/v1/jobs/:id/timeimputar horas
POST/v1/jobs/:id/materialsacar material del almacén contra el trabajo (con Idempotency-Key, una sola vez aunque se repita)
POST/v1/jobs/:id/material/returndevolver al almacén material imputado y sin usar { productId, qty, note? } (exige Idempotency-Key; vuelve con el coste con que salió; no más de lo imputado y sin facturar). Una factura con jobId que factura material ya imputado no lo saca otra vez del almacén
POST/v1/jobs/:id/expensesgasto imputado (subcontrata, permiso…)
GET/v1/jobs/:id/linkable-bookingscitas del cliente aún sin trabajo
POST/v1/jobs/:id/bookings/:bookingIdenlazar una cita como sesión
PATCH/v1/jobs/:idcambio parcial; para mudanzas admite además loadVolumeL (litros), loadWeightKg, requiredEquipment (["TAIL_LIFT", …], ver Flota), permitStatus (TO_REQUEST · REQUESTED · OBTAINED; null = no hace falta y borra días y referencia), permitFrom, permitTo (AAAA-MM-DD) y permitReference; unos días al revés dan 422
GET/v1/jobs/:id/fotosfotos del trabajo (sin el contenido) con jobPhotoKind: BEFORE · AFTER · DAMAGE
POST/v1/jobs/:id/fotos{ fileName, mime, dataBase64, kind, note? } — JPG, PNG o WebP, máx. 8 MB y 60 por trabajo; no salen entre las entregas (/v1/archivos?jobId=) ni en Archivos, y en un trabajo cerrado responden 422
GET/v1/jobs/:id/fotos/:docIdla foto, con su hash comprobado (404 si no es de ese trabajo)
DELETE/v1/jobs/:id/fotos/:docIdla borra (la única puerta: desde Archivos —suelto o en lote— y desde el cambio de nombre responden 422); en un trabajo cerrado, 422
PUT/v1/quotes/:ideditar una oferta en borrador con el mismo cuerpo que el alta (customerId obligatorio desde el 2026-10-02: toda oferta va a un cliente de la ficha y customerName ya no se acepta en su lugar, date, validUntil, notes, lines[] con serviceId opcional) · las líneas se sustituyen enteras y los totales se recalculan; número y estado no cambian · 422 si ya no está en borrador (scope comercial:write)
POST/v1/quotes/:id/acceptaceptar la oferta y abrir su trabajo · devuelve { accepted, job } · job es null si el centro no tiene el módulo trabajos · solo una oferta abierta (DRAFT o SENT, no sustituida y con validUntil no vencido); rechazada o caducada responde 422: se revive con new-version (scope comercial:write)
PATCH/v1/quotes/:id{ status } · solo los pasos sin efectos: DRAFT → SENT, y DRAFT/SENT → EXPIRED; cualquier otro salto responde 422 (aceptar, rechazar y versión nueva tienen su ruta)
POST/v1/quotes/:id/declineapuntarla como rechazada: { reason, note? }, reason = PRICE · TIMING · COMPETITOR · NO_RESPONSE · SCOPE · OTHER
POST/v1/quotes/:id/new-versioncopia en borrador (A-2026-0004-v2) de una oferta ya enviada; la vieja queda con supersededAt y su enlace ya no acepta
GET/v1/quotes/:id/pdfel PDF de la oferta (el mismo que va en el correo), en el idioma del cliente
GET/v1/quotes/:id/email-draftel correo ya redactado desde la plantilla del centro: to, subject, body (con {enlace}), attachment
POST/v1/quotes/:id/emailenviarla: { to[], cc?[], bcc?[], subject, body, attachPdf?, files?[] } (hasta 10 direcciones; files = { filename, contentType, dataBase64 }, 5 ficheros y 8 MB). {enlace} se cambia por el enlace donde el cliente la acepta. La oferta pasa a SENT y sale quote.sent

La misma ventana de correo existe para la factura (/v1/invoices/:id/email-draft y /email, scope facturas), el recordatorio de cobro (/v1/invoices/:id/dunning-email-draft y /dunning-email) y el recibo de una venta (/v1/pos/sales/:id/email-draft y /email). Cada envío queda registrado en la ficha del cliente.

Cliente empresa. POST/PATCH /v1/customers acepta además vatNumber (UID suizo CHE-123.456.789, que se valida con su cifra de control y se guarda como CHE-123.456.789 MWST/TVA/IVA, o un número extranjero con el código del país), paymentTermsDays (plazo pactado, 0–365; sin él, el del centro) y una dirección de facturación aparte: billingName (a la atención de), billingStreet, billingPostalCode, billingCity, billingCountry. Con código postal y población, la factura y el QR van a esa dirección; media dirección se rechaza con 400. null borra cada campo. Las facturas (POST /v1/invoices, /v1/invoices/multi, borradores) y las ofertas aceptan customerReference (hasta 60 caracteres): sale como «Ihre Referenz» en el PDF y en el campo 20 de la información Swico S1 del QR. Desde el 2026-10-02, si el centro tiene IBAN, una factura hecha a mano (POST /v1/invoices, /v1/invoices/multi, emitir un borrador o una renovación) a un cliente sin código postal y población responde 422 sin gastar número: el QR tiene que llevar sus datos. El PDF se llama Rechnung-<número>-<cliente>.pdf (la palabra en el idioma del documento). Un paymentTermsDays de la propia factura manda sobre el del cliente. Desde el 2026-10-06, GET /v1/invoices y GET /v1/invoices/:id traen también customer (id y name, nada más), aunque el cliente esté archivado. Desde el 2026-10-12, POST /v1/invoices/:id/credit-note acepta además amountMinor (bruto, en Rappen) para un abono parcial: la factura sigue viva, guarda lo abonado en creditedMinor y lo pendiente pasa a ser grossMinor − paidMinor − creditedMinor; sin amountMinor, abono total como siempre. Un campo desconocido en ese cuerpo responde 400.

Datos con sentido (desde el 2026-10-14). GET /v1/invoices filtra por ?customerId=, ?status= (DRAFT, ISSUED, PAID, CANCELLED) y ?from=/?to= (AAAA-MM-DD); antes se ignoraban. GET /v1/accounting/journal admite además ?q= (texto del asiento) y ?limit= (1–2000, 500 por defecto). En toda la API una fecha AAAA-MM-DD tiene que existir (el 30 de febrero responde 400, antes se guardaba como el 2 de marzo). Un registro que no existe responde 404, uno repetido 409 nombrando el campo y una referencia a algo que no existe 422 (antes, 500). Un SKU repetido en el centro responde 422; un producto desactivado no se vende (422, salvo un pedido de la tienda ya pagado); una compra con la caducidad ya pasada no entra (el stock inicial sí, para darlo de baja) y lo único que queda caducado no se vende ni se usa.

GET/v1/invoices/customer-statement/:customerIdestado de cuenta: open[] (emitidas con saldo, con overdueDays), recentlyPaid[] (cobradas en 12 meses), totalOpenMinor, totalOverdueMinor, asOf. Scope facturas
GET/v1/invoices/customer-statement/:customerId/pdfel mismo estado de cuenta en PDF, en el idioma del cliente
POST/v1/invoices/customer-statement/:customerId/emailmandarlo por correo, con email-draft igual que la factura

Oportunidades de venta (complemento oportunidades, scope oportunidades). Lo que se espera vender antes de que haya oferta: { title, customerId?, leadId?, quoteId?, amountMinor (neto, Rappen), probabilityBps? (null = la de la etapa), expectedCloseOn? (AAAA-MM-DD), ownerUserId?, notes? }. Etapas: NEW (10 %), QUALIFIED (25 %), PROPOSAL (50 %), NEGOTIATION (75 %), WON, LOST. Cada una devuelve además effectiveProbabilityBps, weightedMinor (importe × probabilidad) y overdue. Si lleva quoteId, aceptar esa oferta la da por ganada sola. Con una clave rk_ las notes salen a null.

GET/v1/opportunities?stage=open|closed|NEW|…, ?owner=me|<userId>, ?customerId=
GET/v1/opportunities/forecastlo abierto: pipelineMinor, weightedMinor, byStage[], byMonth[] (mes de cierre previsto; none = sin fecha) y overdue
GET/v1/opportunities/reportganadas y perdidas entre ?from= y ?to= (por defecto 12 meses): won, lost, winRateBps, avgDaysToWin, lostByReason[]
POST/v1/opportunities · PATCH /v1/opportunities/:id · DELETE /v1/opportunities/:idcrear, cambiar (solo lo que mandes) y borrar
POST/v1/opportunities/:id/stage{ stage, lostReason?, lostNote? } → { changed, opportunity }. LOST exige lostReason: PRICE, TIMING, COMPETITOR, NO_RESPONSE, SCOPE, NO_BUDGET u OTHER

Personas de contacto y límite de crédito. GET/POST /v1/customers/:id/contacts y PATCH/DELETE /v1/customers/:id/contacts/:contactId llevan las personas de una empresa: { name, position?, email?, phone?, notes?, isPrimary?, receivesInvoices? }. Solo una es isPrimary (marcar otra quita la marca a la anterior). En el borrador de correo de una factura, un recordatorio o un estado de cuenta, las que tienen receivesInvoices llegan en cc, y si la empresa no tiene correo propio la principal va en to. Con una clave rk_ las notes salen a null. creditLimitMinor (Rappen, null = sin límite) se manda con el cliente; el estado de cuenta devuelve creditLimitMinor y overLimit. Solo avisa: no bloquea ninguna factura. Los documentos de la ficha del cliente (/v1/customers/:id/documents) no están disponibles por la API: pueden ser copias de documentos de identidad.

Campos propios y fichas repetidas. GET /v1/customers/fields da los campos que el centro añadió a la ficha: [{ key, label, type, options? }] con type = text, number, date (AAAA-MM-DD), select (uno de options) o checkbox. Los valores van en customFields: { <key>: valor } de cada cliente y se mandan igual en POST/PATCH /v1/customers: lo que no envíes se conserva, null o vacío lo borra, una clave desconocida se ignora y un valor del tipo equivocado devuelve 422 con el nombre del campo. Solo la dirección cambia la definición (PUT /v1/customers/fields, no con clave rk_). GET /v1/customers/duplicates agrupa las fichas activas con el mismo correo, el mismo teléfono (+41 y 0 cuentan igual) o el mismo nombre; POST /v1/customers/:id/merge { intoId } une la ficha :id con intoId (solo la dirección, desde el panel: una clave recibe 403). Devuelve { sourceId, targetId, moved, filled }; si las dos tienen cuenta de la tienda o están en el mismo curso, 409 sin tocar nada.

Contactos (los que todavía no son clientes). GET /v1/leads lista los últimos 200; POST /v1/leads da de alta uno a mano: { name, company?, email?, phone?, message?, notes?, ownerUserId?, source? } con source = MANUAL (por defecto), PHONE, EVENT, REFERRAL, WEB u OTHER. PATCH /v1/leads/:id cambia los mismos campos y el status (NEW, CONTACTED, CLOSED); al cerrar se guarda closedAt y closeReason (sin él, OTHER; CONVERTED solo lo pone la conversión), y al reabrir se vacían. POST /v1/leads/:id/convert lo pasa a cliente: si trae empresa abre una ficha COMPANY con la persona como contacto, el responsable pasa al cliente y las notas a su bitácora; repetirlo devuelve la misma ficha con already: true. Las notes internas de un contacto y de una tarea son del equipo: con una clave rk_ salen siempre a null y lo que mande en ese campo se ignora.

Responsable, tareas e historial (scope clientes). POST/PATCH /v1/customers acepta ownerUserId: una cuenta activa del centro que pueda abrir Clientes (si no, 422; null lo quita). Cada tarea cuelga de un cliente, tiene fecha (dueAt, ISO con zona) y una persona (assignedUserId, opcional). Al asignársela otra persona, y otra vez cuando llega la hora si sigue abierta, esa cuenta recibe un aviso en la campana y por correo; sin persona, el de la hora va a quien la creó. Cambiar la fecha o la persona vuelve a dejar pendiente el aviso.

GET/v1/customers/:id/timelinehistorial del cliente, lo más nuevo arriba: [{ at, kind, title, detail?, amountMinor?, status?, refId, by? }]; kind = note · booking · sale · invoice · quote.created|sent|accepted|declined · email · task.created|done. Las notas solo salen si el rol puede verlas
GET/v1/customers/:id/taskstareas del cliente (?status=open|done|all, por defecto todas)
GET/v1/customer-taskstareas abiertas del centro; ?mine=1 las de la cuenta que llama, ?assignedUserId= las de otra, ?status= igual que arriba
GET/v1/customer-tasks/assigneescuentas a las que se puede dar una tarea o hacer responsables
POST/v1/customer-tasks{ customerId, title, dueAt, notes?, assignedUserId? } → 201 con la tarea (overdue calculado)
PATCH/v1/customer-tasks/:idcambiar title, notes, dueAt o assignedUserId
POST/v1/customer-tasks/:id/done{ done: true|false }: marcar hecha o reabrir
DELETE/v1/customer-tasks/:idborrarla (204)

El coste de un trabajo se compone solo: las horas que le imputas (valoradas al coste/hora de cada persona), el material que sale de almacén y los gastos que le asignes. Horas y material se congelan al imputarlos —con la tarifa y el coste medio de ese momento—, de modo que una subida de tarifa o una compra posterior no reescriben el margen de un trabajo ya cerrado. Si alguien no tiene coste/hora, sus horas entran a 0 y la respuesta lo señala con costing.hasUnpricedLabor. Un trabajo cerrado rechaza toda imputación: hay que reabrirlo moviéndolo a una etapa activa. Con Flota, el coste suma además el vehículo: costing.vehicleCostMinor = km de cada viaje × CHF/km de su vehículo, congelado al apuntar los km (costing.vehicleKm y el detalle en costing.vehicleLines); un trabajo sin viajes con km tiene vehicleCostMinor: 0 y el margen de siempre. Si algún viaje tiene km pero su vehículo no tiene un CHF/km fiable, costing.hasUnpricedTrips lo señala igual que hasUnpricedLabor. Un gasto imputado a la vez a un vehículo y al trabajo (el alquiler del camión) cuenta una sola vez, en expenseCostMinor; costing.vehicleExpenseMinor dice cuánto es, y un gasto anulado en Contabilidad (Storno) no cuenta. Mover un trabajo a una etapa que lo cierra devuelve además plannedTrips: cuántos viajes le quedan por delante en la planificación.

Además, jobId se acepta directamente al crear una reserva (POST /v1/bookings), un movimiento de stock (POST /v1/products/:id/movements), un gasto (POST /v1/expenses) y una factura (POST /v1/invoices y /v1/invoices/multi). Varias facturas pueden apuntar al mismo trabajo: anticipo, hitos y liquidación final; su suma es costing.invoicedNetMinor.

Lotes y caducidad. Un producto con trackLots: true exige lote en cada ENTRADA: en POST /v1/products/:id/movements con type: "IN" se envían lotCode (se guarda en mayúsculas) y opcionalmente expiryDate (AAAA-MM-DD); sin lotCode responde 422. Lo mismo al recibir un pedido (POST /v1/purchase-orders/:id/receive): cada línea de un producto con lotes lleva lotCode/expiryDate, y recibir todo sin lines da 422. En una SALIDA (type: "OUT") se puede indicar lotId; sin él sale primero lo que caduca antes (FEFO), y una venta o un uso interno deja los lotes ya caducados para el final. Consulta: GET /v1/products/:id/lots (lotes con existencias y lo que está «sin lote») y GET /v1/stock-lots?days=30 (lo que caduca, con su valor a coste); el historial GET /v1/products/:id/movements incluye lots con la cantidad por lote. Quitar trackLots a un producto con lotes vivos da 422.

Peso para envíos. weightGrams es el peso de una unidad lista para enviar, en gramos enteros (de 1 a 1’000’000; null = sin pesar). Se envía en POST /v1/products y PATCH /v1/products/:id, y la importación CSV de productos lo lee de la columna peso_g. Cada pedido de la tienda copia el peso de sus líneas al comprar, y con él el panel propone el peso de la etiqueta de Die Post. Las tarifas de envío del centro son configuración y no están abiertas a las claves rk_.

Envíos (módulo envios). GET /v1/shipments lista todas las etiquetas de Die Post del centro (de pedidos de la tienda y sueltas) con from/to (AAAA-MM-DD), kind (ALL, SHOP, DIRECT) y q; devuelve items (sin el PDF), count y costGrossMinor (lo que costaron según la tarifa del centro). POST /v1/shipments crea un envío suelto: to es {kind:"CUSTOMER",customerId}, {kind:"SUPPLIER",supplierId} o {kind:"ADDRESS",name,street,postalCode,city,country} (solo CH o LI), con service ECO/PRI, weightGrams (1 a 30000) y reference opcional; pide la etiqueta a la Post con el contrato del centro (cada llamada genera un envío real y su coste) y responde 201 con el número EN-AAAA-#### y el seguimiento, o 422 con el motivo (sin credenciales, dirección incompleta, rechazo de la Post). GET /v1/shipments/:id/label.pdf devuelve la etiqueta guardada. Lectura con envios:read y creación con envios:write; las credenciales (/v1/shipments/post-config) y la tarifa (/v1/shipments/rates) nunca están abiertas a una clave. La lista admite también kind=RETURN: las etiquetas de devolución, que llegan con kind: "RETURN". Cada etiqueta trae también el seguimiento automático de la Post: trackStatus (NOT_YET_SENT, ON_GOING_DELIVERY, WAITING_FOR_PICKUP, DELIVERED, RETURNED, CUSTOMS; null si aún no se ha consultado), trackTitle (el texto de la Post en el idioma del centro), trackedAt y deliveredAt. Se consulta cada 2 horas; el botón del panel que lo fuerza (/v1/shipments/track-now) no está abierto a una clave.

Gastos repercutibles (módulo facturas). Lo que el centro pagó por un cliente y le cobra tal cual. GET /v1/disbursements (status = PENDING|IN_DRAFT|INVOICED|CANCELLED, customerId) devuelve items (description, grossMinor con IVA, taxTreatment, shipmentId, draftId, invoiceId) y pendingByCustomer. POST /v1/disbursements apunta uno (customerId, description, grossMinor, treatment opcional, date); PATCH /v1/disbursements/:id y POST /v1/disbursements/:id/cancel solo mientras está pendiente (409 si no). POST /v1/disbursements/draft con {customerId, ids?} junta lo pendiente en un borrador de factura (origin: "DISBURSEMENT", una línea por gasto con su neto); al emitir el borrador pasan a INVOICED y al borrarlo vuelven a PENDING. POST /v1/shipments/:id/charge (módulo envios) carga el franqueo de un envío a su cliente, una sola vez. Lectura con facturas:read y escritura con facturas:write.

Etiqueta de devolución (módulo tienda). POST /v1/shop-returns/:id/label con service (ECO/PRI), weightGrams, another (otra etiqueta para otro paquete) y email (mandársela al comprador) hace la etiqueta de devolución de Die Post (código GAS, con la licencia de devoluciones del centro) de una devolución en estado REQUESTED; responde 201 con shipment y mailed, o 422 con el motivo. GET /v1/shop-returns trae en cada devolución sus labels. Requiere tienda:write.

Contratos de mantenimiento. Un contrato no termina: cobra una cuota periódica y cubre revisiones. Cada intervención es un trabajo hijo (Job.agreementId), así que hereda etapas, horas, material y margen. Su rentabilidad se mide ACUMULADA (cuota devengada + facturado aparte − coste de las intervenciones), no al cerrar. Mismo scope trabajos:*. No confundir con /v1/contracts, que son los acuerdos B2B de tarifas con hoteles.

GET/v1/service-agreementsactivos (?active=false = todos)
GET/v1/service-agreements/:idficha + intervenciones + rentabilidad y aviso de renovación
POST/v1/service-agreementsalta (Idempotency-Key)
PATCH/v1/service-agreements/:ideditar / desactivar
POST/v1/service-agreements/:id/generatecrear las revisiones del año como trabajos · idempotente por (contrato, año)

Paginación: ?limit= (1–500, def. 100) y ?offset= (def. 0).  Dinero: añade Idempotency-Key: <8–200 chars>, estable por intento.

Fidelidad: acreditar puntos

Con el scope de clientes (clientes:write). El saldo, el nivel y la equivalencia en francos los manda el ERP en GET /portal/me (portal) y en GET /v1/customers/:id/loyalty (server-to-server). Para SUMAR puntos —recomendación, promo, ajuste— una sola ruta (la BIENVENIDA la da el ERP solo, no por aquí):

POST/v1/loyalty/creditabona puntos · idempotente por externalRef · devuelve el saldo
POST/v1/loyalty/reverserevierte un abono por su externalRef (signo contrario, no borra; nunca deja saldo negativo)
GET/v1/customers/:id/loyaltysaldo, nivel y equivalencia de un cliente (server-to-server)
GET/v1/customers/:id/loyalty/transactionslibro de movimientos del cliente (los 200 más recientes: motivo, puntos con signo, saldo resultante)
POST/v1/customers/:id/loyalty/adjustajuste manual del operador: points con signo (negativo = restar) + note; deja rastro MANUAL, nunca deja saldo negativo
POST /v1/loyalty/credit      Authorization: Bearer rk_…
     { "customerId": "cms0…", "points": 50, "reason": "REFERRAL",
       "note": "Recomendó a AX-00412", "externalRef": "ref-cms0-cmt0" }
  → { "balance": 150, "created": true, "transaction": { … } }   # 201 al crear · 200 si el mismo externalRef ya existía

reason: REFERRAL · PROMO · MANUAL (cerrado; WELCOME es exclusivo del ERP). externalRef es OBLIGATORIO: es la clave de idempotencia. Reenviar el MISMO externalRef devuelve el movimiento existente sin volver a acreditar (como las pre-alertas). Rechaza puntos no positivos, cliente archivado y superar el tope de puntos por abono del centro. La BIENVENIDA la da el ERP al crear la ficha (cantidad configurable por centro, en cualquier canal) — no la acredites tú, o se duplicaría; usa la ruta solo para recomendación/promo/manual. Los puntos no caducan. Las reglas del programa (cuántos da una compra, niveles, equivalencia) y el canje son del ERP. Cada abono o reverso dispara el webhook loyalty.updated (con customerId y balance), para avisar al cliente sin preguntar en bucle.

Etiquetas de cliente

Con el scope de clientes (clientes:read para leer, clientes:write para crear, asignar y borrar). Una clave solo ve y toca etiquetas de cliente (entityType=CUSTOMER); las de vehículo devuelven 403. Una etiqueta sirve también de destinatario de una campaña de correo en el panel.

GET/v1/tags?entityType=CUSTOMERetiquetas del centro con cuántos clientes tiene cada una
POST/v1/tagscrear (name, color, description); el nombre no se repite (sin distinguir mayúsculas)
POST/v1/tags/:id/assignponer la etiqueta a un cliente: { "entityType": "CUSTOMER", "entityId": "…" } · idempotente
POST/v1/tags/:id/unassignquitarla (mismo cuerpo)
PUT/v1/tags/entity/CUSTOMER/:customerIdsustituye TODAS las etiquetas de un cliente por las de tagIds (para una sola, mejor assign/unassign: no pisa lo que otro puso)

Exportación anual de ventas en segundo plano

POST /v1/accounting/exports acepta {"year":2026,"requestKey":"UUID"} y devuelve 202 con id, status y statusUrl. Conservar el mismo requestKey al reintentar una solicitud: la deduplicación se hace por empresa y solicitante. La generación ocurre en un worker separado.

GET /v1/accounting/exports lista los encargos propios; GET /v1/accounting/exports/:id consulta estado, filas, bytes, fecha de instantánea y caducidad. GET /v1/accounting/exports/:id/file descarga un CSV completado; DELETE /v1/accounting/exports/:id cancela y elimina el resultado. Estados: QUEUED, RUNNING, COMPLETED, FAILED. Un encargo eliminado o caducado deja de estar disponible.

Sesiones ADMIN/ACCOUNTANT o claves con contabilidad:write para solicitar/eliminar y contabilidad:read para consultar/descargar. Cada usuario o clave solo accede a sus propios encargos. Se aplica también el plan y la suscripción. Máximo 3 encargos retenidos por empresa (429 al alcanzar el límite), 128 MiB o 2 millones de filas por archivo, caducidad de 24 horas. El archivo no tiene enlace público. Un resultado no disponible devuelve 404.

El CSV contiene ventas de caja, incluidas devoluciones; no sustituye las cuentas anuales. Año civil suizo, marcas de tiempo UTC, importes en unidades menores y moneda por fila. El delimitador es punto y coma; UTF-8 con BOM. No se mezclan monedas ni se incluyen todas las facturas pendientes. Si falla por tamaño (EXPORT_TOO_LARGE), no se ofrece un archivo parcial.

Contabilidad: inmovilizado y concordancia de IVA

La contabilidad completa es alcanzable con contabilidad:read / contabilidad:write: además de balances, diario y liquidación de IVA, están el inmovilizado (activar un bien y amortizarlo durante su vida útil, OR 960a párr. 3) y la concordancia anual de IVA (art. 72 LIVA). Las escrituras piden Idempotency-Key.

GET/v1/accounting/assetsbienes con su valor contable · ?includeDisposed=1 incluye las bajas · trae las clases y sus tasas ESTV
POST/v1/accounting/assetsactivar: name, assetClass, acquisitionDate, netMinor; opcionales method, rateBps, residualMinor, paymentAccount
GET/v1/accounting/assets/:idficha: amortizaciones contabilizadas + plan previsto año a año
POST/v1/accounting/assets/previewplan previsto sin dar de alta nada (misma función que luego contabiliza)
POST/v1/accounting/assets/depreciation-runamortizar el ejercicio: year. Idempotente por (bien, año): repetirlo no amortiza dos veces
POST/v1/accounting/assets/:id/disposebaja: date y kind = SALE (repercute MWST, cifra 302) · SCRAP · PRIVATE (autoconsumo art. 31 sobre el valor fiscal)
GET/v1/accounting/anlagespiegelcuadro del inmovilizado del ejercicio: ?year=
GET/v1/accounting/vat-filingsliquidaciones ya presentadas a la ESTV: ?year=
POST/v1/accounting/vat-filingsmarcar un periodo como presentado y congelar sus cifras: year, period (Q1…Q4, S1, S2)
DELETE/v1/accounting/vat-filings/:iddeshacer el marcado
GET/v1/accounting/vat-reconciliationconcordancia del ejercicio (?year=): deriva por periodo, Umsatzabstimmung por cuenta, importe a regularizar y fecha límite

La concordancia necesita que cada periodo se marque como presentado cuando se presenta. La liquidación se recalcula siempre del diario, así que sin esa foto se compararía consigo misma y siempre daría cero; lo que un control busca es lo contrario, los asientos que entraron en un periodo ya declarado. Un ejercicio se declara por trimestres o por semestres, nunca mezclados (422 si se intenta).

La liquidación devuelve el formulario completo de la ESTV: turnoverTotalMinor (200), bases taxableBase* (302/303/305), exemptBaseMinor (230), outputVatMinor (399), inputVatMaterialMinor (400), inputVatInvestMinor (405), inputVatDepositMinor (410), inputVatCorrectionMinor (415, resta), inputVatTotalMinor (479) y vatPayableMinor (500). Solo el método efectivo: el de tipo de saldo (Saldosteuersatz) no está implementado.

Dinero: ingresos sueltos, gastos, reembolsos, traspasos y pasarelas

El día a día del dinero que no pasa por una factura ni por la caja. Gastos con comercial:read / comercial:write; el resto con contabilidad:read / contabilidad:write. Las escrituras piden Idempotency-Key y rechazan fechas futuras (422).

POST/v1/expenseso netMinor + vatMinor, o el total del tique grossMinor + vatRateBps (810 = 8.1 %). employeeId: lo pagó un empleado de su bolsillo; la deuda va a la cuenta 2210 y no lleva supplierId. Para pagarla después por fichero al banco: supplierInvoiceNo (nº de su factura) y paymentReference (referencia QR de 27 cifras o RF; se comprueba el dígito de control). Si parece repetido (mismo proveedor con el mismo supplierInvoiceNo; mismo proveedor e importe a ±10 % en ±31 días; o, sin proveedor, el mismo importe en ±31 días) responde 409 con code: "POSSIBLE_DUPLICATE" y el expenseId del que ya existe, sin registrar nada; repetir con allowDuplicate: true si son dos gastos distintos. Lo adelantado por un empleado no se compara
POST/v1/expenses/:id/pay{ date?, cashAccount? } — de qué cuenta de dinero salió y cuándo (sin ellos: hoy y 1020). En un gasto con employeeId es el reembolso
GET/v1/expenses/reimbursementslo que se debe a cada empleado, con el detalle de sus gastos
POST/v1/expenses/reimburse{ employeeId, date?, cashAccount?, expectedTotalMinor? } — reembolsa todo lo pendiente de esa persona; con expectedTotalMinor responde 422 si lo pendiente cambió
POST/v1/accounting/other-income{ kind: INTEREST · SUBSIDY · COMPENSATION · OTHER, description, date, grossMinor, cashAccount, treatment? }
GET/v1/accounting/money-movements?from=&to= — cada línea de caja y banco; los totales cuentan lo que entró y salió del negocio (sin traspasos ni pares anulados) · truncated si la lista se cortó
POST/v1/accounting/transfers{ fromAccount, toAccount, amountMinor, date, note? } — entre cuentas de dinero propias (caja → banco, pagar la tarjeta 2101…). Desde la caja (1000/1001) o «en camino» (1090) no sale más de lo que hay a esa fecha (422); un banco sí puede quedar en descubierto
POST/v1/accounting/gateway-payouts{ provider, fromAccount, toAccount, grossMinor, netMinor, date, allowDuplicate? } — liquidación de Stripe/Payrexx/SumUp; la comisión (bruto − neto) va a 6900. Si ya hay una del mismo neto en esa cuenta a ±5 días responde 409 con code: "POSSIBLE_DUPLICATE" y su entryId; repetir con allowDuplicate: true si son dos distintas
GET/v1/accounting/payroll-liabilitieslo que se debe de AHV/ALV/UVG/FAK (2260), BVG (2261) e impuesto en la fuente (2262)
POST/v1/accounting/liability-payments{ accountCode, amountMinor, date, cashAccount, note? } — nunca más de lo que se debe
GET/v1/accounting/vat-due?asOf= — IVA pendiente hasta esa fecha, descontado lo ya pagado (paidThrough, laterPaymentThrough)
POST/v1/accounting/vat-payments{ asOf, date, cashAccount, refundReceived? } — salda el IVA del periodo; no paga dos veces ni un periodo ya incluido en otro pago. Solo método efectivo. Si la ESTV DEVUELVE, sin refundReceived: true (el dinero ya llegó al banco en date) responde 409 con code: "VAT_REFUND_CONFIRM" y refundMinor
POST/v1/accounting/cash-accounts/:id/reconcile{ asOf, realMinor, note?, bookDifference? } — con bookDifference en una caja, apunta el sobrante (3600) o el faltante (6700)

cashAccount, fromAccount y toAccount son cuentas de dinero del plan: cajas 1000/1001, bancos 1020–1023, dinero en camino 1090, descubierto 2100 y tarjeta de crédito 2101. Cualquier otra responde 422.

Vales de plataforma (DeinDeal, Groupon…)

Una plataforma vende cupones de tus servicios, cobra al cliente y te liquida el neto más tarde. Es una entidad con su comisión por defecto y su cuenta de deudor; los cupones se cuelgan de ella. Scope vales:read / vales:write.

GET/v1/platformslistar con sus cifras · ?status=all incluye inactivas
POST/v1/platformsalta: name, code, commissionBps, commissionMode
PATCH/v1/platforms/:ideditar (el código no cambia)
GET/v1/platforms/:id/settlementsliquidaciones registradas
POST/v1/platforms/:id/settlementsregistrar la transferencia: date, receivedMinor, voucherIds

Al crear un vale, platformId sustituye a channel y la comisión se calcula sola con el % de la plataforma si no mandas commissionMinor. commissionMode vale ISSUE (la comisión va a gasto al emitir) o SETTLEMENT (entra al liquidar, con su IVA soportado). Sin platformId, channel sigue funcionando como antes.

La liquidación admite además customerPackIds: si la plataforma vendió opciones de varias sesiones, esos bonos son deuda suya igual que los cupones y se cobran en el mismo asiento. Sin voucherIds ni customerPackIds se liquida todo lo pendiente de esa plataforma.

Ofertas de plataforma y sus opciones

Una oferta es el deal publicado (una página con varias líneas comprables). Cada opción lleva servicio, nº de sesiones, precio regular y lo que paga el cliente. Emitir desde una opción decide la forma: sessions = 1 crea un vale; más de una, un bono de sesiones (el cliente compró sesiones, no un saldo). Scope vales:read / vales:write.

GET/v1/platform-dealsofertas con opciones y cifras · ?status=all
POST/v1/platform-dealsalta: platformId, name, endsAt
PATCH/v1/platform-deals/:ideditar / cerrar la campaña (active)
POST/v1/platform-deals/:id/optionsañadir opción: label, serviceId, sessions, regularMinor, paidMinor
PATCH/v1/platform-deals/options/:ideditar la opción
POST/v1/platform-deals/options/:id/issueemitir lo comprado: code, customerId · requiere Idempotency-Key
GET/v1/platform-deals/lookup?code=qué se emitió con ese código (vale o bono)
# El cliente llega con su código: se emite lo que compró y se devuelve qué salió.
curl -X POST https://ritualerp.ch/v1/platform-deals/options/OPT_ID/issue \
  -H "Authorization: Bearer rk_…" \
  -H "Idempotency-Key: dd-7F3A-2026-08" \
  -H "Content-Type: application/json" \
  -d '{ "code": "DD-7F3A", "customerId": "cus_…" }'

# → { "kind": "PACK", "voucherId": null, "customerPackId": "cpk_…" }

El mismo code no se puede emitir dos veces (422): escanear por error no duplica la deuda de la plataforma. Una opción de varias sesiones exige customerId — un bono es el saldo de sesiones de alguien. El ERP no valida el cupón: de eso se encarga la app de la plataforma.

Pre-alertas · listas de empaque (paquetería)

Una pre-alerta es la declaración del contenido de un envío que llega antes que el bulto físico: tu web la empuja al finalizarla y, al recepcionar, Ritual la vincula al bulto por su código. Se consume con clave rk_: scope recepcion:write (crear / vincular) o recepcion:read (consultar).

POST/v1/packing-listscrear / actualizar (idempotente por code)
GET/v1/packing-listslistar · ?status= & ?customerId=
GET/v1/packing-lists/:codeconsultar por código
GET/v1/packing-lists/:code/pdfPDF imprimible con su código de barras (Code128)
DELETE/v1/packing-lists/:codeborrar una lista aún en SUBMITTED (no vinculada)
POST/v1/packing-lists/:code/linkvincular al bulto → webhook
GET/v1/recepciones/:id/lista-empaque-cliente.pdfcopia del cliente de una recepción: bultos, contenido y desglose de lo cobrado (la factura solo lleva «Transporte de X a Y» y el total) · recepcion:read; una clave atada a un cliente no la alcanza (403)
# La web empuja la lista al finalizarla. Si mandas "code" es idempotente
# (reenviar el mismo code NO duplica); si lo omites, Ritual genera un PKL-AÑO-NNNNN.
curl -X POST https://ritualerp.ch/v1/packing-lists \
  -H "Authorization: Bearer rk_…" -H "Content-Type: application/json" \
  -d '{
    "code": "LE-2026-00042",
    "customerId": "<id del cliente en Ritual>",
    "destination": "La Habana",
    "sender":    { "name": "Remitente" },
    "recipient": { "name": "Destinatario", "municipio": "Centro Habana" },
    "items": [ { "description": "Camiseta", "qty": 2, "weightGrams": 400, "declaredValueMinor": 3500, "category": "Ropa", "bag": "1" } ]
  }'

# Al recepcionar el bulto físico: vincúlala por bultoCode (o bultoId)
curl -X POST https://ritualerp.ch/v1/packing-lists/LE-2026-00042/link \
  -H "Authorization: Bearer rk_…" -H "Content-Type: application/json" \
  -d '{ "bultoCode": "BLT-2026-01234" }'
  → { "code": "LE-2026-00042", "status": "LINKED", "bultoCode": "BLT-2026-01234", "changed": true }

# Opcional: "syncItemsFromBulto": true hace que la lista ADOPTE los artículos del bulto
# (descripción/cantidad/peso). Lo usa el panel al EDITAR una recepción; el valor declarado
# del cliente se conserva emparejando por descripción.

La respuesta incluye trackingCode (13 díg.): el número de seguimiento del cliente, listo para enseñar/imprimir, estable toda la vida del paquete y buscable en /public/tracking. Si la lista se crea en recepción (sin push previo), ese número llega en el webhook packinglist.linked. Estados: SUBMITTED (declarada) → LINKED (vinculada al bulto) → SHIPPED → DELIVERED. Reenviar un code ya vinculado no pisa los datos: a partir de la recepción manda Ritual. Cada artículo lleva description (obligatorio) y, opcionales, qty, weightGrams, declaredValueMinor (en céntimos de USD: 3500 = 35 USD; el nombre no cambia) y los metadatos category y bag (se conservan tal cual y salen en el PDF, bajo la descripción). sender/recipient son objetos libres (nombre + datos de contacto). El evento packinglist.linked (§06) se dispara al vincular por PRIMERA vez; reenviar el mismo code con cambios (respuesta 200) dispara packinglist.updated; la respuesta incluye changed (true = vínculo nuevo, false = ya estaba vinculado a ese bulto) y volver a enlazar al mismo bulto es idempotente: no re-dispara el webhook.

PDF con código de barras: GET /v1/packing-lists/:code/pdf devuelve la lista en PDF imprimible con un Code128 de su code. Se pega al envío; al recibir la mercancía, el operador escanea ese código en Recepción y la lista se vincula sola al bulto de esa recogida (el mismo paso link, sin teclear). Sirve con clave rk_ (scope recepcion:read) para imprimirlo desde tu web, o desde el panel del operador.

Clave atada a un cliente (para incrustar en la web del cliente final): al crear una clave en el panel → Desarrolladores puedes atarla a un cliente. Esa clave solo puede leer las listas de empaque de ese cliente: GET /v1/packing-lists devuelve únicamente las suyas (el filtro ?customerId= se ignora y se fuerza el suyo), y :code / :code/pdf de cualquier otro cliente responden 404. Además, una clave atada no alcanza ninguna otra ruta del módulo (recepciones, bultos, seguimiento…): responde 403. Así puedes exponer el PDF o la consulta directamente en el navegador del cliente sin filtrar las listas de los demás. Una clave sin atar es servidor-a-servidor y ve todo el módulo.

05 Entornos de prueba (sandbox)

Prueba tu integración en un centro AISLADO antes de tocar producción. No es un "modo test" sobre tus datos reales: es un centro propio, con aislamiento total por RLS.

Qué es

Un sandbox es un centro (tenant) separado: sus datos, usuarios y claves de API viven aparte y no tocan producción. Es el mismo aislamiento por centro que protege a un cliente de otro (ver §02), aplicado a tu banco de pruebas.

Qué datos trae: al crearlo (y al vaciarlo) se copian los servicios del centro y se crean clientes, destinatarios y remitentes inventados con la misma forma que los reales (cuántos, empresa o persona, idioma, ciudad y código postal): nombres de ejemplo, correos cliente-<n>@ejemplo.invalid, teléfonos ficticios, sin notas, IBAN, documentos ni fotos. Ningún dato personal del centro real entra en el sandbox.

Cómo se crea y se entra

El administrador lo crea en Suscripción → Entorno de pruebas → Crear. Para entrar hay dos caminos: el botón «Entrar al entorno de pruebas» de esa misma tarjeta (un clic, sin cerrar sesión) o la pantalla de acceso con las mismas credenciales, eligiendo «… (Sandbox)» en el selector de centro.

GET/v1/sandboxestado (¿es sandbox?, ¿tiene uno?)
POST/v1/sandboxcrear (idempotente) — solo ADMIN
POST/v1/sandbox/enterentrar con la sesión actual — solo ADMIN
POST/v1/sandbox/resetvaciar sus datos y empezar de cero — solo ADMIN
Área del alumno (escuelas)

Un centro con cursos no tiene una web propia que autentique al alumno, así que además del modelo de integración hay una puerta directa: POST /public/<centro>/alumno/acceso con su email le manda por correo un enlace con su token de portal (mismo cst_…, misma caducidad). Con ese token, GET /portal/escuela devuelve sus cursos, próximas clases, asistencia y recuperaciones pendientes, y en videos las clases grabadas que puede ver. En un curso grabado (onDemand: true) los vídeos solo aparecen con la plaza pagada; cada curso trae onDemand, videoCount y videosLocked (tiene vídeos que aún no puede ver por falta de pago). El catálogo GET /public/<centro>/courses marca los cursos grabados con onDemand, videoCount y videoMinutes; en ellos spotsLeft es null porque no tienen aforo. El pago online de una inscripción se confirma también por el webhook de Stripe del centro (checkout.session.completed con metadata.enrollmentId): no depende de que el alumno vuelva a la web. La respuesta de /alumno/acceso es SIEMPRE la misma exista o no el email: si dijera lo contrario, cualquiera podría comprobar quién es alumno del centro.

La puerta va en un solo sentido

POST /v1/sandbox/enter devuelve un token de solo acceso al sandbox (sin refresco), así que la sesión de producción se queda intacta y volver es inmediato. No existe la ruta contraria, y es a propósito: desde dentro del sandbox no se salta a producción. Si se pudiera, quien tuviera acceso de administrador al banco de pruebas —por ejemplo un integrador externo— podría crear allí un usuario con el correo de un administrador de producción y entrar con él.

Reiniciar: desde Suscripción → Entorno de pruebas → Vaciar (o POST /v1/sandbox/reset) se borran TODOS los datos del sandbox y se deja limpio, sin tocar producción. El borrado está acotado por RLS al centro de pruebas.

Claves de API de prueba

Las claves creadas dentro del sandbox son claves de prueba reales: llevan el tenantId del sandbox embebido (rk_<sandbox>_…), así que solo ven y escriben datos del sandbox. Se generan y usan igual que en producción (§04, §05), pero contra el centro de pruebas.

Regla

Desarrolla y prueba SIEMPRE con la clave del sandbox; pasa a la de producción solo cuando la integración funcione. Una clave de sandbox nunca puede leer ni tocar datos de producción, ni al revés: son centros distintos, aislados por RLS.

El sandbox NO manda correo

Se crea con los avisos por email apagados, y es a propósito: un banco de pruebas se llena de clientes inventados, y cada dirección falsa que rebota ensucia la reputación de envío que comparten los centros de verdad. Si necesitas ver el contenido de un aviso, míralo en el historial de avisos — se guarda el texto completo aunque no se envíe.

06 Webhooks — reaccionar a eventos

En vez de sondear, suscribe un endpoint y Ritual te avisa cuando algo pasa. Cada entrega va firmada.

Suscribir y eventos

POST/v1/developer/webhooks{ url, events[], … }
GET/v1/developer/webhookslistar
PATCH/v1/developer/webhooks/:ideditar / habilitar
POST/v1/developer/webhooks/:id/pingprueba
POST/v1/developer/webhooks/:id/rotate-secretrotar secreto

Eventos disponibles (o *): booking.created · booking.updated · booking.cancelled · customer.created · customer.updated · loyalty.updated (cambió el saldo de puntos de un cliente: abono o reverso) · invoice.created · invoice.paid · sale.created · voucher.created · job.created (se abrió un trabajo, tanto a mano como al aceptar una oferta) · quote.sent (se mandó una oferta por correo; trae la oferta con sentAt y sendCount) · quote.accepted (aceptada en el panel o por el cliente desde su enlace: respondedVia PANEL o LINK) · quote.declined (rechazada, con declineReason: PRICE, TIMING, COMPETITOR, NO_RESPONSE, SCOPE u OTHER) · opportunity.created (se abrió una oportunidad de venta) · opportunity.stage_changed (cambió de etapa; ganada trae wonAt, perdida lostAt y lostReason). Ninguno lleva las notas internas · customer.merged (se unieron dos fichas repetidas: { sourceId, targetId }; la primera queda archivada y todo su historial pasa a la segunda, así que cambia uno por otro donde guardes ids) · lead.created (entró un contacto: por la web, source WEB; por un formulario propio, FORM, con message vacío; o a mano o por la API) · lead.updated (cambió un contacto; al cerrarlo trae closeReason: CONVERTED, NOT_INTERESTED, NO_RESPONSE, DUPLICATE, SPAM u OTHER, y closedAt) · lead.converted (pasó a cliente: { lead, customerId, created }). Los eventos de contacto nunca llevan las notas internas · job.stage_changed (cambió de etapa; si la etapa es terminal trae también closedAt y outcome) · packinglist.linked (pre-alerta vinculada a su bulto en recepción) · packinglist.updated (pre-alerta ya declarada actualizada, mismo code) · fleet.vehicle.created · fleet.vehicle.updated (cambió la ficha de un vehículo; trae la ficha entera, sin el uso privado) · fleet.deadline.completed (se marcó como hecho un vencimiento; trae done y, si se programó, el siguiente en next) · fleet.deadline.due (un vencimiento entró en su plazo de aviso o venció; sale una vez por vencimiento) · fleet.fuel.created (se apuntó un repostaje) · fleet.fine.created (se apuntó una multa) · fleet.repair.opened (se abrió una reparación, a mano, con una MFK con defectos o desde un parte de daño) · fleet.repair.status_changed (una reparación cambió de estado) · fleet.damage.opened (se apuntó un parte de daño de un vehículo) · incident.created · incident.status_changed · incident.assigned (Incidencias: ver 06g). La URL debe ser https en producción (se bloquean IPs privadas y el endpoint de metadatos de la nube — anti-SSRF).

Tu endpoint recibe un POST con este sobre y estas cabeceras:

Headers
X-Ritual-Event:       invoice.paid
X-Ritual-Event-Id:    <uuid>          # idempotencia en tu lado
X-Ritual-Delivery-Id: <uuid>
X-Ritual-Signature:   t=<unix>,v1=<hmac>   # ver abajo

Body
{ "id": "<uuid>", "type": "invoice.paid", "tenantId": "…",
  "createdAt": "2026-07-25T…Z", "data": { /* el recurso */ } }

Verifica la firma (HMAC-SHA256) — obligatorio

La firma se calcula sobre <t>.<cuerpo-crudo>. Recalcúlala sobre el cuerpo sin parsear, compárala en tiempo constante y rechaza timestamps viejos (corta los ataques de repetición). Responde 2xx en menos de 10 s.

import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(rawBody, header, secret) {
  const [t, v1] = header.split(',').map(p => p.split('=')[1]);
  if (Math.abs(Date.now()/1000 - Number(t)) > 300) return false;  // >5 min: fuera
  const mac = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return v1.length === mac.length && timingSafeEqual(Buffer.from(v1), Buffer.from(mac));
}
Entrega fiable

Reintentos con backoff (10 s · 30 s · 2 min · 10 min · 30 min · 2 h; 6 intentos), cola de fallidos y auto-desactivación tras 20 fallos consecutivos. Usa el id del evento para aplicar idempotencia en recepción.

06h Conectar ChatGPT o Claude (MCP)

Cada persona del equipo puede conectar SU ChatGPT o Claude a Ritual y preguntarle por citas, clientes, huecos o facturas pendientes, con exactamente los permisos de su puesto. Ritual es un servidor MCP (Model Context Protocol) con OAuth 2.1 por persona.

Estado

Construido y apagado. Lo enciende la plataforma por centro y, dentro del centro, la administración en Desarrolladores → ChatGPT y Claude (MCP). Mientras esté apagado, todas estas direcciones responden 404.

Conectar

Lo que puede y lo que nunca puede

Para quien integra un cliente MCP

GET/.well-known/oauth-protected-resource/mcpRFC 9728
GET/.well-known/oauth-authorization-serverRFC 8414
POST/oauth/registerregistro dinámico (RFC 7591); solo vueltas exactas de Claude, ChatGPT o del propio ordenador
GET/oauth/authorizecódigo + PKCE S256 obligatorio, resource (RFC 8707), iss en la vuelta (RFC 9207)
POST/oauth/tokenauthorization_code y refresh_token (rotado; reusar uno corta la conexión). Formulario o JSON
POST/oauth/revokeRFC 7009
POST/mcpJSON-RPC 2.0 sin estado: initialize, ping, tools/list, tools/call. Sin llave: 401 con WWW-Authenticate: Bearer resource_metadata=…

Ámbitos: ritual.read y offline_access (refresco de 30 días). Llave de acceso de 1 h. Frenos: 60 llamadas por minuto y 2000 al día por conexión, 600 por minuto por centro (y el freno general del servidor, 600 peticiones por minuto por dirección IP; al pasarse responde 429 y hay que reintentar pasado el minuto). Si tu cliente MCP llama desde una web propia y necesita que admitamos su Origin, escríbenos por soporte.

Cabecera Origin (especificación MCP, transporte Streamable HTTP): una petición a /mcp que la traiga y no venga de un origen admitido recibe 403 con un error JSON-RPC sin id, antes de mirar la llave. De serie se admiten https://claude.ai, https://chatgpt.com y el propio Ritual; un cliente MCP que llame desde su servidor, sin Origin, entra como siempre. El CORS de /mcp ya no es *: solo devuelve esos orígenes.

06e Flota de vehículos

Vehículos, lo que vence de cada uno y el carnet de quien los conduce. Para conectar un GPS, un programa de taller o las tarjetas de carburante.

GET/v1/flota/resumenlo vencido y lo que está en plazo de aviso, de vehículos y de carnés; con source: "DRIVER", lo que falla en quien lleva un vehículo (CATEGORY_MISSING, CATEGORIES_UNKNOWN, DRIVER_INACTIVE); con source: "RECALL" (kind: "RECALL", recallId, sinceOn), una campaña del fabricante sin hacer; con source: "CHECK" (kind: "CHECK_ISSUES", checkId, problemCount), una revisión antes de salir con problemas sin atender
GET/v1/flota/panel?vehiculos=id1.id2 · ?sin=id3cifras de la flota: vehículos por estado, vencidos, multas abiertas, coste y km del mes y de los mismos días del mes anterior (previousMonth), serie de 12 meses y los 5 más caros; con vehiculos, solo esos; con sin, todos menos esos
GET/v1/flota/vehiculosfichas con sus vencimientos abiertos, su conductor habitual, site (sucursal), tags, la garantía (warrantyUntil, warrantyKm y warranty calculada hoy) y daysStopped12m
POST/v1/flota/vehiculosalta (basta plate o name); admite warrantyUntil? (AAAA-MM-DD) y warrantyKm?
PATCH/v1/flota/vehiculos/:idcambio parcial: lo que no mandas no se toca (p. ej. solo odometerKm); null borra un dato ("warrantyKm": null)
DELETE/v1/flota/vehiculos/:idsolo sin historial (tampoco con campañas del fabricante ni revisiones antes de salir); si lo tiene, status: RETIRED
GET/v1/flota/vehiculos/:id/historialla ficha completa en una llamada: vehicle (la misma fila que la lista), documents, deadlines (abiertos e historial), costs ({ from, to, row }: la fila de Costes de los últimos 12 meses), daysStopped, odometer (24 meses: { month, km, odometerKm }), fuel, fines, repairs, damages, unrepairedZones, tires, recalls y center.name. Un vehículo de otro centro, o que no existe, es 404
GET/v1/flota/vencimientos?vehicleId=&todos=1abiertos (con todos=1, también los cumplidos)
POST/v1/flota/vencimientos{ vehicleId, kind, dueOn, noticeDays?, label?, dueKm?, noticeKm?, intervalKm? } — los km, solo en SERVICE, TIRES y OTHER
POST/v1/flota/vencimientos/:id/hecho{ doneOn?, nextDueOn?, nextDueKm? } — abre el siguiente si mandas la fecha (no si ya hay otro del mismo tipo abierto)
PATCH/v1/flota/vencimientos/:id{ dueOn?, noticeDays?, label?, notes?, dueKm?, noticeKm?, intervalKm? } — otra fecha, otros km u otro plazo vuelven a avisar
POST/v1/flota/vencimientos/:id/reabrirdeshace el «hecho»: borra el siguiente que abrió si sigue abierto
GET/v1/flota/conductoresel equipo con su carnet, lo que conduce y los avisos. Con una clave rk_ el carnet llega sin number, notes ni medicalDueOn, y sin el aviso MEDICAL_DUE (el control médico es dato de salud; lo mismo en /v1/flota/resumen)
PUT/v1/flota/conductores/:employeeId/carnet{ categories[], expiresOn?, czvExpiresOn?, medicalDueOn? } — responde con problems: los vehículos que lleva y para los que ya no vale. Solo desde el panel: una clave rk_ recibe 403
DELETE/v1/flota/conductores/:employeeId/carnetborra el carnet (dato personal apuntado por error o de alguien que se fue)
GET/v1/flota/vehiculos/:id/documentossus documentos, sin el contenido, con vehicleDocKind
POST/v1/flota/vehiculos/:id/documentos{ fileName, mime, dataBase64, kind? } — JPG, PNG, WebP o PDF, máx. 8 MB
GET/v1/flota/vehiculos/:id/documentos/:docIdel fichero, con su hash comprobado
DELETE/v1/flota/vehiculos/:id/documentos/:docIdlo borra; si era la foto vigente de una cara, pasa a valer la anterior

Con clave rk_: scope flota:read para consultar y flota:write para escribir. Fechas en AAAA-MM-DD. Tipos de vencimiento (kind): MFK · INSURANCE · VIGNETTE · ROAD_TAX · LEASE_END · SERVICE · TIRES · TACHOGRAPH · LSVA · OTHER (este último con label).

Dos reglas que responden 422 con el motivo: los kilómetros no bajan (salvo que mandes "correctOdometer": true, que también sirve para corregir una cifra que se tecleó de menos: sustituye la lectura mala), y al poner o cambiar defaultDriverId se comprueba el carnet de esa persona — caducado, o sin la categoría que pide el vehículo (B, C1, C, D1 o D según el peso total y las plazas). Cada problema del conductor (driverProblems) trae code, message en castellano y los datos para traducirlo (name, category, date). mfkMaybeDue es la MFK que según la primera matriculación tocaba en los últimos 12 meses y no consta.

Sucursal y etiquetas: site es texto libre (máx. 60) que se manda en el alta o el PATCH, igual que tankLiters (capacidad del depósito, en litros). tags es de solo lectura por la API ([{ id, name, color, kind }]); las etiquetas se ponen desde el panel. En /v1/flota/panel los ids van separados por punto o coma; vehiculos= vacío es ningún vehículo y los ids que no son del centro se ignoran. Los importes van en céntimos (…Minor), el coste por km en céntimos (costPerKmRp) y el consumo en centilitros por 100 km (clPer100Km).

Permiso de circulación: cada cara se sube con kind = REGISTRATION_FRONT o REGISTRATION_BACK; sin kind es un documento cualquiera (póliza, taller). Una foto nueva de la misma cara no borra la anterior: vale la más reciente. GET /v1/flota/vehiculos trae registration: { front, back } para saber qué cara falta.

Costes, repostajes y multas

GET/v1/flota/costes?from=&to=coste por vehículo: gastos imputados por tipo, amortización, km, CHF/km, l/100 km y daysStopped (días en el taller dentro del periodo, hasta hoy; en totals, la suma)
GET/v1/flota/vehiculos/:id/gastos?from=&to=los gastos imputados a un vehículo
GET/v1/flota/repostajes?vehicleId=&from=&to=con el consumo del tramo y los avisos
POST/v1/flota/repostajes{ vehicleId, filledOn, odometerKm, litersCl, amountMinor?, fullTank?, alsoExpense? } — con alsoExpense: true (y amountMinor) crea además un gasto pendiente imputado al vehículo (cuenta 6200, IVA normal incluido); exige lo mismo que registrar un gasto (comercial:write). Con Idempotency-Key se crea una sola vez aunque se repita; la respuesta trae expenseId, y el repostaje no se puede borrar (422) mientras ese gasto siga sin anular
GET/v1/flota/multas?abiertas=1todas, o solo las pendientes
POST/v1/flota/multas{ vehicleId, occurredOn, occurredTime?, amountMinor, payBy?, driverId?, alsoExpense? } — con alsoExpense: true crea además el gasto pendiente (sin IVA), con los mismos permisos que un gasto; igual que el repostaje: Idempotency-Key, expenseId y no se borra con el gasto vivo
PATCH/v1/flota/multas/:idp. ej. { "status": "PAID" }
PATCH/v1/expenses/:id/vehicle{ vehicleId, kind? } — imputar un gasto ya registrado (scope comercial:write)

Los litros van en centilitros enteros (45,30 l = 4530) y el dinero en Rappen, como en el resto de la API. Un repostaje con menos kilómetros que uno anterior (o más que uno posterior) del mismo vehículo responde 422. El coste sale de los gastos contabilizados: POST /v1/expenses acepta vehicleId y vehicleCostKind (FUEL · REPAIR · INSURANCE · TAX · LEASING · TOLL_PARKING · FINE · OTHER). Los repostajes y las multas no crean asientos.

Taller, MFK con defectos, kilómetros y neumáticos

GET/v1/flota/reparaciones?vehicleId=&abiertas=1reparaciones (las abiertas primero) con su lista de tareas (items), sus facturas (expenses, con voided), invoicedNetMinor / invoicedGrossMinor, daysInShop (de la estancia en curso) y underWarranty (abierta con el vehículo en garantía el día de su apertura: la fecha contra openedOn y los km contra la lectura más alta del contador hasta ese día, o los de hoy si no hay ninguna)
GET/v1/flota/reparaciones/:iduna reparación
POST/v1/flota/reparaciones{ vehicleId, title, reason?, workshop?, supplierId?, estimateMinor?, status?, items?: ["…"], notes? }
PATCH/v1/flota/reparaciones/:idcambio parcial de la ficha (título, taller, presupuesto, fechas, notas); el estado va aparte
POST/v1/flota/reparaciones/:id/estado{ status, on? } — PENDING · IN_SHOP · WAITING_PARTS · READY · CLOSED · CANCELLED; para y devuelve el vehículo (ver abajo). Una cerrada solo se reabre (a PENDING) o se anula; una anulada solo se reabre. Pedir el estado que ya tiene responde changed: false y no emite webhook
DELETE/v1/flota/reparaciones/:idsolo si nunca se ha cerrado (wasClosed), no está anulada y no tiene facturas vinculadas; si no, 422 (es historial: se anula)
POST/v1/flota/reparaciones/:id/tareas{ text, costMinor? } — añade una tarea; responde con la reparación
PATCH/v1/flota/reparaciones/:id/tareas/:itemId{ done?, text?, costMinor? } — marcar, desmarcar o corregir
DELETE/v1/flota/reparaciones/:id/tareas/:itemIdquita la tarea; responde con la reparación
GET/v1/flota/reparaciones/:id/gastos-libresgastos «Taller» (REPAIR) de ese vehículo aún sin reparación, sin anular
POST/v1/flota/reparaciones/:id/gastos{ expenseId } — vincula la factura del taller (tiene que estar imputada al mismo vehículo); con clave, además comercial:write
DELETE/v1/flota/reparaciones/:id/gastos/:expenseIdla desvincula (el gasto y su asiento no cambian)
GET/v1/flota/talleresproveedores activos para elegir como taller (solo id y name)
POST/v1/flota/vencimientos/:id/defectos{ defects: ["…"], representOn, workshop?, supplierId?, title?, label? } — MFK con defectos: abre la reparación y mueve la MFK en una transacción; responde { repair, deadline }
GET/v1/flota/neumaticos?vehicleId=juegos de neumáticos (el montado primero) con warnings
POST/v1/flota/neumaticos{ vehicleId, season, size?, brand?, dot?, treadMm?, storedAt?, status? }
PATCH/v1/flota/neumaticos/:idcambio parcial (medida, marca, DOT, dibujo, dónde se guarda)
POST/v1/flota/neumaticos/:id/estado{ status: "MOUNTED" | "STORED" | "DISPOSED", storedAt? } — montar uno deja guardado el que estaba puesto
DELETE/v1/flota/neumaticos/:idborra un juego apuntado por error

El vehículo entra y sale del taller solo: una reparación que pasa a IN_SHOP o WAITING_PARTS pone el vehículo en OUT_OF_SERVICE si estaba en servicio (y lo recuerda en previousVehicleStatus); al pasar a cualquier otro estado lo devuelve, pero solo si sigue parado por ella y ninguna otra reparación lo tiene en el taller. Un vehículo parado a mano o dado de baja (RETIRED) no se toca. reason: MFK_DEFECTS · BREAKDOWN · DAMAGE · MAINTENANCE · OTHER. Las facturas son gastos ya contabilizados (Expense.vehicleRepairId): lo anulado, en Comercial o con Storno, se enseña con voided: true y no suma; imputar el gasto a otro vehículo lo suelta de la reparación. Vincular y desvincular exige lo mismo que PATCH /v1/expenses/:id/vehicle (dirección o contabilidad). Tras …/defectos, la MFK queda con reinspection: true y su descripción anterior en labelBefore: al marcarla hecha, la siguiente MFK recupera esa descripción.

Vencimientos por kilómetros: SERVICE, TIRES y OTHER aceptan además dueKm (y noticeKm, 1000 por defecto, e intervalKm) en el alta y el PATCH. Vencen en dueOn o al llegar el contador a dueKm, lo que llegue antes: state es lo peor de las dos reglas, kmLeft los km que faltan (negativo = pasados) y byKm dice si lo ha decidido el contador; GET /v1/flota/resumen y los avisos usan la misma regla. Un vehículo dado de alta sin kilómetros (0 km y ninguna lectura) cuenta como contador desconocido: solo manda la fecha y no se proponen km. Si el contador baja (un repostaje borrado, unos km corregidos) y el vencimiento vuelve a estar al día, su aviso se olvida y sale otra vez cuando venza de verdad. En …/hecho, nextDueKm fija los km del siguiente: sin mandarlo se proponen los del contador más intervalKm (nextKmSuggestion), y null deja el siguiente solo por fecha. «Lo que vence» trae además las reparaciones que se alargan (source: "REPAIR", kind REPAIR_IN_SHOP o REPAIR_WAITING_PARTS, con repairId, sinceOn y daysInShop): más de 7 días en el taller, en aviso; más de 21, vencidas. /v1/flota/panel trae en vehicles también inShop y waitingParts, y GET /v1/flota/vehiculos un inShop por vehículo.

Neumáticos: season = SUMMER · WINTER · ALL_SEASON; el dibujo treadMm va en décimas de milímetro enteras (1,6 mm = 16), como los litros en centilitros. warnings: TREAD_ILLEGAL (menos de 1,6 mm) y TREAD_WINTER_LOW (invierno con menos de 3 mm); avisan, nunca bloquean.

Ficha completa, garantía y campañas del fabricante

GET/v1/flota/campanas?vehicleId=&abiertas=1campañas del fabricante (llamadas a revisión), las pendientes primero; con abiertas=1, solo las que no están hechas
POST/v1/flota/campanas{ vehicleId, description, announcedOn, reference?, doneOn?, notes? } — ni el anuncio ni doneOn pueden ser futuros, y no se hace antes de anunciarse
PATCH/v1/flota/campanas/:idcambio parcial; hecha = { "doneOn": "AAAA-MM-DD" }, deshacerlo = { "doneOn": null }
DELETE/v1/flota/campanas/:idsolo una pendiente (apuntada por error); una hecha es historial y responde 422

Garantía: warrantyUntil (día) y warrantyKm (km) se mandan en el alta o el PATCH del vehículo; cubre hasta el día o los km, lo que llegue ANTES, con los dos límites incluidos. warranty (solo lectura) es null sin nada apuntado, o { active, until, km, endedBy: "DATE" | "KM" | null, daysLeft, kmLeft }; sin saber los km del vehículo solo decide la fecha. Días parado (daysStopped12m, daysStopped): días en el taller desde la entrada (inShopFrom) hasta que está lista (readyOn), el cierre o hoy, con los tramos de varias reparaciones FUNDIDOS (no se cuentan dos veces) y sin las anuladas. Una reparación que vuelve al taller después de haber salido (de «lista» a IN_SHOP otro día, o cerrada y reabierta a PENDING) archiva su estancia anterior, que sigue contando, y la nueva empieza el día de la vuelta: los días en servicio de en medio no cuentan. En un vehículo dado de baja, una estancia que se quedó abierta no se alarga hasta hoy; «12 meses» es desde el día 1 del mes de hace once meses, la misma ventana que /v1/flota/panel. Una campaña sin hacer de un vehículo que no está dado de baja sale en /v1/flota/resumen como DUE_SOON (sin correo) hasta que se marca hecha. Las campañas no emiten eventos.

Golpes y accidentes (partes de daño)

GET/v1/flota/danos?vehicleId=&abiertos=1&sinReparar=1partes con el vehículo, driverName, zones, los datos del otro y del seguro, ourTotalMinor, repaired / repairedOn / repairedBy, repair y las fotos (photos, sin el contenido). Lo pendiente sale entero y primero (los no cerrados y los daños sin reparar); debajo, los 500 cerrados y reparados más recientes. abiertos=1, solo los no cerrados; sinReparar=1, solo los daños conocidos
GET/v1/flota/danos/:idun parte
POST/v1/flota/danos{ vehicleId, occurredOn, occurredTime?, description, place?, driverId?, fault?, zones?: ["FRONT", …], otherPartyName?, otherPartyPlate?, otherPartyInsurer?, otherPartyPolicy?, otherPartyPhone?, policeReport?, policeReference?, insurer?, claimNumber?, deductibleMinor?, insurancePaysMinor?, ourCostMinor?, driverChargeMinor?, driverChargeNote?, notes? }
PATCH/v1/flota/danos/:idcambio parcial (lo que no mandas no se toca); el vehículo no se cambia, y el estado y la reparación van aparte
POST/v1/flota/danos/:id/estado{ status } — OPEN · ASSESSED · APPROVED · PAID · CLOSED, en cualquier orden; pedir el que ya tiene responde changed: false
POST/v1/flota/danos/:id/reparado{ repaired, on? } — marca el daño como reparado a mano (repairedBy: MANUAL; on ni futuro ni anterior al golpe) o lo deshace. Con su reparación CLOSED no se deshace aquí (422): se reabre la reparación
POST/v1/flota/danos/:id/reparacion{ title?, items?: ["…"], workshop?, supplierId?, inShopNow? } — abre la reparación (reason: DAMAGE, una tarea por zona si no mandas items) y la enlaza con el parte; responde { damage, repair } (la reparación ya trae el parte en damages). Un parte ya reparado, o con una reparación viva (abierta en Taller), responde 422; con una anulada, o cerrada sin dejarlo reparado, se abre otra y el enlace pasa a ella
DELETE/v1/flota/danos/:idsolo un parte OPEN sin reparación y sin fotos; si no, 422 (es historial)
GET/v1/flota/danos/:id/fotoslas fotos del parte, sin el contenido, con damagePhotoKind
POST/v1/flota/danos/:id/fotos{ fileName, mime, dataBase64, kind? } — JPG, PNG, WebP o PDF, máx. 8 MB y 40 fotos por parte
GET/v1/flota/danos/:id/fotos/:docIdel fichero, con su hash comprobado
DELETE/v1/flota/danos/:id/fotos/:docIdborra la foto; con el parte PAID o CLOSED, 422 (es prueba). Es la única ruta que la borra: DELETE /v1/archivos/:id y DELETE /v1/flota/vehiculos/:id/documentos/:docId responden 422, y el borrado en lote de Archivos la salta
GET/v1/flota/danos/estadisticas?vehiculos=id1.id2 · ?sin=id3partes de los últimos 12 meses (desde el día 1 del mes de hace once meses, por el día SUIZO del golpe) en totals, byVehicle y byDriver (driverId: null = sin conductor): count, insurancePaysMinor, ourCostMinor (franquicia + lo pagado aparte) y driverChargeMinor

El seguro solo se apunta (insurer, claimNumber, deductibleMinor, insurancePaysMinor): Ritual no tramita nada con la aseguradora. driverChargeMinor es lo anotado a cargo del conductor y no se descuenta de la nómina; necesita driverId. driverId tiene que ser una persona del centro (puede estar ya de baja). Con fault: PARKED no se admiten ni driverId ni driverChargeMinor (422). policeReference sin policeReport marca policeReport: true; policeReport: false vacía la referencia. occurredOn + occurredTime son día y hora suizos (sin hora, las 00:00), como en las multas; ni el día ni la hora pueden ser futuros. fault: OWN · OTHER_PARTY · UNKNOWN · PARKED. zones (se guardan sin repetir y en este orden): FRONT · HOOD · WINDSHIELD · ROOF · REAR · LEFT_FRONT · LEFT_REAR · RIGHT_FRONT · RIGHT_REAR · MIRROR_LEFT · MIRROR_RIGHT · WHEELS · UNDERBODY · INTERIOR. Tipos de foto (kind): OVERVIEW · DETAIL (sin kind) · OTHER_VEHICLE · PLACE · ACCIDENT_REPORT (el parte europeo firmado). Las fotos son también documentos del vehículo: GET /v1/flota/vehiculos/:id/documentos las trae con vehicleDamageId y damagePhotoKind.

repaired es el «daño conocido»: sigue en false hasta que se repara, aunque el parte esté cerrado. Cerrar la reparación enlazada (POST /v1/flota/reparaciones/:id/estado con CLOSED) lo pone en true con repairedBy: REPAIR y repairedOn = el día del cierre (nunca antes del día del golpe; corregir el cierre en la reparación lo corrige también aquí); reabrir o anular esa reparación lo devuelve a false, pero solo si lo había dado por reparado ella: una marca a mano (MANUAL) no se toca. Las reparaciones traen damages (los partes que reparan). GET /v1/flota/vehiculos trae damagesUnrepaired por vehículo y /v1/flota/panel un bloque damages: { open, unrepaired, last12, insurancePaysMinor, ourCostMinor, driverChargeMinor, top } (open = los no cerrados). «Lo que vence» trae los partes sin cerrar apuntados hace más de 14 días (desde el golpe o desde que se apuntó, lo último) a los que les falta algo: missingRepair (sin reparar y sin reparación viva, en cualquier estado) o missingClaim (sin aseguradora o sin número de siniestro, solo mientras está OPEN). Salen con source: "DAMAGE", kind: "DAMAGE_OPEN", siempre DUE_SOON, con damageId, sinceOn y daysOpen; los de vehículos dados de baja no salen, y no se avisa por correo.

Revisiones antes de salir (lo que manda el conductor)

GET/v1/flota/revisiones?vehicleId=&problemas=1&vehiculos=id1.id2 · ?sin=id3las revisiones que mandan los conductores desde el móvil: lo pendiente (con problemas, sin atender y de un vehículo que no está dado de baja) entero y primero; debajo, las 500 más recientes. Los filtros (vehicleId, problemas=1 y la selección de vehículos vehiculos o sin, como en /v1/flota/panel) se aplican ANTES de ese tope. Cada una con el vehículo, driverName, checkedAt, odometerKm, kmDoubtful, fuelLevel, items ([{ key, status, note }]), problemCount, zones, overall, notes, lo que hizo la oficina (handledAt, handledBy, handledAction, handledNote, damage, repair) y las fotos (photos, sin el contenido, con checkItem). Con problemas=1, solo las que traen algún problema
GET/v1/flota/revisiones/:iduna revisión
POST/v1/flota/revisiones/:id/parte{ description?, zones? } — abre un parte de daño (el mismo que POST /v1/flota/danos, con sus reglas) con el vehículo, el día de la revisión, quien la hizo como driverId, fault: UNKNOWN, la descripción (sin mandarla, los problemas y la nota del conductor) y las zonas (sin mandarlas, las que marcó el conductor); las fotos de la revisión pasan al parte como DETAIL. Responde { check, damage }
POST/v1/flota/revisiones/:id/reparacion{ title?, items?: ["…"], reason? } — abre una reparación PENDING (la misma que POST /v1/flota/reparaciones) con una tarea por problema si no mandas items; reason por defecto OTHER. Responde { check, repair }
POST/v1/flota/revisiones/:id/descartar{ note } — la da por atendida sin parte ni reparación; la nota es obligatoria
GET/v1/flota/revisiones/:id/fotos/:docIduna foto de la revisión, con su hash comprobado (404 si no es de esa revisión)

Cada revisión se atiende una sola vez: una ya atendida, o una sin problemas (overall: OK), responde 422. Puntos (items[].key, siempre los siete y en este orden): LIGHTS · TIRES · FLUIDS · CLEAN · DOCUMENTS · SAFETY_KIT · NEW_DAMAGE; status: OK · PROBLEM. fuelLevel: EMPTY · QUARTER · HALF · THREE_QUARTERS · FULL. handledAction: DAMAGE_OPENED · REPAIR_OPENED · DISMISSED. Las fotos son también documentos del vehículo (GET /v1/flota/vehiculos/:id/documentos las trae con vehicleCheckId y checkItem). Los kilómetros nunca impiden guardar una revisión: si cuadran y van por delante de la ficha, la ponen al día y dejan una lectura del contador con source: "CHECK" (nunca la bajan); si son menos que la ficha, más de 10'000 por encima, o las revisiones de las últimas 24 h suman más de 10'000 sobre la última lectura que no es de una revisión de hoy, la revisión se guarda con kmDoubtful: true y ni la ficha ni las lecturas cambian (se corrigen, si hace falta, con correctOdometer). Las fotos de una revisión no se borran por ninguna puerta (DELETE /v1/archivos/:id y DELETE /v1/flota/vehiculos/:id/documentos/:docId responden 422; el borrado en lote las salta); si pasaron a un parte, DELETE /v1/flota/danos/:id/fotos/:docId solo las quita del parte. Borrar el parte (DELETE /v1/flota/danos/:id) o la reparación (DELETE /v1/flota/reparaciones/:id) que se abrió desde una revisión la devuelve a pendiente. La reparación trae checks ([{ id, checkedAt, photos }]): la revisión de la que salió. Mientras una revisión con problemas no se atiende, GET /v1/flota/resumen la trae como source: "CHECK", kind: "CHECK_ISSUES", siempre DUE_SOON, con checkId, problemCount, sinceOn (el día de la revisión) y en label quién la hizo; los de vehículos dados de baja no salen. El aviso a la oficina va solo a la campana (sin correo), a las cuentas que pueden leer Flota. Un vehículo con revisiones tiene historial y no se borra.

La revisión en sí la manda el conductor desde el panel (pantalla «Mi vehículo», rol DRIVER) por rutas /v1/me/vehiculos…: son solo del panel, no forman parte de esta API y responden 403 a una clave rk_. Solo dejan tocar los vehículos de los que esa persona es conductor habitual o que le toca conducir hoy según la planificación (no dados de baja), y con su ficha de empleado de alta: ir de acompañante no cuenta —la revisión antes de salir la hace quien conduce, y con el vehículo viajan los datos del cliente—, así que a un acompañante GET /v1/me/vehiculos le devuelve la lista vacía y el resto le responde 404. Fotos: solo JPG, PNG o WebP de 1,5 MB como mucho, de un punto marcado como problema, hasta dos horas después de la revisión (también si la oficina ya la atendió: si abrió un parte, la foto va también al parte), 12 por revisión y 40 al día por cuenta. El móvil manda un clientRef al abrir la revisión: reenviarla tras perder la respuesta devuelve la misma (200) en vez de crear otra. Los «no» al conductor llevan un code (NOT_LINKED, INACTIVE_EMPLOYEE, NOT_FOUND, TOO_MANY_CHECKS, PHOTO_WINDOW, PHOTO_TOO_LARGE, PHOTO_DAILY_CAP, TOO_MANY_PHOTOS, PHOTO_TYPE, PHOTO_NOT_PROBLEM, CLIENT_REF_TAKEN) para decirlo en su idioma. El rol DRIVER no entra por defecto en ningún módulo (tampoco en Flota); el centro puede darle alguno en Usuarios › Permisos. Su GET /v1/me trae de la suscripción solo modules. Los avisos de vencimientos que le llegan como conductor habitual son solo los que le importan para salir (MFK, revisión, neumáticos, viñeta, tacógrafo) y sin la descripción libre de la oficina.

Planificación: qué vehículo va a qué trabajo y con quién

GET/v1/flota/asignaciones?desde=&hasta= · ?jobId= · ?pickupId= · ?vehicleId=los viajes de un periodo (días suizos, 62 como mucho), de un trabajo, de una recogida o de un vehículo. Cada uno con el vehículo, day/from/to (hora suiza) y startsAt/endsAt, job (number, title, siteAddress, customerName, closed) o pickup, driverId/driverName, helpers, notes, los km (startOdometerKm, endOdometerKm, km) y su coste congelado (costPerKmRp, costMinor), startKmSuggestion con startKmSource ("TRIP" = el contador con el que volvió el viaje anterior de ese vehículo ese día, "CHECK" = la revisión antes de salir), tripSize y los avisos (warnings). Quien no puede leer Trabajos o Recepción recibe job/pickup recortados al número del trabajo o al código de la recogida (sin título, dirección ni cliente)
GET/v1/flota/asignaciones/opcioneslo que se puede elegir al asignar: trabajos abiertos (con su carga estimada y su equipo) y recogidas agendadas, sin importes; solo de los módulos que tiene el centro y que el puesto puede leer (una clave rk_ necesita el ámbito de lectura del módulo: con flota:read a secas no llegan ni trabajos ni recogidas). Admite ?q= (número o título) y ?jobId= para que el trabajo que ya lleva el viaje salga siempre en la lista
GET/v1/flota/asignaciones/:idun viaje
POST/v1/flota/asignaciones{ vehicleId, date, from, to, jobId?, pickupOrderId?, driverId?, helperIds?, notes? } — date en AAAA-MM-DD y from/to en HH:MM suizas; una vuelta a la misma hora o antes es del día siguiente; 24 h como mucho. A un trabajo o a una recogida, no a los dos
PATCH/v1/flota/asignaciones/:idcambio parcial: lo que no mandas no se toca ni se vuelve a comprobar
DELETE/v1/flota/asignaciones/:idlo quita; de un trabajo cerrado, solo si no tiene km apuntados
POST/v1/flota/asignaciones/:id/km{ startOdometerKm?, endOdometerKm?, km? } — los dos contadores o los km a secas (todo null los quita); congela costPerKmRp y costMinor para el trabajo (corregir después solo los km mantiene la tarifa congelada). Solo con el viaje ya salido (antes de su hora de salida, 422: serían km que nadie ha hecho), ni en un trabajo cerrado; más de 1'500 km en un viaje, 422
POST/v1/flota/alquiladosun vehículo alquilado para un trabajo: los campos del alta de un vehículo con rentedFrom y rentedTo obligatorios (ownership queda en RENTED)

Lo que bloquea (422 con el motivo en error): el mismo vehículo en dos franjas que se pisan (code: "VEHICLE_BUSY") y una persona, de conductor o de acompañante, en dos vehículos a la vez (code: "PERSON_BUSY"), los dos con conflict = { assignmentId, vehicleId, startsAt, endsAt, jobId, jobNumber, pickupOrderId, employeeId? } (recortado igual que las listas para quien no lee ese módulo); un vehículo dado de baja; uno parado o en el taller (una reparación IN_SHOP o WAITING_PARTS) cuando el viaje toca hoy —para otro día no bloquea, avisa, porque puede estar arreglado—; uno alquilado fuera de sus días; un trabajo cerrado o una recogida anulada como destino NUEVO; y un conductor cuyo carnet no vale para el vehículo el día del viaje (los mismos mensajes que defaultDriverId, CZV caducado incluido). Un viaje de un trabajo ya cerrado se puede cambiar o quitar mientras no tenga km; con km, 422. Al cambiar (PATCH) solo se comprueban las personas que ENTRAN, así que alguien que ya no está de alta no deja el viaje bloqueado. Dos asignaciones del mismo vehículo con la misma franja y el mismo conductor (o una sin conductor) son el mismo viaje con dos cargas, no una doble reserva: sus km se apuntan una sola vez (code: "TRIP_KM_TAKEN" en la segunda) y dos viajes del mismo vehículo con contadores que se pisan responden code: "ODOMETER_OVERLAP". Lo que solo avisa (warnings: [{ code, message, rule?, … }]): VOLUME (loadL, capacityL) y WEIGHT (loadKg, capacityKg) con la carga de todo el viaje contra cargoVolumeL/payloadKg —si el mismo trabajo va repartido entre varios vehículos a la vez, contra la SUMA de sus capacidades, con splitAcross—, EQUIPMENT (missing), SEATS (crew, seats), SUNDAY/HOLIDAY (date) y NIGHT para más de 3,5 t (22:00–05:00; regla general SVG art. 2 ap. 2 y VRV art. 91-92, con excepciones con permiso; los festivos son los ocho federales —Año Nuevo, Viernes Santo, Lunes de Pascua, Ascensión, Lunes de Pentecostés, 1 de agosto, Navidad y San Esteban—, calculados desde la Pascua: los cantonales no están), NO_DRIVER, LICENSE_UNKNOWN (category), VEHICLE_RETIRED, VEHICLE_IN_SHOP, VEHICLE_OFF, VEHICLE_NOT_RENTED, PICKUP_CANCELLED, PICKUP_MOVED y NO_RATE (km apuntados sin CHF/km fiable). Los tiempos de conducción y descanso (OTR 1) todavía no se comprueban.

Vehículo: equipment (TAIL_LIFT · FURNITURE_LIFT · TOW_HITCH · STRAPS · BLANKETS · HAND_TRUCK) y, en uno RENTED, rentedFrom/rentedTo (los dos o ninguno) se mandan en el alta o el PATCH. El CHF/km que se congela es el de /v1/flota/costes del vehículo en los 12 meses que terminan el día del viaje, sin los gastos imputados también a un trabajo, y solo si en esa ventana hay al menos 500 km recorridos y sale una tarifa creíble (tope de 20 CHF/km, y de 50'000 CHF por viaje): si no, costPerKmRp y costMinor quedan en null, el viaje avisa con NO_RATE y el trabajo lo dice con hasUnpricedTrips. Un vehículo con viajes planificados tiene historial y no se borra, y tampoco se le puede recortar el alquiler dejando fuera un viaje ya planificado ni darlo de baja con viajes por delante (422 diciendo cuántos y cuándo): primero se quitan o se mueven. «Lo que vence» (/v1/flota/resumen) trae con source: "PERMIT", kind: "JOB_PERMIT" (jobId, jobNumber, permitStatus; dueOn = inicio del trabajo) el permiso de zona de carga de un trabajo abierto que empieza en 5 días o menos y no está concedido (vencido si ya empezó y no ha terminado). El conductor ve en GET /v1/me/vehiculos también los vehículos que le toca conducir hoy, con trips (hora, número y título del trabajo, lugar, nombre y teléfono del cliente, o los datos de la recogida sin el importe a cobrar, los nombres de quien le acompaña y la nota de la oficina en crewNote); sin importes. También ve la segunda carga del mismo viaje aunque esa asignación no lleve conductor. Borrar una recogida se lleva por delante sus viajes sin km (con km, 422: primero se quitan), y anularla o moverla deja el viaje en su sitio con el aviso PICKUP_CANCELLED o PICKUP_MOVED. Las asignaciones no emiten eventos.

Eventos: fleet.vehicle.created (también al dar de alta uno con POST /v1/flota/alquilados) · fleet.vehicle.updated (los dos SIN los campos del uso privado: privateUse* es un dato de nómina y no sale por webhook) · fleet.deadline.completed · fleet.deadline.due (sale una vez por vencimiento, cuando entra en su plazo de aviso; trae dueKm y kmLeft) · fleet.fuel.created · fleet.fine.created · fleet.repair.opened (a mano, con una MFK con defectos, desde un parte de daño o desde una revisión antes de salir) · fleet.repair.status_changed · fleet.damage.opened (al apuntar un parte, también desde una revisión antes de salir; trae el parte sin el contenido de las fotos). Las revisiones en sí no emiten eventos.

Normas suizas: LSVA, tacógrafo y parte privada

GET/v1/flota/lsva?mes=AAAA-MMel informe de la LSVA de un mes: por cada vehículo sujeto (más de 3'500 kg de peso total, no remolque) km, startKm/startOn (la lectura de la que parte el mes), endKm/endOn (la más alta del mes), readingsUsed, gapStart (la lectura de la que parte el mes está a más de 3 días del día 1), gapEnd (la última lectura DENTRO del mes está a más de 3 días del último día, o de hoy en el mes en curso; una lectura de después del mes no lo tapa) y declaration (la constancia, o null; con correctedOn/correctedBy si se corrigió); además completed (el mes ya terminó), deadline (día orientativo), rule, totals y flatRate + flatRateNote: los vehículos de pasajeros de más de 3'500 kg (kind: "CAR", o "VAN" con 10 plazas o más) pagan la LSVA a tanto alzado, no salen en rows ni se declaran aquí (422)
POST/v1/flota/lsva/declaraciones{ vehicleId, month, kmDeclared?, reference?, notes? } — deja constancia de la declaración de un mes ya terminado (una por vehículo y mes). Sin kmDeclared, los calculados; se guardan los dos (kmDeclared y kmComputed). Repetirla la CORRIGE y solo cambia lo que se manda: sin kmDeclared se quedan los km declarados, sin reference/notes se quedan los que había (null los borra); declaredOn, declaredBy y kmComputed no cambian y la corrección se apunta en correctedOn/correctedBy. Un vehículo no sujeto, uno de pasajeros (tanto alzado) o un mes sin terminar responde 422
DELETE/v1/flota/lsva/declaraciones/:idquita la constancia (se marcó por error): el mes vuelve a /v1/flota/resumen si es de los tres últimos
POST/v1/flota/conductores/:employeeId/tarjeta«tarjeta de conductor descargada hoy»: desde ahí cuentan los 28 días siguientes. Solo con tachoCard: true en su carnet (422 si no)
GET/v1/flota/parte-privada?mes=AAAA-MMla parte privada de un mes por persona, sacada del HISTORIAL (no del estado de hoy del vehículo): una fila por periodo que toca el mes, con periodId, priceMinor, shareMinor (null sin precio), sinceOn, untilOn (último día; null si sigue), startedInMonth, endedInMonth, vehicleStatus y employeeDeleted (la ficha de la persona se borró: sale con el nombre que tenía); además minMinor, ratePerMille, rule, note, deletedNote y totals. Solo con acceso a la NÓMINA en la matriz de permisos (por defecto ADMIN y ACCOUNTANT); si no, 403. Una clave rk_ recibe siempre 403
PUT/v1/flota/parte-privada/ajustes{ minMinor } — el mínimo mensual del centro, en Rappen (15000 = CHF 150 por defecto). Pide escribir en la nómina; una clave rk_, 403

LSVA: los km de un mes son los de /v1/flota/costes para ese mes (la lectura más alta del mes menos la última anterior al mes, cada lectura en su día SUIZO). Ritual no presenta nada a la AFD/BAZG: la declaración se hace allí «según la normativa LSVA», y deadline (20 días tras acabar el mes) es solo orientativo, confirma los plazos con la AFD/BAZG. Mientras un mes terminado de un vehículo sujeto y EN SERVICIO (status: "ACTIVE") no tenga constancia, GET /v1/flota/resumen lo trae con source: "LSVA", kind: "LSVA_DECLARATION" y month (en aviso desde que acaba el mes, vencido pasado deadline; los tres últimos meses, nunca antes de septiembre de 2026 ni del alta del vehículo, sin correo). Fuera de servicio sale en el informe pero no en /v1/flota/resumen. Un vehículo sin grossWeightKg no cuenta como pesado; un remolque nunca (sus km van con el vehículo tractor). Las declaraciones no emiten eventos.

Tacógrafo: la memoria del vehículo sigue siendo el vencimiento TACHOGRAPH (90 días); la tarjeta de conductor va en el carnet: PUT …/carnet acepta tachoCard (sin mandarlo no cambia) y GET /v1/flota/conductores trae tachoCardNextOn y, en license, tachoCardDownloadedAt/tachoCardDownloadedBy. Con tarjeta, /v1/flota/resumen trae source: "LICENSE", kind: "TACHO_CARD": PENDING si nunca se descargó, en aviso 5 días antes de los 28 y vencida después. Los tiempos de conducción y descanso (OTR 1) no se calculan: no hay lectura de los datos del tacógrafo.

Parte privada: el vehículo lleva privateUseEmployeeId, privateUsePriceMinor (precio de compra sin IVA, en Rappen; sin mandarlo al marcar el uso privado se toma el costMinor del activo fijo enlazado, pero SOLO si el centro deduce el IVA soportado —método efectivo y con IVA—; con el método de saldo queda vacío) y privateUseFrom (sin mandarlo, hoy; al cambiar de persona, el día de la anterior no vale y se toma hoy), en el alta y el PATCH; GET /v1/flota/vehiculos añade privateUseEmployee y privateUsePriceFromAsset, y la ficha completa un bloque privateUse con shareMinor. Cada cambio de persona, quitarlo o dar de baja el vehículo cierra el periodo del historial y el informe lo respeta. Estos campos son dato de nómina: sin acceso a la nómina (y siempre con una clave rk_) la lista, el alta, el PATCH, la ficha completa (privateUse: null) y los webhooks salen SIN ellos, y los que se manden se ignoran. La cuota es max(minMinor, round(priceMinor × 0,9 %)) por mes (práctica de la AFC: Wegleitung zum Lohnausweis, Rz 21 ss., versión 2022; antes Merkblatt N1/2007). No se añade a ninguna nómina: la API la devuelve siempre con note = «No se ha añadido a la nómina: pendiente de confirmar con el fiduciario», y un mes empezado a medias se cuenta entero.

06f Alquiler de vehículos

Los vehículos de la flota alquilados a clientes: disponibilidad, contrato, entrega y devolución firmadas, cargos y factura. Es un complemento que se contrata aparte del plan y necesita flota, clientes y facturas: sin el complemento activo, estas rutas responden 403.

GET/v1/alquiler/ajusteslos ajustes del módulo: { rentalTreatment, deductibleTreatment, fineTreatment, feeTreatment, deductibleVat } — el tratamiento de IVA de cada concepto, uno de los de las líneas de factura (STANDARD, REDUCED, ACCOMMODATION, EXEMPT, THERAPEUTIC): alquiler y sus cargos (extras, km, combustible, limpieza, retraso), franquicia por daños, multa repercutida y tasa de gestión. deductibleVat (obsoleto) se deriva: false si la franquicia va exenta
PUT/v1/alquiler/ajustes{ rentalTreatment?, deductibleTreatment?, fineTreatment?, feeTreatment? } — solo cambia lo que se manda; lo decide el centro con su fiduciario. Por defecto STANDARD, STANDARD, EXEMPT, STANDARD (lo que se hacía antes), y solo afecta a las facturas que se emitan después. Las líneas con IVA desglosan el importe (los precios del alquiler llevan el IVA incluido). Se sigue aceptando { deductibleVat } (obsoleto): true → STANDARD, false → EXEMPT; si llegan los dos, manda deductibleTreatment
GET/v1/alquiler/vehiculosla flota con su ficha de alquiler (profile, null si no se alquila) y requiredCategories, las categorías de carnet que pide el vehículo (salen de su peso y sus plazas: no se guardan)
PUT/v1/alquiler/vehiculos/:id/ficha{ rentable?, category?, dailyPriceMinor, weeklyPriceMinor?, includedKmPerDay?, extraKmPriceRp?, depositMinor?, deductibleMinor?, minDriverAge?, minLicenseYears?, fuelQuarterMinor?, cleaningMinor?, lateRule?, latePriceMinor?, notes? } — crea o cambia la ficha. Todo en Rappen; extraKmPriceRp es el precio de UN kilómetro
DELETE/v1/alquiler/vehiculos/:id/fichaquita la ficha (no se puede con alquileres reservados o en marcha)
GET/v1/alquiler/calendario?desde=AAAA-MM-DD&dias=28la rejilla de disponibilidad: por vehículo alquilable, blocks (lo que lo ocupa) y freeDays. 62 días como mucho
GET/v1/alquiler/contratos?estado=&vehicleId=&customerId=&desde=&hasta=los contratos, con su tarifa congelada, quién conduce, extras, cargos y las actas de entrega y devolución
GET/v1/alquiler/contratos/:idun contrato
POST/v1/alquiler/contratos{ customerId, vehicleId, fromDate, fromTime?, toDate, toTime?, pickupPlace?, returnPlace?, notes?, extras?, drivers } — reserva. drivers lleva al menos a quien firma; fromTime/toTime son horas suizas (por defecto 09:00). Numera RA-AAAA-00001
PATCH/v1/alquiler/contratos/:idcambio parcial. Con el vehículo ya entregado no se cambian ni las fechas, ni el vehículo, ni el cliente, ni los extras
POST/v1/alquiler/contratos/:id/anular{ reason? } — deja de bloquear el vehículo. Con el vehículo fuera, primero hay que registrar la devolución
DELETE/v1/alquiler/contratos/:idsolo uno reservado o anulado y sin entrega ni devolución firmadas
POST/v1/alquiler/contratos/:id/conductores{ fullName, birthDate?, licenseNumber?, licenseCategories?, licenseIssuedOn?, licenseExpiresOn?, notes? } — un conductor adicional (5 por contrato como mucho)
PATCH/v1/alquiler/contratos/:id/conductores/:driverIdcorrige sus datos
DELETE/v1/alquiler/contratos/:id/conductores/:driverIdquien firma el contrato no se puede quitar
POST/v1/alquiler/contratos/:id/conductores/:driverId/revisado«he visto el carnet original»: apunta quién y cuándo
POST/v1/alquiler/contratos/:id/fianza{ amountMinor?, method?, reference?, note? } — apunta la fianza RETENIDA. Es un dato, no un asiento
POST/v1/alquiler/contratos/:id/fianza/devolver{ retainedMinor?, note? } — la libera reteniendo lo que se diga (nunca más de lo retenido); una sola vez: una segunda devolución responde 422
POST/v1/alquiler/contratos/:id/entrega{ odometerKm, fuelLevel, signedName?, notes?, licenseSeen? } — entrega el vehículo. Comprueba el carnet de TODOS los conductores y guarda los daños que el vehículo ya tenía. licenseSeen: true es la confirmación de haber visto el carnet ORIGINAL: sin ella (y sin que alguien lo haya marcado antes con …/revisado) responde 422, y con ella queda apuntado quién y cuándo
POST/v1/alquiler/contratos/:id/devolucion{ odometerKm, fuelLevel, dirty?, newZones?, signedName?, notes?, labels? } — devolución: calcula los cargos y, con newZones, abre el parte de daño. Registrarla otra vez rehace los cargos automáticos
POST/v1/alquiler/contratos/:id/cargos{ kind?, label, qty?, unitMinor?, amountMinor? } — un cargo a mano
PATCH/v1/alquiler/contratos/:id/cargos/:chargeIdcorrige un cargo (deja de ser automático)
DELETE/v1/alquiler/contratos/:id/cargos/:chargeIdlo quita
POST/v1/alquiler/contratos/:id/factura{ date? } — emite la factura QR con una línea por cargo. Responde { contract, invoice }
GET/v1/alquiler/contratos/:id/fotoslos metadatos de las fotos del contrato (de las actas y de los carnés), sin el contenido
GET/v1/alquiler/contratos/:id/fotos/:docIduna foto, con su hash comprobado (404 si no es de ese contrato)
POST/v1/alquiler/contratos/:id/eventos/:eventId/fotos{ fileName, mime, dataBase64, kind? } — una foto del acta (o la firma, con kind: SIGNATURE)
DELETE/v1/alquiler/contratos/:id/eventos/:eventId/fotos/:docIdla borra (no con el contrato ya facturado)
POST/v1/alquiler/contratos/:id/conductores/:driverId/fotos{ fileName, mime, dataBase64, kind } — la cara o el dorso del carnet (LICENSE_FRONT · LICENSE_BACK), 4 como mucho
DELETE/v1/alquiler/contratos/:id/conductores/:driverId/fotos/:docIdla borra
POST/v1/alquiler/multas/:id/reasignarla multa pasa a cargo de quien tenía el vehículo alquilado (estado CHARGED_TO_RENTER, con su rentalContractId). 422 si no cae dentro de ningún alquiler de ese vehículo
POST/v1/alquiler/multas/:id/factura{ feeMinor?, feeLabel?, date? } — factura la multa al cliente: la sanción sin IVA (EXEMPT: no es una prestación del centro) más la tasa de gestión, que sí lo lleva. La multa se bloquea y se guarda la factura en renterInvoiceId: pedirla dos veces responde 422 (una factura emitida es inmutable)
POST/v1/alquiler/contratos/:id/confirmarconfirma una petición que entró por internet: REQUESTED → RESERVED. El vehículo ya estaba bloqueado desde que entró, así que confirmar no puede chocar con nada. Idempotente
PUT/v1/alquiler/vehiculos/:id/preparacion{ prepStatus, note? } — READY · TO_CLEAN · BLOCKED. La devolución lo deja solo en TO_CLEAN; con TO_CLEAN o BLOCKED no se entrega, y BLOCKED además lo saca de la disponibilidad
GET/v1/alquiler/online/ajusteslos ajustes de la reserva por internet. Sin fila, los de fábrica: enabled: false
PUT/v1/alquiler/online/ajustes{ enabled?, requireConfirmation?, minLeadHours?, maxDaysAhead?, minDays?, maxDays?, pickupFrom?, pickupTo?, returnFrom?, returnTo?, closedWeekdays?, payMode?, depositPercent?, depositMinMinor?, termsText?, termsUrl?, pickupPlace? }. 422 si se enciende sin condiciones de alquiler escritas. Desde el 06-10-2026, solo dirección y contabilidad desde el panel: una clave rk_ recibe 403 aquí y en PUT /v1/alquiler/ajustes (IVA, cobro y fianza)
GET/v1/alquiler/claseslas clases con las que se vende por internet, con hasPhoto y cuántos vehículos alquilables tiene cada una (la foto NO viaja en la lista: pesa)
POST/v1/alquiler/clases{ name, description?, seats?, doors?, cargoVolumeL?, transmission?, fuel?, photoDataUrl?, bookableOnline?, position? } — transmission: MANUAL · AUTOMATIC. La foto va como data: (máx. 400 KB)
PATCH/v1/alquiler/clases/:idcambio parcial. Renombrarla arrastra el texto category de las fichas de sus vehículos
DELETE/v1/alquiler/clases/:idla borra. 422 con el número de vehículos si todavía la usa alguno
GET/v1/alquiler/clases/:id/foto{ photoDataUrl } para el panel (la CSP no admite blob:)
GET/v1/alquiler/temporadaslas temporadas, con fromDay/toDay INCLUSIVE
POST/v1/alquiler/temporadas{ name, categoryId?, fromDay, toDay, dailyPriceMinor?, weeklyPriceMinor?, minDays?, position? } — categoryId vacío = todas las clases. Un precio vacío no toca ese precio
PATCH/v1/alquiler/temporadas/:idcambio parcial
DELETE/v1/alquiler/temporadas/:idla borra (los contratos ya firmados llevan su precio dentro)
GET/v1/alquiler/extrasel catálogo de extras del centro
POST/v1/alquiler/extras{ key, label?, priceMinor, perDay?, maxQty?, bookableOnline?, position? }
PATCH/v1/alquiler/extras/:idcambio parcial
DELETE/v1/alquiler/extras/:idlo borra del catálogo (no toca los contratos, que llevan su copia)
GET/v1/alquiler/cifras?from=AAAA-MM-DD&to=AAAA-MM-DDocupación, ingreso NETO, coste y margen por vehículo (rows) y por clase (byCategory), más totals. occupancyBps en puntos básicos (8350 = 83,50 %) y null sin días disponibles: no es un 0 %. Máximo 5 años por consulta

Rutas públicas del alquiler

Son el escaparate, no una integración: no llevan clave de API. Como las de reservas y las de la tienda, piden el slug del centro y su token de sitio (?t= o la cabecera X-Site-Token), y cuelgan de /public/*, que el proxy ya enruta. El centro tiene que tener el complemento alquiler Y el módulo reservas, la suscripción al corriente y la reserva online encendida: si falta cualquiera de las cuatro cosas responden 404, sin decir cuál.

GET/public/:tenant/alquilerhorarios, antelación, condiciones, clases publicadas y extras. SIN precios: un precio sin fechas no existe (hay temporadas)
GET/public/:tenant/alquiler/clases/:slug/fotola foto de una clase, con Cross-Origin-Resource-Policy: cross-origin (se ve desde la web del centro) y caché de un día
GET/public/:tenant/alquiler/disponibilidad?desde=&horaDesde=&hasta=&horaHasta=qué clases quedan libres y a qué precio (con IVA). Devuelve problems con los «no» de la ventana de reserva y, por clase, available, left (topado en 3), rentalMinor, includedKm y depositMinor. Nunca ids de vehículo ni matrículas
POST/public/:tenant/alquiler/reservas{ categoria, desde, horaDesde, hasta, horaHasta, extras?, nombre, email, telefono?, nacimiento?, carnet?, carnetCategorias?, carnetDesde?, carnetHasta?, nota?, totalMinor?, acceptTerms: true, acceptPrivacy: true, lang?, captchaToken?, website? }. Crea el contrato en REQUESTED (o RESERVED si el centro no pide confirmación) y manda los dos correos

Cómo se protege ese POST: honeypot (website lleno → respuesta indistinguible de una real), reCAPTCHA obligatorio si el centro lo tiene configurado (acción rental), 5 peticiones por minuto y por IP en su propio presupuesto, un techo de 60 reservas por centro y día y otro de 5 por correo y día, los dos contados en la base y SIN contar las anuladas (contándolas, 60 reservas basura cerraban el canal 24 h aunque el centro las anulara todas). La BÚSQUEDA tiene su propio tope (30 por minuto y por IP) y un máximo de 90 días por consulta: cotizar recorre un día por vehículo libre, y una consulta de 1950 a 2200 bloqueaba el servidor. La comprobación de X-Embed-Origin es la misma que la de la tienda: en el dominio propio del centro no se mira el referente, para no dejar fuera a quien llega de Google o de un enlace de WhatsApp.

Todas las puertas contestan el MISMO 404, con el mismo cuerpo: que el centro no exista, que no tenga el complemento, que no esté al corriente o que la reserva online esté apagada son indistinguibles desde fuera. El complemento se mira ANTES que el origen.

Una petición sin confirmar CADUCA. Al crearse se le pone requestExpiresAt (de requestExpiryHours, 48 h de fábrica); pasada esa hora deja de bloquear el vehículo y de contar para los topes, sin borrarse. Confirmarla se la quita. Y un mismo cliente no puede tener dos peticiones de internet que se pisen: la segunda responde 409 con code: "ALREADY_REQUESTED" y el número de la primera.

Todavía no se cobra por internet: no hay pasarela conectada al alquiler, así que payMode solo admite NONE y PUT /v1/alquiler/online/ajustes rechaza DEPOSIT y FULL con un 422. Quien reserva paga al recoger, y eso es lo que dicen la web y el correo. El enlace de las condiciones (termsUrl) solo admite http(s)://: se pinta como enlace en el dominio del centro, y un javascript: ahí se ejecutaría en el navegador de cada visitante.

El precio que se enseña es el que se cobra. La web manda en totalMinor lo que vio; si el servidor calcula otro (otra reserva se llevó el coche más barato, o se tocó una temporada), responde 409 con code: "PRICE_CHANGED" y el precio nuevo en detail, y no reserva nada. Sin hueco libre responde 409 con code: "VEHICLE_BUSY". Los «no» de la ventana llegan con 422 y su code: LEAD_TIME · TOO_FAR · TOO_SHORT · TOO_LONG · PICKUP_HOURS · RETURN_HOURS · PICKUP_CLOSED · RETURN_CLOSED · SEASON_MIN_DAYS · TENANT_LIMIT · EMAIL_LIMIT · SPAN_TOO_LONG · ALREADY_REQUESTED (este último, 409).

Quien reserva elige una CLASE, no un vehículo. El servidor bloquea con SELECT … FOR UPDATE todos los candidatos de la clase ANTES de mirar cuál está libre (si se mira primero, dos peticiones simultáneas ven el mismo coche libre) y asigna el más barato; la oficina lo puede cambiar después. El carnet se pide pero NO rechaza: los problemas vuelven en licenseWarnings y quien decide es quien ve el carnet original en el mostrador. Las rutas públicas no exponen matrículas, ids de vehículo, nombres de otros clientes ni notas internas.

El cobro por adelantado (payMode: NONE · DEPOSIT · FULL) se apunta en el contrato (prepaidMinor, paidAt, payProvider, payRef) y no genera ningún asiento, exactamente igual que la fianza: se aplica como cobro de la factura desde Facturación.

Con clave rk_: scope alquiler:read para consultar y alquiler:write para escribir. Estados del contrato: REQUESTED · RESERVED · HANDED_OVER · RETURNED · INVOICED · CANCELLED. fuelLevel: EMPTY · QUARTER · HALF · THREE_QUARTERS · FULL (el mismo enum que las revisiones de Flota). lateRule: HOUR · DAY. Fianza (method): CASH · CARD · TRANSFER. Extras (key): CHILD_SEAT · GPS · ADDITIONAL_DRIVER · SNOW_CHAINS · ROOF_RACK · TRAILER_HITCH · DELIVERY · CLEANING · OTHER (este último exige label). Cargos (kind): RENTAL · EXTRA · EXTRA_KM · FUEL · CLEANING · LATE · DAMAGE · FINE · OTHER. Fotos (kind): FRONT · REAR · LEFT · RIGHT · INTERIOR · ODOMETER · DAMAGE · SIGNATURE · LICENSE_FRONT · LICENSE_BACK · OTHER; las zonas del dibujo son las mismas que las de un parte de daño.

Lo que bloquea (422 con el motivo en error): una franja ocupada responde además code (VEHICLE_BUSY · IN_SHOP · OUT_OF_SERVICE · NOT_RENTABLE · NOT_OURS) y conflict = { kind, startsAt, endsAt, label, contractId? }. Ocupa un vehículo: otro alquiler, un viaje interno de la planificación de Flota, el taller, estar parado o dado de baja, y los días en que un vehículo alquilado POR NOSOTROS todavía no es nuestro. La comprobación va en las dos direcciones: asignar un viaje interno a un vehículo alquilado también responde 422. El carnet se comprueba al ENTREGAR, no al reservar, y el «no» llega con code: "LICENSE" y problems = [{ code, message, name, category?, date?, years?, age? }] con TODOS los motivos (LICENSE_MISSING · LICENSE_EXPIRED · CATEGORY_MISSING · TOO_YOUNG · LICENSE_TOO_RECENT).

Cómo se calcula lo que se cobra: días EMPEZADOS de 24 h (mínimo 1); con tarifa semanal se cobra lo más barato entre días sueltos, semanas más días y semanas completas redondeando hacia arriba (13 días nunca cuestan más que 14). Con temporadas, cada día del alquiler toma la tarifa de la temporada que le toca, los días seguidos con la misma tarifa forman un tramo y cada tramo se cobra con esa regla: un alquiler de junio a agosto no se cobra entero a precio de junio ni entero a precio de agosto. El total así calculado se CONGELA en el contrato (tariff.rentalPriceMinor); los contratos anteriores a esto no lo llevan y se siguen calculando con su tarifa diaria, que da exactamente lo mismo. Kilómetros incluidos = includedKmPerDay × días (sin ese campo, kilometraje libre y no se cobra nada de más). Depósito: la diferencia de CUARTOS entre la entrega y la devolución × fuelQuarterMinor (devolverlo más lleno no se abona). Retraso: horas o días empezados × latePriceMinor, con 30 minutos de cortesía. Daños nuevos: la franquicia del contrato. Todo se guarda como cargos editables y solo se congela al facturar.

La fianza no genera ningún asiento: es un dato del contrato (importe, método, referencia, cuándo se retuvo y cuánto se retiene al devolver). Lo retenido se aplica como COBRO de la factura desde Facturación, por la puerta de siempre. Las líneas de la factura del alquiler van al tipo normal de MWST del centro y con el importe CON IVA incluido (se desglosa, no se suma encima), porque las tarifas de un mostrador se anuncian brutas.

Datos personales (nLPD): el número del carnet, sus fechas, la fecha de nacimiento y las fotos de un cliente se borran automáticamente a los dos años de su último alquiler (contando también los anulados). El contrato se conserva —lleva su factura detrás— con el nombre de quien conducía y purgedAt como constancia. El borrado lo hace un proceso diario en todo centro que tenga contratos, aunque haya dejado de pagar o se haya quitado el complemento: la obligación de borrar no se suspende con la suscripción. Se puede apagar con RENTAL_RETENTION_WORKER=off, y cada borrado deja su línea de auditoría.

El carnet NO sale del módulo. Las fotos de las actas, la firma y las del carnet son documentos del vehículo, pero no se listan ni se sirven por /v1/archivos* ni por /v1/flota/vehiculos/:id/documentos* (responden 404), ni las borra el borrado en lote del archivador: la única puerta son las rutas de fotos de este módulo. La lista de contratos (GET /v1/alquiler/contratos) devuelve los conductores SIN licenseNumber, birthDate, categorías ni fechas del carnet —solo problemCount—, y con una clave rk_ tampoco los trae la ficha de un contrato: una integración no necesita el carnet de la clientela.

IVA: cada concepto lleva el tratamiento que eligió el centro en /v1/alquiler/ajustes (uno de los de las líneas de factura): rentalTreatment para el alquiler y sus cargos, deductibleTreatment para la franquicia por daños (según la fiduciaria, indemnización fuera del impuesto o prestación gravada), fineTreatment para la multa repercutida y feeTreatment para su tasa de gestión. Por defecto STANDARD, STANDARD, EXEMPT y STANDARD. Las líneas con IVA llevan el importe CON IVA incluido (se desglosa); las exentas, bruto = neto.

Eventos: alquiler.contract.created · alquiler.contract.handed_over · alquiler.contract.returned · alquiler.contract.cancelled · alquiler.contract.invoiced (trae { contract, invoice }). Los daños nuevos de una devolución emiten además el fleet.damage.opened de Flota. El contrato que viaja en el evento va RECORTADO: número, estado, cliente (id y nombre), vehículo, fechas, importes, cargos, actas y, de cada conductor, solo name, main y licenseChecked. Ni número de carnet, ni categorías, ni fechas del carnet, ni fecha de nacimiento: un webhook va a una dirección que configura un tercero.

06g Incidencias

Lo que ha salido mal y alguien tiene que resolver (una queja, un daño, una entrega fallida, una avería), con responsable, prioridad, plazo e historial. Complemento para cualquier plan (desde el 30-09-2026).

GET/v1/incidencias?status=&assignee=&q=&customerId=&category=&linkType=&linkId={ items, counts, linkTypes }. status: active (por defecto: abiertas y en curso) · all · un estado. assignee: me · none · el id de una cuenta. counts es siempre del centro entero (open, inProgress, overdue, urgent, unassigned, mine). 500 como mucho, ordenadas por lo que pide trabajo: prioridad, plazo y fecha
GET/v1/incidencias/:idla incidencia con su historial (events)
POST/v1/incidencias{ title, description?, category?, priority?, customerId?, assigneeUserId?, dueDate?, linkType?, linkId? } — la abre (201). Numera INC-AAAA-0001. dueDate es AAAA-MM-DD y vale hasta el final de ese día en hora suiza. Sin customerId, toma el cliente de lo enlazado (remitente del paquete, cliente del trabajo, de la cita o del pedido)
PATCH/v1/incidencias/:id{ title?, description?, category?, priority?, customerId?, dueDate?, linkType?, linkId? } — cambia SOLO lo que se mande (un campo desconocido responde 400). El responsable y el estado tienen su propia ruta
POST/v1/incidencias/:id/estado{ status, note? } — RESOLVED exige note (qué se hizo; queda como resolution), si no 422 con code: "RESOLUTION_REQUIRED". Volver a OPEN o IN_PROGRESS limpia resolvedAt/closedAt. Responde { changed, incident }: pedir el estado que ya tiene no cambia nada
POST/v1/incidencias/:id/asignar{ assigneeUserId } (null la deja sin responsable) — solo a una cuenta activa que pueda abrir el módulo, si no 422. Responde { changed, incident }
POST/v1/incidencias/:id/comentarios{ text } — añade un comentario al historial (4000 caracteres como mucho)
DELETE/v1/incidencias/:idla borra con su historial (queda una línea de auditoría)
GET/v1/incidencias/responsableslas cuentas que pueden llevar una incidencia: [{ id, name, role }]
GET/v1/incidencias/enlaces?type=&q=busca lo que se puede enlazar (20 como mucho): [{ id, label }]. Solo tipos de módulos que el centro tiene; los demás devuelven []

Con clave rk_: scope incidencias:read para consultar y incidencias:write para escribir. Estados (status): OPEN · IN_PROGRESS · RESOLVED · CLOSED. Prioridad: LOW · NORMAL · HIGH · URGENT. Tipo (category): COMPLAINT · DAMAGE · DELIVERY · BREAKDOWN · BILLING · SERVICE · OTHER. Enlace (linkType), según el módulo del centro: BULTO (Paquetería) · VEHICLE (Flota) · JOB (Trabajos) · BOOKING (Agenda) · SHOP_ORDER (Tienda); se comprueba que exista y se guarda una copia legible en linkLabel. Historial (events[].kind): CREATED · COMMENT · STATUS · ASSIGNED · PRIORITY · DUE · EDITED. Una escritura hecha con una clave rk_ queda en el historial sin autor.

Con Paquetería, marcar un bulto con incidencia de entrega (POST /v1/bultos/incidencia) abre la incidencia sola (source: "PAQUETERIA", tipo DELIVERY, enlazada al bulto y con el remitente como cliente); un segundo intento fallido entra como comentario en la que ya está abierta, y entregar o devolver el bulto la da por resuelta. Solo si el centro tiene el complemento.

Con Envíos, el seguimiento automático de la Post abre una incidencia sola cuando un paquete espera en la oficina o vuelve al remitente (source: "ENVIOS", tipo DELIVERY, enlazada al pedido de la tienda o, en un envío suelto, con su cliente); un segundo aviso del mismo pedido entra como comentario y, si al final se entrega, se da por resuelta.

Texto cifrado: description, resolution y las notas del historial se guardan cifradas (una queja puede llevar datos de salud); la API las devuelve en claro a las PERSONAS con acceso desde el panel. A una clave rk_ no se le devuelven nunca (desde el 01-10-2026): en todas las respuestas llegan description: null, resolution: null y events[].note: null, aunque la clave pueda escribirlas al abrir o comentar. Por eso la búsqueda (q) mira número, título, cliente y linkLabel, no la descripción. Borrar los datos de un cliente (POST /v1/customers/:id/erase) borra también sus incidencias.

Eventos: incident.created (solo las que se abren por POST /v1/incidencias; las que abre Paquetería no lo emiten) · incident.status_changed (solo por POST /v1/incidencias/:id/estado; la que Paquetería da por resuelta sola al entregar o devolver el bulto tampoco lo emite: para seguirlas, consulta GET /v1/incidencias?linkType=BULTO&status=all) · incident.assigned. Borrar (DELETE) es solo de personas ADMIN y MANAGER: una clave rk_ recibe 403 (que la cierre con /estado). La búsqueda de enlaces y el enlazar exigen además poder leer el módulo de lo enlazado (el puesto en la matriz de permisos, o el scope de la clave: recepcion:read, flota:read, trabajos:read, agenda:read, tienda:read); sin él, la búsqueda devuelve [] y enlazar responde 403. La incidencia del evento va RECORTADA: número, título, tipo, prioridad, estado, cliente y responsable (id y nombre), plazo, enlace y fechas. Ni la descripción, ni la resolución, ni el historial: un webhook va a una dirección que configura un tercero.

06b Reservas en tu web y correo

Cómo se incrusta el motor de reservas en la web del centro, y cómo salen los avisos a sus clientes.

Incrustar el widget

El widget se sirve desde nuestro dominio y se mete en la web del centro con un <iframe>. El enlace y el código ya montados están en Ajustes → Reservas online; llevan dentro el slug del centro y su token de sitio.

<iframe src="https://ritualerp.ch/reservas/?tenant=<slug>&t=<token>&lang=de"
        style="width:100%;max-width:560px;height:780px;border:0"
        loading="lazy" title="Termin buchen"></iframe>

Tres canales, uno por página: reservas (por defecto), &view=packs (comprar un bono dejando las citas puestas) y &view=contact. El idioma se hornea en el enlace con &lang= (de · fr · it · es · en).

Errores en el idioma del visitante. Las rutas /public/… contestan los errores con { "error": "…", "code": "SLOT_TAKEN", "params"?: { … } }. El code es fijo (SLOT_TAKEN hora ya ocupada, CAPTCHA, OUT_OF_STOCK con params.name, NOT_IN_ZONE con name y zone, GENERIC…) y el widget lo traduce; error es solo un texto de respaldo. Si montas tu propia web contra estas rutas, traduce por code, no por el texto. El enlace de confirmación de la newsletter caduca a los 30 días (410 · LINK_EXPIRED), y POST /public/:tenant/forms/:token acepta lang para el idioma de ese correo.

Fichas sin verificar. Una cita, un bono o un pedido de la tienda hecho sin cuenta con el correo de un cliente que ya existe no se cuelga de ese cliente (cursos, entradas y alquiler sí, para no inscribir ni cobrar dos veces a la misma persona): va a una ficha aparte con unverifiedOfId (el cliente original) y el correo en unverifiedEmail (email va vacío: es único por centro). Se une sola cuando la persona pulsa «Soy yo» en su correo (POST /forms/verify-customer, la llama la web) o desde el panel (POST /v1/customers/:id/unite-unverified, recepción o administración; una clave rk_ recibe 403). Si integras clientes por correo, ten en cuenta que puede haber dos fichas para la misma persona hasta que se unan.

Antirrobots: ALTCHA (propio) y reCAPTCHA (en retirada). Ritual cambia el antirrobots de reCAPTCHA (Google) a ALTCHA autoalojado: una prueba de trabajo que el navegador resuelve en ~1-2 s y que comprobamos nosotros, sin cookies ni llamadas a terceros. Cuando está encendido, /site, /contact-config, /forms/:token y /cliente traen altcha: { challengeUrl: "/public/altcha/challenge" }. Flujo: GET /public/altcha/challenge (sin token de sitio; límite por IP; 404 si está apagado) devuelve { parameters, signature } (formato v2 de altcha-lib, PBKDF2/SHA-256, caduca a los 5 min); lo resuelves con altcha (widget o altcha/lib) y mandas en captchaToken el base64 de { challenge: { parameters, signature }, solution: { counter, derivedKey } }. Cada resultado vale una sola vez: el segundo envío con el mismo recibe 400 · CAPTCHA. Durante la transición, un centro (o la plataforma) que aún tenga reCAPTCHA sigue aceptando su token v3. Un ALTCHA mal hecho, caducado o repetido se rechaza siempre. Hoy es opcional: un envío sin ALTCHA se trata como antes. Cuando pase a obligatorio (se avisará con tiempo), captchaRequired será true y las altas públicas sin antirrobots recibirán 400 · CAPTCHA: prepara tu web para mandarlo ya. La demo y el alta de la landing (/public/demo-request, /public/checkout) aceptan ALTCHA pero aún no lo exigen.

Captcha y lo no pagado. Si el centro no pone su clave de reCAPTCHA, se usa la común de Ritual (cuando está configurada): las altas públicas sin captchaToken válido reciben 400 · CAPTCHA. Un pedido de la tienda de pago al recoger o por WhatsApp guarda la mercancía 2 días de apertura del centro (holdUntil: el cierre del segundo día abierto después del pedido, nunca menos de 48 h; sin horario configurado, 48 h) y un bono «pagar en el centro» 1 día de apertura (igual, nunca menos de 24 h), salvo que el centro los confirme (POST /v1/shop-orders/:id/keep, POST /v1/customer-packs/:id/keep, o confirmar una cita del bono).

Que no parezca un iframe

Dos parámetros más, para que el widget deje de ser un recuadro pegado encima de la web del centro:

Con más de 12 servicios repartidos en categorías, la lista del widget sale plegada por categorías (desde el 14-10-2026); al buscar se despliega.

Los dos son opcionales y por defecto están apagados, así que ningún embebido existente cambia de aspecto. En Ajustes → Reservas online salen ya como opciones del enlace que se copia.

Alto automático (opcional, recomendado)

Un <iframe> no se ajusta solo a su contenido: la altura del ejemplo de arriba es una conjetura. Si se pasa queda un hueco muerto debajo; si se queda corta aparece una barra de scroll dentro de la reserva. Por eso el widget publica su altura real con postMessage cada vez que cambia, y la web del centro solo tiene que escucharla:

window.addEventListener('message', (e) => {
  if (e.origin !== 'https://ritualerp.ch') return;      // solo nuestro dominio
  if (!e.data || e.data.type !== 'ritual:height') return;
  document.querySelectorAll('iframe').forEach((f) => {
    if (f.contentWindow === e.source) f.style.height = e.data.height + 'px';
  });
});

Solo se manda; el widget no acepta mensajes del padre, así que una web ajena no puede empujarlo a un tamaño absurdo. Quien no escuche sigue con su altura fija: esto no rompe ningún embebido existente.

El equipo en tu web y «reservar con ella» (desde el 14-10-2026)

Una web propia (no el widget) puede pintar las tarjetas del equipo y su catálogo con los datos del centro. Mismo slug y mismo token de sitio que el widget (cabecera X-Site-Token o ?t=), y la web pública del centro encendida:

GET/public/:tenant/site?lang=enmarca, horario y servicios; con lang, nombre, descripción y categoría en ese idioma si el centro los tradujo (si no, el texto base)
GET/public/:tenant/team?lang=en{ team: [{ id, name, title, bio, photoUrl, logoUrl, galleryUrls, bookable, serviceIds }] }: solo quien el centro marcó como visible en Equipo → Ficha web. serviceIds: null = hace todos los servicios
GET/public/:tenant/team/:id/fotola foto (JPEG/PNG/WebP/AVIF), sin token para poder ir en un <img>; 404 si el perfil no es visible
GET/public/:tenant/team/:id/logo · /galeria/:nsu logotipo y las fotos de sus trabajos (0 a 5), con las mismas reglas que la foto
GET/public/:tenant/availability?serviceId=&date=&therapistId=con therapistId, solo las horas en que ESA persona está libre y dentro de su horario
GET/public/:tenant/month?serviceId=&year=&month=&therapistId=ídem para los días del mes
POST/public/:tenant/bookingscon therapistId, la cita queda asignada a ella o no se crea (422)

El botón «Reservar con ella» de una tarjeta puede abrir el widget con &therapist=<id> (y &service=<id> para dejar el servicio elegido): enseña «Termin bei …» arriba y solo los servicios que hace esa persona.

Qué no sale nunca

La web solo ve el perfil público: el nombre que el centro escribe para la web, la especialidad, la presentación y la foto. Ni el nombre legal de la ficha ni nada de RRHH. Un therapistId de alguien no visible no da ninguna hora. Las citas de pareja no se reservan «con una persona». El perfil público solo se edita desde el panel (ADMIN): una clave rk_ recibe 403.

Si tu web declara su propia CSP

Tiene que permitir frame-src https://ritualerp.ch, o el widget saldrá en blanco sin ningún error visible.

Dominio permitido

En Ajustes → Reservas online el centro puede declarar el dominio de su web. Si lo declara, las escrituras públicas (reservar, contactar, pedir un bono, enviar un formulario) solo se aceptan desde ahí y responden 403 · Origen no permitido desde cualquier otra.

Cómo se sabe desde dónde: el widget vive en un <iframe> de otro dominio, así que la cabecera Origin de sus llamadas somos siempre nosotros y no distingue nada. Lo que identifica a la página padre es su document.referrer, que el widget reenvía en X-Embed-Origin. El apex y el www se consideran la misma web.

Alcance real de esta comprobación

Un sitio no puede falsificar el referrer de otro (lo pone el navegador), pero sí puede suprimirlo: sin cabecera se deja pasar, porque el enlace directo que se comparte por WhatsApp tampoco tiene página padre. Sube el listón, no cierra la puerta. Lo que de verdad protege son el token de sitio, el antirrobots (ALTCHA; reCAPTCHA en retirada), el honeypot y el límite por IP.

Formularios es un módulo propio desde el 17-09-2026

Las rutas /v1/forms* y /v1/form-submissions* ya no cuelgan de Marketing: una clave rk_ necesita el scope formularios:read para consultarlas y formularios:write para escribir. Con marketing:* responden 403. Los centros que tenían Marketing han recibido Formularios en su plan.

Formularios públicos: enlace, envío y errores (desde el 07-10-2026)

El formulario público ya no depende de la reserva online: GET/POST /public/:tenant/forms/:token no piden el token de sitio (ni t= ni X-Site-Token; si llega, no se mira). Basta con que el plan del centro incluya Formularios y la suscripción esté al corriente; el fm_… del formulario, aleatorio, hace de secreto. El enlace que copia el panel es https://ritualerp.ch/formulario/?tenant=<centro>&view=form&form=fm_…&lang=de. El dominio permitido de Ajustes → Reservas online sigue valiendo para el envío.

GET/public/:tenant/forms/:token?lang={ token, title, description, fields, successText, marketingOptIn, redirectUrl, recaptchaSiteKey, captchaRequired, altcha? } en el idioma pedido (lo no traducido, en el base). La clave pública del captcha viene aquí: sin reserva online no hay /contact-config
POST/public/:tenant/forms/:token{ answers, marketingConsent?, lang, captchaToken?, website? (trampa para robots), utmSource, utmMedium, utmCampaign, utmContent, utmTerm, referrer, landingPath } → 201 { ok, needsConfirm, successText, redirectUrl }

Captación y bandeja de respuestas (desde el 07-10-2026)

Cada formulario público decide qué se hace con cada respuesta: createCustomer (cliente por email, como siempre), createLead (contacto de origen FORM con el resumen de las respuestas), createOpportunity + opportunityStage (NEW · QUALIFIED · PROPOSAL · NEGOTIATION; solo con el complemento Oportunidades), ownerUserId (responsable del contacto y la oportunidad: una cuenta que pueda llevar clientes), tagId (etiqueta MANUAL de cliente, solo a clientes nuevos) y redirectUrl. En un formulario personal no actúan.

GET/v1/formslos activos; ?archived=1 solo archivados; ?status=all todos. Cada uno trae además createLead, createOpportunity, opportunityStage, ownerUserId, tagId, redirectUrl, archivedAt y canManage · formularios:read
POST/v1/formsy PATCH /v1/forms/:id aceptan esos campos · solo la administración desde el panel (una clave recibe 403) · 422 si el responsable no puede llevar clientes, la etiqueta no es manual de cliente o la dirección no es https
POST/v1/forms/:id/unarchivesale del archivo como borrador, con el mismo enlace; publicarlo es aparte · solo administración
GET/v1/forms/:id/submissions?take (1–100, 100 por defecto) &page &from &to (AAAA-MM-DD, día suizo, to incluido) &q → { form, total, page, take, submissions[] }; cada respuesta con answers, fieldsSnapshot (las preguntas tal y como se vieron al enviar; null en las antiguas), customer, lead, opportunityId, confirmed, confirmedAt, lang, utm { source, medium, campaign }, createdAt, inviteId, uploads[]. Antes devolvía hasta 500 sin páginas: hay que paginar · formularios:read
DELETE/v1/forms/:id/submissions/:sidborra UNA respuesta (y el envío personal del que salió, con sus archivos) · 204 · solo administración; cliente, contacto y oportunidad se quedan
POST/v1/forms/:id/submissions/:sid/opportunitycrea la oportunidad de la respuesta, o devuelve la que ya tiene · 201/200 { opportunityId, created } · pide además el complemento Oportunidades y poder escribir en él
GET/v1/customers/:id/form-submissionslas respuestas de un cliente (públicas y personales) para su ficha · solo desde el panel
Respuestas de clientes y claves de API

Una respuesta puede ser una anamnesis (datos de salud): una clave rk_ recibe answers: null en la bandeja, no puede buscar en ellas (?q= responde 403) y GET /v1/customers/:id/form-submissions le responde 403. Por lo mismo, un contacto de origen FORM llega con message vacío en el webhook lead.created y por clave: el resumen de las respuestas solo se lee en el panel.

Formularios personales

Un formulario en modo personal no se incrusta: el centro lo manda a un cliente desde el panel y cada envío tiene su enlace propio, https://ritualerp.ch/formulario/?lang=de#invite=fi_… (el token va tras #: el navegador no lo manda al servidor ni queda en registros). El enlace lleva el centro dentro, así que no necesita ?tenant=, token de sitio ni reserva online activada, y la regla del dominio permitido no se le aplica. Lo que escribe el cliente se guarda como borrador y puede volver con el mismo enlace hasta que caduque (90 días; reenviarlo le da al menos 30 más).

GET/public/form-inviteel formulario, lo que lleva escrito y la marca del centro · cabecera X-Form-Invite: fi_…
PUT/public/form-invite/answersguardar el borrador (no exige obligatorios) · con baseSavedAt, 409 STALE si otro dispositivo guardó antes · 409 si el envío ya está cerrado
POST/public/form-invite/submitenviar: valida los obligatorios y entrega la respuesta al centro

Son las rutas de nuestra propia página, no una integración: el enlace es la única credencial y el token viaja en la cabecera, nunca en la URL de la API. Por lo mismo, una clave rk_ no recibe nunca el enlace de un cliente: los listados de envíos no lo traen. GET/POST /public/:tenant/forms/:token solo sirve formularios públicos; uno personal responde 404 por esa vía.

Fotos y archivos (desde el 17-09-2026)

La cuota de almacenamiento se comprueba y reserva dentro de la misma transacción para todos los formularios del centro. Dos subidas simultáneas desde enlaces distintos no pueden gastar el mismo espacio disponible; la que exceda la cuota responde 422 QUOTA.

Un formulario personal puede llevar campos de tipo file: el cliente sube fotos (JPG, PNG, WebP; las HEIC del iPhone se convierten a JPG en su navegador), logos en SVG y PDF. Un formulario público no admite ese tipo: crear o cambiar uno así responde 422. La respuesta de un campo file es la lista de ids de lo subido (en el borrador, en FormSubmission.answers y en los eventos); la lista buena la decide el servidor por lo que de verdad se subió, no lo que mande la página.

POST/public/form-invite/files{ fieldId, fileName, dataBase64, thumbBase64? } · máx. 8 MB por archivo, 20 por campo, 60 por envío y 1 GB por centro · el tipo se decide por los primeros bytes · 422 con code: TYPE, TOO_BIG, EMPTY, NOT_FILE_FIELD, FIELD_FULL, INVITE_FULL, QUOTA
DELETE/public/form-invite/files/:fileIdel cliente quita uno de sus archivos mientras el envío siga abierto
GET/public/form-invite/files/:fileIdel archivo, o su miniatura con ?thumb=1 (para su propia página)
GET/v1/forms/invites/:inviteIdel envío con sus respuestas y uploads[] (id, fieldId, fileName, mimeType, sizeBytes, sin contenido) · formularios:read
GET/v1/forms/invites/:inviteId/files/:fileIddescarga el archivo (o ?thumb=1). Solo desde el panel: como las respuestas, puede ser una anamnesis o un documento de identidad, y una clave rk_ recibe 403 (desde el 06-10-2026)
DELETE/v1/forms/invites/:inviteId/files/:fileIdborra un archivo para siempre (solo ADMIN) · formularios:write
GET/v1/forms/uploads/usage{ usedBytes, quotaBytes, files } del centro · formularios:read

POST /v1/forms/:id/invites admite recipientIsOther: true: el envío es para el cliente de customerId (o del trabajo), y sus respuestas y archivos quedan en su ficha, pero lo rellena otra persona (p. ej. cada especialista de un salón). Entonces recipientName es obligatorio y ni el correo ni el idioma se toman de la ficha: sin recipientEmail no se manda correo (422 si falta el nombre).

Campo de tipo color (en cualquier formulario): se elige con un selector y la respuesta es texto con los códigos en orden, "#1E3A5F, #2EC4B6" (siempre #RRGGBB en mayúsculas, sin repetir, 8 como mucho). Un texto sin ningún código cuenta como vacío.

GET /v1/forms/:id/submissions añade a cada respuesta inviteId y uploads[]. Todo archivo sale siempre como descarga (Content-Disposition: attachment, X-Content-Type-Options: nosniff y CSP sandbox): lo sube alguien de fuera y nunca se interpreta en nuestro dominio. Los archivos no viajan en la copia de seguridad que sale por correo; sí en la copia diaria del servidor.

Cómo salen los avisos

Dos modos, en Ajustes → Avisos:

Con el dominio del centro verificado (DKIM comprobado contra el DNS), el correo gestionado sale además con la dirección del propio centro. Mientras no lo esté, sale con la de la plataforma: nunca se rompe el envío por esta causa.

Direcciones suprimidas

Un rebote definitivo o una queja de spam apuntan la dirección y el sistema deja de escribirle. Se consulta y se revierte desde Ajustes → Avisos o por API:

GET/v1/notifications/suppressionslas direcciones quemadas del centro — solo ADMIN
DELETE/v1/notifications/suppressions/:idrehabilitar una dirección — solo ADMIN

Un fallo temporal (4xx del servidor de destino, retraso de entrega) no quema ninguna dirección: solo los rechazos permanentes.

06d Tienda online en tu web

El centro administra su tienda desde el panel —catálogo, fotos, precios, pedidos, envíos, devoluciones— y la tienda se enseña donde tú quieras: incrustada en dos minutos, o construida a medida dentro de tu web.

Dos maneras, y cuándo usar cada una

Rutas públicas de la tienda

Todas llevan el slug del centro y su token de sitio (?t= o la cabecera X-Site-Token), igual que las de reservas. No necesitan clave de API: son el escaparate.

GET/public/:tenant/shopel catálogo entero + las reglas del centro (portes, recogida/envío, condiciones) + store, el perfil del centro. ?lang= para el horario
GET/public/:tenant/shop/items/:slugficha de un artículo por su dirección, con relacionados y opiniones
GET/public/:tenant/shop/images/:idfoto. ?size=thumb para la versión de 400 px
GET/public/:tenant/shop/pages/:sluguna página de la tienda (condiciones, entregas, aviso legal…) con su texto
GET/public/:tenant/shop/faqlas preguntas frecuentes publicadas, en el orden del centro
POST/public/:tenant/shop-orderscrear el pedido (si paga online devuelve la URL de la pasarela; por transferencia, los datos del pago)
GET/public/:tenant/shop/pickpost-points?zip=<PLZ>los 8 puntos PickPost / My Post 24 más cercanos a un código postal (si el centro los ofrece)
POST/public/:tenant/shop-orders/:id/confirmconfirmar el cobro al volver de la pasarela
POST/public/:tenant/shop/trackembudo: ITEM, CART, CHECKOUT (sin cookies ni IP)

Cada artículo trae un slug estable: se fija la primera vez que sale al escaparate y no cambia aunque el centro renombre el producto, porque es la dirección que Google indexa y la que la gente comparte. Úsalo para tus URLs (/tienda/aceite-de-argan), no el id.

Categorías, artículos sin precio y diseño

Cada artículo trae category y, si esa categoría cuelga de otra, categoryParent (la principal). La respuesta trae además categories, el árbol de lo que hay a la venta: las principales en el orden que les dio el centro y, detrás de cada una, sus subcategorías. Cada nodo lleva name, parent (null en una principal) y count; en una principal, count incluye lo de sus subcategorías. Úsalo para el menú y la barra lateral en vez de recorrer el catálogo.

Si el centro enseña artículos que aún no tienen precio, llegan con priceOnRequest: true: se muestran, pero no se pueden pedir (el pedido los rechaza). layout dice qué diseño eligió el centro para su escaparate: CLASSIC o MARKET (catálogo grande, con menú, categorías a un lado y rejilla). En una tienda a medida es solo una pista.

Páginas, preguntas frecuentes y aspecto

El centro escribe en su panel (Tienda › Web) las páginas de su tienda, sus preguntas frecuentes y el aspecto del escaparate. La respuesta de /public/:tenant/shop trae pages (solo slug, title y section: POLICY = políticas de la tienda, LEGAL = aviso legal, privacidad y cookies, INFO = otras), faqCount y storefront: logoOnBrandUrl (logo para ir sobre el color de la marca, como imagen embebida), faviconUrl (icono de la pestaña del navegador, imagen embebida cuadrada; null = usar el logo), paymentLogos (lista cerrada: VISA, MASTERCARD, MAESTRO, AMEX, TWINT, POSTFINANCE, APPLE_PAY, GOOGLE_PAY, PAYPAL), instagramUrl, tiktokUrl, facebookUrl, whatsappUrl y topBarText. Lo que el centro no ha puesto llega como null.

El texto de una página (/shop/pages/:slug) y la respuesta de una pregunta usan un formato mínimo: ## delante de un título, ### delante de un apartado, - delante de cada punto de una lista y una línea en blanco entre párrafos. Píntalo como texto, nunca como HTML: lo escribe una persona y sale en una web pública.

Zonas de entrega

Un centro puede repartir por zonas (Tienda › Entregas): cada zona tiene unas siglas, sus municipios y, en cada municipio, sus formas de entrega (a domicilio o recogida en un punto) con precio y plazo. Cada producto se vende solo en las zonas que el centro le marca. Si la tienda usa zonas, /public/:tenant/shop trae zones y cada artículo físico trae zoneIds (las zonas donde se entrega; lista vacía = en ninguna). Una zona cuenta cuando está activa y tiene al menos un municipio activo. Sin zonas que cuenten, zones llega vacío (o no viene) y todo funciona como antes (recogida y envío de Ajustes). La tienda se decide por lo que traiga zones en cada carga: si no está vacío, pide zona y manda delivery.

"zones": [ { "id": "…", "code": "HOL", "minOrderGrossMinor": 5000,
             "areas": [ { "id": "…", "name": "Gibara",
                          "methods": [ { "id": "…", "kind": "HOME", "name": "Entrega a domicilio",
                                         "priceGrossMinor": 800, "minDays": 3, "maxDays": 7,
                                         "address": null, "note": null } ] } ] } ]

Enseña solo las siglas (code) y el nombre del municipio: el nombre completo de la zona y lo que cobra el proveedor no salen del panel. Una forma de entrega con priceGrossMinor: null tiene el precio pendiente: se puede enseñar, pero el pedido la rechaza. Al crear el pedido, manda la entrega elegida:

POST /public/<slug>/shop-orders
{ "name": "...", "email": "...", "cart": [...], "acceptPrivacy": true,
  "delivery": { "areaId": "…", "methodId": "…",
                "recipientName": "…", "recipientPhone": "…",
                "address": "…", "reference": "…" } }

Con zonas, delivery es obligatorio si hay algo físico en la cesta; fulfilment y la dirección suiza se ignoran. address es obligatoria en una entrega a domicilio y reference es opcional. El pedido responde 422 si un artículo no se entrega en esa zona, si la forma de entrega no tiene precio o si los productos (sin contar servicios) no llegan al pedido mínimo de la zona. Los portes son el precio de la forma de entrega elegida, y la entrega queda congelada en el pedido tal como se pidió, con la dirección y la nota del punto si es una recogida.

Los datos del centro, sin copiarlos

La respuesta de /public/:tenant/shop trae store con lo que el centro ya tiene en Ajustes: name, logoUrl, brand (color, accentColor, font = SANS|SERIF|CONDENSED, esta última una letra estrecha de tienda en línea), contact (email, phone, website), address (con line, la dirección en una línea), vatNumber, openingHours, openingLines (el horario legible en el idioma de ?lang=, si el centro trabaja en él), locales y pickupInfo.

Úsalos para la cabecera, el pie y el aviso legal de la tienda en vez de escribirlos en tu web: si el centro se muda o cambia de horario, lo cambia en su panel y tu tienda lo recoge. Los colores llegan siempre como #rrggbb o null, y lo que falta llega como null, nunca como cadena vacía.

«Cómo recoger» nunca llega vacío si hay dirección

pickupInfo es lo que escribió el centro; si no escribió nada, su dirección y su horario. Es el mismo texto que va en el correo de confirmación del pedido.

Permiso para llamar desde tu dominio

Declara la web del centro en Ajustes → Reservas online. Ese dominio queda autorizado para llamar a las rutas /public/* desde el navegador; sin declararlo, el navegador bloqueará las llamadas por CORS. Cambiarlo tarda hasta un minuto en surtir efecto (la lista se refresca sola; no hace falta reiniciar nada). El apex y el www valen los dos, y solo se admite https.

Que el pago vuelva a tu web

Manda returnUrl al crear el pedido y el comprador volverá ahí desde la pasarela, en vez de aterrizar en nuestro dominio:

POST /public/<slug>/shop-orders
{ "name": "...", "email": "...", "cart": [...], "fulfilment": "SHIPPING",
  "payMode": "ONLINE", "acceptPrivacy": true,
  "returnUrl": "https://latienda.ch/gracias" }

Volverá con ?paid=ok&order=<id>&session_id=<…> (o paid=cancel), y esa página tiene que llamar a /shop-orders/:id/confirm con el session_id: confirma el cobro al momento y te devuelve el número de pedido para enseñarlo. Llamarla dos veces no cobra dos veces.

Si el comprador paga y cierra la pestaña antes de volver, el pedido no se pierde: lo confirma el webhook de Stripe del centro (si lo tiene dado de alta) o, como tarde, la comprobación que Ritual hace cada 15 minutos. La sesión de pago caduca a la vez que la reserva de la mercancía (unos 30 minutos): pasado ese tiempo ya no se puede pagar.

Pedir y pagar después

Si GET /public/:tenant/shop trae payOnsite: true, se puede pedir con "payMode": "ONSITE" y pagar al recoger o a la entrega. Solo se ofrece cuando el centro no cobra con tarjeta y lo tiene permitido (Tienda › Ajustes); si no, el pedido responde 422. Al comprador le llega un correo con su pedido. freeShippingFromMinor llega como null cuando no hay umbral de envío gratis (nunca como 0), y al volver de la pasarela la dirección conserva el lang con el que se compró.

Si el centro tiene antirrobots (ALTCHA o, en la transición, reCAPTCHA), el pedido también lo exige: manda captchaToken (acción shopOrder) como en el resto de formularios públicos. POST /public/:tenant/account/code (código de acceso a la cuenta) lo exige igual. Las líneas repetidas del mismo artículo se suman antes de comprobar existencias.

Cuentas de cliente de la tienda

El centro puede pedir que quien compre tenga cuenta (Tienda › Ajustes). Se entra con el correo y un código de un solo uso que llega a ese correo: sin contraseña. GET /public/:tenant/shop trae accounts: mode (OFF = sin cuentas · OPTIONAL · REQUIRED, sin cuenta no se puede pedir) y available (el centro puede mandar los códigos). Con OFF, las rutas de cuenta responden 404.

POST /public/<slug>/account/code      { "email": "ana@example.ch", "locale": "es" }   → { "ok": true }
POST /public/<slug>/account/verify    { "email": "…", "code": "048213", "firstName": "Ana",
                                        "lastName": "Pérez", "phone": "…", "password": "…",
                                        "marketingConsent": false }
  → { "token": "shs_…", "created": true, "account": { … } }
POST /public/<slug>/account/login     { "email": "…", "password": "…" }  → { "token": "shs_…", "account": { … } }

El token de la sesión viaja en la cabecera X-Shop-Session (nunca en la dirección). Dura 90 días y se alarga al usarla. /account/code contesta igual exista o no la cuenta —no sirve para saber quién compra ahí—, admite 3 códigos cada 10 minutos y 10 al día por correo (429 con code: "TOO_MANY"), y cada código caduca a los 10 minutos, admite 5 intentos y sirve una vez.

Una cuenta NUEVA necesita firstName y lastName: sin ellos responde 422 con code: "NAME_REQUIRED" y el código no se gasta (basta con repetir con el nombre). La password es opcional y solo un atajo: quien la ponga puede entrar con /account/login (mismo error para correo desconocido y contraseña mala, code: "BAD_LOGIN"), y el código por correo sigue funcionando siempre. Se pone o se quita con PUT y DELETE /account/password; account.hasPassword dice si la tiene.

Con la sesión: GET /account (datos, destinatarios guardados, favoritos y aparatos donde está abierta), PATCH /account, DELETE /account (borra la cuenta; los pedidos se guardan porque son contabilidad), POST /account/logout, POST /account/logout-others, GET /account/orders, POST|PATCH|DELETE /account/recipients[/:id] y PUT /account/favorites. Una sesión caducada o cerrada responde 401 con code: "SESSION".

Al crear el pedido, manda la misma cabecera: el correo del comprador será el de la cuenta (el que venga en el cuerpo se ignora) y el pedido queda colgado de su ficha de cliente. Con mode: "REQUIRED" y sin sesión, el pedido responde 401 con code: "ACCOUNT_REQUIRED". Quien recibe se guarda para la próxima vez salvo que mandes "saveRecipient": false.

Pagar por transferencia (factura QR)

Si GET /public/:tenant/shop trae bankTransfer: true, el centro acepta transferencia: manda "payMode": "TRANSFER" al crear el pedido. No hay pasarela ni url: la respuesta trae transfer con lo que el comprador necesita para pagar, y a él le llega lo mismo por correo con la factura QR adjunta.

{ "ok": true, "number": "TI-2026-0012", "payMode": "TRANSFER", "totalGrossMinor": 8700,
  "transfer": { "iban": "CH44 3199 9123 0008 8901 2",
                "creditor": { "name": "…", "street": "…", "postalCode": "8001", "town": "Zürich", "country": "CH" },
                "reference": "77 00000 00020 26001 23456 78901", "referenceType": "QRR",
                "amountMinor": 8700, "currency": "CHF", "payBy": "2026-09-23" } }

La mercancía queda reservada hasta payBy (los días que decide el centro: bankTransferDays). Si el pago no llega a tiempo, el pedido se anula solo. Cuando el centro importa el extracto del banco, el ingreso casa con el pedido por su referencia y, al contabilizarlo, se emite shop.order.paid como con cualquier otro cobro.

Pagar con PostFinance (Payrexx)

Si GET /public/:tenant/shop trae payrexxPayment: true, el centro cobra con PostFinance Pay a través de Payrexx (y con los demás medios que tenga activados allí). Manda "payMode": "PAYREXX": la respuesta trae url, la página de pago de Payrexx, igual que con Stripe.

Pagar con PostFinance Checkout

PostFinance Checkout (checkout.postfinance.ch) es la plataforma de pago propia de PostFinance, distinta de la de Payrexx y con su propio contrato: un centro puede tener una, la otra o las dos. Si GET /public/:tenant/shop trae pfcPayment: true, manda "payMode": "PFCHECKOUT": la respuesta trae url, su página de pago, igual que con Stripe.

La vuelta llega con ?paid=ok&order=<id>, sin session_id: igual que con Payrexx, llama a /shop-orders/:id/confirm con el cuerpo vacío ({}) y el servidor comprueba con PostFinance la transacción guardada en el pedido. Si el comprador cierra la pestaña, lo confirma el webhook del centro (/pfcheckout/webhook/:tenantId, suscrito a los cambios de estado de Transaction) o la comprobación de cada 15 minutos. El cobro caduca a la vez que la reserva.

PickPost / My Post 24. Si GET /public/:tenant/shop trae pickPost: true, el comprador puede recoger el paquete en un punto de la Post. Busca los puntos con GET /public/:tenant/shop/pickpost-points?zip=8004 (devuelve points[] con id, kind = PickPost|MyPost24, name, street, zip, city, hint y km, ordenados por distancia; 503 si la Post no da la lista ahora) y crea el pedido con "fulfilment": "SHIPPING" y "pickPost": {"userId": "<su número PickPost>", "pointId": "<id del punto>"}. La dirección de envío la pone el servidor (la de entrega del punto, sacada de la lista de la Post): de shipping solo cuenta el nombre. Un número que no lo parece o un punto que no existe responden 422 con el motivo. El número PickPost lo tiene el comprador en su cuenta de post.ch; no se le crea ninguna cuenta.

La vuelta llega con ?paid=ok&order=<id>, sin session_id: llama a /shop-orders/:id/confirm con el cuerpo vacío ({}) y el servidor comprueba con Payrexx el cobro guardado en el pedido. Si responde 422 con «aún no está confirmado», PostFinance está terminando: el pedido se cobrará solo en unos minutos y el comprador recibirá su correo. Si el comprador cierra la pestaña, lo confirma el webhook de Payrexx del centro (/payrexx/webhook/:tenantId, con el tipo de contenido JSON) o la comprobación de cada 15 minutos. El cobro caduca a la vez que la reserva.

La vuelta solo puede ser tu propio dominio

returnUrl tiene que estar en el dominio declarado en Ajustes, o la petición responde 422 y no se crea el pedido. Es lo que impide convertir la tienda de un centro en un redirector hacia una web ajena que pida «confirmar la tarjeta».

Enterarte de los pedidos

Cuatro eventos de webhook (sección 06), con el mismo sobre y la misma firma que el resto:

shop.order.createdentró un pedido — todavía SIN cobrar
shop.order.paidcobrado: hay venta, existencias descontadas y asiento
shop.order.status_changedpreparado, enviado (con seguimiento), entregado o anulado
shop.return.openedse abrió una devolución

Un pedido pagado por la pasarela emite los dos primeros, en ese orden. Si lo que te interesa es «he vendido», escucha shop.order.paid: sale una sola vez por pedido, tanto si lo confirma la vuelta del navegador como el webhook de Stripe o la comprobación periódica. Los cuatro llevan el pedido con la misma forma (id, number, status, buyer, fulfilment, importes, seguimiento y líneas), también cuando el cambio se hace desde el panel: nunca salen el coste de compra, los identificadores de la pasarela ni el enlace para opinar.

Anular un pedido ya cobrado (status_changed con CANCELLED) deshace la venta en la contabilidad y devuelve las existencias; el dinero lo devuelve el centro por donde se pagó. Si el pedido ya tiene devoluciones, no se anula entero: se sigue por la devolución.

Lo que NO tiene que hacer tu web

Ni calcular precios ni portes ni comprobar existencias: todo eso se resuelve en el servidor contra el catálogo real cuando se crea el pedido (por eso los precios no viajan en la petición). Tu web enseña y recoge datos; las cuentas las hace Ritual, con el mismo IVA y el mismo asiento que una venta de mostrador.

06c Calendario en el móvil

Sacar la agenda del panel y meterla en Apple, Google u Outlook. Dos rieles con propósitos distintos: una suscripción que funciona en los tres sin configurar nada, y una sincronización con Google de ida y vuelta.

Suscripción iCalendar (.ics)

El centro crea el enlace en Ajustes → Calendario, y cada persona el suyo en Perfil → Mi agenda en el móvil. La URL se pega como «calendario suscrito» en el teléfono.

GET/public/calendar/<token>/ritual.icsla agenda en iCalendar (RFC 5545) → text/calendar

Sin cabeceras de autenticación: la URL ES la credencial, porque un calendario suscrito no puede mandar ninguna. De ahí que todo lo demás sea estricto:

El detalle es del enlace, no del centro

FULL (cliente y servicio) · LIMITED (sin nombre de cliente) · BUSY (solo «ocupado»). Lo que se elija es lo que acaba guardado en los servidores de Google o Apple. Para datos de salud, publicar «ocupado» y nada más es la opción correcta por defecto.

Frescura: Apple sí, Google no

Apple refresca los calendarios suscritos cada pocos minutos. Google los refresca cuando le parece (típicamente horas): sirve para ver la semana, no para «acabo de mover una cita». Quien necesite Google al minuto tiene que usar la sincronización de abajo.

Gestión desde el panel (sesión, módulo agenda). El token se devuelve una sola vez, al crear o al rotar:

GET/v1/calendar/feedslos enlaces del centro (nunca el secreto)
POST/v1/calendar/feedscrear → devuelve token y path
PATCH/v1/calendar/feeds/:idcambiar ámbito, detalle o qué publica
POST/v1/calendar/feeds/:id/rotatenuevo token; el anterior deja de valer
DELETE/v1/calendar/feeds/:idrevocar
GET/v1/bookings/:id/icsuna cita suelta como .ics

Un profesional (rol THERAPIST) solo puede crear y ver enlaces de su propia agenda: el servidor ata el ámbito a su ficha, no se lo cree del cuerpo de la petición.

Leer un calendario de fuera para tapar huecos (sin OAuth)

La dirección contraria, y la que más se pide: que lo que una persona tiene en su calendario personal deje de ofrecerse como hueco libre. Pega la dirección privada iCal de su calendario (Google, Apple u Outlook) y Ritual la descarga cada pocos minutos. No necesita registrar ninguna app.

Del calendario ajeno solo entran HORAS

Inicio y fin. El título, los invitados y la ubicación se leen para saber si el evento ocupa —cancelado, «disponible», libre de Outlook— y se descartan en el acto: no se guardan, no se registran y no aparecen en la agenda.

GET/v1/calendar/externallas suscripciones del centro (nunca la URL)
POST/v1/calendar/externalalta: comprueba el enlace ANTES de guardarlo
PATCH/v1/calendar/external/:idnombre, ventana, activar/desactivar
POST/v1/calendar/external/:id/syncforzar una descarga ahora
DELETE/v1/calendar/external/:idquitar (los huecos vuelven al instante)

Ese «ocupado» resta disponibilidad —no se ofrece el hueco ni en la reserva online ni en las sugerencias— pero no bloquea al mostrador: un dato de fuera no puede dejar a recepción sin poder trabajar.

Sincronización con Google (OAuth)

Ida y vuelta de verdad, y con dos límites que son la parte importante del diseño:

GET/v1/calendar/google/status¿está configurada?, URL de retorno, permisos
POST/v1/calendar/google/connectdevuelve la URL de consentimiento (state firmado)
GET/public/calendar/google/callbackretorno de Google (sin sesión; la identidad va en el state)
GET/v1/calendar/connectionscuentas conectadas (sin tokens)
POST/v1/calendar/connections/:id/syncforzar una pasada ahora
DELETE/v1/calendar/connections/:iddesconectar y revocar en Google

Requiere GOOGLE_CALENDAR_CLIENT_ID y GOOGLE_CALENDAR_CLIENT_SECRET en el servidor. Sin ellas la función queda apagada y el panel lo dice: la suscripción .ics sigue funcionando igual.

07 Seguimiento de envíos (para tu web)

El mostrador de «¿dónde va mi paquete?» hacia tu web. Devuelve el recorrido completo por paquete (6 fases con estado, fecha y lugar) y los datos del viaje (buque, ruta y ETA). Server-to-server con la clave de integración (X-Api-Key: trk_live_…); solo lectura.

GET/public/tracking?ref=<HBL o código de bulto>un paquete → { parcel }
GET/public/tracking/customer?code=<cardCode>todos los envíos de un cliente → { shipments[] }
curl "https://ritualerp.ch/public/tracking?ref=AIN26000002" \
  -H "X-Api-Key: trk_live_…"
  → {
    "parcel": {
      "trackingCode": "0313950174026",     // nº del cliente (13 díg.): el que teclea en "Seguir"
      "reference": "AIN26000002",        // HBL si lo tiene; si no, el código del bulto
      "milestone": "EN_TRANSITO",        // la fase actual (ver tabla)
      "recipient": null,                   // OCULTO en público; visible en el portal (login)
      "destination": "Centro Habana, La Habana",
      "weightKg": 12.4,
      "deliveredAt": null,
      "journey": [                          // EL RECORRIDO: las 6 fases, siempre en orden
        { "phase":"RECIBIDO",   "label":"Recibido",    "state":"done",    "at":"2026-07-20T…Z", "location":"Zúrich" },
        { "phase":"PREPARADO",  "label":"Preparado",   "state":"done",    "at":"2026-07-22T…Z", "location":null },
        { "phase":"EN_TRANSITO","label":"En tránsito", "state":"current", "at":"2026-07-25T…Z", "location":"Rotterdam" },
        { "phase":"EN_ADUANA",  "label":"En aduana",   "state":"pending", "at":null, "location":null },
        { "phase":"EN_REPARTO", "label":"En reparto",  "state":"pending", "at":null, "location":null },
        { "phase":"ENTREGADO",  "label":"Entregado",   "state":"pending", "at":null, "location":null }
      ],
      "transit": {                        // el viaje que lo lleva (null si aún no está asignado)
        "mode":"SEA", "carrier":"MSC", "vessel":"MSC Ambra", "voyage":"021W",
        "from":"Rotterdam", "to":"Mariel",
        "departedAt":"2026-07-25T…Z", "etaAt":"2026-08-12T…Z"
      },
      "alert": null,                     // { type:"INCIDENCIA"|"DEVUELTO", at, note } si hay desvío
      "timeline": [ … ],                   // hitos ocurridos (compat.); usa "journey" para pintar el progreso
      "packingList": null            // en público, items ocultos; el portal (login) los muestra
    }
  }

Público vs. privado: el tracking público oculta el recipient (nombre) y los items de la lista — cualquiera con el número no debería verlos. El mismo parcel en el portal con login (GET /portal/shipments) sí los incluye. Búscalo por trackingCode (nº del cliente), HBL o código de bulto.

Las 6 fases (phase / milestone)

CódigoSignifica
RECIBIDOEntró en el almacén de origen.
PREPARADOEmpaquetado / etiquetado / en manifiesto, listo para salir.
EN_TRANSITOCargado y de camino (el barco/avión salió). Mira transit.
EN_ADUANALlegó al país de destino; en trámite aduanal.
EN_REPARTOSalió a la dirección del destinatario.
ENTREGADOEntregado (con acuse). deliveredAt lleva la fecha.

Cada paso del journey trae state: done (ya pasó), current (donde está ahora) o pending (falta) — pinta una barra de progreso sin lógica extra. Si el paquete tiene una incidencia o fue devuelto, milestone vale INCIDENCIA/DEVUELTO y alert trae el motivo; el journey se congela en la última fase alcanzada.

Mismo objeto en el portal

El portal de clientes (§08) devuelve exactamente este objeto parcel dentro de cada envío en GET /portal/shipments. Documenta las fases una vez y reutilízalas en ambas vistas.

08 Portal de clientes (para tu web)

Que tus clientes sigan sus datos desde tu web sin que tú tengas que reexponer nada — con la propiedad forzada en Ritual, no en tu código.

Flujo: tu web autentica al cliente (tu login). Tu servidor, con la clave DEDICADA del portal (psk_live_…, separada de la de tracking y atable a IP), pide una sesión para ese cliente; Ritual devuelve un token corto atado a él. Recomendado (proxy): guarda el token en tu backend (cookie httpOnly) y reenvía las llamadas a /portal/* — así el token no toca el navegador y no hace falta CORS.

# 1) Tu servidor (clave dedicada del portal) pide sesión para un cliente ya autenticado
POST /public/portal/session      X-Api-Key: psk_live_…
     { "customerId": "<id del cliente en Ritual>" }
  → { "token": "cst_…", "expiresAt": "…" }        # 60 min · 403 si la IP no está en la allowlist · 429 por encima de 20/min por IP (desde 06-10-2026); /portal/* y /public/tracking*, 120/min

# 2) Las llamadas a /portal/* con ese token (Authorization: Bearer cst_…) — vía tu backend (proxy)
GET/portal/shipmentssus envíos + línea de tiempo
GET/portal/mesu ficha · ver (GET) · editar sus datos (PATCH)
GET/portal/facturas · /portal/facturas/:id/pdfsus facturas
GET/portal/renovacioneslo que tiene contratado y se le vuelve a cobrar: concepto, cada cuánto, próxima fecha e importe con IVA (solo si el centro usa Renovaciones; nada interno)
GET/portal/estado · /portal/estado/pdfsu estado de cuenta: open[], recentlyPaid[], totalOpenMinor, totalOverdueMinor (sin el límite de crédito, que es interno del centro) · PDF
GET/portal/recibos · /portal/recibos/:id/pdfsus recibos de mostrador (ventas POS) · PDF
GET/portal/destinatarios · /portal/remitentesver (GET) · crear (POST) · editar (PATCH) · quitar de su lista (DELETE)
GET/portal/direccionessus direcciones de entrega · ver (GET) · crear (POST) · editar (PATCH) · archivar (DELETE)
GET/portal/packing-listssus listas de empaque · ver (GET) · crear (POST) · borrar no vinculada (DELETE)
GET/portal/packing-lists/:code/pdfel PDF con código de barras de una lista suya
GET/portal/envios/:ref/hbl.pdfel HBL de un envío suyo

Al crear una lista (POST /portal/packing-lists) el customerId lo pone la sesión (nunca el cuerpo) y Ritual genera el código PKL-…; devuelve la lista con su código para descargar el PDF. Si el remitente/destinatario se referencia por { id }, debe ser del propio cliente. Una lista solo se puede borrar mientras siga en SUBMITTED (aún no vinculada a un envío).

Área del cliente de Ritual (/cliente/). Sin web propia, el centro tiene ya una página para sus clientes empresa: https://ritualerp.ch/cliente/?c=<slug>. Se entra sin contraseña: la persona pide el enlace con su correo (POST /public/:slug/cliente/acceso { email }, que responde siempre { ok: true }) y le llega si el correo es el de la ficha o el de una persona de contacto con portalAccess. El enlace vale una hora. Una sesión abierta así por una persona de contacto lleva esa persona dentro: GET /portal/me devuelve además contact: { name, position }, PATCH /portal/me le responde 403 (los datos de la empresa los cambia el centro) y, si se le quita el acceso o se borra la persona, sus sesiones dejan de valer al momento. El centro también puede mandar el enlace desde la ficha (POST /v1/customers/:id/contacts/:contactId/portal-link, solo desde el panel). Desde el 06-10-2026 una clave rk_ sigue pudiendo crear y editar personas de contacto, pero no les da el acceso: portalAccess en el cuerpo responde 403, y cambiar el email de una persona que ya lo tiene, también. En el panel, crear, editar y borrar personas de contacto es de dirección, gerencia y recepción.

Editar su ficha (PATCH /portal/me). El cliente cambia SUS datos de contacto y dirección postal: salutation, name, firstName, lastName, email, phone, language (de|fr|it|en|es), street, postalCode, city, canton, country. Es un PATCH parcial (los campos que no mandas no se tocan). Quedan FUERA a propósito los que no le corresponde tocar: código de tarjeta, saldo/nivel de fidelidad, consentimientos y notas internas. Si el email ya lo usa otra cuenta del centro devuelve 422. Responde la ficha ya actualizada, igual que GET /portal/me.

Cambio de correo y fichas archivadas (desde 10-2026). Si PATCH /portal/me cambia el email (no cuenta un cambio solo de mayúsculas), Ritual escribe a la dirección ANTERIOR, en el idioma de la ficha, con la nueva y a quién avisar si no fue ella: el correo es la llave para pedir el enlace de acceso y un cambio silencioso dejaría la cuenta en otras manos. Tu web no tiene que hacer nada. Y una ficha archivada invalida al momento todos sus cst_ abiertos (las llamadas a /portal/* devuelven 401); si el centro la desarchiva, vuelven a valer mientras no caduquen. Pedir un token nuevo para una ficha archivada ya daba error.

Devolver un bono (desde 10-2026). POST /v1/customer-packs/:id/refund { paymentMethod: "CASH"|"CARD"|"TWINT"|"TRANSFER", reason } con Idempotency-Key: le devuelve a la clienta lo que queda de un bono pagado (el neto sin usar más su parte del IVA; en efectivo, redondeado a 5 Rp), cierra el bono (CANCELLED) y anula sus citas futuras. Solo sesiones de dirección, gerencia o contabilidad: una clave rk_ recibe 403 (sale dinero). Un bono de plataforma o ya cerrado responde 422; de la caja no sale más de lo que hay. GET /v1/customer-packs trae refundableMinor (lo que se devolvería hoy; 0 = no se puede). Y una factura cobrada por Stripe se apunta como tarjeta (a la cuenta que Tesorería da a la tarjeta, «dinero en camino» si la hay) y por el importe que Stripe cobró; si no cuadra con lo pendiente, se apunta lo que cuadra y el centro recibe el aviso invoice.payment.mismatch.

Destinatarios y remitentes: favoritos del cliente. La lista que ve el cliente (GET /portal/destinatarios · /portal/remitentes) son SUS favoritos. Un destinatario/remitente pasa a ser favorito de un cliente de dos formas: cuando el propio cliente lo da de alta (POST), o cuando el mostrador lo USA en una recepción suya (aunque venga del pool compartido del centro). Así, tras un envío, ese contacto aparece solo en su ficha y en su portal. El cliente puede EDITAR (PATCH) cualquiera de su lista, no solo los que creó: si el contacto es exclusivamente suyo se edita en el sitio; si lo comparten otros clientes, Ritual lo CLONA a una copia propia del cliente (copy-on-write) y edita la copia, para no cambiarle el dato a terceros. El identificador puede cambiar tras esa primera edición; vuelve a leer la lista. Con DELETE el cliente lo QUITA de su lista (se borra su favorito, no la ficha del pool, que puede ser de otro o estar en envíos ya emitidos). En destinatarios, POST/PATCH aceptan además la foto del carné (fotoFrontalUrl/fotoTraseraUrl, dataURL de imagen); Ritual la cifra en reposo y el listado no la devuelve (solo hasFotoFrontal/hasFotoTrasera).

Nunca en el navegador

La clave psk_live_… es de servidor: jamás en el navegador, y conviene atarla a las IPs de tu servidor. En el modo proxy (recomendado) el token cst_… tampoco toca el navegador; si vas a navegador-directo, el cst_… puede vivir en el cliente (corto, de un cliente, sin acceso a administración) pero asume el riesgo XSS.

09 Calculadora de envíos (para tu web)

Un cotizador en tu web alimentado por el MISMO motor de precios que el mostrador: tarifas, precios por zona, envases, servicios y arancel. Lo que edites en el ERP se refleja al instante.

Server-to-server con la clave de integración (X-Api-Key: trk_live_…), igual que el tracking. Solo lectura; no escribe nada. El precio es orientativo (el arancel es provisional y el tipo de cambio es el del centro).

GET/public/paqueteria/opcionesdestinos con tarifa, modos, envases (con su code) y servicios
POST/public/paqueteria/cotizarprecio de un envío, con desglose
# país destino (ISO3) + modo + paquetes (+ aduana) + servicios
curl -X POST https://ritualerp.ch/public/paqueteria/cotizar \
  -H "X-Api-Key: trk_live_…" -H "Content-Type: application/json" \
  -d '{
    "pais": "CUB",
    "modo": "maritimo",
    "paquetes": [
      { "tipo":"peso", "pesoKg":12,
        "articulos":[ { "descripcion":"Ropa", "pesoKg":12, "valorCup":100 } ] },
      { "tipo":"envase", "envase":"<code del envase>" }
    ],
    "servicios": [ { "id":"<id servicio>", "cantidad":1 } ]
  }'
  → { "zona":"CU", "tarifa":"Temporada 4", "total":"146.50",
      "aduana":{ "regimen":"Aduana de Cuba", "modo":"WEIGHT", "moneda":"CUP" },
      "paquetes":[ { "lineas":[ … ], "subtotal":"…" } ], "aviso":"Precio orientativo…" }

El arancel depende del destino. Cada zona tiene su régimen aduanal (la fórmula del arancel, configurable en el ERP): por peso, por porcentaje del valor declarado, o ninguno. El bloque aduana dice cuál se aplicó y en qué moneda; si llega null, ese destino no prepaga arancel en origen —lo liquida la aduana de destino con el destinatario— y la cotización no lleva línea de aduana. Los articulos se declaran igual: la declaración de mercancía es obligatoria en todos los destinos.

El valor declarado va en USD. valorCup (aquí), declaredValueCup (artículos de un bulto) y declaredValueMinor (listas de empaque, en céntimos) son el valor de la mercancía en USD: el nombre técnico se mantiene para no romper integraciones, pero nunca fue CUP. El arancel y el servicio (bloque aduana) sí siguen en la moneda del régimen (CUP en Cuba). Los documentos (HBL, lista de empaque, declaración) imprimen el valor en USD y su equivalente en CUP con el cambio USD→CUP que el centro pone en Ajustes › Paquetería; sin cambio puesto, solo USD y la nota «cambio sin definir».

Artículos del catálogo. Un artículo puede llevar "catalogo": "<código>" (p. ej. "PRODUC-001"): lo que no mandes (peso, valor, servicio) se toma de la lista de mercancías del centro, la que se edita en Ajustes › Paquetería › Catálogos › «Categorías de mercancía» (solo artículos activos). Si el centro no tiene esa lista, se usa la tabla de serie. Un código que no esté en la lista se cotiza como artículo libre, con lo que mandes.

Cómo montarla en tu web

La clave es de servidor, así que el patrón es: un pequeño proxy en tu backend guarda la clave y reenvía a Ritual; tu frontend llama a tu proxy (nunca a Ritual directo).

1) Proxy en tu servidor — aquí vive la clave:

// Node / Express — la clave NUNCA sale de tu servidor
const BASE = 'https://ritualerp.ch', KEY = process.env.RITUAL_KEY; // trk_live_…
const H = { 'X-Api-Key': KEY, 'Content-Type': 'application/json' };
app.get('/api/envio/opciones', async (_q, res) => {
  const r = await fetch(BASE + '/public/paqueteria/opciones', { headers: H });
  res.status(r.status).json(await r.json());
});
app.post('/api/envio/cotizar', async (req, res) => {
  const r = await fetch(BASE + '/public/paqueteria/cotizar',
    { method:'POST', headers: H, body: JSON.stringify(req.body) });
  res.status(r.status).json(await r.json());
});

# …o en PHP (Hostinger): cotizar.php
<?php
$ch = curl_init('https://ritualerp.ch/public/paqueteria/cotizar');
curl_setopt_array($ch, [ CURLOPT_POST=>true, CURLOPT_RETURNTRANSFER=>true,
  CURLOPT_HTTPHEADER=>['X-Api-Key: '.getenv('RITUAL_KEY'), 'Content-Type: application/json'],
  CURLOPT_POSTFIELDS=>file_get_contents('php://input') ]);
echo curl_exec($ch);

2) La calculadora en tu página — llama a TU proxy:

<form id="calc">
  <select name="pais"></select> <select name="modo"></select>
  <input name="pesoKg" type="number" placeholder="Peso (kg)">
  <button>Calcular</button>
</form>
<p id="res"></p>
<script>
// 1) poblar desplegables desde /opciones
fetch('/api/envio/opciones').then(r => r.json()).then(o => {
  calc.pais.innerHTML = o.zonas.map(z => `<option>${z}</option>`).join('');
  calc.modo.innerHTML = o.modos.map(m => `<option value="${m.code}">${m.code}</option>`).join('');
  // o.envases (code+nombre) y o.servicios (id+nombre) para más campos
});
// 2) cotizar al enviar
calc.addEventListener('submit', async e => {
  e.preventDefault();
  const body = { pais: calc.pais.value, modo: calc.modo.value,
    paquetes: [{ tipo:'peso', pesoKg: Number(calc.pesoKg.value) }] };
  const q = await (await fetch('/api/envio/cotizar',
    { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) })).json();
  res.textContent = q.total ? `Total: ${q.total} CHF — ${q.aviso}` : (q.error || 'Error');
});
</script>

Amplía a gusto: envase (tipo:"envase", envase:"<code>"), volumen (tipo:"volumen" + largoCm/anchoCm/altoCm), aduana (articulos:[…]) y servicios (servicios:[{id,cantidad}]). Los code de envase y los id de servicio salen de /opciones.

Cómo se calcula

El pais (ISO3) se resuelve a zona; con modo se elige la tarifa, y con la zona, el régimen aduanal. Cada paquete es peso (kg×tarifa), volumen (m³×tarifa) o envase (precio del envase, con su precio por zona si lo hay); fleteManualChf fija el flete a mano. Los articulos suman la aduana si la zona tiene régimen. GET /opciones te da los code de envases y los id de servicios para tus desplegables.

Nunca en el navegador

La clave trk_live_… es de servidor: llama al cotizador desde tu backend y sirve el resultado a tu web. No la expongas en el frontend.

10 Construir un módulo dentro del ERP

Un módulo es una unidad vendible (agenda, facturas, paquetería…) con sus rutas, roles, plan y scopes. El catálogo vive en un solo sitio y todo lo demás se deriva de él.

Añadir un módulo con clave <clave>:

#DóndeQué
1entitlements.ts · MODULE_CATALOGLa clave y su grupo (operación / recursos / finanzas).
2MODULE_BY_PREFIXQué rutas /v1/<seg> cobra el módulo. Sin esto la ruta es gratis (no gateada).
3DEFAULT_ROLE_ACCESSQué roles lo ven por defecto.
4MODULE_REQUIRESSolo si necesita otro módulo para funcionar.
5PLAN_TIERSEn qué peldaños de plan se vende.
6Panel: permissions.ts + Dashboard.tsxMODULE_ACCESS / MODULE_MATRIX y la pestaña (Tab) + render.
7i18n nav.<clave>Etiqueta en es / de / fr / it.
8Tablas nuevas con tenantIdAñádelas a TENANT_TABLES y aplícales RLS+GRANT en una migración.

Los scopes de la API (<clave>:read / <clave>:write) se generan solos desde el catálogo — no hay listas paralelas. Hay un generador (tsx scripts/new-module.ts) que escribe los pasos 1–3 y 6–7; si te saltas alguno, plan-tiers.spec (1–3) o rls-coverage.spec (8) fallan a propósito.

Gating real

Una petición autenticada pasa tres puertas: rol (¿este usuario/clave puede?), plan/suscripción (¿el centro tiene el módulo contratado y al día?) y propiedad/RLS (¿es de su centro?). Un módulo bien dado de alta las respeta sin que escribas el chequeo en cada handler.

11 Checklist de seguridad senior

Antes de dar por bueno un endpoint, un módulo o una integración.

AislamientoToda tabla con tenantId → RLS + GRANT + TENANT_TABLES. Todo acceso vía withTenant. Nada de where tenantId a pelo como única barrera.
DineroIdempotency-Key en toda mutación de dinero. El servidor recalcula importes/impuestos; nunca confía en el cliente. Índices únicos que hagan imposible el doble cobro/devolución.
SecretosHash (SHA-256) + comparación en tiempo constante. Se enseñan una vez. Nunca en logs ni en la URL. Caducidad y revocación.
AutorizaciónRol × scope × propiedad en cada ruta. Valida el cuerpo con esquema. Las rutas de administración jamás son alcanzables por clave de API.
EnumeraciónNo expongas identificadores secuenciales sin atar la consulta a su dueño (el token del portal ata la propiedad; por eso no hay enumeración).
WebhooksFirma HMAC verificada sobre el cuerpo crudo + anti-repetición (timestamp) + idempotencia por id de evento. URL solo https, anti-SSRF.
TransporteTLS + HSTS. Rate-limit por IP. Responde rápido; el trabajo pesado, a una cola.
Ritual ERP · Manual para desarrolladores. El OpenAPI (/v1/developer/openapi.json) y el panel de Desarrolladores son la fuente de la verdad viva; este manual explica el porqué y las reglas que no salen en el contrato.

Ritual ERP · un producto de MiFirma

Contacto y analítica web

GET /public/:tenant/contact-config?t=pk_? expone recaptchaSiteKey, captchaRequired, analyticsEnabled y, con ALTCHA encendido, altcha.challengeUrl; nunca secretos. POST /public/:tenant/contact admite serviceInterest (90 caracteres), message (2000) y submissionKey UUID para reintentos idempotentes. Requiere email o teléfono. reCAPTCHA configurado exige token v3 reciente, acción exacta y hostname autorizado.

POST /public/:tenant/events requiere token, Origin exacto y activación WEB_ANALYTICS_TENANTS. Campos: event (PAGE_VIEW, CONTACT_CLICK, SERVICE_SELECT, FORM_START) y path estático sin query. Límite de 60/minuto por IP, por instancia. Contadores por día UTC sin identificadores de visitante; se excluyen DNT/GPC y bots conocidos. Retención de los últimos 90 días UTC. GET /v1/marketing/web-analytics?days=30 requiere sesión y permisos de Marketing. Las conversiones corresponden a consultas recibidas en el periodo; los ingresos están en el informe de atribución existente. No se enlazan interacciones históricas sin leadId mediante conjeturas.