Integraciones
Webhooks
Los webhooks avisan a tu servidor en cuanto cambia algo en un negocio: una cita nueva, una cancelación, una clienta, un cobro. Cada aviso es un POST JSON firmado con HMAC-SHA256, con reintentos durante 3 días.
Crear un endpoint#
En el portal de desarrolladores → Webhooks añade la URL y elige los eventos. Al crearlo verás su secreto de firma (whsec_…).
- Pruebas: recibe los eventos de tu negocio de pruebas.
- Real: recibe los eventos de los negocios que han conectado una app de tu equipo, y solo de lo que te dejaron ver (citas con
citas:*, clientas conclientas:*, cobros concobrosofinanzas). Sinclientas:telefono, el teléfono llega comophone_last3.
La URL tiene que ser https:// pública (puertos 443 u 8443). No se aceptan direcciones privadas, locales ni internas, y no se siguen redirecciones: un 3xx cuenta como fallo.
Eventos#
| Evento | Cuándo |
|---|---|
appointment.created | Se crea una cita (panel, reserva online, bot, API…) |
appointment.updated | Cambia la hora, la profesional, el estado u otro dato público de la cita |
appointment.cancelled | La cita pasa a cancelada |
client.created | Hay una ficha de clienta nueva |
client.updated | Cambia el nombre, los apellidos, el correo, el teléfono o el cumpleaños |
payment.succeeded | Se registra un cobro de una cita |
El cuerpo#
{
"id": "evt_9c1f0d2a7b3e4f5a6b7c8d9e",
"object": "event",
"type": "appointment.updated",
"api_version": "2026-10-27",
"created": 1793101176,
"livemode": true,
"org_id": "5f0c…",
"data": {
"object": { "object": "appointment", "id": "…", "status": "confirmed", "start_at": "2026-11-03T09:00:00Z", … },
"previous_attributes": { "start_at": "2026-11-03T08:00:00Z" }
}
}
En los .updated y .cancelled, previous_attributes trae solo lo que cambió, con su valor anterior. data.object tiene la misma forma que la API /api/v1.
Cabeceras#
| Cabecera | Valor |
|---|---|
Citeri-Signature | t=<unix>,v1=<hmac hex> (dos v1= durante una rotación de secreto) |
Citeri-Event-Id | Id del evento: igual en todos los reintentos |
Citeri-Event-Type | Tipo del evento |
Citeri-Delivery-Id | Id de la entrega (lo ves en el historial del portal) |
Comprobar la firma#
La firma es HMAC-SHA256(secreto, t + "." + cuerpo) en hexadecimal, sobre el cuerpo crudo tal como llega (sin volver a serializar el JSON). Rechaza también las marcas de tiempo de más de 5 minutos: así una petición capturada no se puede repetir.
// Node.js (Express): usa el cuerpo crudo
import crypto from "node:crypto";
app.post("/webhooks/citeri", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("Citeri-Signature") || "";
const parts = Object.groupBy(header.split(","), (p) => p.split("=")[0]);
const t = Number(parts.t?.[0].split("=")[1]);
const firmas = (parts.v1 || []).map((p) => p.split("=")[1]);
const esperada = crypto.createHmac("sha256", process.env.CITERI_WEBHOOK_SECRET)
.update(`${t}.${req.body}`).digest("hex");
const vale = Math.abs(Date.now() / 1000 - t) < 300 && firmas.some((f) =>
f.length === esperada.length && crypto.timingSafeEqual(Buffer.from(f), Buffer.from(esperada)));
if (!vale) return res.status(400).send("firma no válida");
const evento = JSON.parse(req.body);
// … procesa evento.id una sola vez …
res.sendStatus(200);
});
# Python (Flask)
import hmac, hashlib, os, time
from flask import request, abort
@app.post("/webhooks/citeri")
def citeri():
partes = [p.split("=", 1) for p in request.headers.get("Citeri-Signature", "").split(",")]
t = next((int(v) for k, v in partes if k == "t"), 0)
firmas = [v for k, v in partes if k == "v1"]
esperada = hmac.new(os.environ["CITERI_WEBHOOK_SECRET"].encode(),
f"{t}.".encode() + request.get_data(), hashlib.sha256).hexdigest()
if abs(time.time() - t) > 300 or not any(hmac.compare_digest(f, esperada) for f in firmas):
abort(400)
evento = request.get_json()
return "", 200
Respuesta, reintentos e idempotencia#
- Contesta con cualquier 2xx en menos de 10 s. Si tienes trabajo pesado, encólalo y responde ya.
- Si no, se reintenta a los 1 min, 5 min, 30 min, 2 h, 5 h, 10 h y luego cada 12 h, hasta 3 días desde el evento.
- Un mismo evento puede llegar más de una vez (reintentos, reenvíos a mano). Guarda el
idy no lo proceses dos veces. - El orden no está garantizado: usa
data.object(estado actual) y, si hace falta, consulta la API. - Tras 20 fallos seguidos el endpoint se desactiva y avisamos por correo a la dueña y administradoras del equipo. Lo pendiente se guarda 3 días: reactívalo cuando esté arreglado.
Historial, pruebas y rotación#
En cada endpoint del portal ves cada entrega con la petición y la respuesta (recortadas), puedes reenviarla, mandar un evento de prueba y rotar el secreto: durante 24 h se firma con el nuevo y con el viejo, para que cambies tu servidor sin perder avisos.