Ritual ERP · Plataforma
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 obligatorioEl vocabulario y los formatos que se repiten en toda la API.
| Multi-tenant | Cada 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. |
| Dinero | Enteros en la unidad menor (Rappen). Los campos terminan en Minor: grossMinor: 12500 = CHF 125.00. Sin coma flotante. |
| Fechas | ISO 8601 en UTC (2026-07-25T09:30:00.000Z). Zona de negocio: Europe/Zurich. |
| Texto | UTF-8 completo (ñ, tildes, acentos). |
| Moneda | CHF. |
| Errores | Có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. |
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
No son opcionales ni "buenas prácticas": es cómo está construido el sistema. Si escribes código o consumes la API, respétalos.
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;
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.
timingSafeEqual). La clave en claro se enseña una sola vez.Idempotency-Key. La fila de idempotencia vive en la MISMA transacción que la operación → o se confirman ambas o ninguna. Un reintento con la misma clave devuelve la respuesta original; con otro cuerpo, 409.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.
type, take, before=seq) — ADMIN/ACCOUNTANT{ 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.
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.
Elige la correcta según quién llama y a qué necesita acceder. No mezcles: cada una tiene un alcance distinto a propósito.
| Credencial | Formato / cabecera | Para | Alcance |
|---|---|---|---|
| Sesión de usuario | JWT Authorization: Bearer <jwt> |
El panel (personas). | El rol del usuario (ADMIN, RECEPTION…). |
| 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. |
Para que tu backend hable con el ERP: crea una clave de API, dale scopes por módulo y llama a los recursos.
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.
Módulos con scope: agenda, clientes, ventas, servicios, vales, avisos, chat, terapeutas, inventario, marketing, contactos, facturas, contabilidad, nomina, b2b, comercial, recepcion (paquetería).
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í.
# 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:*).
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.
?open=false = cerrados)Idempotency-Key){ accepted, job } · job es null si el centro no tiene el módulo trabajos (scope comercial:write)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.
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.
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.
?active=false = todos)Idempotency-Key)Paginación: ?limit= (1–500, def. 100) y ?offset= (def. 0). Dinero: añade Idempotency-Key: <8–200 chars>, estable por intento.
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í):
externalRef · devuelve el saldoexternalRef (signo contrario, no borra; nunca deja saldo negativo)points con signo (negativo = restar) + note; deja rastro MANUAL, nunca deja saldo negativoPOST /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.
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.
?includeDisposed=1 incluye las bajas · trae las clases y sus tasas ESTVname, assetClass, acquisitionDate, netMinor; opcionales method, rateBps, residualMinor, paymentAccountyear. Idempotente por (bien, año): repetirlo no amortiza dos vecesdate y kind = SALE (repercute MWST, cifra 302) · SCRAP · PRIVATE (autoconsumo art. 31 sobre el valor fiscal)?year=?year=year, period (Q1…Q4, S1, S2)?year=): deriva por periodo, Umsatzabstimmung por cuenta, importe a regularizar y fecha límiteLa 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.
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.
?status=all incluye inactivasname, code, commissionBps, commissionModedate, receivedMinor, voucherIdsAl 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.
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.
?status=allplatformId, name, endsAtactive)label, serviceId, sessions, regularMinor, paidMinorcode, customerId · requiere Idempotency-Key# 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.
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).
?status= & ?customerId=SUBMITTED (no vinculada)# 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 Rappen) 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.
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.
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.
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.
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. 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.
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.
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.
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.
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.
En vez de sondear, suscribe un endpoint y Ritual te avisa cuando algo pasa. Cada entrega va firmada.
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) · 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). 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 */ } }
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)); }
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.
Cómo se incrusta el motor de reservas en la web del centro, y cómo salen los avisos a sus clientes.
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).
Dos parámetros más, para que el widget deje de ser un recuadro pegado encima de la web del centro:
&theme=dark — paleta oscura y fondo transparente, de modo que el widget se apoya en el fondo de la web en vez de plantar un rectángulo blanco en medio. El acento sigue siendo el color de marca del centro (Ajustes → Reservas online), así que conviene ponerlo antes.&chrome=0 — esconde el logo y el pie propios del widget. Incrustado no hacen falta: la web del centro ya los pone justo encima, y repetirlos es lo que delata el iframe.&radius=soft · &radius=square — escala el redondeo de las esquinas. No fija un radio único: mantiene la jerarquía (la tarjeta más redonda que un campo) y la encoge, para que encaje con webs de esquinas rectas.&font=serif · &font=system — familia tipográfica. Solo familias presentes en cualquier equipo: el widget vive en un iframe de otro dominio y no puede cargar las fuentes de la web del centro, así que prometer la suya exacta sería mentir; lo que sí se puede es acertar el registro.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.
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.
Tiene que permitir frame-src https://ritualerp.ch, o el widget saldrá en blanco sin ningún error visible.
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.
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 reCAPTCHA, el honeypot y el límite por IP.
Dos modos, en Ajustes → Avisos:
Reply-To a su dirección. El centro no configura nada.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.
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:
Un fallo temporal (4xx del servidor de destino, retraso de entrega) no quema ninguna dirección: solo los rechazos permanentes.
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.
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.
text/calendarSin 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:
cal_<centro>_<id>_<secreto> con 256 bits de secreto, del que solo se guarda el SHA-256, comparado en tiempo constante.ETag y responde 304 si nada ha cambiado — un móvil que sondea cada 15 minutos casi nunca descarga nada.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.
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:
token y path.icsUn 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.
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.
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.
https/webcal, con tope de tamaño y de tiempo, y con ETag para no descargar lo que no ha cambiado.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.
Ida y vuelta de verdad, y con dos límites que son la parte importante del diseño:
calendar.app.created: no da acceso a los eventos de nadie.calendar.freebusy). Ni título, ni invitados, ni ubicación.state firmado)state)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.
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.
{ parcel }{ 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.
phase / milestone)| Código | Significa |
|---|---|
RECIBIDO | Entró en el almacén de origen. |
PREPARADO | Empaquetado / etiquetado / en manifiesto, listo para salir. |
EN_TRANSITO | Cargado y de camino (el barco/avión salió). Mira transit. |
EN_ADUANA | Llegó al país de destino; en trámite aduanal. |
EN_REPARTO | Salió a la dirección del destinatario. |
ENTREGADO | Entregado (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.
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.
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 # 2) Las llamadas a /portal/* con ese token (Authorization: Bearer cst_…) — vía tu backend (proxy)
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).
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.
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).
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.
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).
code) y servicios# 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.
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.
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.
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.
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ónde | Qué |
|---|---|---|
| 1 | entitlements.ts · MODULE_CATALOG | La clave y su grupo (operación / recursos / finanzas). |
| 2 | MODULE_BY_PREFIX | Qué rutas /v1/<seg> cobra el módulo. Sin esto la ruta es gratis (no gateada). |
| 3 | DEFAULT_ROLE_ACCESS | Qué roles lo ven por defecto. |
| 4 | MODULE_REQUIRES | Solo si necesita otro módulo para funcionar. |
| 5 | PLAN_TIERS | En qué peldaños de plan se vende. |
| 6 | Panel: permissions.ts + Dashboard.tsx | MODULE_ACCESS / MODULE_MATRIX y la pestaña (Tab) + render. |
| 7 | i18n nav.<clave> | Etiqueta en es / de / fr / it. |
| 8 | Tablas nuevas con tenantId | Añá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.
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.
Antes de dar por bueno un endpoint, un módulo o una integración.
| Aislamiento | Toda tabla con tenantId → RLS + GRANT + TENANT_TABLES. Todo acceso vía withTenant. Nada de where tenantId a pelo como única barrera. |
| Dinero | Idempotency-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. |
| Secretos | Hash (SHA-256) + comparación en tiempo constante. Se enseñan una vez. Nunca en logs ni en la URL. Caducidad y revocación. |
| Autorización | Rol × scope × propiedad en cada ruta. Valida el cuerpo con esquema. Las rutas de administración jamás son alcanzables por clave de API. |
| Enumeración | No expongas identificadores secuenciales sin atar la consulta a su dueño (el token del portal ata la propiedad; por eso no hay enumeración). |
| Webhooks | Firma HMAC verificada sobre el cuerpo crudo + anti-repetición (timestamp) + idempotencia por id de evento. URL solo https, anti-SSRF. |
| Transporte | TLS + HSTS. Rate-limit por IP. Responde rápido; el trabajo pesado, a una cola. |
/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.