Galmia
Documentación de webhooks
galmia.ai →

Webhooks de Agendario

Guía de integración para recibir eventos de citas de Agendario en tu propio sistema vía HTTP.

  • Protocolo: HTTP POST con payload JSON.
  • Autenticidad: firma HMAC-SHA256 por header.
  • Reintentos: backoff exponencial hasta 5 intentos.
  • API version actual: 2026-04-23.

1. Configurar un webhook

Desde el dashboard: Configuración → Integraciones avanzadas → Webhooks → Nuevo webhook.

Campos:

CampoDescripción
NombreEtiqueta descriptiva (no se envía al endpoint).
URLEndpoint HTTPS al que enviaremos POST. Debe empezar con http:// o https://. Máx. 2000 caracteres.
SucursalOpcional. Si se deja vacío, el webhook recibe eventos de todas las sucursales del enterprise.
EventosAl menos uno. Ver catálogo en §3.
ActivoSi está desactivado, no se encolan nuevas entregas. Si se desactiva mientras hay deliveries en vuelo, esos deliveries se marcan como abandoned.

Al crear el webhook, la respuesta incluye una sola vez el secret. Guárdalo en un gestor seguro: no lo volveremos a mostrar. Si lo pierdes, puedes rotarlo desde la UI o vía API (§7).


2. Estructura del request que enviamos

Cada entrega es un POST con:

Headers:

HeaderValor
Content-Typeapplication/json
User-AgentAgendario-Webhooks/1.0
X-Agendario-EventTipo de evento (ej. appointment.created).
X-Agendario-Delivery-IdUUID único de esta entrega. Úsalo como clave de idempotencia.
X-Agendario-TimestampUnix epoch en segundos (UTC).
X-Agendario-Signaturesha256=<hex> — firma HMAC del body. Ver §5.

Body (envelope estándar):

{
  "id": "evt_4a9b31f2...",
  "type": "appointment.created",
  "api_version": "2026-04-23",
  "created_at": "2026-04-23T14:32:11.482301+00:00",
  "data": { ... }
}
  • id: identificador único del evento (no confundir con delivery_id del header: un mismo evento puede generar varias entregas si hay reintentos; el id es estable entre ellas).
  • type: mismo valor que X-Agendario-Event.
  • api_version: versión del contrato de payload. Si la subimos (breaking change) te avisaremos con al menos 60 días.
  • data: bloque específico del evento. Ver §4.

Respuesta esperada:

  • 2xx → delivery marcada como success. Fin.
  • 410 Gone → delivery marcada como abandoned inmediatamente (sin reintentar). Úsalo si el recurso ya no existe en tu lado.
  • Cualquier otro código (incluido timeout de red) → reintento con backoff. Ver §6.

3. Catálogo de eventos

Event typeCuándo se dispara
appointment.createdSe crea una cita nueva (desde dashboard, API, booking page, IA, etc.).
appointment.updatedCualquier modificación en la cita (fecha/hora, profesional, servicio, notas, status).
appointment.status_changedSe dispara adicionalmente a appointment.updated cuando cambia el status. Suscríbete a éste si sólo te interesan transiciones de estado.
appointment.deletedSe eliminó la cita. El payload incluye los datos previos al delete.
webhook.testEvento sintético que envías manualmente desde el dashboard o llamando al endpoint de test. No se dispara automáticamente.

Estados posibles de una cita (appointment.status)

pending, confirmed, completed, cancelled, no_show, rescheduled.


4. Payloads de ejemplo

4.1 appointment.created

{
  "id": "evt_4a9b31f2c8d74e1a9f2e8d7c6b5a4f3e",
  "type": "appointment.created",
  "api_version": "2026-04-23",
  "created_at": "2026-04-23T14:32:11.482301+00:00",
  "data": {
    "appointment": {
      "id": 128,
      "reservation_id": "RSV-2026-000128",
      "status": "confirmed",
      "start_time": "2026-04-25T15:00:00+00:00",
      "end_time": "2026-04-25T16:00:00+00:00",
      "notes": "Prefiere entrada lateral.",
      "is_trial": false,
      "meeting_url": "",
      "cancellation_reason": "",
      "cancelled_by": "",
      "cancelled_at": null,
      "branch": {
        "id": 12,
        "name": "La Reina",
        "slug": "la-reina"
      },
      "client": {
        "id": 4421,
        "name": "María Pérez",
        "first_name": "María",
        "last_name": "Pérez",
        "email": "maria@example.com",
        "phone": "+56911223344"
      },
      "professional": {
        "id": 88,
        "name": "Juan Soto",
        "email": "juan@agendario.cl"
      },
      "service": {
        "id": 303,
        "name": "Corte y peinado",
        "duration_minutes": 60,
        "price": 25000.0
      },
      "created_at": "2026-04-23T14:32:11.000000+00:00",
      "updated_at": "2026-04-23T14:32:11.000000+00:00"
    }
  }
}

4.2 appointment.status_changed

Incluye los campos adicionales previous_status y current_status:

{
  "id": "evt_...",
  "type": "appointment.status_changed",
  "api_version": "2026-04-23",
  "created_at": "2026-04-23T16:10:03+00:00",
  "data": {
    "appointment": { ... },
    "previous_status": "pending",
    "current_status": "confirmed"
  }
}

4.3 appointment.deleted

El bloque data.appointment contiene el estado previo al borrado (incluye id, client, professional, etc.).

4.4 webhook.test

{
  "id": "evt_...",
  "type": "webhook.test",
  "api_version": "2026-04-23",
  "created_at": "2026-04-23T14:30:00+00:00",
  "data": {
    "message": "Evento de prueba desde Agendario",
    "webhook_id": 17
  }
}

5. Verificación de firma

Nunca proceses un webhook sin validar la firma. Cualquiera puede hacer POST a tu URL.

Algoritmo

  1. Lee el body crudo (bytes, antes de parsear JSON).
  2. Lee el header X-Agendario-Timestamp.
  3. Construye el string message = f"{timestamp}.{body}".
  4. Calcula HMAC-SHA256(secret, message) y prepéndelo con sha256=.
  5. Compara con X-Agendario-Signature usando comparación de tiempo constante (hmac.compare_digest en Python, crypto.timingSafeEqual en Node).
  6. Valida que el timestamp esté dentro de una ventana razonable (recomendado: ±5 minutos vs. tu reloj) para bloquear replay attacks.

Ejemplo Python (Django / Flask)

import hashlib
import hmac
import time

WEBHOOK_SECRET = "..."  # desde tu gestor de secrets
MAX_SKEW_SECONDS = 300

def verify_agendario_webhook(request_body_bytes: bytes, headers: dict) -> bool:
    signature = headers.get("X-Agendario-Signature", "")
    timestamp = headers.get("X-Agendario-Timestamp", "")

    if not signature.startswith("sha256=") or not timestamp.isdigit():
        return False

    # Rechaza payloads viejos.
    if abs(int(time.time()) - int(timestamp)) > MAX_SKEW_SECONDS:
        return False

    message = f"{timestamp}.{request_body_bytes.decode('utf-8')}".encode("utf-8")
    expected = "sha256=" + hmac.new(
        WEBHOOK_SECRET.encode("utf-8"), message, hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, signature)

Uso en una vista Django:

@csrf_exempt
@require_POST
def agendario_webhook(request):
    if not verify_agendario_webhook(request.body, request.headers):
        return HttpResponse(status=401)

    event = json.loads(request.body)
    # ...procesa por event["type"]...
    return HttpResponse(status=200)

Ejemplo Node.js (Express)

const crypto = require('crypto');
const express = require('express');

const WEBHOOK_SECRET = process.env.AGENDARIO_WEBHOOK_SECRET;
const MAX_SKEW_SECONDS = 300;

const app = express();

// Importante: leer el body como raw para que la firma coincida.
app.post(
  '/webhooks/agendario',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.get('X-Agendario-Signature') || '';
    const timestamp = req.get('X-Agendario-Timestamp') || '';

    if (!signature.startsWith('sha256=') || !/^\d+$/.test(timestamp)) {
      return res.status(401).end();
    }
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > MAX_SKEW_SECONDS) {
      return res.status(401).end();
    }

    const message = `${timestamp}.${req.body.toString('utf8')}`;
    const expected =
      'sha256=' +
      crypto.createHmac('sha256', WEBHOOK_SECRET).update(message).digest('hex');

    const sigBuf = Buffer.from(signature);
    const expBuf = Buffer.from(expected);
    if (
      sigBuf.length !== expBuf.length ||
      !crypto.timingSafeEqual(sigBuf, expBuf)
    ) {
      return res.status(401).end();
    }

    const event = JSON.parse(req.body.toString('utf8'));
    // ...procesa por event.type...
    res.status(200).end();
  }
);

6. Reintentos y timeouts

  • Timeout de request: 10 segundos. Si tu endpoint tarda más, lo marcamos como fallo y reintentamos.
  • Backoff entre reintentos: 1min → 5min → 30min → 2h → 12h (5 intentos totales incluyendo el primero).
  • Tras MAX_ATTEMPTS = 5, la delivery queda en estado abandoned.
  • 410 Gone → abandono inmediato, sin más reintentos.
  • Sin redirects: enviamos el request con allow_redirects=false. Si respondes 301/302, lo contamos como fallo (los redirects rompen la firma).

Responde 2xx lo antes posible (idealmente < 2s). Si tu procesamiento es pesado, aceptá el webhook, encólalo en tu propia cola y procesá asincrónicamente.


7. Idempotencia

Un mismo evento puede llegarte más de una vez. Escenarios:

  • Tu endpoint respondió lento, cerramos el socket por timeout, pero tu lado ya procesó el request.
  • Devolviste 5xx y reintentamos, pero tu primer intento sí escribió en BD.

Clave recomendada: X-Agendario-Delivery-Id (UUID). Cada intento de entrega tiene un delivery_id distinto, pero:

  • Si sólo te importa "no procesar dos veces el mismo intento": guarda los delivery_id vistos en una tabla con unique constraint.
  • Si te importa "no procesar dos veces el mismo evento" (recomendado): usa event.id del body (evt_...). Ese es estable entre reintentos del mismo evento.

Patrón típico:

CREATE TABLE processed_agendario_events (
  event_id TEXT PRIMARY KEY,
  processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
try:
    cursor.execute(
        "INSERT INTO processed_agendario_events (event_id) VALUES (%s)",
        [event["id"]],
    )
except IntegrityError:
    return HttpResponse(status=200)  # ya procesado
# ...procesa y commit...

8. Rotación de secret

Rota si sospechas que el secret fue comprometido, o periódicamente como higiene de seguridad.

Desde la UI: Configuración → Webhooks → ícono de llave junto al webhook.

Desde la API:

curl -X POST https://api.agendario.cl/api/webhooks/17/rotate-secret/ \
  -H "Authorization: Bearer <token>"

Respuesta:

{ "secret": "0f9a...nuevo-valor-sólo-aquí..." }

El secret viejo deja de funcionar en ese instante. No hay solapamiento. Coordina el cambio: actualiza tu config con el nuevo secret antes de que lleguen eventos firmados con él.


9. API REST de gestión

Base: https://api.agendario.cl/api/webhooks/. Requiere auth (JWT) y rol admin o gerente.

MétodoRutaDescripción
GET/api/webhooks/Lista webhooks del enterprise. Query ?branch_id=<id> para filtrar.
POST/api/webhooks/Crea un webhook. Devuelve secret en texto claro (sólo aquí).
GET/api/webhooks/<id>/Detalle. No incluye secret.
PATCH/api/webhooks/<id>/Actualiza campos (name, url, events, is_active, branch_id).
DELETE/api/webhooks/<id>/Elimina el webhook.
POST/api/webhooks/<id>/rotate-secret/Genera secret nuevo. Devuelve el valor.
POST/api/webhooks/<id>/test/Envía un webhook.test al endpoint. Responde 202 Accepted.
GET/api/webhooks/<id>/deliveries/Historial paginado. Query ?page=1&page_size=25 (máx 100).

Payload de creación:

{
  "name": "CRM principal",
  "url": "https://tuapp.com/webhooks/agendario",
  "events": ["appointment.created", "appointment.status_changed"],
  "branch_id": 12,
  "is_active": true
}

10. Debugging

En el dashboard

Cada webhook tiene un Historial de entregas con:

  • Estado (pending, in_progress, success, failed, abandoned).
  • Nº de intentos.
  • HTTP status devuelto por tu endpoint.
  • Payload exacto que enviamos (copiar/pegar para replay local).
  • Respuesta de tu servidor (truncada a 2 KB).
  • Mensaje de error si falló red (DNS, TLS, timeout).

Checklist si no te llega nada

  1. ¿El webhook está activo? (columna "Estado" en la UI).
  2. ¿El evento del que esperas señal está en la lista de eventos del webhook?
  3. ¿Configuraste filtro por sucursal? Los eventos sólo llegan para la sucursal indicada (o para todas si dejaste el filtro vacío).
  4. Prueba con el botón "Enviar evento de prueba". Si eso no llega, es problema de red/firewall.
  5. Revisa el historial de entregas: si ves failed con HTTP 000 o mensaje de error de red, el request no llegó a tu servidor.

Checklist si la firma no valida

  1. ¿Estás leyendo el body crudo (bytes), antes de cualquier middleware que lo reformatee? Un JSON.stringify(body) no produce bytes idénticos al original.
  2. ¿El secret en tu config es el actual? (Si rotaste, el viejo dejó de funcionar).
  3. ¿Estás concatenando timestamp y body con un punto literal .?
  4. ¿Tu request pasó por un proxy/CDN que modificó el body (compresión, reescritura)?
  5. ¿Estás usando comparación de tiempo constante (no ==)?

Replay local

Copia el payload desde el historial y simula:

BODY='{"id":"evt_...","type":"appointment.created",...}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print "sha256="$2}')

curl -X POST http://localhost:3000/webhooks/agendario \
  -H "Content-Type: application/json" \
  -H "X-Agendario-Event: appointment.created" \
  -H "X-Agendario-Delivery-Id: $(uuidgen)" \
  -H "X-Agendario-Timestamp: $TS" \
  -H "X-Agendario-Signature: $SIG" \
  -d "$BODY"

11. Límites y notas operativas

  • Tamaño de body: los payloads de appointment.* suelen ser de 1-3 KB. No hay límite declarado actualmente pero mantenemos < 64 KB por evento.
  • Orden de eventos: no garantizado. Si una cita se crea y se actualiza casi al mismo tiempo, el appointment.updated podría llegarte antes que el appointment.created. Usa appointment.updated_at del payload para determinar la versión más reciente.
  • appointment.updated vs appointment.status_changed: cuando cambia el status, se disparan ambos eventos. Si te suscribes a los dos, espera duplicados con el mismo appointment.id pero distinto event.id.
  • Eventos perdidos por webhook inactivo: si el webhook estaba inactivo al momento del evento, no hay backfill. Reactivarlo sólo afecta eventos futuros.

12. Contacto

Reporta problemas a soporte@agendario.cl con:

  • delivery_id (del header X-Agendario-Delivery-Id) o rango de fechas.
  • webhook_id (visible en la UI).
  • Descripción del síntoma y lo que esperabas ver.