Ir al contenido

Webhooks

Los webhooks notifican a tu aplicación en tiempo real cuando ocurren eventos (por ejemplo, se crea o actualiza una reserva). Tú registras una URL; CitaPro envía una petición POST a esa URL con el payload del evento.

Para el formato del payload, lista de eventos y campos rastreables por evento, consulta la Referencia API de Webhooks. Esta guía se centra en recibir y verificar webhooks.

La verificación de la firma usa el cuerpo raw exacto de la petición. Si tu framework parsea el body (p. ej. como JSON) antes de que se ejecute tu handler, la cadena usada para verificar puede ser distinta (codificación, orden de claves, espacios) y la verificación fallará.

  • lee el body como string o buffer raw y pásalo a tu función HMAC.
  • No uses el body ya parseado como JSON para recalcular la firma.

Responde con un estado 2xx antes de hacer trabajo pesado. Si tu endpoint tarda o devuelve error, CitaPro reintentará (ver Reintentos). Confirma la recepción primero y luego procesa el evento de forma asíncrona si hace falta.

Usa el header X-CitaPro-Delivery-Id como identificador único de la entrega. Guarda los IDs ya procesados e ignora duplicados para no aplicar el mismo evento dos veces si llega un reintento.


CitaPro firma cada petición con HMAC-SHA256 usando el secret de tu webhook. La firma se envía en el header X-CitaPro-Signature. Debes verificarla para asegurarte de que la petición vino de CitaPro y no fue alterada.

Pasos:

  1. Leer el body raw de la petición (sin modificar).
  2. Calcular HMAC-SHA256(rawBody, secret). Usa la misma codificación que el header (hex).
  3. Comparar el resultado con X-CitaPro-Signature usando una comparación en tiempo constante para evitar ataques por tiempo.

El secret solo se devuelve al crear el webhook. Guárdalo de forma segura (p. ej. variable de entorno).

Usa express.raw() para la ruta del webhook y deja el body sin parsear. Si usas express.json() global, registra la ruta del webhook antes o usa middleware solo para esa ruta.

const express = require('express');
const crypto = require('crypto');
const app = express();
const WEBHOOK_SECRET = process.env.CITAPRO_WEBHOOK_SECRET;
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body;
const signature = req.headers['x-citapro-signature'];
if (!signature || !WEBHOOK_SECRET) {
return res.status(401).send('Missing signature or secret');
}
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(rawBody)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(signature, 'hex'))) {
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(rawBody.toString());
const event = req.headers['x-citapro-event'];
const deliveryId = req.headers['x-citapro-delivery-id'];
res.status(200).send('OK');
});
app.use(express.json());

Usa el stream de entrada raw. No uses $_POST ni JSON ya parseado para la firma.

<?php
$webhookSecret = getenv('CITAPRO_WEBHOOK_SECRET');
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_CITAPRO_SIGNATURE'] ?? '';
if (empty($signature) || empty($webhookSecret)) {
http_response_code(401);
exit('Missing signature or secret');
}
$expected = hash_hmac('sha256', $rawBody, $webhookSecret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit('Invalid signature');
}
$payload = json_decode($rawBody, true);
// Procesar $payload['event'] y $payload['data'] ...
http_response_code(200);
echo 'OK';

Usa request.get_data() para obtener los bytes raw. No uses request.get_json() para verificar.

import hmac
import hashlib
import os
from flask import Flask, request
app = Flask(__name__)
WEBHOOK_SECRET = os.environ.get('CITAPRO_WEBHOOK_SECRET', '').encode('utf-8')
@app.route('/webhook', methods=['POST'])
def webhook():
raw_body = request.get_data()
signature = request.headers.get('X-CitaPro-Signature', '')
if not signature or not WEBHOOK_SECRET:
return 'Missing signature or secret', 401
expected = hmac.new(WEBHOOK_SECRET, raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
return 'Invalid signature', 401
payload = request.get_json(force=True, silent=True) or {}
# Procesar payload['event'] y payload['data'] ...
return 'OK', 200

Lee el body con request.body.read.

require 'openssl'
require 'json'
WEBHOOK_SECRET = ENV['CITAPRO_WEBHOOK_SECRET']
post '/webhook' do
raw_body = request.body.read
signature = request.env['HTTP_X_CITAPRO_SIGNATURE'].to_s
if signature.empty? || WEBHOOK_SECRET.to_s.empty?
status 401
return 'Missing signature or secret'
end
expected = OpenSSL::HMAC.hexdigest('SHA256', WEBHOOK_SECRET, raw_body)
unless Rack::Utils.secure_compare(expected, signature)
status 401
return 'Invalid signature'
end
payload = JSON.parse(raw_body)
status 200
'OK'
end

Lee el body raw con io.ReadAll y usa hmac.Equal para comparar.

rawBody, _ := io.ReadAll(r.Body)
signature := r.Header.Get("X-CitaPro-Signature")
mac := hmac.New(sha256.New, webhookSecret)
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(signature)) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}

Para mitigar ataques de replay puedes rechazar peticiones demasiado viejas. Usa el header X-CitaPro-Timestamp (Unix en segundos). Por ejemplo, rechaza si abs(ahora - timestamp) > 300 (5 minutos).


Si tu endpoint no responde con un estado 2xx, CitaPro reintenta hasta 3 veces con backoff progresivo: 10s, 60s, 5min. Usa X-CitaPro-Delivery-Id para idempotencia y no procesar la misma entrega más de una vez.


PasoAcción
1Leer el body raw de la petición (no parsear antes de verificar).
2Calcular HMAC-SHA256(rawBody, secret) (hex).
3Comparar con X-CitaPro-Signature en tiempo constante.
4Responder 2xx rápido; procesar el evento de forma asíncrona si hace falta.
5Usar X-CitaPro-Delivery-Id para deduplicar reintentos.

Para estructura del payload, tipos de evento y campos rastreables, consulta la Referencia API de Webhooks.