OpenAPI. La descripción de todos los recursos, para importarla en Postman o generar un cliente: openapi-integracion.json.
Entorno de pruebas. Hay un sandbox con datos inventados: véase el apartado «El entorno de pruebas». Para pedir acceso o resolver dudas, escríbanos desde el formulario de contacto.
La API de integración de Turnera — v1
Turnera publica una API para que un programa externo —el ERP del centro, el de su asesoría— pueda leer su catálogo, sus clientes y sus ventas. Es de lectura salvo en una cosa: reservar un hueco de la agenda, que existe para la plataforma de campañas de un centro, que trae al cliente y necesita la cita en el momento (apartado 5.1). Nada más se modifica desde fuera. Y hay un camino de vuelta: los webhooks (apartado 5.2), con los que Turnera avisa a su sistema cuando una cita cambia, sin que tenga que preguntar cada pocos minutos.
1. En dos minutos
# 1. Cambiar las credenciales por un token (media hora de vida)
curl -s -X POST https://sucentro.turnera.devel.es/api/v1/integration/token \
-H 'Content-Type: application/json' \
-d '{"grant_type":"client_credentials","client_id":"...","client_secret":"..."}'
# {"access_token":"eyJ...","token_type":"Bearer","expires_in":1800,"scope":"catalog:read sales:read"}
# 2. Leer, con el token en la cabecera
curl -s https://sucentro.turnera.devel.es/api/v1/integration/catalog/services \
-H 'Authorization: Bearer eyJ...'
La dirección base es la del centro: https://<centro>.turnera.devel.es/api/v1. Para probar, hay un
sandbox con datos inventados (apartado 11). Cada centro tiene su
dominio y su base de datos, así que no hay ningún parámetro que diga a qué centro se está llamando — lo dice
el dominio.
2. Darse de alta como cliente
Esto no se hace por la API. Quien programa el ERP recibe unas credenciales; no la posibilidad de crearse otras.
Lo hace alguien con rol de administración del centro, en Administración → Integraciones → Nueva integración. Ahí se decide:
| Campo | Qué es |
|---|---|
| Nombre | Cómo lo reconocerá el centro en la lista. «ERP de la asesoría». |
| Correo de contacto | El de quien mantiene la integración. Se usa para avisar de mantenimientos, cambios de versión o incidencias de la API. Es obligatorio. |
| Delegación | La única que verá la integración. Va fijada aquí porque una máquina no puede elegirla en una pantalla. |
| Permisos | Qué puede leer. Véase la tabla de scopes más abajo. |
| Direcciones autorizadas | Desde dónde puede entrar. Una por línea: una IPv4 o un rango en CIDR, o any para no restringir (véase «La IP»). |
Al guardar, el titular del centro recibe un correo con el alta y sus datos (nunca el secreto), para que no
haya integraciones que nadie sepa que existen. La pantalla enseña el client_id y el client_secret una sola vez. El secreto no se guarda
en claro, así que si se pierde no se recupera: se rota (§6).
Lo que hay que pedirle al centro
- El dominio de su Turnera.
- Las credenciales, por un canal que no sea el correo del día a día.
- Que autorice la IP fija del servidor donde correrá el ERP. Durante el desarrollo, si se trabaja desde un
portátil que cambia de red, el centro puede poner
any; al pasar a producción debería sustituirlo por la IP del servidor.
3. Autenticación
OAuth 2.0 client credentials. Ni el usuario de una persona, ni una clave estática en una cabecera.
POST /api/v1/integration/token
Content-Type: application/json
{ "grant_type": "client_credentials", "client_id": "...", "client_secret": "..." }
{
"access_token": "eyJhbGciOiJIUzI1NiJ9...",
"token_type": "Bearer",
"expires_in": 1800,
"scope": "catalog:read sales:read"
}
Los nombres de este endpoint van en snake_case porque son los de OAuth 2.0; el resto de la API va en
camelCase.
- El token dura media hora y lleva dentro el
client_id, la delegación y los scopes. - No hay refresh token: cuando caduca se vuelve a pedir, que es una línea en el ERP. Lo razonable es
pedirlo al arrancar cada sincronización y renovarlo ante un
401. - Cualquier problema —identificador que no existe, secreto que no coincide, dirección no autorizada, integración apagada— contesta lo mismo:
401 Unauthorized
{ "title": "Unauthorized", "status": 401, "error": "invalid_client", "detail": "Las credenciales no son válidas" }
No se distingue cuál de los cuatro falló, a propósito. Si las credenciales son buenas, no hace falta el detalle; si se están probando, tampoco.
La IP
La dirección se comprueba al pedir el token y además en cada llamada: un token robado dura media hora, y
media hora desde otra máquina es media hora de más. Una llamada desde una dirección que no está en la lista
contesta 403.
Un cambio en la lista de direcciones tarda hasta un minuto en hacerse efectivo.
Sin restricción de IP. Si la lista contiene any, se puede entrar desde cualquier dirección. Está pensado
para el periodo de desarrollo, cuando quien integra se mueve entre redes. En ese caso el único freno es el
client_secret: quien lo tenga puede leer lo que abran los permisos de la integración desde cualquier sitio.
Conviene darle solo los permisos que necesita y sustituir any por la IP del servidor al pasar a producción.
4. Los permisos (scopes)
Vocabulario propio y corto. Los roles de Turnera son de pantallas y no significan nada para un ERP.
| Scope | Qué abre |
|---|---|
catalog:read |
/catalog/services, /catalog/products |
customers:read |
/customers |
sales:read |
/sales, /accounting-draft |
availability:read |
/availability |
bookings:write |
/bookings, y poder dar de alta webhooks de citas |
customers:exists |
/customers/exists |
Llamar a un recurso sin su scope contesta 403. No hay un scope de historia clínica, y no es que no se dé por
defecto: es que no existe. customers:read devuelve la ficha administrativa y nada de antecedentes, casos
ni evoluciones.
5. Los recursos
Todos los listados devuelven el mismo sobre:
{
"items": [ ... ],
"nextCursor": "412"
}
Paginación por cursor
No hay número de página. Si alguien cobra mientras el ERP está paginando, una página numerada se descoloca y se pierde una venta sin que nadie se entere; el cursor es el último identificador leído, así que lo que ya pasó no se mueve.
GET /integration/sales?limit=200
GET /integration/sales?limit=200&cursor=412
GET /integration/sales?limit=200&cursor=980 → "nextCursor": null ← se acabó
limit: por defecto 100, máximo 500.nextCursor: nulles la señal de que no queda nada. No hay total de elementos que contar.
Sincronización incremental
Todos los listados admiten updatedSince, en ISO-8601 con huso:
GET /integration/catalog/services?updatedSince=2026-09-01T00:00:00Z
Una fecha sin huso obliga a adivinar en qué zona la interpretamos, y ahí es donde se pierde la venta de las
once de la noche. Por eso se rechaza con un 400.
La comparación es mayor o igual, así que conviene guardar el updatedOn más alto que se haya visto y
usarlo como marca de agua en la pasada siguiente. Puede repetir una fila; nunca saltársela.
En la primera pasada, sin
updatedSince, se lo lleva todo. Es lo que tiene que pasar.
GET /integration/catalog/services
{
"id": 41,
"name": "Sesión de fisioterapia",
"category": "Fisioterapia",
"description": null,
"price": "35.00",
"taxRate": null,
"taxExemption": "E1",
"durationInMinutes": 45,
"enabled": true,
"updatedOn": "2026-09-18T11:04:22+02:00"
}
GET /integration/catalog/products
{
"id": 7,
"name": "Crema hidratante",
"category": "Cosmética",
"brand": "…",
"reference": "…",
"price": "18.50",
"taxRate": 21,
"taxExemption": null,
"enabled": true,
"updatedOn": "2026-09-18T11:04:22+02:00"
}
Sobre el IVA en los dos: taxRate es el tipo en tanto por ciento y taxExemption el código de exención
de la AEAT (E1 por el artículo 20, y los demás de su lista). Se excluyen: una operación exenta no lleva un
tipo del cero por ciento, lleva la causa por la que lo está. Los dos a null significa que ese servicio o
producto todavía no tiene el IVA decidido en el catálogo del centro. Eso no es un error de la API: es la
lista de deberes del centro, y el ERP debería avisar en vez de suponer un 21.
GET /integration/customers
La ficha administrativa. Nunca nada clínico.
{
"id": 1204,
"taxId": "12345678Z",
"name": "…", "surname": "…",
"email": "…", "phone": null, "mobile": "…",
"address": "…", "postalCode": "…", "city": "…", "province": "…",
"updatedOn": "2026-09-19T09:12:00+02:00"
}
GET /integration/customers/exists?email=…&mobile=…
Scope customers:exists. Dice solo si un correo o un teléfono son de un cliente del centro, y nada más:
ni el nombre ni el identificador. Sirve para que una plataforma de campañas no duplique fichas.
{ "exists": true }
Hay que mandar email, mobile o los dos; si manda los dos basta con que coincida uno. El teléfono se compara
por sus últimas nueve cifras, sin fijarse en espacios, guiones ni prefijo, y tiene que tener al menos siete.
Esta pregunta tiene su propio límite, mucho más estricto que el general: 20 por minuto y 2.000 al día por
integración. Pasado el límite, 429 con Retry-After. Turnera apunta cuánto se usa (nunca qué se pregunta) y
avisa por correo al titular del centro y al contacto de la integración si el uso parece raro: el cupo diario
casi agotado, muchas consultas y casi ninguna acierta, o intentos repetidos con credenciales o direcciones que no
valen. Es una pregunta pensada para comprobar fichas de una en una, no para recorrer una lista de gente.
GET /integration/sales
Las visitas cobradas, con sus líneas.
{
"id": 9812,
"date": "2026-09-19T17:30:00+02:00",
"customerId": 1204,
"delegationId": 1,
"total": "53.50",
"updatedOn": "2026-09-19T17:31:02+02:00",
"refundOf": null,
"lines": [
{
"id": 44120,
"description": "Sesión de fisioterapia",
"amount": "35.00",
"discountPercentage": 0,
"serviceId": 41,
"productId": null,
"taxRate": null,
"taxExemption": "E1"
}
]
}
amountya viene con el descuento aplicado; así lo guarda el cobro.discountPercentagees informativo.totales la suma de las líneas.- Una devolución es una venta más, con las líneas y el total en negativo y
refundOfapuntando alidde la venta que deshace. La venta original no cambia: para saber lo que quedó vendido de verdad, se suman las dos. - El IVA de la línea es el que tiene hoy el catálogo, no el que tenía el día de la venta. Mientras no esté la fase 2 de contabilidad, esto es un dato para conciliar y todavía no un libro de facturas.
GET /integration/accounting-draft
El asiento resumen por día y tipo de IVA: un apunte por día y por tipo, no cuatrocientas líneas de ticket. No pagina y pide las dos fechas.
GET /integration/accounting-draft?from=2026-09-01&to=2026-09-30
{
"from": "2026-09-01",
"to": "2026-09-30",
"disclaimer": "Borrador para revisar con su asesoría. No sustituye a sus libros.",
"entries": [
{ "day": "2026-09-01", "taxRate": 21, "taxExemption": null,
"total": "184.20", "tickets": 6, "firstSaleId": 9780, "lastSaleId": 9791 },
{ "day": "2026-09-01", "taxRate": null, "taxExemption": null,
"total": "70.00", "tickets": 2, "firstSaleId": 9782, "lastSaleId": 9788 }
]
}
La segunda línea del ejemplo es lo cobrado sin IVA decidido. Sale aparte a propósito, porque es
exactamente lo que hay que repasar. El disclaimer viaja en la respuesta y conviene enseñarlo donde se
enseñen los números.
5.1 Reservar en la agenda
Pensado para una plataforma de campañas: pregunta qué horas quedan libres, su cliente elige una y la reserva. Si mientras tanto la ha cogido otro —recepción, el portal u otra reserva—, Turnera contesta que está ocupada.
Los huecos son los mismos que ofrece el portal de reservas del centro: sólo servicios y sitios marcados como se reserva por internet, con la antelación mínima y los días por delante que tenga configurados el centro. La plataforma elige la hora; Turnera pone el primer sitio y el primer profesional libres.
GET /integration/availability?serviceId=12&day=2026-10-02
Scope availability:read. Las horas de inicio libres de ese día para ese servicio:
{
"day": "2026-10-02",
"serviceId": 12,
"bookableUntil": "2026-11-21",
"slots": [ { "start": "10:00:00", "end": "11:00:00" }, { "start": "11:00:00", "end": "12:00:00" } ]
}
bookableUntil es el último día que hoy se puede reservar: la agenda está abierta tantos días por delante como
tenga configurados el centro, y la ventana avanza sola cada día. Una lista vacía para un día posterior es que la
agenda aún no está abierta, no que esté llena. Para un día dentro de plazo, que no queda nada o que ese
servicio no se reserva por internet.
POST /integration/bookings
Scope bookings:write.
{
"serviceId": 12,
"day": "2026-10-02",
"start": "10:00",
"reference": "VIR-000123",
"customer": { "name": "Ana", "surname": "García", "phone": "600123456", "email": "ana@example.org" }
}
| Respuesta | Qué significa |
|---|---|
201 |
Reservada. Devuelve la cita (appointmentId, reference, day, start, end, customerId). |
200 |
Esa referencia ya estaba reservada: devuelve la misma cita, sin coger otro hueco. |
409 BOOKING_SLOT_TAKEN |
El hueco ya no está libre: lo ha cogido otro. Vuelva a pedir la disponibilidad. |
409 BOOKING_TOO_FAR_AHEAD |
Ese día la agenda aún no está abierta (posterior a bookableUntil). |
409 BOOKING_EQUIPMENT_BUSY |
El servicio necesita un aparato y a esa hora está ocupado. No es lo mismo que BOOKING_SLOT_TAKEN: el hueco existe, pero falta el aparato. La respuesta trae equipmentName. Pruebe otro hueco. |
409 BOOKING_MINIMUM_NOTICE |
La hora ya ha pasado o no cumple la antelación mínima del centro. La respuesta trae minimumNoticeHours. |
400 |
Falta un dato, o el cliente no trae ni teléfono ni correo. |
- La referencia hace seguro reintentar. Es el identificador de la reserva en su sistema y debe ser única para cada reserva. Si no le llega la respuesta, repita la misma petición con la misma referencia: recibirá la cita que ya existe, nunca una segunda.
- El cliente. Si en el centro ya hay una ficha con ese correo, la cita va a esa ficha y la ficha no se toca. Si no, se abre una ficha nueva con los datos que manda.
- Queda marcada. La cita lleva escrito en sus comentarios el nombre de la integración y la referencia, para que recepción sepa de dónde viene, y se guarda de qué integración vino para poder medir cada canal.
- El aviso al cliente lo manda Turnera, igual que en el portal, si el cliente tiene correo.
- Anular o cambiar una cita desde fuera todavía no se puede: se hace desde el centro.
5.2 Webhooks: que Turnera le avise
En vez de preguntar cada pocos minutos si hay citas nuevas, el centro puede dar de alta una dirección de su
sistema (Administración → Integraciones → Webhooks) y Turnera la llama cuando una cita cambia. Hace falta el
scope bookings:write en la integración, y cada integración admite hasta cinco webhooks.
La dirección tiene que ser https://, pública y sin usuario ni clave. No se admiten direcciones de redes
internas ni redirecciones. Una vez creado, la dirección no se cambia: para apuntar a otra se da de alta otro
webhook. Al darlo de alta, el titular del centro recibe un correo con a dónde van a salir los datos.
Los eventos que se pueden contratar:
| Evento | Cuándo |
|---|---|
booking.created |
Se crea una cita. |
booking.updated |
Cambia una cita que sigue vigente (hora, servicio, cliente…). |
booking.cancelled |
Se anula o se borra una cita. |
Además existe webhook.ping, que no se contrata: es la prueba que el centro puede lanzar a mano desde la
pantalla para comprobar que su dirección contesta.
Qué citas. Por defecto el webhook avisa solo de las citas creadas por esa misma integración. El centro puede desmarcarlo para recibir también las de recepción y las del portal.
El aviso es un POST con Content-Type: application/json:
{
"event": "booking.created",
"deliveryId": "5b0c2c1e-6c1e-4d6e-9f43-0b1c0d2e3f4a",
"occurredAt": "2026-10-02T08:15:03Z",
"data": {
"appointmentId": 48213,
"reference": "VIR-000123",
"state": "booked",
"day": "2026-10-05",
"start": "10:00",
"end": "10:45",
"service": "Limpieza facial",
"customerId": 1204,
"bookedByThisIntegration": true
}
}
state es booked o cancelled. Si la integración tiene además el scope customers:read, data lleva un bloque
customer con name, surname, email y mobile; sin ese scope, no sale ningún dato de la persona.
deliveryId es único para cada aviso y es lo que debe usar para no procesar dos veces el mismo.
La firma. Cada aviso lleva estas cabeceras:
X-Turnera-Signature: sha256=<hex>
X-Turnera-Timestamp: 1790928903
X-Turnera-Event: booking.created
X-Turnera-Delivery: 5b0c2c1e-6c1e-4d6e-9f43-0b1c0d2e3f4a
La firma es el HMAC-SHA256, en hexadecimal, de timestamp + "." + cuerpo con el secreto del webhook (el que
empieza por whsec_, que se enseña una sola vez al crearlo o al renovarlo). Compruébela antes de fiarse del
contenido, con una comparación de tiempo constante, y rechace avisos cuyo X-Turnera-Timestamp tenga más de
cinco minutos: así nadie puede repetir un aviso viejo que haya copiado.
Qué contestar y reintentos. Conteste con cualquier 2xx en menos de 10 segundos; el contenido de la
respuesta no importa. Cualquier otra cosa se reintenta con esperas crecientes: 1 minuto, 5 minutos, 30 minutos,
2 horas y 6 horas; si tras el último reintento sigue sin contestar, el aviso se da por perdido (el centro lo ve
en el registro de la pantalla). No se siguen redirecciones. Los avisos se guardan catorce días y después se
borran. Si la cita se guarda varias veces sin que cambie nada de lo que se cuenta, se envía un solo aviso.
Orden y duplicados. Los avisos de una misma cita salen en orden, pero no se garantiza que lleguen en orden si
alguno se reintenta, y un aviso puede llegar más de una vez. Use deliveryId y el estado de data (no el orden
de llegada) para decidir.
6. Rotar el secreto
Se rota desde Administración → Integraciones → Rotar secreto. Los dos secretos valen a la vez hasta que el viejo se retire: se genera el nuevo, se configura el ERP con calma, y cuando responde se pulsa Retirar el anterior. Así se cambia el secreto sin cortar la integración ni un minuto.
7. Límite de peticiones
120 peticiones por minuto y por integración. Cada respuesta trae:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
Pasado el límite, 429 con Retry-After: 60. Aparte de este límite general, /customers/exists tiene el suyo
(20 por minuto y 2.000 al día, véase el apartado 5). Un ERP con un bucle de reintentos mal puesto tumba un centro un
sábado, así que conviene respetar el Retry-After en vez de reintentar en seguida.
8. Los errores
| Código | Qué ha pasado |
|---|---|
400 |
Un parámetro mal: fecha sin huso, cursor que no dimos, to anterior a from. El detail lo dice. |
401 |
El token falta, ha caducado o es de otro centro. En /token, invalid_client. |
403 |
El scope no da para ese recurso, o la llamada viene de una dirección no autorizada. |
409 |
Al reservar: el hueco ya no está libre (BOOKING_SLOT_TAKEN), falta un aparato que el servicio necesita a esa hora (BOOKING_EQUIPMENT_BUSY), la agenda aún no llega a ese día (BOOKING_TOO_FAR_AHEAD) o la hora no cumple la antelación mínima (BOOKING_MINIMUM_NOTICE). |
429 |
Límite de peticiones. Espere lo que diga Retry-After. |
Todas las respuestas de error son application/problem+json (RFC 7807).
9. Las reglas del contrato
Escritas antes de tener clientes, porque después se negocian:
- Se puede añadir un campo o un recurso. Un cliente que no lo conoce lo ignora, así que el parseo del ERP no debe romperse ante un campo que no esperaba.
- No se puede quitar ni renombrar un campo, ni cambiar su tipo, ni cambiar el significado de uno que ya
existe. Para eso está la
v2. - Un campo que deja de tener sentido se marca como obsoleto en el OpenAPI y se sigue enviando.
- La
v1se mantiene al menos doce meses después de publicar lav2. - Los cambios se avisan a los centros que tengan una integración activa, y al correo de contacto de cada
integración. Cuando exista la
v2, las respuestas de lav1llevarán las cabecerasDeprecationySunset(RFC 9745 y RFC 8594) con la fecha de retirada, para que el ERP lo detecte sin leer ningún correo.
Un campo nulo siempre viaja como null; nunca desaparece del JSON. Es la diferencia con los endpoints que
consume la aplicación web, y es la razón de que la API de integración tenga sus propias clases.
La descripción OpenAPI de esta API está publicada junto a esta guía, en
devel.es/turnera/api/openapi-integracion.json, para
importarla en Postman o generar un cliente. Una prueba del programa comprueba en cada cambio que ese fichero dice
lo mismo que el código. Las instalaciones no sirven Swagger.
10. Lo que conviene saber antes de empezar
- Una venta anulada. Hoy la anulación deshace la venta; con la fase 2 de contabilidad se rectificará. El
ERP se entera por
updatedSinceen los dos casos, pero lo que ve es distinto: hoy la venta deja de aparecer, mañana aparecerá una rectificativa. - Los importes son cadenas decimales, no números en coma flotante. Léalos como
decimal. Si los lee comodouble, un35.00acaba siendo34.999999. - El borrador contable es un borrador. No numera ni emite nada. La facturación con VeriFactu es la fase 2 y todavía no está.
- Protección de datos. Sacar la ficha de los clientes de un centro hacia su ERP convierte al proveedor de ese ERP en otro encargado del tratamiento del centro. El centro tiene que tenerlo en su registro de actividades; la pantalla de crear una integración lo recuerda.
11. El entorno de pruebas
Para probar una integración sin tocar los datos de un centro hay un sandbox: un centro de mentira en
https://sandbox.turnera.devel.es/api/v1, con clientes, catálogo, ventas cobradas y citas inventados.
- Las credenciales se piden a DEVEL, una por integrador, con los permisos que necesite. Se crean sin restricción
de IP (
any), para poder trabajar desde un portátil que cambia de red. - Se reinicia cada noche: se borra lo que se haya creado (reservas, sobre todo) y los datos de ejemplo vuelven a su estado inicial, con las citas de la semana en curso.
- Las credenciales sobreviven al reinicio: el
client_idy el secreto siguen valiendo. - Todo lo de esta guía vale igual; solo cambia el dominio. Al pasar al centro real, el centro crea unas credenciales nuevas y autoriza la IP del servidor.