Webhooks
Signed messages to your own system when something changes in your agency.
What webhooks are
A webhook is a message we send to your system when something happens, such as a visit being published or a medicine being given. Your system does not need to keep asking.
Creating an endpoint
Agency admins add, change, delete endpoints and rotate their secrets on the Developers page in the ops app (/app/ops/developers), after a step-up check. An API key with the webhooks:manage scope can list endpoints, read their deliveries and send test events, but never add or change one. The URL must start with https://. Choose the events you want; the concern.filed and concern.decided events need the safeguarding triage function in your role. An agency can hold up to 10 endpoints.
The signing secret (it starts whsec_) is shown once, when the endpoint is made or its secret is rotated. Keep it in a secret store.
What a delivery looks like
Each delivery is a POST with a JSON body:
{
"id": "evt_...",
"type": "visit.published",
"api_version": "v1",
"created_at": "2026-11-01T08:00:00.000Z",
"org_id": "...",
"data": { ... }
}Headers: Content-Type: application/json, Elavinya-Signature, Elavinya-Event-Id, Elavinya-Event-Type and User-Agent: Elavinya-Webhooks/1.
Events
| Event | Fields in data |
|---|---|
| visit.published | visit_id, case_id, starts_at, ends_at |
| visit.changed | visit_id, case_id, starts_at, ends_at, change |
| visit.finished | visit_id, case_id, starts_at, ends_at, checked_in_at, checked_out_at |
| medication.given | administration_id, case_id, order_id, due_at, recorded_at |
| concern.filed | concern_id, reference, urgency |
| concern.decided | concern_id, reference, stage |
| booking.requested | request_id, case_id, starts_at |
| ping | message |
In visit.changed, change says what happened: assigned, unassigned, moved, cancelled. moved means a new time or carer; starts_at and ends_at are the visit's times after the change.
Messages carry identifiers, times and states only. They never hold names, addresses or notes. To get more detail, call the API with a key; the same access checks apply.
Checking the signature
Every delivery carries Elavinya-Signature: t=<unix seconds>,v1=<signature>. The signature is the hex HMAC-SHA256 of <t>.<raw body>, keyed with your secret.
- Read t and every v1 value from the
Elavinya-Signatureheader. - Refuse the delivery if t is more than 300 seconds from your clock.
- Work out the HMAC-SHA256 of t, a full stop, and the raw body exactly as received.
- Accept if any v1 value matches, compared in constant time.
Node:
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody is the request body exactly as received; header is the Elavinya-Signature header.
const TOLERANCE_SECONDS = 300;
export function verify(rawBody, header, secret) {
const parts = header.split(",").map((p) => p.split("="));
const t = Number(parts.find(([k]) => k === "t")?.[1]);
const signatures = parts.filter(([k]) => k === "v1").map(([, v]) => v);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = Buffer.from(createHmac("sha256", secret).update(t + "." + rawBody).digest("hex"));
return signatures.some((s) => {
const given = Buffer.from(s);
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
// In your handler: verify(rawBody, req.headers["elavinya-signature"], process.env.ELAVINYA_WEBHOOK_SECRET)Python:
import hashlib, hmac, time
# raw_body is the request body as bytes; header is the Elavinya-Signature header.
TOLERANCE_SECONDS = 300
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = [p.split("=", 1) for p in header.split(",")]
t = next((v for k, v in parts if k == "t"), None)
signatures = [v for k, v in parts if k == "v1"]
if t is None or not t.isdigit() or abs(time.time() - int(t)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(s, expected) for s in signatures)
# In your handler: verify(request.body, request.headers["Elavinya-Signature"], secret)Retries and failed deliveries
A 2xx answer within 10 seconds counts as delivered. Anything else is tried again, up to 8 more times, then the delivery is marked dead. The delivery log on the Developers page shows each attempt.
| Retry | Wait before it |
|---|---|
| 1 | 30 seconds |
| 2 | 1 minute |
| 3 | 2 minutes |
| 4 | 4 minutes |
| 5 | 8 minutes |
| 6 | 16 minutes |
| 7 | 32 minutes |
| 8 | 1 hour |
Every retry keeps the same event id. Store the ids you have handled and skip any you have seen before.
Send test event
The Send test event button sends a signed event of type ping straight away, so you can check your endpoint and your signature code. An agency can send 20 test events an hour; over that, the answer is 429 RATE_LIMITED with a Retry-After header.
Rotating the secret
After you rotate a secret, the old one also signs deliveries for 24 hours: the header then carries two v1 values. Switch your system to the new secret within that time.