CarePlan Events
Fire Arrow Server can automatically schedule CarePlan activities into Task resources and notify your application when those tasks become due. This enables server-side care plan orchestration - define a care plan once, and the server handles the timeline, creates the tasks, and calls your webhook when it's time for action.
Overview
In many digital health applications, care plans include recurring activities: daily medication reminders, weekly check-ins, monthly assessments. Traditionally, the application has to manage the scheduling logic itself, which is complex, error-prone, and difficult to scale.
Fire Arrow Server's CarePlan event system solves this by:
- Materializing CarePlan activities into individual Task resources up to a configurable time horizon
- Transitioning tasks to "ready" status when they become due
- Notifying your application via webhooks when tasks are ready
Enabling CarePlan Scheduling
CarePlan scheduling is opt-in per CarePlan. Tag a CarePlan with the scheduling meta tag to tell Fire Arrow Server to manage its activities:
{
"resourceType": "CarePlan",
"meta": {
"tag": [{
"system": "https://firearrow.io/fhir/careplan-scheduling",
"code": "scheduled"
}]
},
"status": "active",
"intent": "plan",
"subject": { "reference": "Patient/123" },
"activity": [
{
"detail": {
"description": "Daily blood pressure check",
"status": "scheduled",
"scheduledTiming": {
"repeat": {
"frequency": 1,
"period": 1,
"periodUnit": "d"
}
}
}
},
{
"detail": {
"description": "Weekly symptom questionnaire",
"status": "scheduled",
"scheduledTiming": {
"repeat": {
"frequency": 1,
"period": 1,
"periodUnit": "wk"
}
}
}
}
]
}
Once this CarePlan is created (or updated with the tag), Fire Arrow Server begins materializing Task resources for each activity according to its schedule.
How Materialization Works
The server maintains a scheduling horizon - a rolling time window (e.g., 30 days into the future) during which it creates Task resources. As time passes, the server continues to materialize new tasks to fill the horizon.
For each activity occurrence within the horizon:
- A Task resource is created with status
requested - The Task references the CarePlan via
basedOn - The Task has an
executionPeriodand arestriction.periodindicating when it should be performed and how long it remains actionable - When the due time arrives, the server transitions the Task to
readystatus - This status change triggers any active Subscriptions matching
Task?status=ready - If the Task is not completed before its validity window expires, the server transitions it to a terminal status (
cancelledby default) so thatTask?status=readyonly returns currently actionable work
The server only materializes tasks within the horizon window. For a 30-day horizon, tasks more than 30 days in the future won't exist yet. They will be created as the horizon advances.
See Task Expiration and Validity for how to control the validity window and the terminal transition.
Subscribing to Due Events
When tasks become due (transition to ready), you'll want to be notified so your application can take action. $subscribe-due-events is the client-facing operation for this. It creates and owns a Subscription on the caller's behalf, so a client only needs the subscribe permission, not the right to write Subscription resources directly. Choose the channel based on whether you want webhooks:
| Approach | Operation | Auth operation | Creates a Subscription? | Channel to enable |
|---|---|---|---|---|
| Webhook notifications | $subscribe-due-events (rest-hook) | subscribe | yes | resthook_enabled |
| Polling, no webhook | $subscribe-due-events (nop channel) | subscribe | yes | message_enabled |
Both add the scheduling tag, so the server begins materializing Tasks, and both need a HAPI subscription channel enabled for the Subscription to activate (see Enabling subscription processing). See Polling-only setup for the nop-channel walkthrough.
Privileged or service-to-service callers can instead use $materialize / $dematerialize to turn materialization on or off directly, without a Subscription or a channel. Those operations use a separate materialize permission and are not the path a regular client app uses to receive due events.
Easy Way: $subscribe-due-events
The $subscribe-due-events operation is a convenience endpoint that creates a properly configured Subscription for a specific CarePlan:
curl -X POST http://localhost:8080/fhir/CarePlan/789/\$subscribe-due-events \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <your-token>" \
-d '{
"resourceType": "Parameters",
"parameter": [
{ "name": "endpoint", "valueUrl": "https://your-app.example.com/webhooks/careplan-tasks" }
]
}'
The server creates a Subscription with:
- Criteria:
Task?status=ready&based-on=CarePlan/789 - Channel: REST hook to your specified endpoint
- End date: Automatically set based on the server's maximum subscription TTL
Enabling subscription processing
$subscribe-due-events writes the Subscription row and adds the scheduling tag, but two things depend on HAPI's subscription pipeline being active:
- Synchronous materialization (
_synchronous=true) waits for the Subscription to transition fromrequestedtoactive. - Webhook delivery requires an active subscription channel.
That pipeline turns on when supportedSubscriptionTypes is non-empty, which happens when at least one HAPI channel flag is set. Enable the channel that matches your endpoint:
hapi:
fhir:
subscription:
resthook_enabled: true
There is no separate subscription.enabled flag. Setting any of hapi.fhir.subscription.resthook_enabled, message_enabled, websocket_enabled, or email.enabled to true activates the shared matcher and activation pipeline. The channel you enable must match the channel your subscription uses: rest-hook needs resthook_enabled, and the nop channel needs message_enabled.
careplan-events.enabled: true is not sufficient on this pathOn a default-config server, subscriptions are disabled and startup logs:
Subscriptions are disabled on this server. Subscriptions will not be activated and incoming resources will not be matched against subscriptions.
With fire-arrow.careplan-events.enabled: true but no channel enabled, $subscribe-due-events still returns 200 OK and creates the Subscription, but the subscription stays in requested status and no Tasks are materialized. _synchronous=true returns synchronousTimedOut=true. Enable the channel that matches your subscription. A privileged backend that does not need a Subscription can instead use $materialize (1.11+), which materializes Tasks without any channel.
Waiting for Initial Materialization (_synchronous)
By default, $subscribe-due-events returns as soon as the Subscription has been created. Task materialization for activities that are already due happens asynchronously in the background, which means there is a short delay between the operation returning and the first Task being queryable as status=ready. Orchestrators that want to dispatch the first due Task immediately, without polling the FHIR API, can pass _synchronous=true to make the operation block until materialization completes:
curl -X POST http://localhost:8080/fhir/CarePlan/789/\$subscribe-due-events \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <your-token>" \
-d '{
"resourceType": "Parameters",
"parameter": [
{ "name": "endpoint", "valueUrl": "https://your-app.example.com/webhooks/careplan-tasks" },
{ "name": "_synchronous", "valueBoolean": true }
]
}'
When the synchronous mode is used, the response includes a task parameter for each materialized Task with its taskId and status:
{
"resourceType": "Parameters",
"parameter": [
{ "name": "subscriptionId", "valueId": "Subscription/123" },
{ "name": "effectiveEnd", "valueInstant": "2026-06-01T00:00:00Z" },
{ "name": "action", "valueString": "created" },
{
"name": "task",
"part": [
{ "name": "taskId", "valueId": "Task/456" },
{ "name": "status", "valueString": "ready" }
]
}
]
}
The synchronous wait is bounded at 10 seconds. If materialization does not finish within the wait window — typically because of unusually high server load — the operation still returns 200 OK with synchronousTimedOut=true in the response, and the caller can fall back to the regular notification path. The asynchronous materialization continues to run regardless of the synchronous outcome, so no Tasks are lost on a timeout.
{
"resourceType": "Parameters",
"parameter": [
{ "name": "subscriptionId", "valueId": "Subscription/123" },
{ "name": "effectiveEnd", "valueInstant": "2026-06-01T00:00:00Z" },
{ "name": "action", "valueString": "created" },
{ "name": "synchronousTimedOut", "valueBoolean": true }
]
}
Default behaviour is unchanged: with _synchronous absent or set to false, the response shape is the standard Subscription resource and no extra processing runs.
The _synchronous parameter was introduced in Fire Arrow Server 1.10.0. Earlier releases ignore it and always return as soon as the Subscription is committed.
Advanced: Create a Subscription Directly
For more control, create a FHIR Subscription resource yourself:
curl -X POST http://localhost:8080/fhir/Subscription \
-H "Content-Type: application/fhir+json" \
-H "Authorization: Bearer <your-token>" \
-d '{
"resourceType": "Subscription",
"status": "requested",
"criteria": "Task?status=ready&based-on=CarePlan/789",
"channel": {
"type": "rest-hook",
"endpoint": "https://your-app.example.com/webhooks/careplan-tasks",
"payload": "application/fhir+json"
},
"end": "2026-04-28T00:00:00Z"
}'
The end field is required on all Subscriptions. The server enforces a maximum TTL - if the end date exceeds the maximum, it will be clamped to the allowed maximum.
Start or stop materialization directly: $materialize
$materialize opts a CarePlan into scheduling by adding the scheduling tag, without creating a Subscription. It needs no webhook endpoint and no HAPI subscription channel. Available since 1.11.0.
This is a privileged, service-to-service control, not the path a client app uses to receive due events. It uses a separate materialize permission (see below) and is meant for backends that want to turn materialization on or off irrespective of any Subscription resources. Client apps that need to be notified or poll should use $subscribe-due-events instead, which creates a Subscription on their behalf.
curl -X POST http://localhost:8080/fhir/CarePlan/789/\$materialize \
-H "Authorization: Bearer <your-token>"
The operation adds the scheduling tag and returns Parameters with status=success. The server then materializes Tasks and transitions them to ready on schedule. There is no synchronous variant, so poll for due Tasks after the call:
curl "http://localhost:8080/fhir/Task?status=ready&based-on=CarePlan/789" \
-H "Authorization: Bearer <your-token>"
$materialize requires the materialize authorization operation on CarePlan and a CarePlan read rule. It is idempotent: repeating it on an already-scheduled CarePlan is a no-op that still returns status=success.
To stop scheduling, call $dematerialize, which removes the tag (leaving existing Tasks and any Subscriptions in place):
curl -X POST http://localhost:8080/fhir/CarePlan/789/\$dematerialize \
-H "Authorization: Bearer <your-token>"
Because $dematerialize only removes the tag, it does not touch Subscriptions created by $subscribe-due-events. To stop a client subscription, use $unsubscribe-due-events instead.
See Custom Operations for the full parameter and response reference, including $renew-due-events and $unsubscribe-due-events.
Webhook Payload
When a Task transitions to ready, Fire Arrow Server sends a minimal notification to your webhook endpoint. The payload is a lightweight hint that does not contain Protected Health Information (PHI):
{
"resourceType": "Task",
"id": "task-456",
"status": "ready",
"basedOn": [{ "reference": "CarePlan/789" }]
}
Your application should then fetch the full Task resource via the API to get all details:
curl http://localhost:8080/fhir/Task/task-456 \
-H "Authorization: Bearer <your-token>"
This two-step pattern keeps webhook payloads small and avoids transmitting sensitive data over webhook channels.
Completing Tasks
When the patient or practitioner completes an activity, update the Task status:
curl -X PUT http://localhost:8080/fhir/Task/task-456 \
-H "Content-Type: application/fhir+json" \
-H "Authorization: Bearer <your-token>" \
-d '{
"resourceType": "Task",
"id": "task-456",
"status": "completed",
"basedOn": [{ "reference": "CarePlan/789" }],
"executionPeriod": {
"start": "2026-03-28T08:00:00Z",
"end": "2026-03-28T08:15:00Z"
}
}'
Task Expiration and Validity
Task expiration was introduced in Fire Arrow Server 1.13.0. Earlier releases leave Tasks in ready status indefinitely.
A Task that reaches ready status stays ready until your application completes it. But if the activity is never acted on — for example, a patient misses a daily check-in — the Task should stop appearing in due-task queries so that downstream consumers only pick up currently actionable work.
Fire Arrow Server handles this with an expiry sweeper that transitions ready Tasks to a terminal status once their validity window has passed. After the transition, the Task no longer appears in Task?status=ready results; polling and webhook notifications return only work that is still actionable.
How the validity window works
Each materialized Task carries a restriction.period.end value that marks when it stops being actionable. The expiry sweeper transitions any ready Task whose restriction.period.end is in the past.
The end value is set at materialization time, based on the activity's repetition interval:
| Activity shape | How restriction.period.end is set |
|---|---|
| One-off Task (no repeat) | due time + default-validity-period |
| Repeating Task | due time + the validity looked up from task-validity-periods, using the smallest configured interval that is greater than or equal to the activity's repetition interval |
For example, with the default tiers PT1H → PT15M, P1D → PT1H, P1W → P1D:
- An activity repeating every two hours uses the daily tier (
P1D), so the Task getsPT1Hof validity. - A one-minute activity uses the hourly tier (
PT1H), so the Task getsPT15Mof validity. - A monthly activity matches no tier and falls back to
default-validity-period(PT4H).
If a Task already carries an explicit restriction.period.end — for example, it was authored directly rather than materialized by the server — that value is honored as-is.
Choosing a terminal status
The terminal status applied to expired Tasks is configurable via auto-transition-terminal-state. It must be a terminal FHIR Task status:
| Value | When to use |
|---|---|
cancelled (default) | The activity was never picked up. Counts toward CarePlan completion where the server is configured that way. |
failed | The activity was expected but could not be performed. |
entered-in-error | The Task should not have been created. |
Per-CarePlan validity overrides
For deployments that need different validity windows per CarePlan, you can carry the validity configuration directly on a CarePlan using a Fire Arrow extension:
https://firearrow.io/fhir/StructureDefinition/careplan-task-validity
The extension contains a default-validity value and a list of validity-period entries (each pairing a frequency interval with a validity, both as ISO-8601 durations). When enabled, CarePlan-level values fully replace the server-wide settings from application.yaml for that CarePlan — they do not merge.
{
"resourceType": "CarePlan",
"meta": {
"tag": [{
"system": "https://firearrow.io/fhir/careplan-scheduling",
"code": "scheduled"
}]
},
"extension": [{
"url": "https://firearrow.io/fhir/StructureDefinition/careplan-task-validity",
"extension": [
{ "url": "default-validity", "valueDuration": "PT2H" },
{
"url": "validity-period",
"extension": [
{ "url": "frequency-interval", "valueDuration": "PT1H" },
{ "url": "validity", "valueDuration": "PT10M" }
]
},
{
"url": "validity-period",
"extension": [
{ "url": "frequency-interval", "valueDuration": "P1D" },
{ "url": "validity", "valueDuration": "PT30M" }
]
}
]
}],
"status": "active",
"intent": "plan",
"subject": { "reference": "Patient/123" },
"activity": [{
"detail": {
"description": "Hourly wellness check",
"status": "scheduled",
"scheduledTiming": {
"repeat": { "frequency": 1, "period": 1, "periodUnit": "h" }
}
}
}]
}
When you author CarePlans via PlanDefinition $apply, the extension is deep-copied from the PlanDefinition onto the resulting CarePlan during $apply. After that, the server always reads validity from the CarePlan alone — the PlanDefinition is never consulted again at materialization time. If the CarePlan already carries the extension (for example, authored directly), $apply does not overwrite it.
Per-CarePlan validity overrides are gated by careplan-validity-extension.enabled, which defaults to false. Enabling it lets CarePlan authors influence server-side scheduling, so only turn it on in trusted, single-tenant deployments or where CarePlan authoring is otherwise restricted. Deployments that leave it disabled take all validity settings from application.yaml.
Turning expiration off
Expiration is on by default wherever CarePlan events are enabled. To keep the pre-1.13.0 behavior — Tasks stay ready indefinitely — set:
fire-arrow:
careplan-events:
task:
auto-transition-to-terminal: false
Existing Tasks that are already ready at upgrade time become eligible for expiry on the next sweeper run according to their restriction.period.end.
SearchParameter for the deadline field
The expiry sweeper matches Tasks on Task.restriction.period.end. Fire Arrow Server deploys a SearchParameter called fa-careplan-task-deadline that indexes that field, alongside the existing fa-careplan-due-tasks parameter (which indexes Task.restriction.period.start).
When the deadline SearchParameter is first created or updated on upgrade, the server automatically reindexes pre-existing Task resources so the field becomes searchable. This is a one-time background operation; on a large Task table it may briefly increase database load during the first startup after upgrade. No action is required.
Polling-only setup (no webhook)
Clients that cannot receive webhooks, such as a mobile app without a server component, can still use CarePlan materialization and poll for ready Tasks. Call $subscribe-due-events with the nop channel to create a Subscription that materializes and transitions Tasks on schedule but delivers no notifications, then query for due Tasks.
This is the client-facing path. The operation creates the Subscription on the caller's behalf, so the client needs only the subscribe permission. (A privileged backend that wants to drive materialization without a Subscription can use $materialize instead.)
Step 1: Create a nop-channel subscription
Call $subscribe-due-events with channelType set to nop:
curl -X POST http://localhost:8080/fhir/CarePlan/789/\$subscribe-due-events \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <your-token>" \
-d '{
"resourceType": "Parameters",
"parameter": [
{ "name": "channelType", "valueString": "nop" }
]
}'
Parameters for a nop-channel subscription:
| Name | Type | Required | Description |
|---|---|---|---|
channelType | string | yes | Must be nop to create a polling-only subscription. The default (rest-hook) would require an endpoint and deliver webhooks. |
endpoint | url | no | Omit it. The endpoint is ignored for the nop channel; the server substitutes the placeholder channel://nop. |
end | dateTime | no | Optional subscription end, capped at the server's maximum TTL. Defaults to now + max-ttl. |
The response is the same Parameters shape as any other $subscribe-due-events call (subscriptionId, effectiveEnd, action).
The nop channel is a FHIR message channel under the hood, so the subscription only activates and materializes Tasks when the message channel type is enabled:
hapi:
fhir:
subscription:
message_enabled: true
Without message_enabled: true, the nop subscription stays in requested status and no Tasks are materialized, the same activation requirement described in Enabling subscription processing.
Step 2: Poll for ready Tasks
Once the subscription is created, poll for Tasks that have become due:
curl "http://localhost:8080/fhir/Task?status=ready&based-on=CarePlan/789" \
-H "Authorization: Bearer <your-token>"
Or via GraphQL:
{
TaskList(status: "ready", based_on: "CarePlan/789") {
id
code { text }
executionPeriod { start end }
}
}
Configuration
Configure CarePlan events under the fire-arrow.careplan-events key in your application.yaml:
fire-arrow:
careplan-events:
enabled: true
horizon-duration: "P30D" # Materialize tasks 30 days ahead
subscriptions:
max-ttl: "P30D" # Maximum subscription lifetime
require-end: true # Subscription.end is mandatory
excess-end-action: reject # Reject subscriptions whose end exceeds max-ttl
delivery:
max-consecutive-failures: 5 # Disable subscription after N consecutive failures
disable-after-no-success: "P7D" # Disable after this long without a success
cleanup:
sweep-interval: "PT1H" # How often the subscription cleanup job runs
delete-disabled-after: "P30D" # Delete disabled subscriptions after this delay
due-task-check-interval: "PT1M" # How often the due-task sweeper runs
expired-task-check-interval: "PT1M" # How often the expired-task sweeper runs
task:
initial-status: requested # Status of newly materialized Tasks
due-status: ready # Status to transition to when due
auto-transition-to-ready: true # Transition Tasks to due-status when due
auto-transition-to-terminal: true # Transition Tasks to terminal status when expired
auto-transition-terminal-state: cancelled # Terminal status for expired Tasks
default-validity-period: "PT4H" # How long a Task is actionable after its due time
task-validity-periods: # Per-frequency validity (ceiling lookup)
PT1H: "PT15M"
P1D: "PT1H"
P1W: "P1D"
careplan-validity-extension:
enabled: false # Honor per-CarePlan validity overrides
| Property | Description | Default |
|---|---|---|
enabled | Enable CarePlan event processing | true |
horizon-duration | How far ahead to materialize Tasks (ISO 8601 duration) | P30D |
subscriptions.max-ttl | Maximum allowed subscription lifetime | P30D |
subscriptions.require-end | Whether Subscription.end is mandatory | true |
subscriptions.excess-end-action | Action when end exceeds max-ttl (reject or truncate) | reject |
delivery.max-consecutive-failures | Consecutive failures before disabling a subscription | 5 |
delivery.disable-after-no-success | Duration without a successful delivery before disabling | P7D |
cleanup.sweep-interval | How often the subscription cleanup job runs | PT1H |
cleanup.delete-disabled-after | When to delete disabled subscriptions (P0D = keep forever) | P30D |
cleanup.due-task-check-interval | How often the due-task sweeper runs | PT1M |
cleanup.expired-task-check-interval | How often the expired-task sweeper runs | PT1M |
task.initial-status | Status of newly materialized Tasks | requested |
task.due-status | Status Tasks transition to when due | ready |
task.auto-transition-to-ready | Automatically move Tasks to due-status when due | true |
task.auto-transition-to-terminal | Automatically transition expired Tasks to terminal status | true |
task.auto-transition-terminal-state | Terminal status for expired Tasks (cancelled, failed, entered-in-error) | cancelled |
task.default-validity-period | How long after its due time a Task remains actionable | PT4H |
task.task-validity-periods | Map of scheduling interval → validity period (ceiling lookup) | PT1H: PT15M, P1D: PT1H, P1W: P1D |
task.careplan-validity-extension.enabled | Honor per-CarePlan validity overrides | false |
See Task Expiration and Validity for how the validity window, terminal transition, and per-CarePlan overrides work in practice.
End-to-End Example
Here's a complete walkthrough: create a CarePlan, subscribe to events, receive a webhook, and fetch the task.
1. Create a CarePlan with Scheduling
curl -X POST http://localhost:8080/fhir/CarePlan \
-H "Content-Type: application/fhir+json" \
-H "Authorization: Bearer <your-token>" \
-d '{
"resourceType": "CarePlan",
"meta": {
"tag": [{
"system": "https://firearrow.io/fhir/careplan-scheduling",
"code": "scheduled"
}]
},
"status": "active",
"intent": "plan",
"subject": { "reference": "Patient/123" },
"period": {
"start": "2026-03-28",
"end": "2026-06-28"
},
"activity": [{
"detail": {
"description": "Daily blood pressure measurement",
"status": "scheduled",
"scheduledTiming": {
"repeat": {
"frequency": 1,
"period": 1,
"periodUnit": "d",
"timeOfDay": ["08:00:00"]
}
}
}
}]
}'
The server returns the created CarePlan with an id (e.g., CarePlan/789).
2. Subscribe to Due Events
curl -X POST http://localhost:8080/fhir/CarePlan/789/\$subscribe-due-events \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <your-token>" \
-d '{
"resourceType": "Parameters",
"parameter": [
{ "name": "endpoint", "valueUrl": "https://your-app.example.com/webhooks/bp-check" }
]
}'
3. Receive Webhook Notification
When the daily task becomes due (at 08:00), your endpoint receives:
{
"resourceType": "Task",
"id": "task-001",
"status": "ready",
"basedOn": [{ "reference": "CarePlan/789" }]
}
4. Fetch Full Task Details
curl http://localhost:8080/fhir/Task/task-001 \
-H "Authorization: Bearer <your-token>"
5. Complete the Task
After the patient takes their measurement:
curl -X PUT http://localhost:8080/fhir/Task/task-001 \
-H "Content-Type: application/fhir+json" \
-H "Authorization: Bearer <your-token>" \
-d '{
"resourceType": "Task",
"id": "task-001",
"status": "completed",
"basedOn": [{ "reference": "CarePlan/789" }],
"executionPeriod": {
"start": "2026-03-28T08:00:00Z",
"end": "2026-03-28T08:10:00Z"
}
}'
The server continues materializing and transitioning tasks according to the CarePlan schedule for the duration of the care plan's period.
Related Guides
- Medication Plan Reminders - end-to-end recipe for medication adherence with MedicationRequest-backed CarePlans.
- Patient Surveillance Questionnaires - scheduled questionnaire delivery using PlanDefinition and
$apply. - Subscriptions - all supported subscription channels (REST hook, email, websocket, Azure Queue).
- Custom Operations - reference for the CarePlan scheduling operations (
$subscribe-due-events,$renew-due-events,$unsubscribe-due-events,$materialize,$dematerialize) and PlanDefinition$apply.