Skip to main content

Patient Surveillance with Standardized Questionnaires

Use case

A mental health clinic treats patients with depression and needs to monitor their progress systematically. Clinical guidelines recommend administering the PHQ-9 (Patient Health Questionnaire-9) every two weeks to track symptom severity. The clinic wants to:

  • Use the standardized LOINC version of the PHQ-9 so that scores are comparable across systems and clinicians.
  • Automatically schedule the questionnaire for every patient on a recurring basis - no manual reminders.
  • Receive alerts when a patient's score indicates worsening symptoms so the care team can intervene.
  • Review trends in the patient dashboard to see how each patient is progressing over time.

Other scenarios this recipe applies to

  • Cardiology heart failure monitoring: A cardiology practice administers the Kansas City Cardiomyopathy Questionnaire (KCCQ-12) every month. Deteriorating scores trigger a follow-up appointment with the cardiologist.
  • Post-surgical outcome tracking: A surgical program tracks recovery milestones using a custom outcome questionnaire at 2 weeks, 6 weeks, and 12 weeks after the operation.

Why PlanDefinition is the right tool here

Unlike medication plans (which are tailored to each patient's specific drugs and doses), a surveillance program applies the same questionnaire on the same schedule to every patient meeting certain criteria. Every depression patient at the clinic gets the PHQ-9 every two weeks - the protocol is standardized.

This is exactly what PlanDefinition is designed for: a reusable template that can be applied to many patients. You define the questionnaire schedule once, and $apply generates a patient-specific CarePlan automatically.

The benefits over creating CarePlans manually:

  • Consistency: Every patient gets exactly the same schedule. No risk of a practitioner forgetting to set up monitoring for a new patient.
  • Efficiency: Applying a PlanDefinition takes one click in the Web UI or one API call. No need to manually configure timing for each patient.
  • Maintainability: If the clinical guideline changes (e.g., from biweekly to monthly), you update the PlanDefinition and apply the new version to future patients. Existing CarePlans continue with their original schedule.

What you will build

Prerequisites

Choosing the right FHIR resources

Questionnaire: the form definition

A Questionnaire defines the structure of the form: its questions, answer options, data types, and scoring rules. For standardized instruments like the PHQ-9, you can import the official LOINC version directly from Fire Arrow Server's LOINC integration - this ensures that codes, scoring, and answer options match the published standard.

You can also create custom questionnaires from scratch using the built-in Questionnaire Builder if no standardized version exists for your use case.

PlanDefinition: the surveillance protocol

A PlanDefinition of type "Clinical Protocol" encodes the surveillance schedule. It contains one action that references the Questionnaire and defines the repeating timing (e.g., every 2 weeks for 6 months). When applied to a patient, it generates a patient-specific CarePlan.

CarePlan: the patient-specific schedule

A CarePlan is generated per patient via the $apply operation. It represents "Patient X should complete the PHQ-9 every 2 weeks for the next 6 months." The scheduling meta tag opts it into automatic Task materialization.

Task: a single questionnaire administration

Task resources are generated automatically by Fire Arrow Server's materialization engine. Each Task represents one scheduled questionnaire administration - "Patient X should complete the PHQ-9 on April 18, 2026." Tasks transition from requested to ready when due, triggering a webhook notification.

QuestionnaireResponse: the completed form

A QuestionnaireResponse is created when the patient completes the questionnaire. It contains the patient's answers and (if the Questionnaire defines scoring) the calculated score. The Patient Dashboard displays these on the clinical timeline for trend analysis.

Securing access

A surveillance program involves several resource types with different access requirements:

  1. Questionnaires and PlanDefinitions are organizational resources. They should be readable by all practitioners in the organization and (optionally) by patients who need to see which questionnaires they are being asked to complete.
  2. CarePlans and Tasks contain information about a specific patient's scheduled activities. Patients should see their own; practitioners should see those for patients in their organization.
  3. QuestionnaireResponses contain the patient's answers - this is PHI. Patients should see only their own responses. Practitioners should see responses for patients in their organization.
  4. The $apply operation creates resources on behalf of a patient, so it should be restricted to practitioners.

Complete authorization rules

fire-arrow:
authorization:
default-validator: Forbidden
validation-rules:
# --- Patient rules ---

- client-role: Patient
resource: Patient
operation: read
validator: PatientCompartment
- client-role: Patient
resource: Patient
operation: me
validator: PatientCompartment

# Patients can see their Questionnaire forms
- client-role: Patient
resource: Questionnaire
operation: read
validator: LegitimateInterest
- client-role: Patient
resource: Questionnaire
operation: search
validator: LegitimateInterest

# Patients can see their own CarePlan and Tasks
- client-role: Patient
resource: CarePlan
operation: read
validator: PatientCompartment
- client-role: Patient
resource: CarePlan
operation: search
validator: PatientCompartment
- client-role: Patient
resource: Task
operation: read
validator: PatientCompartment
- client-role: Patient
resource: Task
operation: search
validator: PatientCompartment
- client-role: Patient
resource: Task
operation: update
validator: PatientCompartment

# Patients submit and view their QuestionnaireResponses
- client-role: Patient
resource: QuestionnaireResponse
operation: create
validator: PatientCompartment
- client-role: Patient
resource: QuestionnaireResponse
operation: read
validator: PatientCompartment
- client-role: Patient
resource: QuestionnaireResponse
operation: search
validator: PatientCompartment

# --- Practitioner rules ---

- client-role: Practitioner
resource: Practitioner
operation: me
validator: Allowed

- client-role: Practitioner
resource: Patient
operation: read
validator: LegitimateInterest
- client-role: Practitioner
resource: Patient
operation: search
validator: LegitimateInterest

# Practitioners manage Questionnaires
- client-role: Practitioner
resource: Questionnaire
operation: read
validator: LegitimateInterest
- client-role: Practitioner
resource: Questionnaire
operation: search
validator: LegitimateInterest
- client-role: Practitioner
resource: Questionnaire
operation: create
validator: LegitimateInterest
- client-role: Practitioner
resource: Questionnaire
operation: update
validator: LegitimateInterest

# Practitioners manage PlanDefinitions
- client-role: Practitioner
resource: PlanDefinition
operation: read
validator: LegitimateInterest
- client-role: Practitioner
resource: PlanDefinition
operation: search
validator: LegitimateInterest
- client-role: Practitioner
resource: PlanDefinition
operation: create
validator: LegitimateInterest
- client-role: Practitioner
resource: PlanDefinition
operation: update
validator: LegitimateInterest

# Practitioners manage CarePlans and monitor Tasks
- client-role: Practitioner
resource: CarePlan
operation: read
validator: LegitimateInterest
- client-role: Practitioner
resource: CarePlan
operation: search
validator: LegitimateInterest
- client-role: Practitioner
resource: CarePlan
operation: create
validator: LegitimateInterest
- client-role: Practitioner
resource: CarePlan
operation: update
validator: LegitimateInterest

- client-role: Practitioner
resource: Task
operation: read
validator: LegitimateInterest
- client-role: Practitioner
resource: Task
operation: search
validator: LegitimateInterest

# Practitioners review QuestionnaireResponses
- client-role: Practitioner
resource: QuestionnaireResponse
operation: read
validator: LegitimateInterest
- client-role: Practitioner
resource: QuestionnaireResponse
operation: search
validator: LegitimateInterest

# Practitioners subscribe to notifications
- client-role: Practitioner
resource: Subscription
operation: subscribe
validator: LegitimateInterest

Step-by-step instructions

Step 1: Import the PHQ-9 from LOINC

Fire Arrow Server's Questionnaire Builder can import standardized questionnaires directly from the LOINC registry.

In the Web UI:

  1. Open Tools > Questionnaires in the sidebar. See Questionnaires for a full guide.
  2. Click Import from LOINC.
  3. In the search field, type "PHQ-9" and press Enter.
  4. The search returns results from the LOINC Clinical Tables Search Service. Select "PHQ-9 quick depression assessment panel" (LOINC code 44249-1).
  5. Click Import. The Questionnaire Builder opens with the imported structure: 9 scored items plus a functional impairment question, with answer options mapped to LOINC answer lists.

The imported questionnaire is created in draft status. Review the items before publishing.

Alternative: Create a custom questionnaire from scratch

If your instrument is not in LOINC (e.g., a clinic-specific recovery questionnaire), click + New Questionnaire instead of importing. Use the builder to add items:

  • Set the type for each item (choice for scored items, integer for numeric answers, text for free-text).
  • For choice items, define answer options with display text and codes.
  • Group related items using group type items.
  • Use the Preview tab to verify the form looks correct.

Step 2: Review and publish the questionnaire

Before the questionnaire can be used in a PlanDefinition, it needs a canonical URL and an active status.

  1. In the Questionnaire Builder, open the Metadata panel.
  2. Set the Title to "Patient Health Questionnaire (PHQ-9)".
  3. Set the URL (canonical URL) to something globally unique, e.g., http://loinc.org/q/44249-1 for the standard PHQ-9 or http://your-clinic.example.org/questionnaire/phq-9 for your customized version.
  4. Set Status to Active.
  5. Click Save.

Via the API (if you prefer to update programmatically):

curl -X PUT http://localhost:8080/fhir/Questionnaire/phq-9 \
-H "Content-Type: application/fhir+json" \
-H "Authorization: Bearer <practitioner-token>" \
-d '{
"resourceType": "Questionnaire",
"id": "phq-9",
"url": "http://your-clinic.example.org/questionnaire/phq-9",
"title": "Patient Health Questionnaire (PHQ-9)",
"status": "active",
"item": [
{
"linkId": "q1",
"text": "Little interest or pleasure in doing things",
"type": "choice",
"required": true,
"answerOption": [
{ "valueCoding": { "code": "0", "display": "Not at all" } },
{ "valueCoding": { "code": "1", "display": "Several days" } },
{ "valueCoding": { "code": "2", "display": "More than half the days" } },
{ "valueCoding": { "code": "3", "display": "Nearly every day" } }
]
}
]
}'

(The full PHQ-9 has 9 items - the above is abbreviated for clarity. The LOINC import creates all 9 items automatically.)

Step 3: Create a PlanDefinition for biweekly surveillance

Build a PlanDefinition that references the Questionnaire with a repeating schedule.

In the Web UI:

  1. Open Tools > Plan Definitions and click + New Plan Definition. See Plan Definitions for the full editor guide.
  2. Set Title to "Depression Monitoring - PHQ-9 Biweekly".
  3. Set Type to Clinical Protocol.
  4. Set Status to Active.
  5. Set the Canonical URL (e.g., http://your-clinic.example.org/plan/phq9-biweekly).
  6. Click + Add Action.
  7. In the action detail panel:
    • Set Title to "Complete PHQ-9".
    • Under Definition, search for the PHQ-9 Questionnaire and link it.
    • Under Timing, select Repeating Schedule:
      • Frequency: 1
      • Period: 2
      • Period Unit: wk (weeks)
      • Bounds Duration: 6 months (optional - limits the schedule to 6 months)
  8. Save the PlanDefinition.

Via the API:

curl -X POST http://localhost:8080/fhir/PlanDefinition \
-H "Content-Type: application/fhir+json" \
-H "Authorization: Bearer <practitioner-token>" \
-d '{
"resourceType": "PlanDefinition",
"url": "http://your-clinic.example.org/plan/phq9-biweekly",
"title": "Depression Monitoring - PHQ-9 Biweekly",
"status": "active",
"type": {
"coding": [{
"system": "http://terminology.hl7.org/CodeSystem/plan-definition-type",
"code": "clinical-protocol"
}]
},
"description": "Administer the PHQ-9 every two weeks for six months to monitor depression symptoms.",
"action": [{
"id": "phq9-biweekly",
"title": "Complete PHQ-9",
"description": "Patient completes the PHQ-9 depression screening questionnaire.",
"definitionCanonical": "http://your-clinic.example.org/questionnaire/phq-9",
"timingTiming": {
"repeat": {
"frequency": 1,
"period": 2,
"periodUnit": "wk",
"boundsDuration": {
"value": 6,
"unit": "mo",
"system": "http://unitsofmeasure.org",
"code": "mo"
}
}
},
"participant": [{
"type": "patient"
}]
}]
}'

Step 4: Apply the PlanDefinition to a patient

Generate a patient-specific CarePlan from the PlanDefinition.

In the Web UI: Open the PlanDefinition, click the Apply icon (play button), select the patient, and confirm. See Plan Definitions > Apply to Patient.

Via the API:

curl -X POST "http://localhost:8080/fhir/PlanDefinition/phq9-plan/\$apply" \
-H "Content-Type: application/fhir+json" \
-H "Authorization: Bearer <practitioner-token>" \
-d '{
"resourceType": "Parameters",
"parameter": [
{ "name": "subject", "valueString": "Patient/patient-123" },
{ "name": "_persist", "valueBoolean": true },
{ "name": "_startDate", "valueDate": "2026-04-04" },
{ "name": "_timezone", "valueString": "Europe/Zurich" }
]
}'
ParameterWhat it does
subjectThe patient to create the CarePlan for.
_persistWhen true, the server saves the generated CarePlan (and any related resources) to the database. Without this, $apply returns the CarePlan without persisting it.
_startDateThe start date for the schedule. Tasks are timed relative to this date.
_timezoneThe patient's timezone. Ensures that "every 2 weeks" is calculated in the patient's local time, not UTC.

The response is the generated CarePlan with activities derived from the PlanDefinition's actions and timing adjusted to the start date. See Custom Operations for the full $apply reference.

Step 5: Enable scheduling on the CarePlan

The generated CarePlan needs the scheduling meta tag and a subscription for notifications.

If the CarePlan was generated via $apply, you may need to add the scheduling tag:

# Add the scheduling tag (if not already present)
# Fetch the CarePlan, add the tag, and PUT it back

Then subscribe to due events:

curl -X POST http://localhost:8080/fhir/CarePlan/careplan-patient-123/\$subscribe-due-events \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <practitioner-token>" \
-d '{
"resourceType": "Parameters",
"parameter": [
{ "name": "endpoint", "valueUrl": "https://your-app.example.com/webhooks/questionnaire-reminders" }
]
}'

Or use the Web UI: open the CarePlan in the Care Plan editor, click Materialize, enter the webhook URL, and confirm.

Fire Arrow Server now materializes Task resources for each biweekly questionnaire occasion and sends a webhook notification when each task becomes due.

Step 6: Scale to all patients

For a clinic with dozens or hundreds of patients, applying the PlanDefinition manually for each patient is impractical. Here is a Python script that automates the process:

import requests

BASE_URL = "http://localhost:8080/fhir"
HEADERS = {
"Authorization": "Bearer <practitioner-token>",
"Content-Type": "application/fhir+json"
}
PLAN_DEFINITION_ID = "phq9-plan"
WEBHOOK_URL = "https://your-app.example.com/webhooks/questionnaire-reminders"

def get_all_patients():
"""Fetch all active patients in the organization."""
patients = []
url = f"{BASE_URL}/Patient?active=true&_count=100"
while url:
response = requests.get(url, headers=HEADERS)
bundle = response.json()
for entry in bundle.get("entry", []):
patients.append(entry["resource"])
# Follow pagination links
next_link = next(
(link["url"] for link in bundle.get("link", []) if link["relation"] == "next"),
None
)
url = next_link
return patients

def patient_already_enrolled(patient_id):
"""Check if the patient already has an active CarePlan from this PlanDefinition."""
response = requests.get(
f"{BASE_URL}/CarePlan",
params={
"subject": f"Patient/{patient_id}",
"status": "active",
"instantiates-canonical": f"http://your-clinic.example.org/plan/phq9-biweekly"
},
headers=HEADERS
)
bundle = response.json()
return bundle.get("total", 0) > 0

def apply_plan(patient_id):
"""Apply the PlanDefinition to create a CarePlan for the patient."""
response = requests.post(
f"{BASE_URL}/PlanDefinition/{PLAN_DEFINITION_ID}/$apply",
json={
"resourceType": "Parameters",
"parameter": [
{"name": "subject", "valueString": f"Patient/{patient_id}"},
{"name": "_persist", "valueBoolean": True},
{"name": "_startDate", "valueDate": "2026-04-04"},
{"name": "_timezone", "valueString": "Europe/Zurich"}
]
},
headers=HEADERS
)
return response.json()

def subscribe_careplan(careplan_id):
"""Subscribe the CarePlan to due event notifications."""
requests.post(
f"{BASE_URL}/CarePlan/{careplan_id}/$subscribe-due-events",
json={
"resourceType": "Parameters",
"parameter": [
{"name": "endpoint", "valueUrl": WEBHOOK_URL}
]
},
headers=HEADERS
)

# Main workflow
patients = get_all_patients()
print(f"Found {len(patients)} active patients")

for patient in patients:
patient_id = patient["id"]
if patient_already_enrolled(patient_id):
print(f" Skipping {patient_id} (already enrolled)")
continue

print(f" Enrolling {patient_id}...")
careplan = apply_plan(patient_id)
careplan_id = careplan.get("id")
if careplan_id:
subscribe_careplan(careplan_id)
print(f" Created CarePlan/{careplan_id} with subscription")
else:
print(f" Warning: $apply did not return a CarePlan ID")

print("Done.")

For new patient onboarding: Integrate the $apply call into your patient registration workflow. When a new patient is created and matches the surveillance criteria (e.g., has a depression diagnosis), automatically apply the PlanDefinition and subscribe to events.

Step 7: Patient completes the questionnaire

When a Task becomes due, the patient's app receives a webhook notification, fetches the Task, and presents the questionnaire form.

After the patient completes the form, the app submits a QuestionnaireResponse:

curl -X POST http://localhost:8080/fhir/QuestionnaireResponse \
-H "Content-Type: application/fhir+json" \
-H "Authorization: Bearer <patient-token>" \
-d '{
"resourceType": "QuestionnaireResponse",
"questionnaire": "http://your-clinic.example.org/questionnaire/phq-9",
"status": "completed",
"subject": {
"reference": "Patient/patient-123"
},
"authored": "2026-04-18T09:30:00+02:00",
"item": [
{
"linkId": "q1",
"text": "Little interest or pleasure in doing things",
"answer": [{
"valueCoding": { "code": "1", "display": "Several days" }
}]
},
{
"linkId": "q2",
"text": "Feeling down, depressed, or hopeless",
"answer": [{
"valueCoding": { "code": "2", "display": "More than half the days" }
}]
}
]
}'

Then mark the Task as completed:

curl -X PUT http://localhost:8080/fhir/Task/task-phq9-001 \
-H "Content-Type: application/fhir+json" \
-H "Authorization: Bearer <patient-token>" \
-d '{
"resourceType": "Task",
"id": "task-phq9-001",
"status": "completed",
"basedOn": [{ "reference": "CarePlan/careplan-patient-123" }],
"for": { "reference": "Patient/patient-123" }
}'

In the Web UI: Open Patients, select the patient, and click Clinical Data. The clinical timeline displays QuestionnaireResponse resources alongside other clinical events. Each response shows the completion date and (if the questionnaire defines scoring) the calculated score. Over time, the practitioner can see a trend line of PHQ-9 scores.

Via the API:

# Get all PHQ-9 responses for a patient, most recent first
curl "http://localhost:8080/fhir/QuestionnaireResponse?patient=Patient/patient-123&questionnaire=http://your-clinic.example.org/questionnaire/phq-9&_sort=-authored" \
-H "Authorization: Bearer <practitioner-token>"

Step 9: Set up alerts for critical scores

To get notified when any patient submits a QuestionnaireResponse, create a FHIR Subscription:

curl -X POST http://localhost:8080/fhir/Subscription \
-H "Content-Type: application/fhir+json" \
-H "Authorization: Bearer <practitioner-token>" \
-d '{
"resourceType": "Subscription",
"status": "requested",
"criteria": "QuestionnaireResponse?questionnaire=http://your-clinic.example.org/questionnaire/phq-9",
"channel": {
"type": "rest-hook",
"endpoint": "https://your-app.example.com/webhooks/phq9-scores",
"payload": "application/fhir+json"
},
"end": "2026-10-04T00:00:00Z"
}'

Your webhook handler can fetch the response, evaluate the total score, and escalate if it exceeds a threshold:

from flask import Flask, request
import requests

app = Flask(__name__)

FIRE_ARROW_BASE = "http://localhost:8080/fhir"
SERVICE_TOKEN = "Bearer <service-account-token>"
CRITICAL_SCORE_THRESHOLD = 20 # PHQ-9 score >= 20 = severe depression

@app.route("/webhooks/phq9-scores", methods=["POST"])
def handle_phq9_score():
notification = request.get_json()
qr_id = notification.get("id")

# Fetch the full QuestionnaireResponse
qr = requests.get(
f"{FIRE_ARROW_BASE}/QuestionnaireResponse/{qr_id}",
headers={"Authorization": SERVICE_TOKEN}
).json()

# Calculate the total PHQ-9 score
total_score = 0
for item in qr.get("item", []):
for answer in item.get("answer", []):
coding = answer.get("valueCoding", {})
try:
total_score += int(coding.get("code", "0"))
except ValueError:
pass

patient_ref = qr["subject"]["reference"]

if total_score >= CRITICAL_SCORE_THRESHOLD:
# Alert the care team
send_alert(
patient_ref=patient_ref,
score=total_score,
message=f"CRITICAL: PHQ-9 score is {total_score} (severe depression). Immediate review recommended."
)
elif total_score >= 15:
# Moderate-severe: flag for review
send_alert(
patient_ref=patient_ref,
score=total_score,
message=f"PHQ-9 score is {total_score} (moderately severe). Consider medication adjustment."
)

return "", 200

def send_alert(patient_ref, score, message):
# Replace with your alerting system (email, Slack, in-app notification)
print(f"Alert for {patient_ref}: {message}")

Configuration recommendations

SettingRecommended valueNotes
careplan-events.enabledtrueRequired for automatic Task materialization.
scheduling.horizon-durationP30DOne month ahead. Biweekly cadence = ~2 tasks per month, so 30 days creates a manageable number.
scheduling.max-occurrences-per-activity156 months of biweekly = ~13 occurrences. Buffer to 15.
subscriptions.max-ttlP180DMatch the surveillance period (6 months).
task.auto-transition-to-readytrueRequired for webhook notifications to fire.
hapi.fhir.cr.enabledtrueRequired for the $apply operation.

Further reading