Webhook API — CrelioHealth → Smart Reports
How to send a CrelioHealth lab report to the Smart Reports engine and retrieve the rendered patient-friendly report (HTML + PDF).
The integration is one inbound webhook plus a few polling endpoints. You
POST a report payload; the service authenticates it, acks immediately (202),
renders asynchronously, and exposes the result at predictable URLs returned in the
ack.
- Base URL (live):
https://smart-reports.crelio.solutions - Content type:
application/json - Auth: shared secret in a request header (see Authentication)
All endpoints are versionless today. Breaking changes will be announced before they ship.
Quick start
BASE="https://smart-reports.crelio.solutions"
SECRET="<your WEBHOOK_SECRET>"
# 1. Send a report (ack is immediate)
curl -sS -X POST "$BASE/webhook/crelio" \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: $SECRET" \
--data @report.json
# → 202 {"id":"a1b2c3d4e5f6","status":"queued","report_url":".../report/a1b2c3d4e5f6", ...}
# 2. Poll until done (usually a few seconds)
curl -sS "$BASE/jobs/a1b2c3d4e5f6" # → {"status":"done","pdf_url":"...", ...}
# 3. Fetch the result
curl -sS "$BASE/report/a1b2c3d4e5f6" # HTML
curl -sS "$BASE/report/a1b2c3d4e5f6.pdf" -o report.pdf # PDF
Authentication
Every request to POST /webhook/crelio must carry the shared secret
(WEBHOOK_SECRET). CrelioHealth's integration config sends the secret as a
static token in a request header (it offers a custom-header field, not request
signing), and the header name is lab-configurable. We therefore accept the
secret in any of these headers — set whichever your CrelioHealth config uses:
| Header | Example value |
|---|---|
X-Webhook-Secret |
<secret> |
Authorization |
<secret> or Bearer <secret> |
X-Webhook-Token |
<secret> |
X-Api-Key |
<secret> |
A Bearer prefix is stripped automatically. Comparison is constant-time.
HMAC fallback (optional). If you'd rather sign the body, send an
HMAC-SHA256 of the raw request body, hex-encoded, in one of X-Crelio-Signature,
X-Signature, or X-Hub-Signature-256. A sha256= prefix is accepted:
X-Crelio-Signature: sha256=<hex(hmac_sha256(WEBHOOK_SECRET, raw_body))>
A request that satisfies neither the static-token nor the HMAC check is
rejected with 401. If the server is started with no WEBHOOK_SECRET, it
rejects all webhooks (fail-closed); local dev can explicitly opt in to
unsigned requests with ALLOW_UNSIGNED_WEBHOOK=1.
POST /webhook/crelio
Receive a CrelioHealth report, enqueue it, and ack fast.
Request
- Headers:
Content-Type: application/json+ an auth header (above). - Body: a CrelioHealth report payload — a single JSON object. Three payload shapes are supported; see Payload shapes.
Query parameters (optional)
| Param | Default | Meaning |
|---|---|---|
template |
clinical_classic |
Report design: clinical_classic, guided_wellness, health_briefing, patient_education (see GET /api/templates). Unknown/archived ids fall back to the default. |
tier2 |
0 |
1/true/on to add the Tier-2 LLM narrative ("AI insights"). |
cover |
1 |
1/true/on to include the PDF cover page; 0 to omit. |
callback_url |
(lab default) | Where to POST the completion callback for this request. Overrides the lab's configured default. Must be https (see below); an invalid URL is rejected with 400. |
Example: POST /webhook/crelio?template=guided_wellness&cover=0
The callback URL may also be supplied as a callbackUrl (or callback_url) key
inside the JSON payload body.
Response — 202 Accepted
The report is not rendered yet; these URLs become live once status is
done.
{
"id": "a1b2c3d4e5f6",
"status": "queued",
"lab_id": "1000942",
"callback": { "url": "https://api.crelio.internal/report-callback", "status": "pending" },
"status_url": "https://.../jobs/a1b2c3d4e5f6",
"report_url": "https://.../report/a1b2c3d4e5f6",
"pdf_url": "https://.../report/a1b2c3d4e5f6.pdf"
}
| Field | Description |
|---|---|
id |
Report id; use it in all follow-up URLs. |
lab_id |
Tenant the report was attributed to (see Lab attribution). |
callback |
The resolved completion callback, or null when none is configured. |
status_url / report_url / pdf_url |
Where to poll and fetch the result. |
Errors
| Status | When |
|---|---|
401 |
Missing/invalid secret (and no valid HMAC). |
400 |
Body isn't valid JSON, isn't a JSON object, or callback_url is invalid. |
Async flow
POST /webhook/crelio ──202── {id, status_url, report_url, pdf_url}
│
▼ (render happens in the background worker)
GET /jobs/{id} ──► status: queued → processing → done (or: error)
│
▼ once done
GET /report/{id} → HTML
GET /report/{id}.pdf → PDF
Rendering is typically a few seconds. Poll status_url (e.g. every 1–2s) until
status is done or error — or skip polling entirely and receive a
completion callback. The raw payload is persisted
off-queue, so a report is replayable and not subject to the 256 KB SQS message
limit.
Completion callback
Instead of polling GET /jobs/{id}, have the service push a notification to
a CrelioHealth endpoint the moment a report finishes (successfully or not).
Configuring the callback URL
Resolution order — first non-empty wins:
callback_urlquery parameter on thePOST /webhook/creliocallcallbackUrl/callback_urlkey in the payload body- the lab's configured default (set for your lab by CrelioHealth support)
CRELIO_CALLBACK_URLenv on the service (global default)
Nothing resolved → no callback; the flow is exactly the pre-callback behaviour.
URLs must be https (plain http only with ALLOW_HTTP_CALLBACK=1, local
dev). Link-local/metadata addresses are rejected. A request that supplies an
invalid URL gets a synchronous 400.
Authentication (Crelio internal APIs)
Set CRELIO_CALLBACK_TOKEN in the service's global config (ECS task env /
secrets manager). Every callback POST carries a token in both headers, so
either Crelio auth style works:
Authorization: Bearer <token>
x-internal-token: <token>
x-is-internal-request: True
Plus routing headers: X-Smart-Reports-Event: report.completed|report.failed
and X-Smart-Reports-Id: <id>.
Token scoping (safeguard). The real internal token is sent only to
hosts under CALLBACK_TOKEN_DOMAINS (default
crelio.solutions,livehealth.solutions — exact domain or subdomain, matched at
a label boundary so lookalikes like evilcrelio.solutions don't qualify).
Any other destination — a lab's own endpoint, localhost during dev — gets
the dummy token instead (CALLBACK_DUMMY_TOKEN, default 1234), so the
internal credential can never leak to a third-party callback URL while the flow
stays fully testable end-to-end.
Token audit trail. Every delivery logs — and records on the job's event row
— a token hint: <real|dummy>:<first 4>…<last 4> (e.g. real:acea…4069;
first 4 only for tokens under 12 chars, e.g. dummy:1234). Enough to identify
which token went out when diagnosing auth failures after an env reset,
without ever storing the credential. Visible per transaction in the Token
column of the /ops lab drill-down and in the server logs; CrelioHealth
support can look this up per transaction when helping you debug delivery
issues.
Callback request body
POST <callback_url> with Content-Type: application/json:
{
"event": "report.completed",
"id": "a1b2c3d4e5f67890a1b2c3d4e5f67890",
"status": "done",
"labId": 1000942,
"labName": "Acme Diagnostics",
"labIntegrationId": 4920,
"billId": 553311,
"bill_id": 553311,
"orderNumber": "ORD-1001",
"patientId": 88123,
"labReportIds": [900022, 900023],
"sampleIds": [70011],
"crelioReportIds": [12001, 12002],
"template": "clinical_classic",
"tier2": false,
"cover": true,
"pdf": true,
"pdfError": "",
"files": { "html": "https://.../report/<id>", "pdf": "https://.../report/<id>.pdf" },
"reportUrl": "https://.../report/<id>",
"pdfUrl": "https://.../report/<id>.pdf",
"statusUrl": "https://.../jobs/<id>",
"summary": { "markers_total": 12, "flagged": 2 },
"receivedAt": "2026-07-02T10:00:00+00:00",
"completedAt": "2026-07-02T10:00:04+00:00",
"error": null
}
| Field group | Notes |
|---|---|
event / status |
report.completed + done, or report.failed + error. |
| Matching keys | labId, billId/bill_id, orderNumber, patientId, labIntegrationId are echoed exactly as the payload carried them. labReportIds / sampleIds / crelioReportIds are collected across every report entry (profile nesting included), de-duplicated, in payload order. |
files / *Url |
The generated artifacts. files.pdf is null when PDF rendering failed (pdf:false + pdfError); the HTML is still live, and the PDF URL regenerates on demand. Fetch with a view_phi service identity, or the link serves the standard PHI interstitial. |
summary |
Present on report.completed only (marker/flag counts — no values). |
error |
On report.failed: the exception type only (no PHI), else null. |
Your endpoint should return 2xx. Anything else is retried up to 3 attempts
(CALLBACK_RETRIES, backoff 1s/2s/4s, timeout CALLBACK_TIMEOUT_S=10s per
attempt); non-429 4xx responses are not retried (they won't heal).
Delivery semantics
- At-least-once. A queue redelivery re-renders the job and re-fires the
callback — make your consumer idempotent on (
id,event). - Never blocking. Callback failure never fails (or requeues) the report job; the outcome (delivered / failed) is recorded per job and per transaction.
- Operator-initiated re-renders do not re-fire callbacks.
GET /jobs/{id} — job status
{ "status": "done",
"template": "clinical_classic",
"pdf": true,
"report_url": "https://.../report/a1b2c3d4e5f6",
"pdf_url": "https://.../report/a1b2c3d4e5f6.pdf" }
The poll response is intentionally PHI-free — it carries status and URLs only. Patient details arrive in the completion callback, which is delivered to your authenticated endpoint.
status |
Meaning |
|---|---|
queued |
Accepted, waiting for a worker. |
processing |
Rendering in progress. |
done |
Rendered; report_url (always) and pdf_url (if pdf:true) are live. |
error |
Rendering failed; error holds the exception type (no PHI). |
unknown |
Unrecognized id (or state not yet recorded). |
pdf:false with a pdf_error means the HTML rendered but PDF generation failed
(e.g. headless Chromium unavailable); the HTML report is still served, and the PDF
is retried on demand when GET /report/{id}.pdf is called.
GET /report/{id} — rendered HTML
Returns the stored HTML (200). While the job is still queued/processing,
returns a small "being generated — refresh in a moment" page with 202.
Unknown id → 404.
Re-render on the fly: add ?template=<id> to render the same report in a
different design without re-sending the payload — e.g.
GET /report/{id}?template=data_dense.
GET /report/{id}.pdf — rendered PDF
Returns application/pdf (200), generating it on demand from the stored model
if it wasn't pre-rendered. Served inline (Content-Disposition: inline).
Unknown id → 404. If PDF rendering is unavailable → 503 with
{"error": "..."}.
Tip — clean PDFs: the on-server PDF (this endpoint, and the Studio's "Download PDF" button) lays biomarker cards out without splitting them across pages. A browser's own Print → Save as PDF is a less reliable fallback.
Trends Report API (provider / lab)
A separate, clinician-facing report type for longitudinal lab trends — one
patient's results plotted panel → parameter → value-over-time. It is not
patient-facing (no patient explanations) and is generated only on request
(no webhook ingestion, nothing stored). Input is CrelioHealth's longitudinal
test_values export, not the per-report webhook payload.
| Method & path | Purpose |
|---|---|
POST /api/trends |
Render a trends report → JSON {html, summary, audience, template, tier2?} |
POST /api/trends.pdf |
Same body → application/pdf |
GET /api/trends_templates |
List the available trends report types |
GET /api/trends_tier2_status |
Whether the clinician Tier-2 (AI) can run |
curl -sS -X POST "https://smart-reports.crelio.solutions/api/trends" \
-H "Content-Type: application/json" \
-d '{"template":"trends_comprehensive","audience":"provider","payload": <test_values JSON> }'
Two report types (the template field; default trends_comprehensive):
| id | Type | What it is |
|---|---|---|
trends_comprehensive |
Comprehensive | A4 portrait per-panel matrix (parameter × draw, dates as columns), sparklines + deltas, smooth focus charts with reference-band shading, pattern-flag insights. Every parameter shown. |
trends_compact |
Compact | Fewer pages: one dense matrix, all parameters with flagged/trending floated to the top. No focus charts. |
audience is provider or lab (both clinician-facing). With tier2:true and an
ANTHROPIC_API_KEY, a clinician-only AI layer adds follow-up-test suggestions
(never patient advice); without a key it falls back to deterministic pattern flags.
Full reference:
docs/TRENDS_API.md— request/response shapes, thetest_valuesinput, grouping/trending/reference-interval behaviour, and the report types. Tier-2 prompt contract:docs/TRENDS_TIER2_CONTRACT.md.
Payload shapes
The engine auto-detects three CrelioHealth payload shapes by their top-level key and normalizes them to one model. You don't pick the shape — send what Crelio sends.
| Top-level key | Shape | Typical source |
|---|---|---|
reportDetails |
structured (Consolidated Report — Structured Data) | the primary integration |
report_details |
billing (Bill Generation HL7 / DFT export) | billing/DFT exports |
testDetails |
compact | compact/curated test lists |
Profile-ordered reports (an entry with isProfile: 1) nest their tests under
testDetails; the adapter descends into them automatically.
Minimal structured example
{
"Patient Name": "RIYAN PATEL",
"Gender": "male",
"Age": "34 years",
"labId": 1000942,
"labUserName": "Front Desk",
"lab_integration_id": 4920,
"orderNumber": "ORD-1001",
"reportDetails": [
{
"Test Name": "Complete Blood Count",
"labReportId": 98765,
"Sample Date": "2026-06-16T10:00:00Z",
"Report Date": "2026-06-17T08:00:00Z",
"reportFormatAndValues": [
{ "value": 13.5,
"reportFormat": {
"testName": "Hemoglobin", "testUnit": "g/dL",
"lowerBoundMale": 13.0, "upperBoundMale": 17.0,
"integrationCode": "HGB" } }
]
}
]
}
Fields beyond this minimum (more tests, reference bounds per sex, flags, units,
DOB, etc.) are used when present. Note: Crelio's structured payload generally
carries Age, not a date of birth — the report shows "Age" when no DOB is
sent.
Age of a child
Production flattens the unit off Age — the LIS record holds "6 months" but
the webhook sends "Age": "6". Three fields settle it, in this order:
| Field | Used for | Notes |
|---|---|---|
ageInDays |
Reference-range classification | Required for anyone under 2. Without it "Age": "6" reads as 6 years and the child is scored against adult ranges. |
ageDisplay |
The header string, verbatim | Optional. Crelio's own wording ("2 Yrs 1 M"), so the report and the LIS can't disagree. ""/"-" = not sent. |
Age |
Fallback | A unit-bearing "6 months" is read correctly; a bare "6" needs ageInDays. |
Without ageDisplay, the header is derived from ageInDays in completed
years/months ("2 Yrs 2 M", "6 M", "2 D") and shown only for the under-2s,
for a Baby./Master. designation under 5, or when the stated Age is itself
in months. ageInDays is trusted only when it agrees with Age — a day count
matching neither is junk and never re-ages an adult. See engine/adapter_common.py
(age_of, age_text) and tests/test_age_units.py.
Lab attribution
Each report is attributed to a tenant lab_id, resolved from the payload in this
order: labId (stable numeric id) → CLIA → orgId → display name. Keying on
the stable labId means renaming a lab in Crelio doesn't split its history, and
two labs sharing a display name don't merge.
lab_nameis the display name (labUserName).lab_integration_idis the Crelio integration instance (lab_integration_id).
These attribute each report to the correct lab. The report's own masthead currently
shows the labId (a lab-details lookup by id is planned).
GET /healthz — health check
{ "ok": true, "queue": "sqs", "events": "dynamodb" }
queue is sqs or inprocess; events is dynamodb or sqlite, depending on
how the service is provisioned. Returns 200 when the app is up.
Report verification (QR)
Every rendered report carries a tamper-evidence QR code (report HTML footer and PDF cover). Verification is online: we do not sign reports cryptographically.
How it works
- At render time the engine computes a stable SHA-256 content hash over the
authenticity-relevant fields only — report id, lab id + name, patient
name/DOB/age/sex, collected/resulted timestamps, and the ordered list of every
(panel, marker, value, unit, flag). The same report always hashes the same; any value/name/date change produces a different hash. - A small public record is stored as
verify.jsonalongside the report artifacts, and the QR encodes a link to our hosted verify page:<BASE>/verify/<id>?h=<short>(where<short>is the first 12 hex chars). - Scanning the QR opens the page, which fetches the stored record and shows a large ✓ Authentic / ✗ indicator plus the report summary.
Set PUBLIC_BASE_URL so the QR encodes an absolute origin; if unset, the QR uses
a host-relative /verify/<id> link (still resolves when opened against this host).
Endpoints
| Method & path | Auth | Returns |
|---|---|---|
GET /verify/<id> |
public | The verification HTML page |
GET /verify/<id>/data?h=<short> |
public | JSON record (see below) |
/verify/<id>/data returns {"found": false} for an unknown id. When found it
returns {"found": true, "match": <bool>, ...} where match is false if a
provided h does not equal the stored short hash.
What the page shows (PHI-minimal — this endpoint is PUBLIC): lab name, patient initials only (never the full name), age/DOB, collected + resulted + issued timestamps, the full + short content hash, and result counts (total / flagged). It deliberately never exposes the full patient name or any individual test values.
Status codes (summary)
| Code | Where | Meaning |
|---|---|---|
202 |
POST /webhook/crelio, GET /report/{id} (pending) |
Accepted / still rendering |
200 |
GET /jobs, /report, /report.pdf, /healthz, /verify/{id} |
OK |
400 |
POST /webhook/crelio |
Bad JSON / not an object |
401 |
POST /webhook/crelio |
Auth failed |
404 |
GET /report/{id}, /report/{id}.pdf |
Unknown id |
503 |
GET /report/{id}.pdf |
PDF renderer unavailable |
GET /verify/{id}and/verify/{id}/dataalways return200; an unknown id is conveyed in the body as{"found": false}, not a404.
Configuring the webhook in CrelioHealth
- Set the destination URL to
<BASE>/webhook/crelio(append query params if you want a non-default template/cover, e.g.?template=guided_wellness). - Add the shared secret as a custom header — e.g.
X-Webhook-Secret: <secret>. Use the same value configured asWEBHOOK_SECRETon the service. - Trigger a test report and confirm a
202with anid, then poll<BASE>/jobs/<id>untildone. - (Optional, recommended) skip the polling: have CrelioHealth support set your
lab's Callback URL (or append
?callback_url=…per request) — you'll receive a completion callback with the report/PDF URLs the moment rendering finishes.
Security: keep
WEBHOOK_SECRETout of source control and rotate it via your secrets manager. Treat report payloads and rendered reports as PHI.