API y webhooks

Conecta Turnizo con tu sistema.

Tu CRM, tu página o tu software consultan y reservan citas, leen la fila de turnos y reciben un aviso al momento de cada reserva, cancelación o llegada. Incluido en el plan Negocio.

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. 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. 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, en error con un code (para tu código) y un message (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 vino

source dice quién la reservó: online (el cliente, en el enlace) o staff (el equipo o la API).

Endpoints

GET/branches

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/services

Los servicios de todas las sedes o de una. En los que se reservan con profesional viene con quién.

ParámetroTipoQué es
branch_iduuid, opcionalSolo 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/availability

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ámetroTipoQué es
service_iduuidEl servicio (debe tener citas encendidas).
dateAAAA-MM-DDEl día, en la hora local de la sede.
professional_iduuid, opcionalSolo los horarios de esa persona.

Respuesta

{ "data": [{ "time": "09:30", "starts_at": "2026-11-03T14:30:00.000Z", "available": 2 }] }
GET/appointments

Las citas entre dos fechas, en orden de hora. Sin fechas, de hoy a 30 días.

ParámetroTipoQué es
fromAAAA-MM-DD, opcionalDesde (incluido). Por defecto, hoy.
toAAAA-MM-DD, opcionalHasta (incluido). Máximo 92 días después de from.
branch_iduuid, opcionalSolo esa sede.
statuslista, opcionalbooked, checked_in, cancelled, no_show (separados por coma).
limit1 a 500, opcionalPor 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/appointments

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 por su id.

Respuesta

{ "data": { "id": "3f6b…", "status": "checked_in", "ticket_code": "A-023", … } }
POST/appointments/{id}/cancel

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/tickets

Los turnos de un día en orden de llegada. Sirve, por ejemplo, para saber cuántas personas esperan.

ParámetroTipoQué es
dateAAAA-MM-DD, opcionalPor defecto, hoy en la sede.
branch_iduuid, opcionalSolo esa sede.
statuslista, opcionalwaiting, called, done, no_show, cancelled.
limit1 a 500, opcionalPor 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

HTTPcodeCuándo
400invalid_param · invalid_body · invalid_json · invalid_rangeUn parámetro o el cuerpo no son válidos. El mensaje dice cuál.
401unauthorizedFalta la llave, está mal escrita o fue revocada.
403plan_requiredLa empresa ya no tiene el plan Negocio.
404not_foundNo existe ese recurso en tu empresa.
409not_cancellableLa cita ya no está reservada.
422booking_failedNo se pudo reservar: el horario se ocupó, el día está cerrado, el servicio no recibe citas…
429rate_limitedPasaste 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"])) < 300

PHP

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.

Ordena tu atención hoy mismo.

Crea tu cuenta, abre la pantalla en un TV y llama tu primer turno en minutos.

14 días de Pro gratis · Sin tarjeta · Cancelas cuando quieras