Guía técnica
API de Turnizo v1
Una API REST con JSON para que tu sistema, CRM o página consulte y reserve citas, lea los turnos de la fila y reciba avisos al momento con webhooks. Está incluida en el plan Negocio.
Inicio rápido
- 1. Crea una llave. En Turnizo, entra a Empresa → Integración → Llaves de la API y toca «Crear llave». Cópiala: solo se muestra una vez.
- 2. Prueba que funciona pidiendo tus sedes:
curl https://turnizo.app/api/v1/branches \ -H "Authorization: Bearer tz_live_TU_LLAVE"
3. Reserva una cita: busca el servicio en /services, pide los horarios libres en /availability y reserva uno con POST /appointments.
curl "https://turnizo.app/api/v1/availability?service_id=ID_DEL_SERVICIO&date=2026-11-03" \
-H "Authorization: Bearer tz_live_TU_LLAVE"
curl -X POST https://turnizo.app/api/v1/appointments \
-H "Authorization: Bearer tz_live_TU_LLAVE" \
-H "Content-Type: application/json" \
-d '{ "service_id": "ID_DEL_SERVICIO", "starts_at": "2026-11-03T14:30:00.000Z", "customer": { "name": "Ana Gómez", "email": "[email protected]" } }'Llaves y seguridad
- Cada llamada lleva el encabezado
Authorization: Bearer tz_live_…. Sin él, o con una llave revocada, responde 401. - Crea una llave por cada sistema (tu CRM, tu web, Zapier). Si una se filtra, la revocas sin afectar a las demás. Puedes tener hasta 10.
- Usa la llave solo desde tu servidor: nunca en el código de una página web o de una app, donde cualquiera la puede ver. Para que tus clientes reserven desde tu web, usa la agenda incrustada.
- Todo va por HTTPS. Turnizo guarda solo una huella de la llave: si la pierdes, crea otra.
- La API ve todas las sedes y citas de la empresa, sin importar quién creó la llave.
Formato y límites
- Dirección base:
https://turnizo.app/api/v1 - Las respuestas son JSON en UTF-8. Lo pedido viene en
data; si algo falla, enerrorcon uncode(para tu código) y unmessage(en español, para mostrar). - Los ids son UUID. Las horas (
starts_at,created_at…) van en ISO 8601 y en UTC. Los días (date,from,to) van como AAAA-MM-DD en la hora local de la sede. - Máximo 120 llamadas por minuto por llave, 500 resultados por llamada y 92 días por consulta de citas.
Estados de una cita
booked Reservadachecked_in Llegó (si se le dio turno, viene ticket_code)cancelled Canceladano_show No vinosource dice quién la reservó: online (el cliente, en el enlace) o staff (el equipo o la API).
Endpoints
GET/branchesSedes
Las sedes activas, con su zona horaria y el enlace público para reservar (si tienen citas en línea).
Respuesta
{ "data": [{ "id": "c14f…", "name": "Sede principal", "address": "Cra 7 # 12-30", "timezone": "America/Bogota", "booking_enabled": true, "booking_url": "https://turnizo.app/agendar/6MTBP4XR" }] }GET/servicesServicios
Los servicios de todas las sedes o de una. En los que se reservan con profesional viene con quién.
| Parámetro | Tipo | Qué es |
|---|---|---|
| branch_id | uuid, opcional | Solo los de esa sede. |
Respuesta
{ "data": [{ "id": "efd9…", "branch_id": "c14f…", "name": "Consulta general", "prefix": "A", "paused": false, "bookable": true, "slot_minutes": 30, "with_professional": true, "professionals": [{ "id": "a84d…", "name": "Dra. Pérez" }] }] }GET/availabilityHorarios libres
Los horarios que se pueden reservar de un servicio en un día, según su horario, los cupos y los días sin citas. Sin professional_id, cuenta a cualquier profesional libre.
| Parámetro | Tipo | Qué es |
|---|---|---|
| service_id | uuid | El servicio (debe tener citas encendidas). |
| date | AAAA-MM-DD | El día, en la hora local de la sede. |
| professional_id | uuid, opcional | Solo los horarios de esa persona. |
Respuesta
{ "data": [{ "time": "09:30", "starts_at": "2026-11-03T14:30:00.000Z", "available": 2 }] }GET/appointmentsListar citas
Las citas entre dos fechas, en orden de hora. Sin fechas, de hoy a 30 días.
| Parámetro | Tipo | Qué es |
|---|---|---|
| from | AAAA-MM-DD, opcional | Desde (incluido). Por defecto, hoy. |
| to | AAAA-MM-DD, opcional | Hasta (incluido). Máximo 92 días después de from. |
| branch_id | uuid, opcional | Solo esa sede. |
| status | lista, opcional | booked, checked_in, cancelled, no_show (separados por coma). |
| limit | 1 a 500, opcional | Por defecto 100. |
Respuesta
{ "data": [{
"id": "3f6b1d2e-8a4c-4f0e-9b7a-2c1d5e6f7a8b",
"status": "booked",
"date": "2026-11-03",
"starts_at": "2026-11-03T14:30:00.000Z",
"ends_at": "2026-11-03T15:00:00.000Z",
"branch": { "id": "c14f…", "name": "Sede principal" },
"service": { "id": "efd9…", "name": "Consulta general" },
"professional": { "id": "a84d…", "name": "Dra. Pérez" },
"customer": { "name": "Ana Gómez", "phone": "+57 300 123 4567", "email": "[email protected]" },
"answers": { "Documento": "1020304050" },
"ticket_code": null,
"source": "staff",
"created_at": "2026-11-01T16:02:11.000Z",
"cancelled_at": null
}] }POST/appointmentsReservar una cita
Reserva como lo haría tu equipo: respeta el horario, los cupos y los días sin citas, pero no la anticipación mínima. Si trae correo, el cliente recibe la confirmación con su enlace para cambiarla o cancelarla.
Cuerpo (JSON)
{
"service_id": "efd9…", // obligatorio
"starts_at": "2026-11-03T09:30:00-05:00", // obligatorio: uno de los starts_at de /availability
"professional_id": "a84d…", // opcional: sin él se asigna a alguien libre
"customer": {
"name": "Ana Gómez", // obligatorio
"phone": "+57 300 123 4567", // opcional
"email": "[email protected]" // opcional
}
}Respuesta
// 201 Created
{ "data": {
"id": "3f6b1d2e-8a4c-4f0e-9b7a-2c1d5e6f7a8b",
"status": "booked",
"date": "2026-11-03",
"starts_at": "2026-11-03T14:30:00.000Z",
"ends_at": "2026-11-03T15:00:00.000Z",
"branch": { "id": "c14f…", "name": "Sede principal" },
"service": { "id": "efd9…", "name": "Consulta general" },
"professional": { "id": "a84d…", "name": "Dra. Pérez" },
"customer": { "name": "Ana Gómez", "phone": "+57 300 123 4567", "email": "[email protected]" },
"answers": { "Documento": "1020304050" },
"ticket_code": null,
"source": "staff",
"created_at": "2026-11-01T16:02:11.000Z",
"cancelled_at": null
} }GET/appointments/{id}Una cita
Una cita por su id.
Respuesta
{ "data": { "id": "3f6b…", "status": "checked_in", "ticket_code": "A-023", … } }POST/appointments/{id}/cancelCancelar una cita
Cancela una cita reservada y deja libre el horario. Si ya estaba cancelada, llegó o no vino, responde 409.
Respuesta
{ "data": { "id": "3f6b…", "status": "cancelled", "cancelled_at": "2026-11-02T10:15:00.000Z", … } }GET/ticketsTurnos del día
Los turnos de un día en orden de llegada. Sirve, por ejemplo, para saber cuántas personas esperan.
| Parámetro | Tipo | Qué es |
|---|---|---|
| date | AAAA-MM-DD, opcional | Por defecto, hoy en la sede. |
| branch_id | uuid, opcional | Solo esa sede. |
| status | lista, opcional | waiting, called, done, no_show, cancelled. |
| limit | 1 a 500, opcional | Por defecto 200. |
Respuesta
{ "data": [{
"id": "9a7e…",
"code": "A-023",
"status": "called",
"date": "2026-11-03",
"branch": { "id": "c14f…", "name": "Sede principal" },
"service": { "id": "efd9…", "name": "Consulta general" },
"counter": { "id": "51b2…", "name": "Módulo 2" },
"priority": false,
"customer_name": "Ana Gómez",
"appointment_id": "3f6b…",
"source": "staff",
"queued_at": "2026-11-03T14:21:40.000Z",
"called_at": "2026-11-03T14:29:05.000Z",
"finished_at": null
}] }Errores
| HTTP | code | Cuándo |
|---|---|---|
| 400 | invalid_param · invalid_body · invalid_json · invalid_range | Un parámetro o el cuerpo no son válidos. El mensaje dice cuál. |
| 401 | unauthorized | Falta la llave, está mal escrita o fue revocada. |
| 403 | plan_required | La empresa ya no tiene el plan Negocio. |
| 404 | not_found | No existe ese recurso en tu empresa. |
| 409 | not_cancellable | La cita ya no está reservada. |
| 422 | booking_failed | No se pudo reservar: el horario se ocupó, el día está cerrado, el servicio no recibe citas… |
| 429 | rate_limited | Pasaste de 120 llamadas por minuto con esa llave. Espera un minuto. |
{ "error": { "code": "booking_failed", "message": "Ese horario ya no está disponible. Elige otro." } }Webhooks
En vez de estar preguntando, tu sistema recibe un aviso al momento. Agrega la dirección (https) en Integración → Webhooks, elige los eventos y copia el secreto de firma para verificar que el aviso viene de Turnizo.
appointment.createdSe reservó una cita
appointment.rescheduledSe cambió la hora de una cita
appointment.cancelledSe canceló una cita
appointment.checked_inEl cliente llegó a su cita
appointment.no_showEl cliente no vino a su cita
ticket.createdSe entregó un turno
ticket.calledSe llamó un turno
Cada aviso es un POST con Content-Type: application/json y estos encabezados: Turnizo-Event (el evento) y Turnizo-Signature (t=<segundos>,v1=<firma>). En data viene la cita o el turno igual que en la API.
Cuerpo de un aviso
{
"id": "6f1c2a90-…", // id único del aviso: úsalo para no procesarlo dos veces
"event": "appointment.created",
"created_at": "2026-11-01T16:02:11.000Z",
"data": {
"id": "3f6b1d2e-8a4c-4f0e-9b7a-2c1d5e6f7a8b",
"status": "booked",
"date": "2026-11-03",
"starts_at": "2026-11-03T14:30:00.000Z",
"ends_at": "2026-11-03T15:00:00.000Z",
"branch": { "id": "c14f…", "name": "Sede principal" },
"service": { "id": "efd9…", "name": "Consulta general" },
"professional": { "id": "a84d…", "name": "Dra. Pérez" },
"customer": { "name": "Ana Gómez", "phone": "+57 300 123 4567", "email": "[email protected]" },
"answers": { "Documento": "1020304050" },
"ticket_code": null,
"source": "staff",
"created_at": "2026-11-01T16:02:11.000Z",
"cancelled_at": null
}
}Verificar la firma: calcula un HMAC-SHA256 de <t>.<cuerpo crudo> con tu secreto, compáralo con v1 y rechaza avisos de hace más de 5 minutos.
Node.js
import crypto from "node:crypto";
// cuerpoCrudo: el cuerpo tal como llegó (texto), antes de convertirlo a JSON.
function avisoValido(cuerpoCrudo, firma, secreto) {
const partes = Object.fromEntries(firma.split(",").map((p) => p.split("=")));
const esperado = crypto.createHmac("sha256", secreto).update(`${partes.t}.${cuerpoCrudo}`).digest("hex");
const igual = esperado.length === partes.v1?.length && crypto.timingSafeEqual(Buffer.from(esperado), Buffer.from(partes.v1));
return igual && Math.abs(Date.now() / 1000 - Number(partes.t)) < 300; // máximo 5 minutos
}Python
import hmac, hashlib, time
def aviso_valido(cuerpo_crudo: bytes, firma: str, secreto: str) -> bool:
partes = dict(p.split("=", 1) for p in firma.split(","))
esperado = hmac.new(secreto.encode(), f"{partes['t']}.".encode() + cuerpo_crudo, hashlib.sha256).hexdigest()
return hmac.compare_digest(esperado, partes.get("v1", "")) and abs(time.time() - int(partes["t"])) < 300PHP
function aviso_valido(string $cuerpoCrudo, string $firma, string $secreto): bool {
parse_str(str_replace(',', '&', $firma), $p);
$esperado = hash_hmac('sha256', $p['t'] . '.' . $cuerpoCrudo, $secreto);
return hash_equals($esperado, $p['v1'] ?? '') && abs(time() - (int) $p['t']) < 300;
}- Responde con un 2xx en menos de 8 segundos. Si tienes trabajo pesado, guárdalo y hazlo después.
- Los avisos no se reintentan. Si tu sistema estuvo caído, ponte al día con GET /appointments.
- Si una dirección falla 20 veces seguidas, se apaga sola. «Enviar prueba» la vuelve a encender cuando responde bien.
- Puedes tener hasta 5 direcciones, cada una con sus eventos.