Citeri Developers
Ayuda citeri.es

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 con clientas:*, cobros con cobros o finanzas). Sin clientas:telefono, el teléfono llega como phone_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#

EventoCuándo
appointment.createdSe crea una cita (panel, reserva online, bot, API…)
appointment.updatedCambia la hora, la profesional, el estado u otro dato público de la cita
appointment.cancelledLa cita pasa a cancelada
client.createdHay una ficha de clienta nueva
client.updatedCambia el nombre, los apellidos, el correo, el teléfono o el cumpleaños
payment.succeededSe 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#

CabeceraValor
Citeri-Signaturet=<unix>,v1=<hmac hex> (dos v1= durante una rotación de secreto)
Citeri-Event-IdId del evento: igual en todos los reintentos
Citeri-Event-TypeTipo del evento
Citeri-Delivery-IdId 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 id y 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.