devel

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

  1. El dominio de su Turnera.
  2. Las credenciales, por un canal que no sea el correo del día a día.
  3. 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.

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ó

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"
    }
  ]
}

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.

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:

  1. 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.
  2. 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.
  3. Un campo que deja de tener sentido se marca como obsoleto en el OpenAPI y se sigue enviando.
  4. La v1 se mantiene al menos doce meses después de publicar la v2.
  5. 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 la v1 llevarán las cabeceras Deprecation y Sunset (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


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.