Nov 13, 202614 min read

Lab Results Backend: Receive Results, Flag Critical Values, Release to Patients

Accept result messages from a lab, keep the original, store observations once per revision, alert the ordering clinician about critical values, escalate when nobody acknowledges them, and release reviewed reports to the patient portal, built by hand on AWS and in Go with Encore.

Illustration for Lab Results Backend: Receive Results, Flag Critical Values, Release to Patients

A clinic or telehealth app that orders lab tests gets results back as a stream of messages from the lab: a report per accession number, a set of observations per report, and corrections that arrive hours or days later. Two of those results matter more than the rest. A critical potassium value has to reach the ordering clinician within minutes, and a routine panel should only reach the patient once a clinician has looked at it. When both depend on someone refreshing an inbox, a critical result can sit unread over a weekend.

This guide builds the backend that receives results, keeps the originals, alerts on critical values, escalates when nobody acknowledges them and releases reviewed reports, first as AWS pieces by hand and then in Go with Encore. Compliance requirements for health data depend on your jurisdiction and contracts and are not covered here. The PHI audit log guide covers recording who read which record.

The lab results backend at a glance

JobPatternHand-built on AWSWith Encore in your AWS account
Receive resultsAuthenticate the lab, store once per lab, accession and revisionALB or API Gateway to a containerTyped Encore endpoint
Keep originalsOne object per revision in a versioned bucketS3 with versioningEncore bucket with Versioned: true
Reports and observationsUnique key per revision, observations per LOINC codeRDS for PostgreSQLEncore database on RDS
Critical alertsPublish on HH, LL or AA flagsSNS or SQS and a workerPub/Sub topic and subscription
EscalationJob marks unacknowledged results onceEventBridge Scheduler or an ECS scheduled taskCron job managed by the Encore platform
ReleaseOnly after the clinician acknowledges any critical valueApplication logicRelease endpoint

How do you build it by hand on AWS?

  1. Receive results. The lab, or an integration engine that turns HL7 v2 or FHIR DiagnosticReport messages into JSON, posts each report to an endpoint behind an ALB or API Gateway with an API key or mutual TLS. Labs resend messages after timeouts, so the endpoint has to accept the same message twice without creating a second report.
  2. Keep originals. Each message goes to a private S3 bucket with versioning before parsing, keyed by lab, accession and revision, so a parser bug never loses what the lab sent.
  3. Store reports. RDS for PostgreSQL holds one row per report revision with a unique key on lab, accession and revision, and one row per observation with its LOINC code, value, unit, reference range and flag.
  4. Alert on critical values. The FHIR observation interpretation codes define HH as critical high, LL as critical low and AA as critical abnormal. The handler publishes an alert to SNS or SQS for any report with one of those flags, and a worker pages the ordering clinician through your paging or secure messaging provider.
  5. Escalate. An EventBridge Scheduler rule or an ECS scheduled task runs every few minutes, finds critical results without an acknowledgement after the window, and pages the on-call clinician.
  6. Release. A clinician endpoint marks a report as released for the patient portal, and refuses while a critical value on it is unacknowledged.
  7. Access and secrets. The lab's API key lives in Secrets Manager, and the task roles need the bucket, the queue, the secret and a network path to RDS.

The schema is the same in both builds:

CREATE TABLE lab_reports ( id BIGSERIAL PRIMARY KEY, lab TEXT NOT NULL, accession TEXT NOT NULL, revision INT NOT NULL, -- 1, then 2+ for corrected results patient_id TEXT NOT NULL, clinician_id TEXT NOT NULL, raw_key TEXT NOT NULL, -- the message as received, in object storage critical BOOLEAN NOT NULL, received_at TIMESTAMPTZ NOT NULL DEFAULT now(), critical_acked_at TIMESTAMPTZ, critical_acked_by TEXT, escalated_at TIMESTAMPTZ, released_at TIMESTAMPTZ, released_by TEXT, UNIQUE (lab, accession, revision) ); CREATE INDEX lab_reports_unacked ON lab_reports (received_at) WHERE critical AND critical_acked_at IS NULL AND escalated_at IS NULL; CREATE TABLE alert_outbox ( report_id BIGINT PRIMARY KEY REFERENCES lab_reports (id), clinician_id TEXT NOT NULL, codes TEXT NOT NULL, -- comma-separated LOINC codes claimed_at TIMESTAMPTZ, published_at TIMESTAMPTZ ); CREATE INDEX alert_outbox_pending ON alert_outbox (report_id) WHERE published_at IS NULL; CREATE TABLE sent_alerts ( report_id BIGINT NOT NULL, kind TEXT NOT NULL, claimed_at TIMESTAMPTZ, delivered_at TIMESTAMPTZ, PRIMARY KEY (report_id, kind) ); CREATE TABLE observations ( report_id BIGINT NOT NULL REFERENCES lab_reports (id), code TEXT NOT NULL, -- LOINC code display TEXT NOT NULL, value NUMERIC NOT NULL, unit TEXT NOT NULL, ref_low NUMERIC, ref_high NUMERIC, flag TEXT NOT NULL, -- N, L, H, LL, HH, AA PRIMARY KEY (report_id, code) );

The partial index covers only critical results that still need an acknowledgement or an escalation, which keeps the job's query small as the table grows. Observations reference their report revision, so a corrected result never changes the values a clinician already saw on the earlier revision.

How do you build it with Encore?

Encore

Encore is a backend framework with built-in infrastructure for TypeScript and Go, where the database, the versioned bucket, the alerts topic, the cron job and the lab's API key are declared in the service code. When the app deploys to your own AWS account, Encore provisions RDS, S3, SNS and SQS and Secrets Manager with IAM for the service, as the AWS infrastructure docs list. The same code deploys to GCP with Cloud SQL, Cloud Storage, Pub/Sub and Secret Manager.

Application code passes through Encore and infrastructure guardrails to resources provisioned in your own AWS or GCP account.
Encore provisions the infrastructure your application needs in your own AWS or GCP account.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create clinic --example=hello-world$ cd clinic && mkdir -p labs/migrations$ encore secret set --type prod,dev,local LabAPIKey$ encore secret set --type prod,dev,local PagerAPIKey

Put the schema above in labs/migrations/1_labs.up.sql. The lab's API key and the pager API key are not stored in the repository, and each environment gets its own values, so a test lab cannot post into production.

The labs service

// labs/labs.go package labs import ( "bytes" "context" "crypto/subtle" "encoding/json" "errors" "fmt" "net/http" "strings" "time" "encore.dev/beta/auth" "encore.dev/beta/errs" "encore.dev/cron" "encore.dev/pubsub" "encore.dev/storage/objects" "encore.dev/storage/sqldb" ) // Set per environment with `encore secret set`, never stored in the repo. var secrets struct { LabAPIKey string // shared with the lab's integration engine PagerAPIKey string // paging or secure messaging provider } var db = sqldb.NewDatabase("labs", sqldb.DatabaseConfig{Migrations: "./migrations"}) // The messages exactly as the lab sent them, kept for audits and disputes. var rawMessages = objects.NewBucket("lab-messages", objects.BucketConfig{Versioned: true}) type Observation struct { Code string `json:"code"` // LOINC, e.g. 2823-3 for serum potassium Display string `json:"display"` Value float64 `json:"value"` Unit string `json:"unit"` RefLow *float64 `json:"ref_low,omitempty"` RefHigh *float64 `json:"ref_high,omitempty"` Flag string `json:"flag"` // N, L, H, LL, HH, AA } type ResultMessage struct { APIKey string `header:"X-Lab-Api-Key" json:"-"` Lab string `json:"lab"` Accession string `json:"accession"` Revision int `json:"revision"` PatientID string `json:"patient_id"` ClinicianID string `json:"clinician_id"` Observations []Observation `json:"observations"` } type ReceiveResponse struct { ReportID int64 `json:"report_id"` Duplicate bool `json:"duplicate"` } // Alert is published for critical results and for escalations. type Alert struct { Kind string `json:"kind"` // "critical" or "escalation" ReportID int64 `json:"report_id"` ClinicianID string `json:"clinician_id"` Codes []string `json:"codes"` } var Alerts = pubsub.NewTopic[*Alert]("lab-alerts", pubsub.TopicConfig{ DeliveryGuarantee: pubsub.AtLeastOnce, }) func isCritical(flag string) bool { return flag == "HH" || flag == "LL" || flag == "AA" } // Receive accepts a result message from the lab's integration engine. // //encore:api public method=POST path=/labs/results func Receive(ctx context.Context, m *ResultMessage) (*ReceiveResponse, error) { if subtle.ConstantTimeCompare([]byte(m.APIKey), []byte(secrets.LabAPIKey)) != 1 { return nil, &errs.Error{Code: errs.Unauthenticated, Message: "invalid lab api key"} } if m.Lab == "" || m.Accession == "" || m.Revision < 1 || len(m.Observations) == 0 { return nil, &errs.Error{Code: errs.InvalidArgument, Message: "lab, accession, revision and observations are required"} } var criticalCodes []string for _, o := range m.Observations { if isCritical(o.Flag) { criticalCodes = append(criticalCodes, o.Code) } } // Keep the original before anything else touches it. key := fmt.Sprintf("%s/%s/r%d.json", m.Lab, m.Accession, m.Revision) raw, err := json.Marshal(m) if err != nil { return nil, err } w := rawMessages.Upload(ctx, key, objects.WithUploadAttrs(objects.UploadAttrs{ContentType: "application/json"})) if _, err := w.Write(raw); err != nil { w.Abort(err) return nil, err } if err := w.Close(); err != nil { return nil, err } tx, err := db.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback() var id int64 err = tx.QueryRow(ctx, ` INSERT INTO lab_reports (lab, accession, revision, patient_id, clinician_id, raw_key, critical) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (lab, accession, revision) DO NOTHING RETURNING id`, m.Lab, m.Accession, m.Revision, m.PatientID, m.ClinicianID, key, len(criticalCodes) > 0).Scan(&id) if errors.Is(err, sqldb.ErrNoRows) { // The lab resent a message we already have. Publish any alert that // was recorded but not yet delivered, then answer with the stored report. err = db.QueryRow(ctx, `SELECT id FROM lab_reports WHERE lab = $1 AND accession = $2 AND revision = $3`, m.Lab, m.Accession, m.Revision).Scan(&id) if err != nil { return nil, err } if err := publishOutstandingAlert(ctx, id); err != nil { return nil, err } return &ReceiveResponse{ReportID: id, Duplicate: true}, nil } else if err != nil { return nil, err } for _, o := range m.Observations { if _, err := tx.Exec(ctx, ` INSERT INTO observations (report_id, code, display, value, unit, ref_low, ref_high, flag) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, id, o.Code, o.Display, o.Value, o.Unit, o.RefLow, o.RefHigh, o.Flag); err != nil { return nil, err } } if len(criticalCodes) > 0 { if _, err := tx.Exec(ctx, ` INSERT INTO alert_outbox (report_id, clinician_id, codes) VALUES ($1, $2, $3) ON CONFLICT (report_id) DO NOTHING`, id, m.ClinicianID, strings.Join(criticalCodes, ",")); err != nil { return nil, err } } if err := tx.Commit(); err != nil { return nil, err } if err := publishOutstandingAlert(ctx, id); err != nil { return nil, err // outbox row remains; a lab retry or the cron will publish it } return &ReceiveResponse{ReportID: id}, nil } func publishOutstandingAlert(ctx context.Context, reportID int64) error { var clinicianID, codes string err := db.QueryRow(ctx, ` UPDATE alert_outbox SET claimed_at = now() WHERE report_id = $1 AND published_at IS NULL AND (claimed_at IS NULL OR claimed_at < now() - interval '2 minutes') RETURNING clinician_id, codes`, reportID).Scan(&clinicianID, &codes) if errors.Is(err, sqldb.ErrNoRows) { return nil } if err != nil { return err } var list []string if codes != "" { list = strings.Split(codes, ",") } if _, err := Alerts.Publish(ctx, &Alert{Kind: "critical", ReportID: reportID, ClinicianID: clinicianID, Codes: list}); err != nil { return err } _, err = db.Exec(ctx, ` UPDATE alert_outbox SET published_at = now() WHERE report_id = $1 AND published_at IS NULL`, reportID) return err } // AckCritical records that the ordering clinician has seen a critical result. // //encore:api auth method=POST path=/reports/:id/ack func AckCritical(ctx context.Context, id int64) error { uid, _ := auth.UserID() res, err := db.Exec(ctx, ` UPDATE lab_reports SET critical_acked_at = now(), critical_acked_by = $2 WHERE id = $1 AND critical AND clinician_id = $2 AND critical_acked_at IS NULL`, id, string(uid)) if err != nil { return err } if res.RowsAffected() == 0 { return &errs.Error{Code: errs.NotFound, Message: "no unacknowledged critical result for you"} } return nil } // Release makes a reviewed report visible in the patient portal. // //encore:api auth method=POST path=/reports/:id/release func Release(ctx context.Context, id int64) error { uid, _ := auth.UserID() res, err := db.Exec(ctx, ` UPDATE lab_reports SET released_at = now(), released_by = $2 WHERE id = $1 AND clinician_id = $2 AND released_at IS NULL AND (NOT critical OR critical_acked_at IS NOT NULL)`, id, string(uid)) if err != nil { return err } if res.RowsAffected() == 0 { return &errs.Error{Code: errs.FailedPrecondition, Message: "report not found, already released or critical result not acknowledged"} } return nil } const ackWindow = 30 * time.Minute // EscalateUnacked publishes an escalation for critical results nobody // acknowledged within the window, once per report. // //encore:api private func EscalateUnacked(ctx context.Context) error { pending, err := db.Query(ctx, `SELECT report_id FROM alert_outbox WHERE published_at IS NULL`) if err != nil { return err } defer pending.Close() for pending.Next() { var id int64 if err := pending.Scan(&id); err != nil { return err } if err := publishOutstandingAlert(ctx, id); err != nil { return err } } if err := pending.Err(); err != nil { return err } rows, err := db.Query(ctx, ` UPDATE lab_reports SET escalated_at = now() WHERE critical AND critical_acked_at IS NULL AND escalated_at IS NULL AND received_at < now() - make_interval(mins => $1::int) RETURNING id, clinician_id`, int(ackWindow.Minutes())) if err != nil { return err } defer rows.Close() var alerts []*Alert for rows.Next() { a := &Alert{Kind: "escalation"} if err := rows.Scan(&a.ReportID, &a.ClinicianID); err != nil { return err } alerts = append(alerts, a) } if err := rows.Err(); err != nil { return err } for _, a := range alerts { if _, err := Alerts.Publish(ctx, a); err != nil { return err } } return nil } var _ = cron.NewJob("escalate-critical-results", cron.JobConfig{ Title: "Escalate unacknowledged critical lab results", Every: 5 * cron.Minute, Endpoint: EscalateUnacked, }) var pager = &http.Client{Timeout: 5 * time.Second} func pageClinician(ctx context.Context, a *Alert) error { body, err := json.Marshal(map[string]string{ "kind": a.Kind, "report_id": fmt.Sprintf("%d", a.ReportID), "clinician": a.ClinicianID, }) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://pager.example.com/v1/page", bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+secrets.PagerAPIKey) req.Header.Set("Idempotency-Key", fmt.Sprintf("alert-%d-%s", a.ReportID, a.Kind)) req.Header.Set("Content-Type", "application/json") resp, err := pager.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 300 { return fmt.Errorf("pager returned %d", resp.StatusCode) } return nil } var _ = pubsub.NewSubscription(Alerts, "page-clinician", pubsub.SubscriptionConfig[*Alert]{ Handler: func(ctx context.Context, a *Alert) error { if _, err := db.Exec(ctx, ` INSERT INTO sent_alerts (report_id, kind) VALUES ($1, $2) ON CONFLICT DO NOTHING`, a.ReportID, a.Kind); err != nil { return err } var claimed int64 err := db.QueryRow(ctx, ` UPDATE sent_alerts SET claimed_at = now() WHERE report_id = $1 AND kind = $2 AND delivered_at IS NULL AND (claimed_at IS NULL OR claimed_at < now() - interval '2 minutes') RETURNING report_id`, a.ReportID, a.Kind).Scan(&claimed) if errors.Is(err, sqldb.ErrNoRows) { var deliveredAt *time.Time if err := db.QueryRow(ctx, ` SELECT delivered_at FROM sent_alerts WHERE report_id = $1 AND kind = $2`, a.ReportID, a.Kind).Scan(&deliveredAt); err != nil { return err } if deliveredAt != nil { return nil } // The provider idempotency key makes retrying this alert safe. } else if err != nil { return err } // For an escalation, page the on-call clinician as well as the ordering one. if err := pageClinician(ctx, a); err != nil { return err } _, err = db.Exec(ctx, ` UPDATE sent_alerts SET delivered_at = now() WHERE report_id = $1 AND kind = $2 AND delivered_at IS NULL`, a.ReportID, a.Kind) return err }, })

Critical alerts are written to alert_outbox in the same transaction as the report. publishOutstandingAlert claims a pending row with an atomic UPDATE ... RETURNING and a two-minute lease, then marks it published only after Alerts.Publish succeeds, so a lab retry and the cron job cannot both publish the same alert. The page-clinician subscriber records a sent_alerts row, claims it with an atomic UPDATE ... RETURNING and a two-minute lease, pages with Idempotency-Key: alert-{reportID}-{kind} on the provider request, and sets delivered_at only after the provider acknowledges the page. When a redelivery arrives while a lease is still held, the handler pages again with the same key rather than acknowledging the message, so a crash after the claim cannot drop the alert, and a completed row is left untouched. A lab retry of a stored report still publishes a pending row, and EscalateUnacked flushes any leftover pending alerts before it looks for unacknowledged results. AckCritical and Release use the auth access level, so they need an auth handler that maps your clinician sessions to user IDs, and both only touch reports where the caller is the ordering clinician. The X-Lab-Api-Key header is parsed into the request struct and left out of the stored JSON with json:"-". The UPDATE ... RETURNING in EscalateUnacked marks and returns each report in one statement, so a report is escalated once even when two runs overlap.

The database docs cover migrations and transactions, the object storage docs cover versioned buckets, and the cron job docs cover schedules. Cron jobs do not run locally or in preview environments, so during development you call EscalateUnacked from the local development dashboard. Once deployed, the Encore platform calls it every five minutes.

Deploy to your AWS account

Connect your AWS account as described in Connect your cloud account, create an environment and run git push encore. When a coding agent later adds a feature, such as notifying the clinician about corrections to released reports, the change is a new alert kind and a query in this service, and it reaches production through the same review and deploy as any other pull request.

Things to decide early

  • Critical limits and windows. The lab sets the flags against its critical limits, and the acknowledgement window and escalation path come from your clinical leadership. Keep both out of code defaults once they are agreed.
  • Corrections after release. A new revision of a released report should notify the clinician and mark the portal entry as corrected instead of replacing it silently.
  • Release rules. When results become visible to patients, and which tests need a clinician conversation first, depends on your jurisdiction and your clinical policy.
  • Patient matching. The lab's patient identifier has to map to yours before anything is stored, and messages that do not match belong in a review queue, not in a guess.

Frequently asked questions

What does a lab results backend do?

It receives result messages from a lab or its integration engine, stores each report and its observations, routes critical values to the ordering clinician right away, and controls when a report becomes visible to the patient. It also keeps the original messages, so the record can be checked against what the lab sent.

How do you detect critical lab values in code?

Use the interpretation flag the lab sends with each observation. In the HL7 FHIR observation interpretation codes, HH means critical high, LL critical low and AA critical abnormal, and the lab sets them against its own critical limits, so the backend routes those flags instead of recomputing thresholds.

How do you handle corrected lab results?

Store every revision of a report as its own row keyed by lab, accession number and revision, and never overwrite the earlier one. Show the latest revision, keep the history, and notify the clinician when a correction arrives for a report that was already released.

What happens if nobody acknowledges a critical result?

A scheduled job looks for critical results without an acknowledgement after a set window, marks them as escalated in the same UPDATE so each one escalates once, and pages the on-call clinician as well as the ordering one. The window and escalation path come from your clinical leadership, not from code defaults.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders