Webhooks
Configure your webhook URL and JustLabs will deliver these events as orders progress:
| Event | Fires when |
|---|---|
order.requisition_ready | The order's status becomes requisition_ready. |
order.results_ready | The order's status becomes results_ready. |
order.cancelled | A partner cancels the order via DELETE /orders/{id}. This is the only source of the event today — a cancellation arranged with JustLabs support does not emit it. |
Payload
Deliberately minimal — ids only, never patient data or test names (this
is a data-minimization choice: your system already has the order and should
fetch fresh details via GET /orders/{id} rather than trust a cached
payload):
{
"id": "order_123__order.results_ready__<trigger>",
"type": "order.results_ready",
"createdAt": "2026-09-10T14:32:00.000Z",
"data": {
"orderId": "order_123",
"externalId": "ext-4711",
"status": "results_ready"
}
}
Headers
| Header | Purpose |
|---|---|
Content-Type | application/json |
X-JustLabs-Event | The event type, e.g. order.results_ready. |
X-JustLabs-Delivery | A unique, stable delivery id. Use this to dedupe — see "At-least-once" below. |
X-JustLabs-Signature | t=<unix seconds>,v1=<hex hmac> — see verification below. |
User-Agent | JustLabs-Webhooks/1 |
Your endpoint must respond 2xx directly. JustLabs sends the request
with redirects disabled — a 3xx
response is never followed and counts as a failed delivery attempt, exactly
like a 4xx/5xx or a timeout. This is deliberate: a signed webhook body
must never be silently re-POSTed to a different origin. Point the
configured URL directly at the handler, with no redirect in between.
At-least-once delivery
Webhooks are at-least-once, not exactly-once. The same event can arrive
more than once (e.g. your endpoint returned a 2xx but the response was lost
in transit, or an internal retry re-fires the same trigger). Dedupe on
X-JustLabs-Delivery — treat a delivery id you've already processed as a
no-op success, and always return 200 for it. Do not rely on receiving each
event exactly once, and there is no ordering guarantee, even for
deliveries on the same order — the first attempt happens inline at the
triggering event, while retries run on a cron with roughly 5-minute
granularity, so two deliveries for the same order can arrive out of the
order their underlying state changes happened in. Don't infer order state
purely from delivery arrival order: treat data.status as a hint for what
changed, and re-fetch GET /orders/{id} for the authoritative current state
if your logic depends on it.
Retry schedule
A non-2xx response (including a 3xx redirect — see above) or a timeout (JustLabs waits up to 10 seconds) is retried on this backoff, in minutes after the previous attempt:
1, 5, 30, 120, 360, 720, 1440, 1440
(1 min, 5 min, 30 min, 2 hr, 6 hr, 12 hr, 1 day, 1 day — 8 attempts total).
The last 1440 is the wait before the 8th attempt; that 8th attempt is
not itself followed by another wait, so the retry window spans the sum of
the first 7 gaps — about 45 hours, not a full 2 days — plus up to ~5
minutes of extra latency per retry from the cron's own polling granularity.
After the final (8th) attempt fails, the delivery is marked failed and
JustLabs support is alerted; you can still poll GET /orders/{id} at any
time regardless of webhook delivery state — polling is always available as
a fallback.
Verifying the signature
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(secret, header, rawBody, now = Date.now()) {
if (!header) return false;
const p = Object.fromEntries(header.split(',').map((kv) => kv.split('=')));
if (Math.abs(now - Number(p.t) * 1000) > 300_000) return false;
const expected = createHmac('sha256', secret).update(`${p.t}.${rawBody}`).digest('hex');
return expected.length === p.v1.length && timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(p.v1, 'hex'));
}
Usage in an Express-style handler (verify against the raw, unparsed request body — signing is computed over the exact bytes sent):
app.post('/webhooks/justlabs', express.raw({ type: 'application/json' }), (req, res) => {
// req.header(...) returns undefined when the header is absent — verify()
// guards against that (and any other malformed header) internally.
const ok = verify(process.env.JUSTLABS_WEBHOOK_SECRET, req.header('X-JustLabs-Signature'), req.body.toString('utf8'));
if (!ok) return res.status(401).end();
let event;
try {
event = JSON.parse(req.body);
} catch {
return res.status(400).end();
}
// ... dedupe on req.header('X-JustLabs-Delivery'), then handle event ...
res.status(200).end();
});
The tolerance window is 5 minutes — reject anything older (protects against replay) or with a timestamp too far in the future (clock skew / forged header).