No atendemos emergencias vitales: llama al 112.

+34 611 673 280

DesarrolladoresAPI v1

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 respuesta 3xx cuenta 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

EventoCuándo se envía
visit.createdSe crea un aviso tuyo, por cualquier canal: API, portal o la centralita por teléfono (mira origin).
visit.assignedLa centralita asigna el aviso a una unidad. Lleva unitType (MEDICAL o NURSING).
visit.en_routeLa unidad sale hacia el domicilio. A partir de aquí hay hora estimada de llegada.
visit.on_siteLa unidad llega al domicilio.
visit.finishedTermina la atención. outcome dice el resultado: atendido, ausente, derivado al hospital o rechazado.
visit.cancelledEl aviso se cancela, por ti o por la centralita. cancelledWithCharge dice si tiene coste.
visit.invoicedEl aviso se incluye en una factura. Lleva invoice con su id y su number.
report.sharedEl informe asistencial está disponible para ti (firmado, compartido y con el consentimiento del paciente). Ver informe.
webhook.testEvento de prueba que enviamos a petición tuya para comprobar tu receptor. No corresponde a ningún aviso.

La petición

HTTP
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
CabeceraDescripción
X-Serad-EventTipo de evento (igual que type en el cuerpo).
X-Serad-DeliveryIdentificador de esta entrega. Los reintentos automáticos de una entrega llevan el mismo.
X-Serad-TimestampMomento de la firma, en segundos Unix. Cambia en cada intento.
X-Serad-Signaturesha256= 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.

JSON
{
  "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
    }
  }
}
CampoDescripción
idIdentificador del evento. Es el mismo aunque la entrega se repita.
typeTipo de evento.
createdAtCuándo ocurrió el hecho (no cuándo se envió).
data.visit.id / codeEl aviso. Con el id puedes consultarlo si necesitas más datos.
data.visit.externalRefTu nº de expediente, si lo enviaste.
data.visit.statusEstado del aviso al ocurrir el evento.
data.visit.serviceTypeCodeCódigo del tipo de servicio.
data.visit.unitTypeMEDICAL o NURSING desde la asignación; null antes.
data.visit.milestonesHora de cada hito hasta ese momento.
data.visit.outcomeResultado, en visit.finished y visit.cancelled.
data.visit.cancelledWithChargeSi 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í:

TEXT
firma = "sha256=" + hex( HMAC-SHA256( secreto, X-Serad-Timestamp + "." + cuerpo ) )

Para verificarla:

  1. 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.
  2. Comprueba que X-Serad-Timestamp es 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.
  3. Calcula el HMAC-SHA256 de "<timestamp>.<cuerpo crudo>" con el secreto completo (incluido el prefijo whsec_) como clave, en hexadecimal y minúsculas, y antepón sha256=.
  4. Compara el resultado con X-Serad-Signature en tiempo constante (las funciones de abajo), nunca con ==: una comparación normal se puede atacar midiendo cuánto tarda.
  5. Si no coincide, responde 401 y 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):

DatoValor
Secretowhsec_prueba_documentacion
X-Serad-Timestamp1790847600
Cuerpo{"id":"evt_1","type":"webhook.test"}
X-Serad-Signaturesha256=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 2xx tambié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:

IntentoCuándo, aproximadamente
1En el momento del evento
230 s después del primero
31 min después del anterior
42 min después
54 min después
68 min después
716 min después
832 min después
9 a 121 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 id del evento (el id del cuerpo) en una tabla con un índice único, y antes de procesar comprueba si ya lo procesaste. Si ya estaba, responde 2xx y no hagas nada más.
  • X-Serad-Delivery identifica la entrega: es igual en los reintentos automáticos, pero cambia si te reenviamos el evento a mano. Para descartar duplicados usa el id del evento; registra X-Serad-Delivery en 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 FINISHED si el aviso no está ya en un estado posterior», no «avanzar un estado»).
SQL
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.test desde 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-Delivery y 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.