Guías
Webhooks
Eventos que te enviamos y cuándo, la carga de cada uno, cómo verificar la firma HMAC-SHA256 en Node.js, Python, PHP, Java y C#, reintentos, deduplicación y cómo probar tu receptor.
Última revisión: 1 de octubre de 2026
Los webhooks son peticiones POST que Salud en Ruta envía a tu sistema cada vez que cambia algo en uno de tus avisos. Son la forma recomendada de mantener tu sistema al día: llegan en segundos y no consumen tu límite de peticiones.
Cómo se configuran
La URL de destino y los eventos que quieres recibir los da de alta Salud en Ruta desde su intranet, a petición tuya (escríbenos). Para cada entorno (producción y sandbox) puedes tener una o varias URL, cada una con sus eventos.
- La URL debe ser HTTPS y públicamente accesible. No se admiten direcciones internas (
localhost, redes privadas) ni redirecciones: una respuesta3xxcuenta como fallo. - Al darla de alta te entregamos un secreto (
whsec_…) para verificar la firma. Como las claves de API, se muestra una sola vez. - Te recomendamos suscribirte a todos los eventos y descartar en tu lado los que no uses: así no hay que tocar la configuración si mañana los necesitas.
Eventos
| Evento | Cuándo se envía |
|---|---|
visit.created | Se crea un aviso tuyo, por cualquier canal: API, portal o la centralita por teléfono (mira origin). |
visit.assigned | La centralita asigna el aviso a una unidad. Lleva unitType (MEDICAL o NURSING). |
visit.en_route | La unidad sale hacia el domicilio. A partir de aquí hay hora estimada de llegada. |
visit.on_site | La unidad llega al domicilio. |
visit.finished | Termina la atención. outcome dice el resultado: atendido, ausente, derivado al hospital o rechazado. |
visit.cancelled | El aviso se cancela, por ti o por la centralita. cancelledWithCharge dice si tiene coste. |
visit.invoiced | El aviso se incluye en una factura. Lleva invoice con su id y su number. |
report.shared | El informe asistencial está disponible para ti (firmado, compartido y con el consentimiento del paciente). Ver informe. |
webhook.test | Evento de prueba que enviamos a petición tuya para comprobar tu receptor. No corresponde a ningún aviso. |
La petición
POST /serad/webhooks HTTP/1.1
Host: integraciones.aseguradora.es
Content-Type: application/json
User-Agent: SaludEnRuta-Webhooks/1.0
X-Serad-Event: visit.finished
X-Serad-Delivery: 9b2f0c4e-1a7d-4e3b-8f6a-5c4d3b2a1f0e
X-Serad-Timestamp: 1790847600
X-Serad-Signature: sha256=3b5f1c9a0e4d7b2c8f6a1e3d5c7b9a0f2e4d6c8b0a1f3e5d7c9b1a3f5e7d9c0b| Cabecera | Descripción |
|---|---|
X-Serad-Event | Tipo de evento (igual que type en el cuerpo). |
X-Serad-Delivery | Identificador de esta entrega. Los reintentos automáticos de una entrega llevan el mismo. |
X-Serad-Timestamp | Momento de la firma, en segundos Unix. Cambia en cada intento. |
X-Serad-Signature | sha256= seguido del HMAC-SHA256 en hexadecimal (ver verificar la firma). |
La carga
Todos los eventos de avisos tienen la misma forma. No llevan datos clínicos ni del paciente (ni siquiera su nombre): solo identificadores, estados, horas y tu nº de expediente.
{
"id": "6f0a9c1e-2b7d-4f8a-9e3c-1d2b3c4d5e6f",
"type": "visit.finished",
"createdAt": "2026-10-01T10:20:00.000Z",
"data": {
"visit": {
"id": "c6a3c348-40b6-4158-a44a-7d85f01a0201",
"code": "AV-000123",
"externalRef": "EXP-2026-004512",
"status": "FINISHED",
"priority": "URGENT",
"origin": "PROVIDER_API",
"serviceTypeCode": "GENERAL_CONSULTATION",
"unitType": "MEDICAL",
"milestones": {
"requestedAt": "2026-10-01T09:10:00.000Z",
"assignedAt": "2026-10-01T09:15:00.000Z",
"departedAt": "2026-10-01T09:32:00.000Z",
"arrivedAt": "2026-10-01T09:47:00.000Z",
"finishedAt": "2026-10-01T10:20:00.000Z",
"cancelledAt": null,
"invoicedAt": null
},
"outcome": "ATTENDED",
"cancelledWithCharge": false,
"invoice": null
}
}
}| Campo | Descripción |
|---|---|
id | Identificador del evento. Es el mismo aunque la entrega se repita. |
type | Tipo de evento. |
createdAt | Cuándo ocurrió el hecho (no cuándo se envió). |
data.visit.id / code | El aviso. Con el id puedes consultarlo si necesitas más datos. |
data.visit.externalRef | Tu nº de expediente, si lo enviaste. |
data.visit.status | Estado del aviso al ocurrir el evento. |
data.visit.serviceTypeCode | Código del tipo de servicio. |
data.visit.unitType | MEDICAL o NURSING desde la asignación; null antes. |
data.visit.milestones | Hora de cada hito hasta ese momento. |
data.visit.outcome | Resultado, en visit.finished y visit.cancelled. |
data.visit.cancelledWithCharge | Si la cancelación tiene coste. |
data.visit.invoice | { "id", "number" } cuando está facturado; si no, null. |
Diferencias por evento:
{
"type": "visit.cancelled",
"data": { "visit": { "status": "CANCELLED", "outcome": "CANCELLED", "cancelledWithCharge": false, "milestones": { "cancelledAt": "2026-10-01T09:20:00.000Z" } } }
}(En estos ejemplos se han recortado los campos que no cambian.)
Verificar la firma
Cada entrega va firmada con el secreto de tu webhook. Verificar la firma es lo que te garantiza que la petición la envió Salud en Ruta y que nadie la ha modificado. No proceses ninguna entrega sin verificarla.
La firma se calcula así:
firma = "sha256=" + hex( HMAC-SHA256( secreto, X-Serad-Timestamp + "." + cuerpo ) )Para verificarla:
- Lee el cuerpo crudo, byte a byte, tal y como llega. No lo conviertas a objeto y vuelvas a serializarlo: cualquier diferencia de espacios u orden cambia la firma.
- Comprueba que
X-Serad-Timestampes un número y que no se aleja más de 300 segundos de tu reloj (en cualquier dirección). Así, alguien que capture una entrega no puede reenviártela más tarde. - Calcula el HMAC-SHA256 de
"<timestamp>.<cuerpo crudo>"con el secreto completo (incluido el prefijowhsec_) como clave, en hexadecimal y minúsculas, y antepónsha256=. - Compara el resultado con
X-Serad-Signatureen tiempo constante (las funciones de abajo), nunca con==: una comparación normal se puede atacar midiendo cuánto tarda. - Si no coincide, responde
401y no hagas nada más.
// Express. El cuerpo se lee crudo (express.raw), no con express.json(): hace falta el texto exacto.
import crypto from 'node:crypto';
import express from 'express';
const TOLERANCE_SECONDS = 300;
/**
* true si la entrega es de Salud en Ruta y no ha caducado.
* @param {Buffer} rawBody Cuerpo tal y como llegó
* @param {string | undefined} signature Cabecera X-Serad-Signature
* @param {string | undefined} timestamp Cabecera X-Serad-Timestamp
* @param {string} secret Secreto del webhook (whsec_…)
*/
export function verifySeradSignature(rawBody, signature, timestamp, secret, now = Math.floor(Date.now() / 1000)) {
if (!signature || !timestamp || !/^\d+$/.test(timestamp)) return false;
if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const digest = crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex');
const expected = Buffer.from(`sha256=${digest}`);
const received = Buffer.from(signature);
return expected.length === received.length && crypto.timingSafeEqual(expected, received);
}
const app = express();
app.post('/serad/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifySeradSignature(
req.body,
req.get('X-Serad-Signature'),
req.get('X-Serad-Timestamp'),
process.env.SERAD_WEBHOOK_SECRET,
);
if (!ok) return res.status(401).send('Firma no válida');
const event = JSON.parse(req.body.toString('utf8'));
// Responde ya y procesa después (cola, worker…): tienes 10 s.
queue.add({ delivery: req.get('X-Serad-Delivery'), event });
return res.sendStatus(204);
});Para comprobar tu implementación, este caso debe dar válido (con now = 1790847600):
| Dato | Valor |
|---|---|
| Secreto | whsec_prueba_documentacion |
X-Serad-Timestamp | 1790847600 |
| Cuerpo | {"id":"evt_1","type":"webhook.test"} |
X-Serad-Signature | sha256=ebce010720159cfe5af2474bee9d5b2cc31312d7a9eaa72d50adb7a9d84bc91a |
Responde rápido
Tu receptor tiene 10 segundos para responder. Cualquier respuesta 2xx (lo normal: 200 o 204) cuenta como entregada; cualquier otra cosa —un 4xx, un 5xx, una redirección, un error de red o pasarse de tiempo— cuenta como fallo y se reintenta.
- Verifica la firma, guarda el evento y responde. Haz el trabajo de verdad (actualizar tu CRM, avisar a un operador, descargar el informe) después, en una cola o un proceso en segundo plano.
- No llames a la API de Salud en Ruta dentro del receptor antes de responder: si tarda, nos pasamos de los 10 s y te reenviaremos el evento.
- Responde
2xxtambién a los eventos que no te interesan (o a los repetidos): si respondes con error, los seguiremos reintentando.
Reintentos
Si una entrega falla, la reintentamos con espera exponencial: la espera se duplica en cada intento, empezando en 30 segundos y con un tope de 1 hora. Son 12 intentos en total, repartidos a lo largo de unas 5 horas, para que una caída o un despliegue de tu lado no te haga perder eventos:
| Intento | Cuándo, aproximadamente |
|---|---|
| 1 | En el momento del evento |
| 2 | 30 s después del primero |
| 3 | 1 min después del anterior |
| 4 | 2 min después |
| 5 | 4 min después |
| 6 | 8 min después |
| 7 | 16 min después |
| 8 | 32 min después |
| 9 a 12 | 1 h después del anterior, cada uno |
Cada intento lleva un X-Serad-Timestamp y una firma nuevos (por eso la ventana de 300 s no impide los reintentos), y el mismo X-Serad-Delivery.
Si se agotan los intentos, la entrega queda como fallida en nuestro registro. Cuando tu receptor vuelva a funcionar, avísanos y te reenviaremos las entregas fallidas desde la intranet. Un reenvío manual es una entrega nueva (otro X-Serad-Delivery) del mismo evento (mismo id en el cuerpo).
Idempotencia del receptor
Por los reintentos (y porque una respuesta tuya se puede perder por el camino aunque hayas procesado la entrega), puedes recibir el mismo evento más de una vez. Tu receptor tiene que ser idempotente:
- Guarda el
iddel evento (eliddel cuerpo) en una tabla con un índice único, y antes de procesar comprueba si ya lo procesaste. Si ya estaba, responde2xxy no hagas nada más. X-Serad-Deliveryidentifica la entrega: es igual en los reintentos automáticos, pero cambia si te reenviamos el evento a mano. Para descartar duplicados usa eliddel evento; registraX-Serad-Deliveryen tus logs, que es lo que nos ayudará a localizar una entrega concreta si hay que investigar.- Aplica los cambios de forma que repetirlos no tenga efecto (por ejemplo, «poner el estado a
FINISHEDsi el aviso no está ya en un estado posterior», no «avanzar un estado»).
CREATE TABLE serad_webhook_events (
event_id UUID PRIMARY KEY, -- "id" del cuerpo
delivery_id UUID NOT NULL, -- X-Serad-Delivery (para tus logs)
type TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- INSERT … ON CONFLICT (event_id) DO NOTHING; si no ha insertado nada, ya estaba procesado.Probar tu receptor
- Evento de prueba. Pídenos que te enviemos un
webhook.testdesde la intranet (en sandbox o en producción). Es la forma más rápida de comprobar que la URL responde, que el certificado es válido y que verificas bien la firma, sin crear ningún aviso. - Historial de entregas. En la intranet vemos cada entrega: estado, intentos, el código HTTP y el principio de la respuesta de tu receptor. Si algo falla, escríbenos con el
X-Serad-Deliveryy te diremos qué recibimos. - Sandbox. Crea avisos en el sandbox para recibir la secuencia completa de eventos, y prueba el escenario de webhook caído: apaga tu receptor, crea un aviso y vuelve a encenderlo para ver los reintentos.
- En local. Para desarrollar en tu equipo, expón tu receptor con un túnel HTTPS (ngrok, Cloudflare Tunnel…) y danos esa URL para el sandbox.
¿Falta algo o algo no cuadra con lo que ves? Escribe al equipo técnico: info@saludenruta.es.
