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:
| Campo | Descripción |
|---|---|
| Nombre | Etiqueta descriptiva (no se envía al endpoint). |
| URL | Endpoint HTTPS al que enviaremos POST. Debe empezar con http:// o https://. Máx. 2000 caracteres. |
| Sucursal | Opcional. Si se deja vacío, el webhook recibe eventos de todas las sucursales del enterprise. |
| Eventos | Al menos uno. Ver catálogo en §3. |
| Activo | Si 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:
| Header | Valor |
|---|---|
Content-Type | application/json |
User-Agent | Agendario-Webhooks/1.0 |
X-Agendario-Event | Tipo de evento (ej. appointment.created). |
X-Agendario-Delivery-Id | UUID único de esta entrega. Úsalo como clave de idempotencia. |
X-Agendario-Timestamp | Unix epoch en segundos (UTC). |
X-Agendario-Signature | sha256=<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 condelivery_iddel header: un mismo evento puede generar varias entregas si hay reintentos; elides estable entre ellas).type: mismo valor queX-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 comosuccess. Fin.410 Gone→ delivery marcada comoabandonedinmediatamente (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 type | Cuándo se dispara |
|---|---|
appointment.created | Se crea una cita nueva (desde dashboard, API, booking page, IA, etc.). |
appointment.updated | Cualquier modificación en la cita (fecha/hora, profesional, servicio, notas, status). |
appointment.status_changed | Se dispara adicionalmente a appointment.updated cuando cambia el status. Suscríbete a éste si sólo te interesan transiciones de estado. |
appointment.deleted | Se eliminó la cita. El payload incluye los datos previos al delete. |
webhook.test | Evento 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
- Lee el body crudo (bytes, antes de parsear JSON).
- Lee el header
X-Agendario-Timestamp. - Construye el string
message = f"{timestamp}.{body}". - Calcula
HMAC-SHA256(secret, message)y prepéndelo consha256=. - Compara con
X-Agendario-Signatureusando comparación de tiempo constante (hmac.compare_digesten Python,crypto.timingSafeEqualen Node). - 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 estadoabandoned. - 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_idvistos en una tabla con unique constraint. - Si te importa "no procesar dos veces el mismo evento" (recomendado): usa
event.iddel 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étodo | Ruta | Descripció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
- ¿El webhook está activo? (columna "Estado" en la UI).
- ¿El evento del que esperas señal está en la lista de eventos del webhook?
- ¿Configuraste filtro por sucursal? Los eventos sólo llegan para la sucursal indicada (o para todas si dejaste el filtro vacío).
- Prueba con el botón "Enviar evento de prueba". Si eso no llega, es problema de red/firewall.
- Revisa el historial de entregas: si ves
failedcon HTTP000o mensaje de error de red, el request no llegó a tu servidor.
Checklist si la firma no valida
- ¿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. - ¿El secret en tu config es el actual? (Si rotaste, el viejo dejó de funcionar).
- ¿Estás concatenando
timestampybodycon un punto literal.? - ¿Tu request pasó por un proxy/CDN que modificó el body (compresión, reescritura)?
- ¿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.updatedpodría llegarte antes que elappointment.created. Usaappointment.updated_atdel payload para determinar la versión más reciente. appointment.updatedvsappointment.status_changed: cuando cambia el status, se disparan ambos eventos. Si te suscribes a los dos, espera duplicados con el mismoappointment.idpero distintoevent.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 headerX-Agendario-Delivery-Id) o rango de fechas.webhook_id(visible en la UI).- Descripción del síntoma y lo que esperabas ver.