Intake Questionnaires: Design and Extraction
An intake questionnaire is different from a typical clinical form (like a PHQ-9 or wound assessment) because a single response fans out into many different FHIR resource types — Patient demographics, allergies, medications, conditions, insurance, consents, and more. This means the Questionnaire structure needs to be designed with extraction in mind: the way you organize items and name linkIds directly determines how reliably the response can be transformed into structured data.
This page covers:
- Questionnaire structure patterns for intake forms
- linkId conventions and the design contract they create
- Processing responses with SDC extraction vs. Bot-based extraction
- How to choose between the two approaches
- Modeling Consent resources captured during intake
Designing Intake Questionnaires
Structure: Groups and Repeating Items
Intake forms collect multiple entries for several categories — a patient may have three allergies, two medications, and one insurance plan. In FHIR Questionnaires, use group items with repeats: true to model these multi-entry sections.
{
"linkId": "allergies",
"text": "Allergies",
"type": "group",
"repeats": true,
"item": [
{
"linkId": "allergy-substance",
"text": "Substance",
"type": "choice"
},
{
"linkId": "allergy-reaction",
"text": "Reaction",
"type": "string"
},
{
"linkId": "allergy-onset",
"text": "Onset Date",
"type": "dateTime"
}
]
}
When a patient reports multiple allergies, the QuestionnaireResponse contains multiple instances of the allergies group — one per allergy — each with its own set of child answers.
repeats: true on GroupsThis is a common point of confusion. repeats: true on a group item means the entire group can appear multiple times in the response, not that individual items within the group repeat. Each repetition of the group represents one complete entry (one allergy, one medication, etc.).
Without repeats: true, the form can only capture a single entry per section, which rarely matches real-world intake needs.
linkId Conventions
The linkId is the key that connects a Questionnaire item to its answer in the QuestionnaireResponse, and to the extraction logic that transforms that answer into a FHIR resource field. Adopting consistent conventions makes extraction logic predictable and reduces bugs.
Pattern: Resource-type prefix + FHIR field hint
The Patient Intake Demo uses linkIds that hint at both the target resource and the target field:
| linkId | Target Resource | Target Field |
|---|---|---|
allergy-substance | AllergyIntolerance | code |
allergy-reaction | AllergyIntolerance | reaction.manifestation |
allergy-onset | AllergyIntolerance | onsetDateTime |
medication-code | MedicationRequest | medicationCodeableConcept |
medication-note | MedicationRequest | note |
medical-history-problem | Condition | code |
medical-history-clinical-status | Condition | clinicalStatus |
immunization-vaccine | Immunization | vaccineCode |
immunization-date | Immunization | occurrenceDateTime |
family-member-history-problem | FamilyMemberHistory | condition.code |
family-member-history-relationship | FamilyMemberHistory | relationship |
Why this matters: When your extraction logic (whether SDC or Bot) looks up answers['allergy-substance'], the linkId immediately tells you what resource and field it maps to. When conventions break down — for example, using generic names like field1 or question3 — the extraction logic becomes fragile and hard to maintain.
Group linkIds as resource-type identifiers: The group-level linkId (allergies, medications, medical-history, vaccination-history, coverage-information) acts as the key for extracting all entries of that type. These map directly to the loop structure in the processing logic:
const allergies = getGroupRepeatedAnswers(questionnaire, response, 'allergies');
for (const allergy of allergies) {
await addAllergy(medplum, patient, allergy);
}
For general Questionnaire mechanics (item types, enableWhen, nested items), see Questionnaires & Assessments.
Processing Responses
Once a patient submits their intake form, the QuestionnaireResponse needs to be transformed into FHIR resources. There are two approaches, and the right choice depends on where you want your extraction logic to live and how much flexibility you need:
- SDC Extensions +
$extract— Less flexible, but all structured data capture business logic lives in the Questionnaire itself. Changes to extraction rules mean updating the Questionnaire, not deploying code. - Subscription to QuestionnaireResponse + Bot — More flexible, but requires Bot code changes whenever the Questionnaire is updated. Gives you full programmatic control for complex business logic.
Approach 1: SDC Extraction ($extract)
Structured Data Capture (SDC) lets you annotate the Questionnaire itself with extraction rules. When a response is submitted, the $extract operation reads those annotations and returns a transaction Bundle containing the extracted resources — no custom code required.
How it works:
- Add template resources to
Questionnaire.contained(e.g., a Patient template, an AllergyIntolerance template) - Annotate the Questionnaire with
templateExtract,templateExtractValue, andtemplateExtractContextextensions that define FHIRPath rules for populating each template field - Call
$extracton the QuestionnaireResponse to get a transaction Bundle - Submit the Bundle to create/update the resources
When to use SDC extraction:
- The questionnaire maps cleanly to FHIR resources (each answer → one field on one resource)
- You don't need conditional logic during extraction (e.g., "only create this resource if another field has a specific value")
- You want the extraction rules to live with the Questionnaire rather than in separate code
For full details on SDC annotation syntax, template resources, and FHIRPath rules, see Structured Data Capture.
Approach 2: Bot-Based Extraction
A Medplum Bot triggered by a Subscription on QuestionnaireResponse creation gives you full programmatic control over the extraction process.
How it works:
- Create a Bot that receives a
QuestionnaireResponseas input - Create a Subscription on
QuestionnaireResponsewith criteria that matches your intake form - When a response is created, the Bot runs and transforms the answers into FHIR resources
The Bot uses two key utility functions from the intake demo:
getQuestionnaireAnswers() — Flattens the QuestionnaireResponse into a dictionary keyed by linkId. Use this for top-level, non-repeating fields like demographics:
import { getQuestionnaireAnswers } from '@medplum/core';
const answers = getQuestionnaireAnswers(response);
// Access flat fields directly
const birthDate = answers['dob']?.valueDate;
const gender = answers['gender-identity']?.valueCoding?.code;
getGroupRepeatedAnswers() — Extracts an array of answer dictionaries from a repeating group. Use this for multi-entry sections like allergies and medications:
import { getGroupRepeatedAnswers } from './intake-utils';
// Returns one dictionary per allergy the patient reported
const allergies = getGroupRepeatedAnswers(questionnaire, response, 'allergies');
for (const allergy of allergies) {
const substance = allergy['allergy-substance']?.valueCoding;
const reaction = allergy['allergy-reaction']?.valueString;
// ... create AllergyIntolerance resource
}
When to use Bot-based extraction:
- You need conditional resource creation (e.g., only create a RelatedPerson if the insurance subscriber is not the patient)
- You need to call external APIs during processing (e.g., verify insurance eligibility)
- You need custom validation beyond what FHIR validation provides
- Your extraction logic involves business rules that don't fit SDC's declarative model
For a complete Bot implementation, see the intake-form.ts in the Patient Intake Demo.
Idempotent Processing with Upserts
In production, intake responses may be resubmitted — whether due to user error, form corrections, or system retries. Use upsertResource to avoid creating duplicate resources. The upsert searches for an existing resource matching your criteria and updates it if found, or creates a new one if not.
Each resource type has a natural upsert key:
| Resource | Upsert Key (Search Params) |
|---|---|
| AllergyIntolerance | patient + code |
| MedicationRequest | subject + code |
| Condition | subject + code |
| FamilyMemberHistory | patient + code + relationship |
| Immunization | patient + vaccine-code + date |
| Coverage | beneficiary + payor |
| CareTeam | name + subject |
| Observation | subject + code |
await medplum.upsertResource(
{
resourceType: 'AllergyIntolerance',
patient: createReference(patient),
code: { coding: [substanceCoding] },
// ... other fields
},
{
patient: getReferenceString(patient),
code: `${substanceCoding.system}|${substanceCoding.code}`,
}
);
Modeling Consent Resources
Intake forms typically capture several types of consent. Each is modeled as a separate Consent resource with a specific combination of scope, category, and policyRule that identifies the consent type.
The policyRule values shown below are examples based on HL7 consent policy codes and custom organizational codes. Consent coding varies significantly across implementations — use the codes that match your organization's policies and compliance requirements.
Common Consent Types
| Consent Type | scope | category | policyRule |
|---|---|---|---|
| Consent for Treatment | treatment | med (Medical) | cric (Common Rule Informed Consent) |
| Agreement to Pay | treatment | pay (Payment) | hipaa-self-pay |
| Notice of Privacy Practices | patient-privacy | nopp (Notice of Privacy Practices) | hipaa-npp |
| Advance Directives | adr (Advanced Care Directive) | acd (Advanced Care Directive) | Organization-specific |
| Communication Preferences | patient-privacy | Consent Document (LOINC 59284-0) | Organization-specific |
Consent Resource Shape
{
"resourceType": "Consent",
"status": "active",
"patient": { "reference": "Patient/example" },
"dateTime": "2026-03-15T10:30:00Z",
"scope": {
"coding": [{
"system": "http://terminology.hl7.org/CodeSystem/consentscope",
"code": "treatment",
"display": "Treatment"
}]
},
"category": [{
"coding": [{
"system": "http://terminology.hl7.org/CodeSystem/v3-ActCode",
"code": "med",
"display": "Medical"
}]
}],
"policyRule": {
"coding": [{
"system": "http://terminology.hl7.org/CodeSystem/consentpolicycodes",
"code": "cric",
"display": "Common Rule Informed Consent"
}]
}
}
Use status: "active" when consent is granted and status: "rejected" when declined. The dateTime field records when the consent was captured.
For communication preferences, the provision field specifies which channels are permitted or denied:
{
"provision": {
"type": "permit",
"action": [{ "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/consentaction", "code": "use" }] }],
"purpose": [{ "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode", "code": "PATADMIN" }],
"code": [
{ "coding": [{ "system": "http://hl7.org/fhir/contact-point-system", "code": "email" }] },
{ "coding": [{ "system": "http://loinc.org", "code": "101134-5", "display": "Appointment reminder" }] }
]
}
}
See Also
- Structured Data Capture — Full SDC annotation guide and
$extractAPI details - Questionnaires & Assessments — General Questionnaire mechanics
- Bot Basics — Creating and deploying Bots
- Subscriptions — Triggering automations on resource creation
- Intake Data Model — Resource graph and relationships
- Patient Intake Demo — Working reference implementation