# API de Turnova > Tomá reservas desde el sitio de tu negocio con el mismo catálogo, la misma agenda y la misma seña que la página de reservas de Turnova. La API externa permite que un negocio que usa Turnova muestre sus servicios y horarios libres en su propio sitio y tome reservas que entran directo a su agenda. Detrás está el mismo motor que la página de reservas de Turnova: mismos precios, mismas reglas de horario, misma seña por Mercado Pago y los mismos mails de confirmación. > La API está incluida en el plan **Enterprise**. Las claves se crean en el panel del negocio, en Configuración > API para tu sitio. ## Qué podés hacer | Endpoint | Para qué | | --- | --- | | [`GET /api/v1/catalogo`](https://turnovapp.com/docs/referencia/leer-catalogo.md) | Servicios, opciones, extras, sedes y profesionales visibles. | | [`GET /api/v1/disponibilidad`](https://turnovapp.com/docs/referencia/consultar-disponibilidad.md) | Horarios libres de un servicio, por día y por profesional. | | [`POST /api/v1/reservas`](https://turnovapp.com/docs/referencia/crear-reserva.md) | Crear una reserva de uno a ocho turnos, con seña si el negocio la cobra. | | [`GET /api/v1/reservas/{compraId}`](https://turnovapp.com/docs/referencia/leer-reserva.md) | Estado de una reserva y de su seña (desde tu servidor). | ## Cómo es una llamada Todas las rutas cuelgan de `https://turnovapp.com/api/v1`. El negocio sale de la clave: no hay identificador del negocio en la URL. Toda respuesta es JSON con la misma forma, tanto si salió bien como si no. curl: ```bash curl "https://turnovapp.com/api/v1/catalogo" \ -H "Authorization: Bearer $TURNOVA_CLAVE_SECRETA" ``` JavaScript: ```js const res = await fetch("https://turnovapp.com/api/v1/catalogo", { headers: { Authorization: "Bearer tnv_pk_TU_CLAVE_PUBLICA" }, }); const r = await res.json(); if (r.ok) console.log(r.datos.servicios); else console.error(r.error.codigo, r.error.mensaje); ``` ## Por dónde seguir - [Primeros pasos](https://turnovapp.com/docs/primeros-pasos.md): crear la clave y hacer la primera llamada. - [Reservar un turno de punta a punta](https://turnovapp.com/docs/guias/reservar-un-turno.md): catálogo, horarios, reserva, seña y vuelta del pago. - [Autenticación](https://turnovapp.com/docs/autenticacion.md), [Errores](https://turnovapp.com/docs/errores.md), [Idempotencia](https://turnovapp.com/docs/idempotencia.md) y [Límites](https://turnovapp.com/docs/limites.md): las reglas comunes a todos los endpoints. - [Guía para agentes de IA](https://turnovapp.com/docs/agentes-ia.md): cómo usar esta documentación desde un asistente, con [llms.txt](https://turnovapp.com/docs/llms.txt). --- # Primeros pasos > Creá una clave en el panel de Turnova y hacé tu primera llamada en cinco minutos. ## 1. Elegí el tipo de clave | Tipo | Empieza con | Dónde se usa | | --- | --- | --- | | Pública | `tnv_pk_` | En el JavaScript de tu sitio. Solo funciona desde los orígenes que cargues (hasta 10). | | Secreta | `tnv_sk_` | Solo en tu servidor (variable de entorno). Se rechaza si llega desde un navegador. | Si tu sitio es estático o una SPA, usá una clave pública. Si tenés backend, podés usar la secreta desde ahí, que además puede consultar el estado de una reserva. ## 2. Creá la clave en el panel 1. Entrá al panel de Turnova con la cuenta de la dueña o de una administradora. 2. Andá a **Configuración > API para tu sitio** y tocá **Nueva clave**. 3. Poné un nombre que reconozcas (por ejemplo, «Web del spa»), elegí el tipo y los permisos. 4. Si es pública, cargá el origen de tu sitio tal como lo ve el navegador: `https://mi-spa.com` (sin barra final ni camino). Para desarrollo vale `http://localhost:3000`. 5. Copiá la clave: **se muestra una sola vez**. Turnova guarda solo una huella para reconocerla. ## 3. Probá la primera llamada curl: ```bash export TURNOVA_CLAVE_SECRETA="tnv_sk_..." curl "https://turnovapp.com/api/v1/catalogo" \ -H "Authorization: Bearer $TURNOVA_CLAVE_SECRETA" ``` JavaScript: ```js // En tu sitio, servido desde https://mi-spa.com const res = await fetch("https://turnovapp.com/api/v1/catalogo", { headers: { Authorization: "Bearer tnv_pk_TU_CLAVE_PUBLICA" }, }); console.log(await res.json()); ``` Si todo está bien, la respuesta trae `ok: true` y en `datos` el nombre del negocio, sus sedes, profesionales y servicios. ## Permisos de cada clave | Permiso | Habilita | Pública | Secreta | | --- | --- | --- | --- | | `catalogo:leer` | Leer el catálogo | Sí | Sí | | `disponibilidad:leer` | Consultar horarios libres | Sí | Sí | | `reservas:crear` | Crear reservas | Sí | Sí | | `reservas:leer` | Leer una reserva hecha por la API | No | Sí | ## Rotar o dar de baja una clave Para rotar, creá una clave nueva, cambiala en tu sitio y después revocá la anterior desde el mismo panel. Desde que se revoca, todo pedido con esa clave responde `401 CREDENCIAL_REVOCADA`. Pueden convivir varias claves activas. --- # Reservar un turno de punta a punta > Catálogo, horarios, reserva, seña por Mercado Pago y qué pasa cuando la clienta vuelve del pago. Esta guía arma el recorrido completo desde un sitio propio con la clave pública, y usa la secreta desde tu servidor para confirmar el pago. ## 1. Mostrar los servicios ```js const API = "https://turnovapp.com/api/v1"; const CLAVE = "tnv_pk_TU_CLAVE_PUBLICA"; const llamar = async (ruta, init = {}) => { const res = await fetch(API + ruta, { ...init, headers: { Authorization: `Bearer ${CLAVE}`, ...init.headers } }); return { res, r: await res.json() }; }; const { r: catalogo } = await llamar("/catalogo"); const servicios = catalogo.datos.servicios.filter((s) => !s.seReservaPorWhatsapp); ``` Usá `precio` y `duracionMin` solo para mostrar: el importe final lo calcula Turnova al reservar (descuentos, cupón, extras). Un servicio con `seReservaPorWhatsapp: true` no se puede reservar por la API: ofrecé el contacto por WhatsApp del negocio. ## 2. Pedir los horarios ```js const q = new URLSearchParams({ servicioId: servicio.id, desde: hoy, dias: "14" }); const { r: disp } = await llamar(`/disponibilidad?${q}`); const horasDelDia = disp.datos.horarios[fechaElegida] ?? []; ``` ## 3. Crear la reserva Generá una `Idempotency-Key` al confirmar y reusala si tenés que reintentar (ver [Idempotencia](https://turnovapp.com/docs/idempotencia.md)). ```js const { res, r } = await llamar("/reservas", { method: "POST", headers: { "Content-Type": "application/json", "Idempotency-Key": idem }, body: JSON.stringify({ turnos: [{ servicioId: servicio.id, fecha: "2030-01-10", hora: "10:00" }], cliente: { nombre: "Ana Pérez", telefono: "3515550101", codigoPais: "+54", email: "ana@example.com" }, }), }); ``` ## 4. Leer el resultado | Resultado | Qué significa | Qué hacer | | --- | --- | --- | | `ok: true`, `progreso.principal: CONFIRMADO` | El negocio no cobra seña (o quedó cubierta). El turno está confirmado. | Mostrá la confirmación. A la clienta le llega el mail de Turnova. | | `ok: true`, `principal: GUARDADO`, `pago: PENDIENTE` | El turno está reservado y espera la seña. | Redirigí a `datos.sena.checkoutUrl` (Mercado Pago). | | `ok: true`, `requiereRevision: true` | La reserva se guardó pero el link de pago no se pudo confirmar. | Reintentá con la misma clave de idempotencia. | | `409 HORARIO_NO_DISPONIBLE` | Alguien tomó el horario. | Volvé al paso 2 y ofrecé otros horarios. | | `400 ENTRADA_INVALIDA` | Falta o sobra un dato. | Mirá `error.campos` y corregí. | ```js if (!r.ok) return mostrarError(r.error); if (r.datos.sena?.checkoutUrl) { localStorage.setItem("ultimaReserva", r.datos.compraId); window.location.href = r.datos.sena.checkoutUrl; } else { mostrarConfirmacion(r.datos.turnos); } ``` ## 5. Seña por Mercado Pago La seña se cobra en la cuenta de Mercado Pago del negocio. El link es el mismo que usa la página de reservas de Turnova, con la misma vigencia. **La seña se confirma solo cuando Mercado Pago avisa a Turnova** (webhook): nunca la des por pagada porque la clienta volvió a tu sitio. ## 6. Qué pasa al volver del pago En esta versión, al terminar el pago Mercado Pago lleva a la clienta a la página de resultado de Turnova del negocio, que le muestra si el pago se acreditó y el detalle de su turno. No vuelve automáticamente a tu sitio. Si querés mostrar el estado en tu sitio, consultalo desde tu servidor con la clave secreta y el `compraId` que guardaste: curl: ```bash curl "https://turnovapp.com/api/v1/reservas/cmh4k2n9x0001" \ -H "Authorization: Bearer $TURNOVA_CLAVE_SECRETA" ``` JavaScript: ```js // En tu servidor const res = await fetch(`https://turnovapp.com/api/v1/reservas/${compraId}`, { headers: { Authorization: `Bearer ${process.env.TURNOVA_CLAVE_SECRETA}` }, }); const { datos } = await res.json(); // datos.estadoPago: SIN_SENA | PAGADA | PENDIENTE | VENCIDA | EN_EL_LOCAL | EN_REVISION ``` | estadoPago | Qué significa | | --- | --- | | `PAGADA` | Mercado Pago acreditó la seña. El turno está confirmado. | | `PENDIENTE` | Todavía no hay pago. `sena.checkoutUrl` sigue sirviendo mientras esté vigente. | | `VENCIDA` | El link venció sin pago. El turno puede liberarse: mirá `turnos[].estado`. | | `EN_EL_LOCAL` | El negocio registra la seña a mano (por ejemplo, la cobró en el local). | | `EN_REVISION` | El pago necesita que alguien del negocio lo revise. | | `SIN_SENA` | La reserva no tenía seña. | --- # Guía para agentes de IA > Cómo leer esta documentación desde un asistente y qué reglas no puede saltearse una integración. Esta documentación está pensada para que un asistente de código (o un agente) pueda leerla y escribir la integración. Cada página tiene su versión en Markdown agregando `.md` a la dirección. | Recurso | Para qué | | --- | --- | | [llms.txt](https://turnovapp.com/docs/llms.txt) | Índice corto con cada página y su versión .md. | | [llms-full.txt](https://turnovapp.com/docs/llms-full.txt) | Toda la documentación en un solo archivo de texto. | | [openapi.yaml](https://turnovapp.com/docs/openapi.yaml) / [openapi.json](https://turnovapp.com/docs/openapi.json) | La especificación OpenAPI 3.1, fuente de la referencia. | ## Reglas para una integración correcta 1. La clave pública va en el navegador; la secreta, solo en el servidor. Nunca pongas una clave secreta en código que llegue al navegador. 2. Decidí por `error.codigo`, `error.recuperacion` y `error.reintentable`; no interpretes `mensaje`. 3. Toda reserva lleva una `Idempotency-Key` estable por intento. Reintentá solo con la misma clave y el mismo cuerpo. 4. No calcules precios ni horarios del lado del cliente para guardar: mostralos desde la API y dejá que la reserva los valide. 5. Tratá `progreso.principal: GUARDADO` con `pago: PENDIENTE` como reservado sin pagar, no como confirmado. 6. La seña se confirma solo por Mercado Pago; la vuelta de la clienta a tu sitio no es prueba de pago. 7. Respetá `Retry-After` en los 429. ## Ejemplo de pedido a un asistente ```text Leé https://docs.turnovapp.com/llms.txt y armá en mi sitio (Next.js) un flujo de reserva con la API de Turnova: catálogo, horarios, reserva con Idempotency-Key y redirección a la seña de Mercado Pago. Usá la clave pública desde el navegador. ``` --- # Autenticación > Clave pública con orígenes permitidos para el navegador, clave secreta para tu servidor. Cada pedido lleva la clave en la cabecera `Authorization`. Nunca la pongas en la URL: quedaría en historiales y registros. ```http GET /api/v1/catalogo HTTP/1.1 Host: turnovapp.com Authorization: Bearer tnv_pk_TU_CLAVE_PUBLICA ``` ## Clave pública (navegador) - Va a la vista de cualquiera en el JavaScript de tu sitio: por eso solo da acceso a lo que tu página de reservas ya muestra, y a crear reservas. - Funciona solo desde los **orígenes permitidos** de la clave. El servidor compara la cabecera `Origin` antes de ejecutar nada: un pedido desde otro origen, o sin `Origin`, responde `403 ORIGEN_NO_PERMITIDO`. - Los orígenes se cargan como `https://dominio` (sin camino). Se acepta `http://` solo para `localhost` y `127.0.0.1`. - No puede leer reservas ya hechas: eso trae datos de la clienta. ### CORS El navegador manda primero un preflight `OPTIONS` sin la clave. Turnova responde `204` con `Access-Control-Allow-Origin` igual a tu origen exacto (nunca `*`) si ese origen está cargado en alguna clave pública activa, y `403` sin cabeceras CORS si no. Las cabeceras permitidas son `Authorization`, `Content-Type` e `Idempotency-Key`; se expone `Retry-After`. No se usan cookies. ## Clave secreta (servidor) - Guardala en una variable de entorno de tu servidor y no la mandes nunca al navegador. - Si un pedido con clave secreta trae cabecera `Origin` (es decir, sale de un navegador), responde `403 CLAVE_SECRETA_EN_NAVEGADOR`. - Es la única que puede tener el permiso `reservas:leer`. ```js // Node 18+ (Next.js route handler, Express, etc.) const res = await fetch("https://turnovapp.com/api/v1/reservas/" + compraId, { headers: { Authorization: `Bearer ${process.env.TURNOVA_CLAVE_SECRETA}` }, }); ``` ## Plan del negocio La API funciona mientras el negocio tenga el plan Enterprise. Si cambia a otro plan, las claves no se borran, pero todo pedido responde `403 PLAN_SIN_API` con `upgrade_url` hasta que vuelva a Enterprise. ## Errores de acceso | HTTP | Código | Qué pasó | | --- | --- | --- | | 401 | `CREDENCIAL_FALTANTE` | Mandá la clave en Authorization: Bearer . | | 401 | `CREDENCIAL_INVALIDA` | La clave no es válida. | | 401 | `CREDENCIAL_REVOCADA` | La clave fue revocada por el negocio. | | 403 | `CLAVE_SECRETA_EN_NAVEGADOR` | La clave secreta es solo para servidores: desde el navegador usá la clave pública. | | 403 | `ORIGEN_NO_PERMITIDO` | Este sitio no está autorizado para usar la clave. | | 403 | `SCOPE_FALTANTE` | La clave no tiene permiso para esta operación. | | 403 | `PLAN_SIN_API` | La API está incluida en el plan Enterprise. El negocio puede cambiar de plan desde su panel. | --- # Errores > Todas las respuestas tienen la misma forma; los errores traen un código estable y qué hacer. Cada respuesta es un objeto con `version: 1`, `ok`, `progreso` y `requiereRevision`. Si `ok` es `true` trae `datos`; si es `false` trae `error`. Decidí siempre por `error.codigo` y `error.recuperacion`, nunca por el texto de `mensaje` (que está pensado para mostrarle a la clienta y puede cambiar). ```json { "version": 1, "ok": false, "progreso": { "principal": "NO_GUARDADO" }, "requiereRevision": false, "error": { "dominio": "compras", "codigo": "HORARIO_NO_DISPONIBLE", "mensaje": "Ese horario ya no está disponible.", "categoria": "ESTADO", "reintentable": false, "recuperacion": "RELEER_Y_CONFIRMAR" } } ``` ## Campos del error | Campo | Qué es | | --- | --- | | `dominio` | `api` (la puerta: clave, origen, límites, formato) o `compras` (el motor de reservas). | | `codigo` | Código estable. Es el que tenés que usar en tu código. | | `mensaje` | Texto en español para mostrar. | | `categoria` | `ENTRADA`, `ACCESO`, `ESTADO`, `IDENTIDAD`, `PROVEEDOR` o `TECNICO`. | | `reintentable` | `true` solo si repetir el mismo pedido, con la misma `Idempotency-Key`, puede andar. | | `recuperacion` | Qué hacer: ver la tabla de abajo. | | `campos` | Con `ENTRADA_INVALIDA`: la lista de campos con problema (`campo`, `codigo`, `mensaje`). | | `referenciaDiagnostico` | Con errores técnicos: un identificador para pedir soporte. No trae detalles internos. | ## Qué hacer según la recuperación | Recuperación | Qué hacer | | --- | --- | | `CORREGIR_ENTRADA` | Corregí los datos (mirá `campos`) y mandá un pedido nuevo. | | `RELEER_Y_CONFIRMAR` | El estado cambió (por ejemplo, alguien tomó el horario). Volvé a consultar la disponibilidad y pedile a la clienta que elija otra vez. | | `REINTENTAR_MISMA_INTENCION` | Reintentá el mismo pedido con la misma `Idempotency-Key`, respetando `Retry-After` si viene. | | `CONSULTAR_ESTADO` | No se sabe si se guardó. Reintentá con la misma clave de idempotencia o consultá la reserva. | | `REVISION_HUMANA` | Necesita que alguien del negocio lo mire. No reintentes en bucle. | | `NO_REINTENTAR` | Repetir da lo mismo. | ## Progreso `progreso.principal` dice qué se sabe de la operación: `CONSULTADO` (lecturas), `NO_GUARDADO`, `GUARDADO` (la reserva existe pero falta algo, por ejemplo el pago), `CONFIRMADO` o `DESCONOCIDO`. En reservas puede venir `progreso.pago`: `PENDIENTE`, `CONFIRMADO`, `EN_REVISION` o `INCIERTO`. Un error nunca se presenta como algo mayor a lo que el servidor confirmó. ## Códigos de la API (dominio api) | HTTP | Código | Reintentable | Mensaje | | --- | --- | --- | --- | | 404 | `API_NO_HABILITADA` | No | La API externa no está habilitada. | | 403 | `PLAN_SIN_API` | No | La API está incluida en el plan Enterprise. El negocio puede cambiar de plan desde su panel. | | 401 | `CREDENCIAL_FALTANTE` | No | Mandá la clave en Authorization: Bearer . | | 401 | `CREDENCIAL_INVALIDA` | No | La clave no es válida. | | 401 | `CREDENCIAL_REVOCADA` | No | La clave fue revocada por el negocio. | | 403 | `CLAVE_SECRETA_EN_NAVEGADOR` | No | La clave secreta es solo para servidores: desde el navegador usá la clave pública. | | 403 | `ORIGEN_NO_PERMITIDO` | No | Este sitio no está autorizado para usar la clave. | | 403 | `SCOPE_FALTANTE` | No | La clave no tiene permiso para esta operación. | | 429 | `LIMITE_USO` | Sí | Demasiados pedidos. Esperá unos segundos y reintentá con los mismos datos. | | 400 | `IDEMPOTENCY_KEY_FALTANTE` | No | Mandá la cabecera Idempotency-Key: una clave estable por operación. | | 400 | `IDEMPOTENCY_KEY_INVALIDA` | No | Idempotency-Key: entre 8 y 100 caracteres A-Z, a-z, 0-9, punto, guion, guion bajo o dos puntos. | | 400 | `ENTRADA_INVALIDA` | No | Revisá los datos enviados. | | 404 | `NO_ENCONTRADO` | No | No existe o no pertenece a este negocio. | | 409 | `SERVICIO_DERIVADO_WHATSAPP` | No | Este servicio se agenda por WhatsApp con la profesional, no por la API. | | 500 | `ERROR_TECNICO` | Sí | No pudimos completar el pedido. Reintentá con los mismos datos. | | 400 o 404 | `CONSULTA_RECHAZADA` | No | Lecturas: el catálogo o la disponibilidad rechazaron el pedido (por ejemplo, un servicio que no existe). El mensaje explica por qué. | ## Códigos de la reserva (dominio compras) | HTTP | Código | Recuperación | Reintentable | | --- | --- | --- | --- | | 400 | `ENTRADA_INVALIDA` | `CORREGIR_ENTRADA` | No | | 400 | `SOLICITUD_NO_PERMITIDA` | `CORREGIR_ENTRADA` | No | | 404 o 400 | `CATALOGO_NO_DISPONIBLE` | `CORREGIR_ENTRADA` | No | | 400 | `DESCUENTO_NO_APLICABLE` | `CORREGIR_ENTRADA` | No | | 409 | `HORARIO_NO_DISPONIBLE` | `RELEER_Y_CONFIRMAR` | No | | 409 | `ESTADO_CAMBIO` | `RELEER_Y_CONFIRMAR` | No | | 409 | `IMPORTE_NO_CORRESPONDE` | `RELEER_Y_CONFIRMAR` | No | | 409 | `OPERACION_NO_PERMITIDA` | `NO_REINTENTAR` | No | | 400 o 409 | `CONFIGURACION_INCOMPLETA` | `REVISION_HUMANA` | No | | 409 | `DATOS_INCONSISTENTES` | `REVISION_HUMANA` | No | | 409 | `COMPRA_GUARDADA_REVISAR` | `CONSULTAR_ESTADO` | No | | 409 | `AGENDA_OCUPADA` | `REINTENTAR_MISMA_INTENCION` | Sí | | 409 | `CONFLICTO_INTENCION` | `REVISION_HUMANA` | No | | 409 | `PAGO_NO_VERIFICADO` | `REVISION_HUMANA` | No | | 404 | `NO_ENCONTRADO` | `NO_REINTENTAR` | No | | 403 | `SIN_ACCESO` | `NO_REINTENTAR` | No | --- # Idempotencia > Una clave por reserva para poder reintentar sin crear dos turnos ni dos cobros. `POST /api/v1/reservas` exige la cabecera `Idempotency-Key`. Si la conexión se corta y no sabés si la reserva se creó, repetí el pedido con **la misma clave y el mismo cuerpo**: vas a recibir la misma reserva, sin otra fila, sin otro mail y sin otro link de pago. | Caso | Respuesta | | --- | --- | | Clave nueva | `201` con la reserva y `operacion.repetida: false`. | | Misma clave, mismo cuerpo | `200` con la misma reserva y `operacion.repetida: true`. | | Misma clave, otro cuerpo | `409 CONFLICTO_INTENCION`. No se crea nada. | | Dos pedidos a la vez con la misma clave | Se crea una sola reserva; los dos reciben el mismo resultado. | | Sin la cabecera | `400 IDEMPOTENCY_KEY_FALTANTE`, sin escribir nada. | ## Cómo elegir la clave - Entre 8 y 100 caracteres: letras, números, punto, guion, guion bajo o dos puntos. - Generala una vez por intento de reserva de la clienta (por ejemplo, al abrir el paso de confirmación) y guardala hasta tener respuesta. Un UUID sirve. - Si la clienta cambia el horario o los datos, es otra reserva: usá otra clave. - La clave es del negocio, no de la credencial: si rotás la clave de la API, el reintento con la nueva recupera la reserva hecha con la anterior. ```js // Una clave por intento de reserva, reutilizada en cada reintento. const idem = sessionStorage.getItem("reservaIdem") ?? crypto.randomUUID(); sessionStorage.setItem("reservaIdem", idem); async function reservar(cuerpo, intentos = 3) { for (let i = 0; i < intentos; i++) { try { const res = await fetch("https://turnovapp.com/api/v1/reservas", { method: "POST", headers: { Authorization: "Bearer tnv_pk_TU_CLAVE_PUBLICA", "Content-Type": "application/json", "Idempotency-Key": idem, }, body: JSON.stringify(cuerpo), }); const r = await res.json(); if (r.ok || !r.error.reintentable) return r; const espera = Number(res.headers.get("Retry-After") ?? 2); await new Promise((ok) => setTimeout(ok, espera * 1000)); } catch { // Red caída: reintentar con la MISMA clave es seguro. } } throw new Error("No pudimos confirmar la reserva"); } ``` Las lecturas (`GET`) no llevan clave de idempotencia: se pueden repetir libremente. --- # Límites > Cuántos pedidos por minuto acepta cada clave y qué hacer al pasarse. Los límites se cuentan por clave en ventanas de un minuto. Son iguales para todos los negocios con la API. | Qué | Límite | | --- | --- | | Lecturas (catálogo, disponibilidad, estado de una reserva) | 120 por minuto por clave | | Escrituras (crear reservas) | 20 por minuto por clave | | Escrituras con clave pública, por IP de la clienta | 6 por minuto | Al pasarse, la respuesta es `429 LIMITE_USO` con la cabecera `Retry-After` (segundos) y `reintentable: true`. Esperá ese tiempo y repetí el pedido; si era una reserva, con la misma `Idempotency-Key`. ```http HTTP/1.1 429 Too Many Requests Retry-After: 23 Content-Type: application/json ``` ## Buenas prácticas - Leé el catálogo una vez por visita (o cachealo en tu servidor unos minutos): cambia poco. - Pedí disponibilidad cuando la clienta elige el servicio, no en cada render. Con `dias` traés varios días en un solo pedido. - La disponibilidad ya viene cacheada hasta 45 segundos del lado de Turnova: pedirla más seguido no trae datos más nuevos. Ver [Disponibilidad y caché](https://turnovapp.com/docs/disponibilidad-y-cache.md). ## Uso de tus claves En el panel, **Configuración > API para tu sitio** muestra por día y por clave cuántas lecturas, escrituras, reservas creadas y rechazos por límite hubo. --- # Disponibilidad y caché > Cómo leer los horarios libres y por qué pueden tener hasta 45 segundos de atraso. `GET /api/v1/disponibilidad` devuelve los horarios libres de un servicio con las mismas reglas que la página de reservas de Turnova: horario del negocio y de cada profesional, turnos tomados, bloqueos, boxes, horario de oferta del servicio y duración de los extras. curl: ```bash curl "https://turnovapp.com/api/v1/disponibilidad?servicioId=cmh3s0abc0012&desde=2030-01-10&dias=7" \ -H "Authorization: Bearer $TURNOVA_CLAVE_SECRETA" ``` JavaScript: ```js const q = new URLSearchParams({ servicioId, desde: "2030-01-10", dias: "7" }); const res = await fetch(`https://turnovapp.com/api/v1/disponibilidad?${q}`, { headers: { Authorization: "Bearer tnv_pk_TU_CLAVE_PUBLICA" }, }); const { datos } = await res.json(); // datos.horarios: { "2030-01-10": ["10:00", "11:00"], ... } ``` ## Cómo leer la respuesta - `horarios`: por fecha (AAAA-MM-DD), las horas de inicio libres con al menos una profesional. - `horariosPorProfesional`: lo mismo, separado por profesional. Útil si la clienta elige con quién. - `diasSinLugar`: fechas del rango que ya no tienen ningún horario. - Las fechas y horas están en la zona del negocio (`negocio.zonaHoraria` del catálogo), no en la del navegador. - Si el servicio tiene opciones, mandá `varianteId`. Si la clienta suma extras, mandá `addOnIds` (separados por coma): estiran la duración y cambian los horarios posibles. ## Caché > **Importante:** La disponibilidad puede tener hasta **45 segundos** de atraso. Turnova guarda cada respuesta ese tiempo por negocio y combinación de parámetros. Eso no permite reservar dos veces el mismo horario: al crear la reserva, el servidor vuelve a validar todo dentro de una transacción. Si el horario se ocupó en esos segundos, la reserva responde `409 HORARIO_NO_DISPONIBLE` con recuperación `RELEER_Y_CONFIRMAR`: volvé a pedir la disponibilidad y ofrecé otros horarios. Una reserva creada por la API limpia la caché del negocio, así que la próxima consulta ya la refleja. Lo que se carga desde el panel o desde otra vía se ve al vencer la caché. --- # Versionado y cambios > Qué puede cambiar dentro de v1 y el historial de cambios. La versión va en la ruta: `/api/v1/`. Dentro de v1 solo se **agregan** cosas: endpoints nuevos, campos nuevos en las respuestas o parámetros opcionales. Quitar un campo, cambiarle el significado o volver obligatorio algo que no lo era es una v2, que convive con v1 un tiempo anunciado. Escribí tu integración tolerando campos desconocidos en las respuestas y códigos de error nuevos: tratá un código que no conocés según su `recuperacion` y `reintentable`. La especificación completa está en [openapi.yaml](https://turnovapp.com/docs/openapi.yaml) y [openapi.json](https://turnovapp.com/docs/openapi.json) (OpenAPI 3.1). ## Historial ### 1 de noviembre de 2026: v1 - Catálogo, disponibilidad, crear reserva (con seña por Mercado Pago) y estado de una reserva. - Claves públicas con orígenes permitidos y claves secretas, con permisos por clave. - Idempotencia obligatoria al reservar, límites por minuto y formato común de errores. - Uso diario por clave en el panel. Incluida en el plan Enterprise. --- # Referencia de la API > Todos los endpoints de la API v1, generados de la especificación OpenAPI. URL base: `https://turnovapp.com`. Descargá la especificación en [openapi.yaml](https://turnovapp.com/docs/openapi.yaml) o [openapi.json](https://turnovapp.com/docs/openapi.json). ## Catálogo Servicios, sedes y profesionales que el negocio muestra. | Endpoint | Qué hace | | --- | --- | | [`GET /api/v1/catalogo`](https://turnovapp.com/docs/referencia/leer-catalogo.md) | Servicios visibles, sedes y profesionales visibles del negocio | ## Disponibilidad Horarios libres para un servicio. | Endpoint | Qué hace | | --- | --- | | [`GET /api/v1/disponibilidad`](https://turnovapp.com/docs/referencia/consultar-disponibilidad.md) | Horarios libres de un servicio | ## Reservas Crear una reserva y consultar su estado. | Endpoint | Qué hace | | --- | --- | | [`POST /api/v1/reservas`](https://turnovapp.com/docs/referencia/crear-reserva.md) | Crear una reserva de uno a ocho turnos | | [`GET /api/v1/reservas/{compraId}`](https://turnovapp.com/docs/referencia/leer-reserva.md) | Estado de una reserva hecha por la API | ## Panel Gestión de claves y uso desde el panel de Turnova (sesión de la dueña o una administradora). | Endpoint | Qué hace | | --- | --- | | [`GET /api/configuracion/api-externa/credenciales`](https://turnovapp.com/docs/referencia/listar-credenciales.md) | Credenciales del negocio (panel, con sesión) | | [`POST /api/configuracion/api-externa/credenciales`](https://turnovapp.com/docs/referencia/crear-credencial.md) | Crear una credencial; la clave se muestra una sola vez | | [`POST /api/configuracion/api-externa/credenciales/{id}/revocar`](https://turnovapp.com/docs/referencia/revocar-credencial.md) | Revocar (repetible) | | [`GET /api/configuracion/api-externa/uso`](https://turnovapp.com/docs/referencia/leer-uso.md) | Uso diario por credencial | --- # Servicios visibles, sedes y profesionales visibles del negocio > GET /api/v1/catalogo `GET /api/v1/catalogo` Scope `catalogo:leer`. Mismo catálogo que la página pública de reservas. > Autenticación: `Authorization: Bearer `. Ver [Autenticación](https://turnovapp.com/docs/autenticacion.md). ## Parámetros | Nombre | En | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | --- | | `sucursalId` | query | string | No | Acota los profesionales a esa sede (si el negocio tiene más de una). | ## Ejemplo curl: ```bash curl "https://turnovapp.com/api/v1/catalogo" \ -H "Authorization: Bearer $TURNOVA_CLAVE_SECRETA" ``` JavaScript: ```js // Desde el navegador, en un origen cargado en la clave pública. const res = await fetch("https://turnovapp.com/api/v1/catalogo", { headers: { Authorization: "Bearer tnv_pk_TU_CLAVE_PUBLICA", }, }); const r = await res.json(); if (!r.ok) throw new Error(`${r.error.codigo}: ${r.error.mensaje}`); ``` ## Respuestas | HTTP | Qué significa | | --- | --- | | 200 | Catálogo | | 400 | Datos inválidos: mirá `error.campos` (`ENTRADA_INVALIDA`, `IDEMPOTENCY_KEY_FALTANTE`…). | | 401 | Clave faltante, inválida o revocada. | | 403 | Origen no permitido, permiso faltante, clave secreta desde un navegador o plan sin la API (`PLAN_SIN_API`). | | 404 | No existe o no es de este negocio. | | 429 | Demasiados pedidos (`LIMITE_USO`): esperá `Retry-After` segundos. | | 500 | Error técnico: reintentá con los mismos datos (y la misma `Idempotency-Key`). | ### Respuesta 200 ```json { "version": 1, "ok": true, "progreso": { "principal": "CONSULTADO" }, "requiereRevision": false, "datos": { "negocio": { "nombre": "Masaje descontracturante", "zonaHoraria": "America/Argentina/Buenos_Aires", "moneda": "ARS", "cobraSena": false }, "sedes": [ { "id": "cmh4k2n9x0001", "nombre": "Masaje descontracturante", "direccion": "Av. Colón 1234, Córdoba", "principal": false } ], "profesionales": [ { "id": "cmh4k2n9x0001", "nombre": "Masaje descontracturante", "especialidad": "Masajista" } ], "servicios": [ { "id": "cmh4k2n9x0001", "nombre": "Masaje descontracturante", "descripcion": "60 minutos con aceites esenciales.", "duracionMin": 60, "precio": 25000, "descuentoPct": 0, "sesiones": 1, "cantidadProfesionales": 1, "comboPorEtapas": false, "seReservaPorWhatsapp": false, "categorias": [ { "id": "cmh3c0cat001", "nombre": "Masajes" } ], "opciones": [ { "id": "cmh4k2n9x0001", "nombre": "Masaje descontracturante", "precio": 25000, "duracionMin": 60, "capacidadPersonas": 1, "cantidadProfesionales": 1, "comboPorEtapas": false } ], "extras": [], "profesionales": [ { "id": "cmh4k2n9x0001", "nombre": "Masaje descontracturante", "especialidad": "Masajista" } ] } ] } } ``` | Campo | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `datos.negocio` | object | No | | | `datos.negocio.nombre` | string | No | | | `datos.negocio.zonaHoraria` | string | No | | | `datos.negocio.moneda` | `"ARS"` | No | | | `datos.negocio.cobraSena` | boolean | No | | | `datos.sedes` | array de object | No | | | `datos.sedes[].id` | string | No | | | `datos.sedes[].nombre` | string | No | | | `datos.sedes[].direccion` | string o null | No | | | `datos.sedes[].principal` | boolean | No | | | `datos.profesionales` | array de object | No | | | `datos.profesionales[].id` | string | No | | | `datos.profesionales[].nombre` | string | No | | | `datos.profesionales[].especialidad` | string o null | No | | | `datos.servicios` | array de object | No | | | `datos.servicios[].id` | string | No | | | `datos.servicios[].nombre` | string | No | | | `datos.servicios[].descripcion` | string o null | No | | | `datos.servicios[].duracionMin` | integer | No | | | `datos.servicios[].precio` | number | No | Precio de lista. El importe final lo calcula el servidor al reservar. | | `datos.servicios[].descuentoPct` | number | No | | | `datos.servicios[].sesiones` | integer | No | Mayor a 1: paquete de sesiones. | | `datos.servicios[].cantidadProfesionales` | integer | No | | | `datos.servicios[].comboPorEtapas` | boolean | No | | | `datos.servicios[].seReservaPorWhatsapp` | boolean | No | true: la reserva por la API responde 409 SERVICIO_DERIVADO_WHATSAPP. | | `datos.servicios[].categorias` | array de object | No | | | `datos.servicios[].categorias[].id` | string | No | | | `datos.servicios[].categorias[].nombre` | string | No | | | `datos.servicios[].opciones` | array de object | No | | | `datos.servicios[].opciones[].id` | string | No | | | `datos.servicios[].opciones[].nombre` | string | No | | | `datos.servicios[].opciones[].precio` | number o null | No | | | `datos.servicios[].opciones[].duracionMin` | integer o null | No | | | `datos.servicios[].opciones[].capacidadPersonas` | integer | No | | | `datos.servicios[].opciones[].cantidadProfesionales` | integer o null | No | | | `datos.servicios[].opciones[].comboPorEtapas` | boolean | No | | | `datos.servicios[].extras` | array de object | No | | | `datos.servicios[].extras[].id` | string | No | | | `datos.servicios[].extras[].nombre` | string | No | | | `datos.servicios[].extras[].precio` | number | No | | | `datos.servicios[].extras[].duracionMin` | integer | No | | | `datos.servicios[].extras[].cantidadMax` | integer | No | Para pedir N unidades, repetir el id N veces en addOnIds. | | `datos.servicios[].profesionales` | array de object | No | | | `datos.servicios[].profesionales[].id` | string | No | | | `datos.servicios[].profesionales[].nombre` | string | No | | | `datos.servicios[].profesionales[].especialidad` | string o null | No | | Los errores siguen el formato común: ver [Errores](https://turnovapp.com/docs/errores.md). --- # Horarios libres de un servicio > GET /api/v1/disponibilidad `GET /api/v1/disponibilidad` Scope `disponibilidad:leer`. Horas en la zona del negocio. Parámetros fuera de la lista dan 400. La respuesta puede tener hasta 45 segundos de antigüedad (caché por negocio y parámetros); la reserva vuelve a validar el horario. > Autenticación: `Authorization: Bearer `. Ver [Autenticación](https://turnovapp.com/docs/autenticacion.md). ## Parámetros | Nombre | En | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | --- | | `servicioId` | query | string | Sí | | | `varianteId` | query | string | No | Opción del servicio (obligatoria si el servicio tiene opciones). | | `profesionalId` | query | string | No | | | `sucursalId` | query | string | No | | | `desde` | query | string (date) | No | AAAA-MM-DD; por defecto, hoy. | | `dias` | query | integer | No | Entre 1 y 60. Por defecto `14`. | | `addOnIds` | query | string | No | Ids de extras separados por coma (estiran la duración). | ## Ejemplo curl: ```bash curl "https://turnovapp.com/api/v1/disponibilidad?servicioId=cmh3s0abc0012&desde=2030-01-01&dias=7" \ -H "Authorization: Bearer $TURNOVA_CLAVE_SECRETA" ``` JavaScript: ```js // Desde el navegador, en un origen cargado en la clave pública. const res = await fetch("https://turnovapp.com/api/v1/disponibilidad?servicioId=cmh3s0abc0012&desde=2030-01-01&dias=7", { headers: { Authorization: "Bearer tnv_pk_TU_CLAVE_PUBLICA", }, }); const r = await res.json(); if (!r.ok) throw new Error(`${r.error.codigo}: ${r.error.mensaje}`); ``` ## Respuestas | HTTP | Qué significa | | --- | --- | | 200 | Horarios | | 400 | Datos inválidos: mirá `error.campos` (`ENTRADA_INVALIDA`, `IDEMPOTENCY_KEY_FALTANTE`…). | | 401 | Clave faltante, inválida o revocada. | | 403 | Origen no permitido, permiso faltante, clave secreta desde un navegador o plan sin la API (`PLAN_SIN_API`). | | 404 | No existe o no es de este negocio. | | 429 | Demasiados pedidos (`LIMITE_USO`): esperá `Retry-After` segundos. | | 500 | Error técnico: reintentá con los mismos datos (y la misma `Idempotency-Key`). | ### Respuesta 200 ```json { "version": 1, "ok": true, "progreso": { "principal": "CONSULTADO" }, "requiereRevision": false, "datos": { "horarios": { "2030-01-10": [ "10:00", "11:00", "15:30" ] }, "horariosPorProfesional": { "cmh3p0lucia01": { "2030-01-10": [ "10:00", "15:30" ] } }, "diasSinLugar": [ "2030-01-12" ], "profesionales": [ { "id": "cmh4k2n9x0001", "nombre": "Masaje descontracturante" } ], "finPorHorario": {} } } ``` | Campo | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `datos.horarios` | object | No | Por fecha AAAA-MM-DD. | | `datos.horariosPorProfesional` | object | No | | | `datos.diasSinLugar` | array de string (date) | No | | | `datos.profesionales` | array de object | No | | | `datos.profesionales[].id` | string | No | | | `datos.profesionales[].nombre` | string | No | | | `datos.finPorHorario` | object | No | Solo combos por etapas: fin real por fecha y hora de inicio. | Los errores siguen el formato común: ver [Errores](https://turnovapp.com/docs/errores.md). --- # Crear una reserva de uno a ocho turnos > POST /api/v1/reservas `POST /api/v1/reservas` Scope `reservas:crear`. Exige `Idempotency-Key`: misma clave y mismo cuerpo devuelven la misma reserva (200, `operacion.repetida: true`); otro cuerpo con la misma clave da 409 `CONFLICTO_INTENCION`. Con seña, la reserva queda GUARDADO con `pago: PENDIENTE` y `datos.sena.checkoutUrl` (Mercado Pago); se confirma solo por el webhook de MP. > Autenticación: `Authorization: Bearer `. Ver [Autenticación](https://turnovapp.com/docs/autenticacion.md). ## Parámetros | Nombre | En | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | --- | | `Idempotency-Key` | header | string | Sí | Formato `^[A-Za-z0-9._:-]{8,100}$`. | ## Cuerpo | Campo | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `turnos` | array de object | Sí | Entre 1 y 8 elementos. | | `turnos[].servicioId` | string | Sí | | | `turnos[].varianteId` | string o null | No | | | `turnos[].profesionalId` | string o null | No | | | `turnos[].fecha` | string (date) | Sí | | | `turnos[].hora` | string | Sí | Hora del negocio. Formato `^(?:[01]\d\|2[0-3]):[0-5]\d$`. | | `turnos[].addOnIds` | array de string | No | Entre 0 y 50 elementos. | | `sucursalId` | string o null | No | | | `modoCadena` | `"uno_tras_otro"` | No | | | `cliente` | object | Sí | | | `cliente.nombre` | string | Sí | Hasta 120 caracteres. | | `cliente.telefono` | string | Sí | Hasta 32 caracteres. | | `cliente.codigoPais` | string | No | Hasta 6 caracteres. Por defecto `+54`. | | `cliente.email` | string | No | Hasta 254 caracteres. | | `cliente.consintioContacto` | boolean | No | | | `notas` | string | No | Hasta 4000 caracteres. | | `cuponCodigo` | string | No | Hasta 64 caracteres. | ## Ejemplo curl: ```bash curl -X POST "https://turnovapp.com/api/v1/reservas" \ -H "Authorization: Bearer $TURNOVA_CLAVE_SECRETA" \ -H "Idempotency-Key: reserva-7f3c2a9e-01" \ -H "Content-Type: application/json" \ -d '{ "turnos": [ { "servicioId": "cmh3s0abc0012", "fecha": "2030-01-10", "hora": "10:00" } ], "cliente": { "nombre": "Ana Pérez", "telefono": "+5493515550101", "email": "ana@example.com" } }' ``` JavaScript: ```js // Desde el navegador, en un origen cargado en la clave pública. const res = await fetch("https://turnovapp.com/api/v1/reservas", { method: "POST", headers: { Authorization: "Bearer tnv_pk_TU_CLAVE_PUBLICA", "Idempotency-Key": claveDeEstaReserva, // la misma en cada reintento "Content-Type": "application/json", }, body: JSON.stringify({ "turnos": [ { "servicioId": "cmh3s0abc0012", "fecha": "2030-01-10", "hora": "10:00" } ], "cliente": { "nombre": "Ana Pérez", "telefono": "+5493515550101", "email": "ana@example.com" } }), }); const r = await res.json(); if (!r.ok) throw new Error(`${r.error.codigo}: ${r.error.mensaje}`); ``` ## Respuestas | HTTP | Qué significa | | --- | --- | | 200 | Reintento de una reserva ya hecha con la misma clave | | 201 | Reserva nueva | | 400 | Datos inválidos: mirá `error.campos` (`ENTRADA_INVALIDA`, `IDEMPOTENCY_KEY_FALTANTE`…). | | 401 | Clave faltante, inválida o revocada. | | 403 | Origen no permitido, permiso faltante, clave secreta desde un navegador o plan sin la API (`PLAN_SIN_API`). | | 404 | No existe o no es de este negocio. | | 409 | Conflicto: el horario ya no está libre, otra intención con la misma `Idempotency-Key` o el servicio se reserva por WhatsApp. | | 429 | Demasiados pedidos (`LIMITE_USO`): esperá `Retry-After` segundos. | | 500 | Error técnico: reintentá con los mismos datos (y la misma `Idempotency-Key`). | ### Respuesta 201 ```json { "version": 1, "ok": true, "progreso": { "principal": "GUARDADO", "pago": "PENDIENTE" }, "operacion": { "referencia": "cmh4k2n9x0001", "repetida": false }, "requiereRevision": false, "datos": { "compraId": "cmh4k2n9x0001", "total": 25000, "sena": { "monto": 6250, "checkoutUrl": "https://www.mercadopago.com.ar/checkout/v1/redirect?pref_id=123456-abcd" }, "turnos": [ { "turnoId": "cmh4k2pa10003", "servicio": "Masaje descontracturante", "profesional": "Lucía", "fecha": "2030-01-10", "hora": "10:00", "duracionMin": 60, "extras": [], "sesionesTotal": null } ] } } ``` | Campo | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `datos.compraId` | string | No | | | `datos.total` | number | No | | | `datos.sena` | object o null | No | | | `datos.sena.monto` | number | No | | | `datos.sena.checkoutUrl` | string o null | No | Link de Mercado Pago. MP vuelve a la página de resultado de Turnova. | | `datos.turnos` | array de object | No | | | `datos.turnos[].turnoId` | string | No | | | `datos.turnos[].servicio` | string | No | | | `datos.turnos[].profesional` | string | No | | | `datos.turnos[].fecha` | string (date) | No | | | `datos.turnos[].hora` | string | No | | | `datos.turnos[].duracionMin` | integer | No | | | `datos.turnos[].extras` | array de string | No | | | `datos.turnos[].sesionesTotal` | integer o null | No | | Los errores siguen el formato común: ver [Errores](https://turnovapp.com/docs/errores.md). --- # Estado de una reserva hecha por la API > GET /api/v1/reservas/{compraId} `GET /api/v1/reservas/{compraId}` Scope `reservas:leer`, solo clave secreta. Solo reservas creadas por la API v1 de este negocio. > Autenticación: `Authorization: Bearer `. Ver [Autenticación](https://turnovapp.com/docs/autenticacion.md). ## Parámetros | Nombre | En | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | --- | | `compraId` | path | string | Sí | | ## Ejemplo curl: ```bash curl "https://turnovapp.com/api/v1/reservas/cmh4k2n9x0001" \ -H "Authorization: Bearer $TURNOVA_CLAVE_SECRETA" ``` JavaScript: ```js // Solo desde tu servidor (Node 18+): la clave secreta no va al navegador. const res = await fetch("https://turnovapp.com/api/v1/reservas/cmh4k2n9x0001", { headers: { Authorization: `Bearer ${process.env.TURNOVA_CLAVE_SECRETA}`, }, }); const r = await res.json(); if (!r.ok) throw new Error(`${r.error.codigo}: ${r.error.mensaje}`); ``` ## Respuestas | HTTP | Qué significa | | --- | --- | | 200 | Estado | | 401 | Clave faltante, inválida o revocada. | | 403 | Origen no permitido, permiso faltante, clave secreta desde un navegador o plan sin la API (`PLAN_SIN_API`). | | 404 | No existe o no es de este negocio. | | 429 | Demasiados pedidos (`LIMITE_USO`): esperá `Retry-After` segundos. | | 500 | Error técnico: reintentá con los mismos datos (y la misma `Idempotency-Key`). | ### Respuesta 200 ```json { "version": 1, "ok": true, "progreso": { "principal": "CONSULTADO" }, "requiereRevision": false, "datos": { "compraId": "cmh4k2n9x0001", "total": 25000, "estadoPago": "SIN_SENA", "sena": { "monto": 6250, "checkoutUrl": "https://www.mercadopago.com.ar/checkout/v1/redirect?pref_id=123456-abcd" }, "turnos": [ { "turnoId": "cmh4k2pa10003", "estado": "CONFIRMADO" } ] } } ``` | Campo | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `datos.compraId` | string | No | | | `datos.total` | number | No | | | `datos.estadoPago` | `SIN_SENA`, `PAGADA`, `PENDIENTE`, `VENCIDA`, `EN_EL_LOCAL`, `EN_REVISION` | No | | | `datos.sena` | object o null | No | | | `datos.sena.monto` | number | No | | | `datos.sena.checkoutUrl` | string o null | No | Solo mientras la seña está PENDIENTE. | | `datos.turnos` | array de object | No | | | `datos.turnos[].turnoId` | string | No | | | `datos.turnos[].estado` | string | No | | Los errores siguen el formato común: ver [Errores](https://turnovapp.com/docs/errores.md). --- # Credenciales del negocio (panel, con sesión) > GET /api/configuracion/api-externa/credenciales `GET /api/configuracion/api-externa/credenciales` Dueña o administradora (la Super Recepción no pasa). Nunca devuelve la clave ni su hash. > Autenticación: sesión del panel de Turnova (dueña o administradora). No se llama con una clave de la API. ## Ejemplo ```js // Desde el panel de Turnova, con la sesión iniciada (cookie). const res = await fetch("/api/configuracion/api-externa/credenciales", { method: "GET", }); const r = await res.json(); ``` ## Respuestas | HTTP | Qué significa | | --- | --- | | 200 | Lista | | 401 | Clave faltante, inválida o revocada. | | 403 | Origen no permitido, permiso faltante, clave secreta desde un navegador o plan sin la API (`PLAN_SIN_API`). | | 404 | No existe o no es de este negocio. | ### Respuesta 200 ```json { "ok": true, "datos": { "plan": { "incluyeApi": false }, "credenciales": [ { "id": "cmh4k2n9x0001", "nombre": "Masaje descontracturante", "tipo": "PUBLICA", "prefijo": "tnv_pk_ab12cd34", "scopes": [ "catalogo:leer", "disponibilidad:leer", "reservas:crear" ], "origenes": [ "https://mi-spa.com" ], "createdAt": "2026-11-01T13:00:00.000Z", "ultimoUsoEn": "2026-11-01T13:05:00.000Z", "revocadaEn": null } ] } } ``` | Campo | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `datos.plan` | object | No | | | `datos.plan.incluyeApi` | boolean | No | false: el plan no incluye la API; las claves existentes no funcionan. | | `datos.credenciales` | array de object | No | | | `datos.credenciales[].id` | string | No | | | `datos.credenciales[].nombre` | string | No | | | `datos.credenciales[].tipo` | `PUBLICA`, `SECRETA` | No | | | `datos.credenciales[].prefijo` | string | No | | | `datos.credenciales[].scopes` | array de string | No | | | `datos.credenciales[].origenes` | array de string | No | | | `datos.credenciales[].createdAt` | string (date-time) | No | | | `datos.credenciales[].ultimoUsoEn` | string (date-time) o null | No | | | `datos.credenciales[].revocadaEn` | string (date-time) o null | No | | Los errores siguen el formato común: ver [Errores](https://turnovapp.com/docs/errores.md). --- # Crear una credencial; la clave se muestra una sola vez > POST /api/configuracion/api-externa/credenciales `POST /api/configuracion/api-externa/credenciales` Exige el plan Enterprise: sin él, 403 `PLAN_SIN_API` con `upgrade_url`. > Autenticación: sesión del panel de Turnova (dueña o administradora). No se llama con una clave de la API. ## Cuerpo | Campo | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `nombre` | string | Sí | Hasta 80 caracteres. | | `tipo` | `PUBLICA`, `SECRETA` | Sí | | | `scopes` | array de `catalogo:leer`, `disponibilidad:leer`, `reservas:crear`, `reservas:leer` | Sí | `reservas:leer` solo para SECRETA. Entre 1 y N elementos. | | `origenes` | array de string | No | Obligatorio (1 a 10) para PUBLICA; prohibido para SECRETA. https, o http://localhost / 127.0.0.1. Entre 0 y 10 elementos. | ## Ejemplo ```js // Desde el panel de Turnova, con la sesión iniciada (cookie). const res = await fetch("/api/configuracion/api-externa/credenciales", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ "nombre": "Masaje descontracturante", "tipo": "PUBLICA", "scopes": [ "catalogo:leer", "disponibilidad:leer", "reservas:crear" ] }), }); const r = await res.json(); ``` ## Respuestas | HTTP | Qué significa | | --- | --- | | 201 | Creada | | 400 | Datos inválidos: mirá `error.campos` (`ENTRADA_INVALIDA`, `IDEMPOTENCY_KEY_FALTANTE`…). | | 401 | Clave faltante, inválida o revocada. | | 403 | Origen no permitido, permiso faltante, clave secreta desde un navegador o plan sin la API (`PLAN_SIN_API`). | | 404 | No existe o no es de este negocio. | ### Respuesta 201 ```json { "ok": true, "datos": { "credencial": { "id": "cmh4k2n9x0001", "nombre": "Masaje descontracturante", "tipo": "PUBLICA", "prefijo": "tnv_pk_ab12cd34", "scopes": [ "catalogo:leer", "disponibilidad:leer", "reservas:crear" ], "origenes": [ "https://mi-spa.com" ], "createdAt": "2026-11-01T13:00:00.000Z", "ultimoUsoEn": "2026-11-01T13:05:00.000Z", "revocadaEn": null }, "clave": "tnv_pk_ab12cd34EjemploNoUsarEstaClave0123456789", "aviso": "Copiá la clave ahora: no se vuelve a mostrar." } } ``` | Campo | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `datos.credencial` | object | No | | | `datos.credencial.id` | string | No | | | `datos.credencial.nombre` | string | No | | | `datos.credencial.tipo` | `PUBLICA`, `SECRETA` | No | | | `datos.credencial.prefijo` | string | No | | | `datos.credencial.scopes` | array de string | No | | | `datos.credencial.origenes` | array de string | No | | | `datos.credencial.createdAt` | string (date-time) | No | | | `datos.credencial.ultimoUsoEn` | string (date-time) o null | No | | | `datos.credencial.revocadaEn` | string (date-time) o null | No | | | `datos.clave` | string | No | Única vez que se devuelve. | | `datos.aviso` | string | No | | Los errores siguen el formato común: ver [Errores](https://turnovapp.com/docs/errores.md). --- # Revocar (repetible) > POST /api/configuracion/api-externa/credenciales/{id}/revocar `POST /api/configuracion/api-externa/credenciales/{id}/revocar` > Autenticación: sesión del panel de Turnova (dueña o administradora). No se llama con una clave de la API. ## Parámetros | Nombre | En | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | --- | | `id` | path | string | Sí | | ## Ejemplo ```js // Desde el panel de Turnova, con la sesión iniciada (cookie). const res = await fetch("/api/configuracion/api-externa/credenciales/cmh4k2n9x0001/revocar", { method: "POST", }); const r = await res.json(); ``` ## Respuestas | HTTP | Qué significa | | --- | --- | | 200 | Revocada (o ya lo estaba, `operacion.repetida`) | | 401 | Clave faltante, inválida o revocada. | | 403 | Origen no permitido, permiso faltante, clave secreta desde un navegador o plan sin la API (`PLAN_SIN_API`). | | 404 | No existe o no es de este negocio. | ### Respuesta 200 ```json { "ok": true, "operacion": { "referencia": "cmh4k2n9x0001", "repetida": false }, "datos": { "credencial": { "id": "cmh4k2n9x0001", "nombre": "Masaje descontracturante", "tipo": "PUBLICA", "prefijo": "tnv_pk_ab12cd34", "scopes": [ "catalogo:leer", "disponibilidad:leer", "reservas:crear" ], "origenes": [ "https://mi-spa.com" ], "createdAt": "2026-11-01T13:00:00.000Z", "ultimoUsoEn": "2026-11-01T13:05:00.000Z", "revocadaEn": null } } } ``` | Campo | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `datos.credencial` | object | No | | | `datos.credencial.id` | string | No | | | `datos.credencial.nombre` | string | No | | | `datos.credencial.tipo` | `PUBLICA`, `SECRETA` | No | | | `datos.credencial.prefijo` | string | No | | | `datos.credencial.scopes` | array de string | No | | | `datos.credencial.origenes` | array de string | No | | | `datos.credencial.createdAt` | string (date-time) | No | | | `datos.credencial.ultimoUsoEn` | string (date-time) o null | No | | | `datos.credencial.revocadaEn` | string (date-time) o null | No | | Los errores siguen el formato común: ver [Errores](https://turnovapp.com/docs/errores.md). --- # Uso diario por credencial > GET /api/configuracion/api-externa/uso `GET /api/configuracion/api-externa/uso` Pedidos por día (hora de Argentina) y credencial: lecturas, escrituras, reservas creadas y rechazos por límite. Por defecto, los últimos 30 días; hasta 92. > Autenticación: sesión del panel de Turnova (dueña o administradora). No se llama con una clave de la API. ## Parámetros | Nombre | En | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | --- | | `desde` | query | string (date) | No | | | `hasta` | query | string (date) | No | Por defecto, hoy. | ## Ejemplo ```js // Desde el panel de Turnova, con la sesión iniciada (cookie). const res = await fetch("/api/configuracion/api-externa/uso?desde=2030-01-01", { method: "GET", }); const r = await res.json(); ``` ## Respuestas | HTTP | Qué significa | | --- | --- | | 200 | Uso | | 400 | Datos inválidos: mirá `error.campos` (`ENTRADA_INVALIDA`, `IDEMPOTENCY_KEY_FALTANTE`…). | | 401 | Clave faltante, inválida o revocada. | | 403 | Origen no permitido, permiso faltante, clave secreta desde un navegador o plan sin la API (`PLAN_SIN_API`). | | 404 | No existe o no es de este negocio. | ### Respuesta 200 ```json { "version": 1, "ok": true, "progreso": { "principal": "CONSULTADO" }, "requiereRevision": false, "datos": { "desde": "2030-01-01", "hasta": "2030-01-31", "total": 25000, "porCredencial": [ { "credencialId": "cmh4k1z7q0000", "nombre": "Masaje descontracturante", "prefijo": "tnv_pk_ab12cd34", "tipo": "PUBLICA", "revocadaEn": null, "lecturas": 340, "escrituras": 12, "reservasCreadas": 9, "limitados": 0 } ], "dias": [ { "dia": "2030-01-10", "lecturas": 340, "escrituras": 12, "reservasCreadas": 9, "limitados": 0 } ] } } ``` | Campo | Tipo | Obligatorio | Descripción | | --- | --- | --- | --- | | `datos.desde` | string (date) | No | | | `datos.hasta` | string (date) | No | | | `datos.total` | object | No | | | `datos.total.lecturas` | integer | No | | | `datos.total.escrituras` | integer | No | | | `datos.total.reservasCreadas` | integer | No | | | `datos.total.limitados` | integer | No | Pedidos rechazados con 429. | | `datos.porCredencial` | array de object | No | | | `datos.porCredencial[].credencialId` | string | No | | | `datos.porCredencial[].nombre` | string | No | | | `datos.porCredencial[].prefijo` | string | No | | | `datos.porCredencial[].tipo` | `PUBLICA`, `SECRETA` | No | | | `datos.porCredencial[].revocadaEn` | string (date-time) o null | No | | | `datos.porCredencial[].lecturas` | integer | No | | | `datos.porCredencial[].escrituras` | integer | No | | | `datos.porCredencial[].reservasCreadas` | integer | No | | | `datos.porCredencial[].limitados` | integer | No | Pedidos rechazados con 429. | | `datos.dias` | array de object | No | | | `datos.dias[].dia` | string (date) | No | | | `datos.dias[].lecturas` | integer | No | | | `datos.dias[].escrituras` | integer | No | | | `datos.dias[].reservasCreadas` | integer | No | | | `datos.dias[].limitados` | integer | No | Pedidos rechazados con 429. | Los errores siguen el formato común: ver [Errores](https://turnovapp.com/docs/errores.md).