We do not handle life-threatening emergencies: call 112.

+34 611 673 280

DevelopersAPI v1

Guides

Webhooks

Events we send you and when, the payload of each one, how to verify the HMAC-SHA256 signature in Node.js, Python, PHP, Java and C#, retries, deduplication and how to test your receiver.

Last reviewed: October 1, 2026

Webhooks are POST requests that Salud en Ruta sends to your system whenever something changes on one of your visits. They're the recommended way to keep your system up to date: they arrive within seconds and don't count towards your rate limit.

How webhooks are set up

The destination URL and the events you want to receive are registered by Salud en Ruta from its intranet, at your request (email us). For each environment (production and sandbox) you can have one or more URLs, each with its own events.

  • The URL must be HTTPS and publicly reachable. Internal addresses (localhost, private networks) and redirects are not allowed: a 3xx response counts as a failure.
  • When it's registered, we give you a secret (whsec_…) to verify the signature. Like API keys, it's shown only once.
  • We recommend subscribing to all events and discarding the ones you don't use on your side: that way nothing needs reconfiguring if you need them tomorrow.

Events

EventWhen it's sent
visit.createdOne of your visits is created, through any channel: API, portal or the dispatch centre by phone (check origin).
visit.assignedThe dispatch centre assigns the visit to a unit. Includes unitType (MEDICAL or NURSING).
visit.en_routeThe unit sets off for the patient's home. From here on there's an estimated arrival time.
visit.on_siteThe unit arrives at the patient's home.
visit.finishedCare is completed. outcome gives the result: attended, patient absent, referred to hospital or refused.
visit.cancelledThe visit is cancelled, by you or by the dispatch centre. cancelledWithCharge says whether it's chargeable.
visit.invoicedThe visit is included in an invoice. Includes invoice with its id and number.
report.sharedThe care report is available to you (signed, shared and with the patient's consent). See care report.
webhook.testTest event we send at your request so you can check your receiver. It isn't tied to any visit.

The request

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
HeaderDescription
X-Serad-EventEvent type (same as type in the body).
X-Serad-DeliveryIdentifier of this delivery. Automatic retries of a delivery carry the same one.
X-Serad-TimestampWhen the payload was signed, in Unix seconds. It changes on every attempt.
X-Serad-Signaturesha256= followed by the hex-encoded HMAC-SHA256 (see verify the signature).

The payload

Every visit event has the same shape. They contain no clinical or patient data (not even the name): only identifiers, statuses, times and your case number.

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
    }
  }
}
FieldDescription
idIdentifier of the event. It stays the same even if the delivery is repeated.
typeEvent type.
createdAtWhen the event happened (not when it was sent).
data.visit.id / codeThe visit. With the id you can retrieve it if you need more data.
data.visit.externalRefYour case number, if you sent one.
data.visit.statusStatus of the visit when the event happened.
data.visit.serviceTypeCodeService type code.
data.visit.unitTypeMEDICAL or NURSING from assignment onwards; null before that.
data.visit.milestonesTime of each milestone up to that moment.
data.visit.outcomeResult, in visit.finished and visit.cancelled.
data.visit.cancelledWithChargeWhether the cancellation is chargeable.
data.visit.invoice{ "id", "number" } once invoiced; otherwise null.

Differences by event:

{
  "type": "visit.cancelled",
  "data": { "visit": { "status": "CANCELLED", "outcome": "CANCELLED", "cancelledWithCharge": false, "milestones": { "cancelledAt": "2026-10-01T09:20:00.000Z" } } }
}

(Fields that don't change have been trimmed from these examples.)

Verify the signature

Every delivery is signed with your webhook's secret. Verifying the signature is what guarantees that the request was sent by Salud en Ruta and that nobody has tampered with it. Don't process any delivery without verifying it.

The signature is computed as follows:

TEXT
signature = "sha256=" + hex( HMAC-SHA256( secret, X-Serad-Timestamp + "." + body ) )

To verify it:

  1. Read the raw body, byte for byte, exactly as it arrives. Don't parse it into an object and serialise it again: any difference in whitespace or ordering changes the signature.
  2. Check that X-Serad-Timestamp is a number and is no more than 300 seconds away from your clock (in either direction). That way, someone who captures a delivery can't replay it to you later.
  3. Compute the HMAC-SHA256 of "<timestamp>.<raw body>" using the full secret (including the whsec_ prefix) as the key, in lowercase hexadecimal, and prepend sha256=.
  4. Compare the result with X-Serad-Signature in constant time (the functions below), never with ==: an ordinary comparison can be attacked by measuring how long it takes.
  5. If it doesn't match, respond 401 and do nothing else.
// Express. Read the body raw (express.raw), not with express.json(): you need the exact text.
import crypto from 'node:crypto';
import express from 'express';

const TOLERANCE_SECONDS = 300;

/**
 * true if the delivery comes from Salud en Ruta and hasn't expired.
 * @param {Buffer} rawBody  Body exactly as it arrived
 * @param {string | undefined} signature  X-Serad-Signature header
 * @param {string | undefined} timestamp  X-Serad-Timestamp header
 * @param {string} secret  Webhook secret (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('Invalid signature');

  const event = JSON.parse(req.body.toString('utf8'));
  // Respond now and process later (queue, worker…): you have 10 s.
  queue.add({ delivery: req.get('X-Serad-Delivery'), event });
  return res.sendStatus(204);
});

To check your implementation, this case must come out as valid (with now = 1790847600):

ItemValue
Secretwhsec_prueba_documentacion
X-Serad-Timestamp1790847600
Body{"id":"evt_1","type":"webhook.test"}
X-Serad-Signaturesha256=ebce010720159cfe5af2474bee9d5b2cc31312d7a9eaa72d50adb7a9d84bc91a

Respond quickly

Your receiver has 10 seconds to respond. Any 2xx response (usually 200 or 204) counts as delivered; anything else — a 4xx, a 5xx, a redirect, a network error or a timeout — counts as a failure and is retried.

  • Verify the signature, store the event and respond. Do the real work (updating your CRM, alerting an operator, downloading the report) afterwards, in a queue or a background process.
  • Don't call the Salud en Ruta API inside the receiver before responding: if it's slow, we'll go past the 10 s and send you the event again.
  • Respond with a 2xx to events you're not interested in (or repeated ones) too: if you respond with an error, we'll keep retrying them.

Retries

If a delivery fails, we retry it with exponential backoff: the wait doubles on each attempt, starting at 30 seconds and capped at 1 hour. There are 12 attempts in total, spread over about 5 hours, so that an outage or a deployment on your side doesn't make you lose events:

AttemptWhen, approximately
1When the event happens
230 s after the first
31 min after the previous one
42 min later
54 min later
68 min later
716 min later
832 min later
9 to 121 h after the previous one, each

Each attempt carries a new X-Serad-Timestamp and signature (which is why the 300 s window doesn't get in the way of retries), and the same X-Serad-Delivery.

Once the attempts are exhausted, the delivery is marked as failed in our log. When your receiver is working again, let us know and we'll redeliver the failed deliveries from the intranet. A manual redelivery is a new delivery (a different X-Serad-Delivery) of the same event (same id in the body).

Receiver idempotency

Because of retries (and because your response can get lost on the way even when you've processed the delivery), you may receive the same event more than once. Your receiver has to be idempotent:

  • Store the event id (the id in the body) in a table with a unique index, and check whether you've already processed it before doing so. If it was already there, respond with a 2xx and do nothing else.
  • X-Serad-Delivery identifies the delivery: it's the same across automatic retries, but changes if we redeliver the event manually. Use the event id to discard duplicates; record X-Serad-Delivery in your logs, as it's what will help us find a specific delivery if something needs investigating.
  • Apply changes in a way that repeating them has no effect (for example, "set the status to FINISHED unless the visit is already in a later status", not "move the status forward one step").
SQL
CREATE TABLE serad_webhook_events (
  event_id     UUID PRIMARY KEY,           -- "id" from the body
  delivery_id  UUID NOT NULL,              -- X-Serad-Delivery (for your logs)
  type         TEXT NOT NULL,
  received_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- INSERT … ON CONFLICT (event_id) DO NOTHING; if nothing was inserted, it was already processed.

Test your receiver

  • Test event. Ask us to send you a webhook.test from the intranet (in the sandbox or in production). It's the quickest way to check that the URL responds, that the certificate is valid and that you verify the signature correctly, without creating any visit.
  • Delivery history. In the intranet we can see every delivery: status, attempts, the HTTP status code and the beginning of your receiver's response. If something fails, email us with the X-Serad-Delivery and we'll tell you what we got back.
  • Sandbox. Create visits in the sandbox to receive the full sequence of events, and try the webhook down scenario: shut down your receiver, create a visit and bring it back up to see the retries.
  • Locally. To develop on your machine, expose your receiver through an HTTPS tunnel (ngrok, Cloudflare Tunnel…) and give us that URL for the sandbox.

Something missing or not matching what you see? Email our technical team: info@saludenruta.es.