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: a3xxresponse 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
| Event | When it's sent |
|---|---|
visit.created | One of your visits is created, through any channel: API, portal or the dispatch centre by phone (check origin). |
visit.assigned | The dispatch centre assigns the visit to a unit. Includes unitType (MEDICAL or NURSING). |
visit.en_route | The unit sets off for the patient's home. From here on there's an estimated arrival time. |
visit.on_site | The unit arrives at the patient's home. |
visit.finished | Care is completed. outcome gives the result: attended, patient absent, referred to hospital or refused. |
visit.cancelled | The visit is cancelled, by you or by the dispatch centre. cancelledWithCharge says whether it's chargeable. |
visit.invoiced | The visit is included in an invoice. Includes invoice with its id and number. |
report.shared | The care report is available to you (signed, shared and with the patient's consent). See care report. |
webhook.test | Test event we send at your request so you can check your receiver. It isn't tied to any visit. |
The request
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| Header | Description |
|---|---|
X-Serad-Event | Event type (same as type in the body). |
X-Serad-Delivery | Identifier of this delivery. Automatic retries of a delivery carry the same one. |
X-Serad-Timestamp | When the payload was signed, in Unix seconds. It changes on every attempt. |
X-Serad-Signature | sha256= 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.
{
"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
}
}
}| Field | Description |
|---|---|
id | Identifier of the event. It stays the same even if the delivery is repeated. |
type | Event type. |
createdAt | When the event happened (not when it was sent). |
data.visit.id / code | The visit. With the id you can retrieve it if you need more data. |
data.visit.externalRef | Your case number, if you sent one. |
data.visit.status | Status of the visit when the event happened. |
data.visit.serviceTypeCode | Service type code. |
data.visit.unitType | MEDICAL or NURSING from assignment onwards; null before that. |
data.visit.milestones | Time of each milestone up to that moment. |
data.visit.outcome | Result, in visit.finished and visit.cancelled. |
data.visit.cancelledWithCharge | Whether 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:
signature = "sha256=" + hex( HMAC-SHA256( secret, X-Serad-Timestamp + "." + body ) )To verify it:
- 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.
- Check that
X-Serad-Timestampis 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. - Compute the HMAC-SHA256 of
"<timestamp>.<raw body>"using the full secret (including thewhsec_prefix) as the key, in lowercase hexadecimal, and prependsha256=. - Compare the result with
X-Serad-Signaturein constant time (the functions below), never with==: an ordinary comparison can be attacked by measuring how long it takes. - If it doesn't match, respond
401and 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):
| Item | Value |
|---|---|
| Secret | whsec_prueba_documentacion |
X-Serad-Timestamp | 1790847600 |
| Body | {"id":"evt_1","type":"webhook.test"} |
X-Serad-Signature | sha256=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
2xxto 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:
| Attempt | When, approximately |
|---|---|
| 1 | When the event happens |
| 2 | 30 s after the first |
| 3 | 1 min after the previous one |
| 4 | 2 min later |
| 5 | 4 min later |
| 6 | 8 min later |
| 7 | 16 min later |
| 8 | 32 min later |
| 9 to 12 | 1 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(theidin 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 a2xxand do nothing else. X-Serad-Deliveryidentifies the delivery: it's the same across automatic retries, but changes if we redeliver the event manually. Use the eventidto discard duplicates; recordX-Serad-Deliveryin 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
FINISHEDunless the visit is already in a later status", not "move the status forward one step").
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.testfrom 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-Deliveryand 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.
