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, area=money|sales|parcels|shop|rental|jobs|stock|data|support, from/to en ISO 8601, 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…). 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. |
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.
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):
mrrNetMinor, arrNetMinor, dueCount/dueNetMinor, next90NetMinor, openGrossMinor, cuántas activas, en pausa, con baja y automáticas{ 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){ items: [{ id, period }], send? } (Idempotency-Key): cada periodo en su propia transacción; devuelve results[] con ok, number o error{ period, reason?, invoiceNumber? }: la factura hecha a mano debe ser del mismo cliente) y deshacerlo{ from, until } (fechas incluidas; until: null = sin fin); { from: null, until: null } la reanuda{ endDate, reason? }: baja; 400 si endDate es anterior a hoy + cancelNoticeDays. { reactivate: true } la deshace{ lines: [{ description, qty, unitNetMinor, serviceId?, treatment? }] } (hasta 30; lista vacía = una sola línea){ 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.
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).
# 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.
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).
| Área | Preferida (inglés) | Obsoleta (español) | Notas |
|---|---|---|---|
| Flota | /v1/fleet | /v1/flota | vehicles, 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/alquiler | contracts (…/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.
?open=false = cerrados)Idempotency-Key)Idempotency-Key, una sola vez aunque se repita){ 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énloadVolumeL (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 422jobPhotoKind: BEFORE · AFTER · DAMAGE/v1/archivos?jobId=) ni en Archivos, y en un trabajo cerrado responden 422404 si no es de ese trabajo)422); en un trabajo cerrado, 422customerId 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){ 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)DRAFT → SENT, y DRAFT/SENT → EXPIRED; cualquier otro salto responde 422 (aceptar, rechazar y versión nueva tienen su ruta){ reason, note? }, reason = PRICE · TIMING · COMPETITOR · NO_RESPONSE · SCOPE · OTHERA-2026-0004-v2) de una oferta ya enviada; la vieja queda con supersededAt y su enlace ya no aceptato, subject, body (con {enlace}), attachment{ 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.sentLa 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.
open[] (emitidas con saldo, con overdueDays), recentlyPaid[] (cobradas en 12 meses), totalOpenMinor, totalOverdueMinor, asOf. Scope facturasemail-draft igual que la facturaOportunidades 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.
?stage=open|closed|NEW|…, ?owner=me|<userId>, ?customerId=pipelineMinor, weightedMinor, byStage[], byMonth[] (mes de cierre previsto; none = sin fecha) y overdue?from= y ?to= (por defecto 12 meses): won, lost, winRateBps, avgDaysToWin, lostByReason[]{ stage, lostReason?, lostNote? } → { changed, opportunity }. LOST exige lostReason: PRICE, TIMING, COMPETITOR, NO_RESPONSE, SCOPE, NO_BUDGET u OTHERPersonas 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.
[{ 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?status=open|done|all, por defecto todas)?mine=1 las de la cuenta que llama, ?assignedUserId= las de otra, ?status= igual que arriba{ customerId, title, dueAt, notes?, assignedUserId? } → 201 con la tarea (overdue calculado)title, notes, dueAt o assignedUserId{ done: true|false }: marcar hecha o reabrirEl 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.
?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.
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.
name, color, description); el nombre no se repite (sin distinguir mayúsculas){ "entityType": "CUSTOMER", "entityId": "…" } · idempotentetagIds (para una sola, mejor assign/unassign: no pisa lo que otro puso)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.
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.
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).
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 comparaemployeeId es el reembolsoexpectedTotalMinor responde 422 si lo pendiente cambiótruncated si la lista se cortó422); un banco sí puede quedar en descubierto409 con code: "POSSIBLE_DUPLICATE" y su entryId; repetir con allowDuplicate: true si son dos distintaspaidThrough, laterPaymentThrough)refundReceived: true (el dinero ya llegó al banco en date) responde 409 con code: "VAT_REFUND_CONFIRM" y refundMinorbookDifference 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.
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)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.
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.
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.
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, 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.
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) · 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 */ } }
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.
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.
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.
rk_ o la sesión del panel no valen en /mcp, y una llave rma_ no vale en /v1.S256 obligatorio, resource (RFC 8707), iss en la vuelta (RFC 9207)authorization_code y refresh_token (rotado; reusar uno corta la conexión). Formulario o JSONinitialize, 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.
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.
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 atenderpreviousMonth), serie de 12 meses y los 5 más caros; con vehiculos, solo esos; con sin, todos menos esossite (sucursal), tags, la garantía (warrantyUntil, warrantyKm y warranty calculada hoy) y daysStopped12mplate o name); admite warrantyUntil? (AAAA-MM-DD) y warrantyKm?odometerKm); null borra un dato ("warrantyKm": null)status: RETIREDvehicle (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 404todos=1, también los cumplidos)SERVICE, TIRES y OTHERrk_ 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)problems: los vehículos que lleva y para los que ya no vale. Solo desde el panel: una clave rk_ recibe 403vehicleDocKindCon 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.
daysStopped (días en el taller dentro del periodo, hasta hoy; en totals, la suma)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 anularalsoExpense: 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 vivocomercial: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.
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)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 webhookwasClosed), no está anulada y no tiene facturas vinculadas; si no, 422 (es historial: se anula)REPAIR) de ese vehículo aún sin reparación, sin anularcomercial:writeid y name){ repair, deadline }warningsEl 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.
abiertas=1, solo las que no están hechasdoneOn pueden ser futuros, y no se hace antes de anunciarse{ "doneOn": "AAAA-MM-DD" }, deshacerlo = { "doneOn": null }422Garantí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.
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 conocidosOPEN · ASSESSED · APPROVED · PAID · CLOSED, en cualquier orden; pedir el que ya tiene responde changed: falserepairedBy: 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ónreason: 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 ellaOPEN sin reparación y sin fotos; si no, 422 (es historial)damagePhotoKindPAID 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 saltatotals, byVehicle y byDriver (driverId: null = sin conductor): count, insurancePaysMinor, ourCostMinor (franquicia + lo pagado aparte) y driverChargeMinorEl 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.
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 problemaPOST /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 }PENDING (la misma que POST /v1/flota/reparaciones) con una tarea por problema si no mandas items; reason por defecto OTHER. Responde { check, repair }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.
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)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 listadate 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 dosnull 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, 422rentedFrom 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.
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)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/v1/flota/resumen si es de los tres últimostachoCard: true en su carnet (422 si no)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 403rk_, 403LSVA: 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.
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.
{ 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 exentaSTANDARD, 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 deductibleTreatmentprofile, 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)extraKmPriceRp es el precio de UN kilómetroblocks (lo que lo ocupa) y freeDays. 62 días como muchodrivers lleva al menos a quien firma; fromTime/toTime son horas suizas (por defecto 09:00). Numera RA-AAAA-00001422licenseSeen: 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ándonewZones, abre el parte de daño. Registrarla otra vez rehace los cargos automáticos{ contract, invoice }404 si no es de ese contrato)kind: SIGNATURE)LICENSE_FRONT · LICENSE_BACK), 4 como muchoCHARGED_TO_RENTER, con su rentalContractId). 422 si no cae dentro de ningún alquiler de ese vehículoEXEMPT: 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)REQUESTED → RESERVED. El vehículo ya estaba bloqueado desde que entró, así que confirmar no puede chocar con nada. IdempotenteREADY · 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 disponibilidadenabled: false422 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)hasPhoto y cuántos vehículos alquilables tiene cada una (la foto NO viaja en la lista: pesa)transmission: MANUAL · AUTOMATIC. La foto va como data: (máx. 400 KB)category de las fichas de sus vehículos422 con el número de vehículos si todavía la usa alguno{ photoDataUrl } para el panel (la CSP no admite blob:)fromDay/toDay INCLUSIVEcategoryId vacío = todas las clases. Un precio vacío no toca ese preciorows) 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 consultaSon 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.
Cross-Origin-Resource-Policy: cross-origin (se ve desde la web del centro) y caché de un díaproblems 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ículasREQUESTED (o RESERVED si el centro no pide confirmación) y manda los dos correosCó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.
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).
{ 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 fechaevents)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)400). El responsable y el estado tienen su propia rutaRESOLVED 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 nadanull la deja sin responsable) — solo a una cuenta activa que pueda abrir el módulo, si no 422. Responde { changed, incident }[{ id, name, role }][{ 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.
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).
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).
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.&font=lux — títulos en Cormorant Garamond y texto en Jost, servidas desde nuestro propio dominio (para webs de aire clásico).&c_brand= · c_ink · c_muted · c_surface · c_line · c_on — colores de tu web en hexadecimal sin # (p. ej. c_brand=d9b36a): botones y elegido, texto, texto secundario, tarjetas y campos, bordes y texto sobre el botón. Con c_brand manda tu color y no el de marca del centro. Un valor que no sea rrggbb se ignora.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.
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.
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:
lang, nombre, descripción y categoría en ese idioma si el centro los tradujo (si no, el texto base){ 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<img>; 404 si el perfil no es visibletherapistId, solo las horas en que ESA persona está libre y dentro de su horariotherapistId, 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.
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.
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 antirrobots (ALTCHA; reCAPTCHA en retirada), el honeypot y el límite por IP.
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.
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.
{ 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{ answers, marketingConsent?, lang, captchaToken?, website? (trampa para robots), utmSource, utmMedium, utmCampaign, utmContent, utmTerm, referrer, landingPath } → 201 { ok, needsConfirm, successText, redirectUrl }error ya traducido al lang del envío (es, de, fr, it, en; sin él, el idioma del centro) y field = id del campo que falla, para marcarlo. code: REQUIRED, INVALID_EMAIL, INVALID_NUMBER, INVALID_PHONE (solo cifras, espacios y + ( ) - / ., de 6 a 20 cifras), INVALID_DATE (AAAA-MM-DD que exista), INVALID_OPTION, CONSENT_REQUIRED, EMAIL_REQUIRED, NOT_AVAILABLE. El captcha fallido responde 400 con code: CAPTCHA_FAILED. Una casilla obligatoria solo vale marcada (true, o "true"/"on" desde un formulario HTML).utm*, referrer y landingPath quedan en la respuesta y como interacción de origen FORM del cliente o contacto; la bandeja enseña la fuente y la campaña.redirectUrl: solo https:// con dominio, sin credenciales ni espacios, 500 caracteres como mucho; nuestra página lleva ahí al visitante tras enviar (a la página entera si va incrustada y el navegador lo permite).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.
?archived=1 solo archivados; ?status=all todos. Cada uno trae además createLead, createOpportunity, opportunityStage, ownerUserId, tagId, redirectUrl, archivedAt y canManage · formularios:readhttps?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:read201/200 { opportunityId, created } · pide además el complemento Oportunidades y poder escribir en élUna 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.
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).
X-Form-Invite: fi_…baseSavedAt, 409 STALE si otro dispositivo guardó antes · 409 si el envío ya está cerradoSon 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.
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.
{ 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?thumb=1 (para su propia página)uploads[] (id, fieldId, fileName, mimeType, sizeBytes, sin contenido) · formularios:read?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)formularios:write{ usedBytes, quotaBytes, files } del centro · formularios:readPOST /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.
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.
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.
&view=shop en el widget de arriba): el escaparate de Ritual dentro de un <iframe>. No hay nada que programar. A cambio, el contenido vive en nuestro dominio: Google no lo indexa bajo el del centro.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.
store, el perfil del centro. ?lang= para el horario?size=thumb para la versión de 400 pxITEM, 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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».
Cuatro eventos de webhook (sección 06), con el mismo sobre y la misma firma que el resto:
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.
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.
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 · 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)
open[], recentlyPaid[], totalOpenMinor, totalOverdueMinor (sin el límite de crédito, que es interno del centro) · PDFAl 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).
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.
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.
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.
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.