Skip to content

Webhooks

Webhooks allow you to receive real-time notifications when events occur in your Eirly account. Instead of polling the API, webhooks push data to your application as events happen.

Webhooks are triggered for the following events:

  • referral.created - Fired when a new referral is created
  • referral.updated - Fired when a referral is updated
  • referral.billing_failed - Fired when the billing failed for the REST api request
  • referral.pending_verification - Fired when the profile requires completion before referral can be issued
  • referral.complete - All results are completed for this referral
  • referral.insufficient_data - Unable to proceed with completion on this referral
  • referral.refunded - If the referral was refunded
  • referral.failed_test - If a test on the referral failed and resulted in a new referral being required
  • referral.partial_results - We have partial results for this referral, can be fired multiple times.
  • referral.transferred - If the original referral was transferred to another person
  • referral.issued - The referral has been issued to the customer
  • referral.scheduled_reissue - The referral is about to be reissued to another customer
  • result.created - Fired when a result is created
  • result.updated - Fired when a result is updated
  • result.complete - All results completed
  • result.in_review - The results are in review with the org, and will be released upon review
  • result.partial_results - We have partial results for this result.
  • collection_centres.updated - The collection centre list changed: at most once per weekly refresh, and only when a centre opened, changed, went missing, came back or was retired. See Collection Centre Events.

You can set up webhooks in the portal or over the REST API. Both manage the same webhooks: one created over the API appears in the portal, and the reverse.

Configure webhooks in your organisation settings page: Organisation => Settings => Api Keys

Webhook configuration

To configure a webhook:

  1. Navigate to Organisation Settings > API Keys
  2. Enter your webhook endpoint URL
  3. Select the events you want to receive
  4. Save your configuration

Webhooks become available once the API is enabled for your organisation, on the same page.

Create, list, update and delete webhooks with the Webhooks endpoints, using the same access token as the rest of the API. GET /v1/webhook-events lists the event names you can subscribe to. The signing secret is not returned over the API: copy it from the portal (see Security).

Creating a webhook, pointing it at a different URL, or deleting it over the API emails your organisation’s owners and everyone who can manage webhooks, so a change nobody expected is noticed. Pausing a webhook or changing its events doesn’t.

  • The URL must use https and must be publicly reachable. URLs pointing at a private or internal address are refused when you save.
  • An event can be sent to a given URL by only one of your webhooks. You can use a single URL for all events, or different URLs for different events, but subscribing the same event to the same URL twice is refused: it would deliver every event twice.
  • A webhook can be inactive: it keeps its URL and events but receives nothing until you activate it again.

Your webhook endpoint must:

  • Respond within 3 seconds - Longer responses will be considered failed
  • Return a 2xx HTTP status code - Any other status code triggers a retry
  • Use HTTPS - Required. A webhook with an http URL cannot be saved
  • Be publicly accessible - Eirly servers must be able to reach your endpoint

Events are delivered in order per entity. For example:

  • You will always receive referral.created before referral.updated for the same referral
  • You will always receive result.created before result.updated for the same result

If webhook delivery fails, Eirly will retry according to this backoff schedule:

AttemptDelay after the previous attemptFormula
1ImmediateInitial attempt
2About 1 minute1² minutes
3About 4 minutes2² minutes
4About 9 minutes3² minutes
5About 16 minutes4² minutes

Formula: after the Nth failed attempt, the next is scheduled N² minutes later. Retries are picked up once a minute, so each can land up to a minute after its scheduled time. The whole schedule spans about 30 minutes.

After 5 failed attempts, the webhook will no longer be retried and you’ll need to poll the API to retrieve the missed data.

Every webhook request includes an x-bgt-hmac-sha256 header containing an HMAC signature of the payload. This signature allows you to verify that the webhook was sent by Eirly and hasn’t been tampered with.

Use your API secret key to verify the signature:

Node.js Example:

const crypto = require("crypto");
function verifyWebhookSignature(payload, secretKey, signature) {
// A SHA-256 HMAC is 64 hex characters. Reject anything else before comparing:
// timingSafeEqual throws when the two buffers differ in length, which would
// turn a missing or malformed header into a server error instead of a 401.
if (typeof signature !== "string" || !/^[0-9a-f]{64}$/i.test(signature)) {
return false;
}
// Calculate expected signature
const expectedSignature = crypto
.createHmac("sha256", secretKey)
.update(payload)
.digest("hex");
// Use timingSafeEqual to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(expectedSignature, "hex"),
Buffer.from(signature, "hex")
);
}
// Usage in an Express endpoint
app.post(
"/webhooks/bgt",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.headers["x-bgt-hmac-sha256"];
const payload = req.body.toString();
if (
!verifyWebhookSignature(payload, process.env.BGT_SECRET_KEY, signature)
) {
return res.status(401).send("Invalid signature");
}
// Process webhook
const event = JSON.parse(payload);
// ... handle event ...
res.status(200).send("OK");
}
);

Python Example:

import hmac
import hashlib
import re
def verify_webhook_signature(payload: bytes, secret_key: str, signature: str | None) -> bool:
# A SHA-256 HMAC is 64 hex characters. compare_digest raises on a missing
# value or non-ASCII text, so reject anything else first.
if not signature or not re.fullmatch(r"[0-9a-fA-F]{64}", signature):
return False
expected_signature = hmac.new(
secret_key.encode(),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected_signature, signature)

Each webhook request includes the following headers:

HeaderDescriptionExample
x-bgt-hmac-sha256HMAC signature for payload verificationa1b2c3d4...
x-bgt-delivery-timeServer-side timestamp of when the event was sentSun, 28 Sep 2025 23:41:01 GMT
Content-TypeAlways application/jsonapplication/json
  • Webhooks fire for all referrals/results in your organisation
  • Currently includes only API-created orders
  • Future updates will include UI and store integration orders
  • The API currently only returns referrals created via the API
  • Results are returned for referrals your organisation ordered, and for results you released from your incoming results
  • Future updates will allow querying all referrals regardless of creation method

The same event can be delivered more than once, so use its eventUuid to process each event only once.

Checking whether you have seen an event and then marking it later is not enough. Two deliveries of the same event that arrive together both pass the check, and both get processed. Instead, claim the eventUuid in one atomic step before you acknowledge the delivery, and only process the event if the claim succeeds. A unique constraint does this for you:

CREATE TABLE webhook_events (
event_uuid uuid PRIMARY KEY,
status text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);
// The insert is the claim: only one delivery of an event can create the row.
async function claimEvent(eventUuid) {
const { rowCount } = await db.query(
`INSERT INTO webhook_events (event_uuid, status)
VALUES ($1, 'processing')
ON CONFLICT (event_uuid) DO NOTHING`,
[eventUuid]
);
return rowCount === 1;
}
app.post(
"/webhooks/bgt",
express.raw({ type: "application/json" }),
async (req, res) => {
// Verify the signature first (see Verifying Signatures above).
const event = JSON.parse(req.body.toString());
if (!(await claimEvent(event.eventUuid))) {
// Another delivery of this event already claimed it.
return res.status(200).send("Already received");
}
res.status(200).send("OK");
processEventAsync(event);
}
);

An in-memory set does not work for this: it is empty after a restart, and each instance of your service has its own.

Once you return a 2xx, Eirly does not deliver that event again. If processing fails after the claim, record the failure against the row and retry it yourself, or fetch the current state from the REST API.

All webhook events follow a consistent structure with an event wrapper containing the data payload.

{
"eventName": "event.type",
"eventUuid": "unique-event-id",
"version": "1.0",
"data": {
/* Event-specific data */
}
}

Fired when referrals are created or updated.

{
"eventName": "referral.created",
"eventUuid": "98603d91-9d1b-4607-8ecb-705b33c66ef0",
"version": "1.0",
"data": {
"uuid": "b69ff810-0823-41ee-8fd2-43ff01329fae",
"status": "ISSUED",
"issuedAt": "2025-09-17T05:35:20.942Z",
"metadata": null,
"createdAt": "2025-09-17T05:35:15.810Z",
"reference": "DBK4EM3H",
"updatedAt": "2025-09-17T05:35:20.942Z",
"profileUuid": null,
"organisationUuid": "dbea642f-b741-4d33-ba7b-10fd0229a961"
}
}

Same structure as referral.created with updated field values.

StatusDescription
PENDING_VERIFICATIONProfile requires completion before referral can be issued
CREATEDReferral has been created but not yet issued
ISSUEDReferral has been issued and sent to the patient
SCHEDULED_REISSUEReferral is scheduled to be reissued to another customer
PARTIAL_RESULTSPartial results available for this referral (can occur multiple times)
COMPLETEAll results are completed for this referral
INSUFFICIENT_DATAUnable to proceed with completion due to insufficient data
REFUNDEDReferral has been refunded
FAILED_TESTTest failed and a new referral is required
BILLING_FAILEDBilling failed for the REST API request
TRANSFERREDReferral was transferred to another person

Fired when test results are created or updated.

{
"eventName": "result.created",
"eventUuid": "fd618482-7463-49dd-baa7-4289c62b0139",
"version": "1.0",
"data": {
"uuid": "89dd5014-750c-48c2-8f39-c39a34a4a637",
"provenance": "ORDERED",
"status": "CREATED",
"metadata": null,
"createdAt": "2025-09-17T08:01:27.616Z",
"updatedAt": "2025-09-17T08:01:27.641Z",
"profileUuid": null,
"referralUuid": "ffc14dd3-7216-4f52-900f-360733183142",
"organisationUuid": "dbea642f-b741-4d33-ba7b-10fd0229a961"
}
}

provenance says how the result reached you: ORDERED (ordered through us), INCOMING (a laboratory sent it to your organisation and you released it to your client) or UPLOADED (reserved, not sent yet). It is null only for a result that reached you by none of these. An INCOMING result has no referral, so its referralUuid and metadata are null. Fetch it with GET /v1/results/{uuid} as usual; see Results not ordered through us.

Same structure as result.created with updated field values. This event fires when:

  • Biomarker data becomes available
  • Result status changes
  • Results are reviewed or finalized
StatusDescription
CREATEDResult record created, awaiting data
IN_REVIEWResults are in review with the organisation, pending release
PARTIAL_RESULTSPartial results are available (can occur multiple times)
COMPLETEAll results finalized and available

Fired after the weekly refresh of collection centres, when it changed anything. Unlike the events above, it is not about one of your records: it tells you the shared list of centres changed, and which ones, so you can update a copy you keep.

  • At most one per refresh, with every lab’s changes in it, and none in a week where nothing changed.
  • It names centres, not their details. Fetch them with ?uuids= (up to 100 per request), one at a time, or everything the refresh changed with changedSince. Every uuid a delivery names can be fetched, retired ones included.
  • Every changed centre is listed, however many. A normal week names a few dozen. After a change that touches many centres at once, a delivery can reach about 250 KB, so make sure your endpoint accepts a body that size: some defaults do not (Express’s express.json() stops at 100 KB unless you raise limit).
{
"eventName": "collection_centres.updated",
"eventUuid": "9c2c7ec0-1b5e-4f0a-a3c7-2d9e8b6f4a11",
"version": "1.0",
"data": {
"runUuid": "a8fcba04-46c1-4d06-9fdc-eee2a69aed24",
"ranAt": "2026-09-27T16:00:00.000Z",
"counts": { "created": 1, "updated": 3, "revived": 1, "missing": 0, "retired": 0 },
"changed": {
"created": ["6274097e-58d1-4c0a-9f7b-3a1e2d4c5b60"],
"updated": [
"e36e14e4-2535-4ac3-92f0-c63a369dc047",
"cbec9dd4-55f5-4cc6-b987-4320a202e434",
"e8414022-bd90-4793-9502-9543e2f49d11"
],
"revived": ["44ad5e41-f8a7-4ba6-8321-a2b7df1bb5ff"],
"missing": [],
"retired": []
}
}
}
FieldDescription
runUuidThe refresh that made these changes. A retried delivery carries the same one.
ranAtWhen that refresh recorded its changes. Pass it as changedSince to fetch exactly what it changed.
counts.createdNew centres.
counts.updatedCentres whose details changed: name, address, phone, notes, services, booking link, opening hours and so on. A change of opening hours alone counts.
counts.revivedCentres back after going missing. One that came back with new details is in both revived and updated.
counts.missingCentres that dropped out of their lab’s listing this week. Still returned by the API, with status: "MISSING"; usually a hiccup on the lab’s side.
counts.retiredCentres missing for three weeks. No longer returned by default; changedSince returns them with status: "RETIRED" so you can remove them.
changedThe uuids of every changed centre, in the same five lists as counts.

To apply one:

async function handleCollectionCentresUpdated(event) {
// Everything the refresh changed, retired centres included.
const url = `https://api.staging.eirly.health/v1/collection-centres?changedSince=${event.data.ranAt}&count=10000`
const response = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } })
const { collectionCentres } = await response.json()
for (const centre of collectionCentres) {
if (centre.status === 'RETIRED') remove(centre.uuid)
else upsert(centre)
}
}

If you miss a delivery, nothing is lost: the next changedSince query, from the time of your last sync, returns everything since.

Webhook payloads contain minimal data. To get complete referral or result details including biomarkers:

async function handleResultUpdated(event) {
const resultUuid = event.data.uuid;
// Fetch full result data from API
const response = await fetch(
`https://api.staging.eirly.health/v1/results/${resultUuid}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
const fullResult = await response.json();
// fullResult includes biomarkers array
}

Use tools like ngrok to expose your local server:

Terminal window
ngrok http 3000

Use the generated HTTPS URL as your webhook endpoint during development.

Monitor webhook delivery in your Eirly organisation settings. Logs show:

  • Delivery attempts
  • Response codes
  • Retry status
  • Failure reasons
  1. Respond quickly - Acknowledge receipt immediately (200 OK), then process asynchronously
  2. Verify signatures - Always validate the HMAC signature in production
  3. Handle duplicates - Use eventUuid for idempotency
  4. Implement retries - Handle transient failures gracefully
  5. Monitor failures - Set up alerts for webhook delivery failures
  6. Use HTTPS - Encrypt webhook data in transit
  7. Log events - Keep audit logs of received webhooks

Complete webhook handler example:

const express = require("express");
const crypto = require("crypto");
const app = express();
// Use raw body for signature verification
app.post(
"/webhooks/bgt",
express.raw({ type: "application/json" }),
async (req, res) => {
try {
// Verify signature
const signature = req.headers["x-bgt-hmac-sha256"];
const payload = req.body.toString();
if (!verifySignature(payload, process.env.BGT_SECRET, signature)) {
return res.status(401).json({ error: "Invalid signature" });
}
// Parse event
const event = JSON.parse(payload);
// Claim the event atomically (see Idempotency). Only one delivery of
// an event gets past this line.
if (!(await claimEvent(event.eventUuid))) {
return res.status(200).json({ message: "Already received" });
}
// Respond immediately
res.status(200).json({ message: "Received" });
// Process asynchronously
processEventAsync(event).catch(console.error);
} catch (error) {
console.error("Webhook error:", error);
res.status(500).json({ error: "Processing failed" });
}
}
);
async function processEventAsync(event) {
// The claim already recorded the event as processing.
try {
switch (event.eventName) {
case "referral.created":
await handleReferralCreated(event.data);
break;
case "result.updated":
await handleResultUpdated(event.data);
break;
// ... other events
}
// Mark as complete
await markComplete(event.eventUuid);
} catch (error) {
await markFailed(event.eventUuid, error);
}
}
function verifySignature(payload, secret, signature) {
// Reject a missing or malformed header before comparing (see Verifying Signatures).
if (typeof signature !== "string" || !/^[0-9a-f]{64}$/i.test(signature)) {
return false;
}
const expected = crypto
.createHmac("sha256", secret)
.update(payload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(signature, "hex")
);
}

Webhooks not being received:

  • Verify your endpoint is publicly accessible
  • Check firewall/security group settings
  • Ensure endpoint returns 2xx status code
  • Verify HTTPS certificate is valid

Webhooks timing out:

  • Respond within 3 seconds
  • Move heavy processing to background jobs
  • Return 200 OK immediately

Duplicate events:

  • Implement idempotency using eventUuid
  • Check retry logic isn’t causing duplicates

For webhook issues:

  • Check webhook logs in your organisation settings
  • Review your endpoint’s error logs
  • Contact Eirly API support with the eventUuid