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.

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

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:

  1. callback_url query parameter on the POST /webhook/crelio call
  2. callbackUrl / callback_url key in the payload body
  3. the lab's configured default (set for your lab by CrelioHealth support)
  4. CRELIO_CALLBACK_URL env 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


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.


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, the test_values input, 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.

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

  1. 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.
  2. A small public record is stored as verify.json alongside 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).
  3. 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}/data always return 200; an unknown id is conveyed in the body as {"found": false}, not a 404.


Configuring the webhook in CrelioHealth

  1. Set the destination URL to <BASE>/webhook/crelio (append query params if you want a non-default template/cover, e.g. ?template=guided_wellness).
  2. Add the shared secret as a custom header — e.g. X-Webhook-Secret: <secret>. Use the same value configured as WEBHOOK_SECRET on the service.
  3. Trigger a test report and confirm a 202 with an id, then poll <BASE>/jobs/<id> until done.
  4. (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_SECRET out of source control and rotate it via your secrets manager. Treat report payloads and rendered reports as PHI.