Skip to main content

Patient Surveillance with Standardized Questionnaires

This guide explains how to build FHIR-native questionnaire reminders and patient surveillance workflows with Fire Arrow Server. It is useful for remote patient monitoring, ePRO collection, mental health follow-up, chronic disease programs, and post-surgical outcome tracking where recurring forms should become visible, authorized FHIR work items.

Want the product overview before implementing? Read the FHIR Patient Questionnaires solution page.

When to use this guide

Use this guide if you need to:

  • schedule standardized questionnaires such as PHQ-9 on a recurring cadence
  • materialize due questionnaire activities into FHIR Task resources
  • trigger notifications or alerts when patient responses are submitted
  • store QuestionnaireResponses as interoperable clinical data
  • let patients and clinicians see only the questionnaire data they are allowed to access

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 PUT http://localhost:8080/fhir/PlanDefinition/phq9-plan \
-H "Content-Type: application/fhir+json" \
-H "Authorization: Bearer <practitioner-token>" \
-d '{
"resourceType": "PlanDefinition",
"id": "phq9-plan",
"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: Activate scheduling and subscribe to notifications

The generated CarePlan already has the timezone extension (from the _timezone parameter passed to $apply). The next step is to activate the scheduling engine and set up webhook notifications. The $subscribe-due-events operation handles both in a single call — it automatically adds the scheduling opt-in tag to the CarePlan and creates the webhook subscription.

Via the API:

# Use the CarePlan ID returned by the $apply operation in Step 4
curl -X POST http://localhost:8080/fhir/CarePlan/<careplan-id>/\$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" }
]
}'

This single call does three things:

  1. Adds the scheduling tag to the CarePlan (https://firearrow.io/fhir/careplan-scheduling / scheduled), which opts it into the materialization engine.
  2. Creates a FHIR Subscription that watches for Task status transitions on this CarePlan and sends webhook notifications to your endpoint.
  3. Triggers initial Task materialization — the server immediately generates Task resources for upcoming questionnaire administrations within the configured time horizon.

In the Web UI: Open the CarePlan in the Care Plan editor, click Materialize, enter your webhook URL, and confirm. This performs the same operation.

Timezone is required for automatic transitions

The $apply call in Step 4 set the timezone on the CarePlan via the _timezone parameter. This is essential — without a timezone, Tasks are still created but will not automatically transition from requested to ready, which means your webhook notifications will not fire at the correct time.

If you skipped the _timezone parameter in $apply, you can still add the timezone extension to the CarePlan or to the Patient resource directly. See Medication Plan Reminders > Why timezone is critical for a detailed explanation and examples.

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-id>" }],
"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.
careplan-events.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).
hapi.fhir.cr.enabledtrueRequired for the $apply operation.

Further reading

FAQ

Can Fire Arrow schedule recurring patient questionnaires?

Yes. Fire Arrow Server can materialize scheduled CarePlan activities into Task resources, which patient-facing applications can use to show due questionnaires.

Which FHIR resources are involved?

Typical questionnaire workflows use Questionnaire for the form definition, PlanDefinition for the reusable protocol, CarePlan for the patient-specific schedule, Task for each due activity, and QuestionnaireResponse for submitted answers.

Can clinicians receive alerts from questionnaire scores?

Yes. Subscriptions or webhook handlers can react when QuestionnaireResponses are submitted and evaluate scores such as PHQ-9 thresholds.

Is access control applied to QuestionnaireResponses?

Yes. QuestionnaireResponses contain PHI and should be protected by the same authorization rules as other patient-specific clinical resources.

Can this pattern support forms other than PHQ-9?

Yes. The same pattern can support heart failure questionnaires, post-surgical follow-up forms, custom registry intake, and other standardized or local instruments.