Skip to main content

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:

  1. Materializing CarePlan activities into individual Task resources up to a configurable time horizon
  2. Transitioning tasks to "ready" status when they become due
  3. 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:

  1. A Task resource is created with status requested
  2. The Task references the CarePlan via basedOn
  3. The Task has an executionPeriod and a restriction.period indicating when it should be performed and how long it remains actionable
  4. When the due time arrives, the server transitions the Task to ready status
  5. This status change triggers any active Subscriptions matching Task?status=ready
  6. If the Task is not completed before its validity window expires, the server transitions it to a terminal status (cancelled by default) so that Task?status=ready only returns currently actionable work
note

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:

ApproachOperationAuth operationCreates a Subscription?Channel to enable
Webhook notifications$subscribe-due-events (rest-hook)subscribeyesresthook_enabled
Polling, no webhook$subscribe-due-events (nop channel)subscribeyesmessage_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 from requested to active.
  • 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 path

On 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.

Available since 1.10.0

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"
}'
Subscription End Date

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

Available since 1.13.0

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 shapeHow restriction.period.end is set
One-off Task (no repeat)due time + default-validity-period
Repeating Taskdue 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 gets PT1H of validity.
  • A one-minute activity uses the hourly tier (PT1H), so the Task gets PT15M of 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:

ValueWhen to use
cancelled (default)The activity was never picked up. Counts toward CarePlan completion where the server is configured that way.
failedThe activity was expected but could not be performed.
entered-in-errorThe 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.

Disabled by default for security

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:

NameTypeRequiredDescription
channelTypestringyesMust be nop to create a polling-only subscription. The default (rest-hook) would require an endpoint and deliver webhooks.
endpointurlnoOmit it. The endpoint is ignored for the nop channel; the server substitutes the placeholder channel://nop.
enddateTimenoOptional 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
PropertyDescriptionDefault
enabledEnable CarePlan event processingtrue
horizon-durationHow far ahead to materialize Tasks (ISO 8601 duration)P30D
subscriptions.max-ttlMaximum allowed subscription lifetimeP30D
subscriptions.require-endWhether Subscription.end is mandatorytrue
subscriptions.excess-end-actionAction when end exceeds max-ttl (reject or truncate)reject
delivery.max-consecutive-failuresConsecutive failures before disabling a subscription5
delivery.disable-after-no-successDuration without a successful delivery before disablingP7D
cleanup.sweep-intervalHow often the subscription cleanup job runsPT1H
cleanup.delete-disabled-afterWhen to delete disabled subscriptions (P0D = keep forever)P30D
cleanup.due-task-check-intervalHow often the due-task sweeper runsPT1M
cleanup.expired-task-check-intervalHow often the expired-task sweeper runsPT1M
task.initial-statusStatus of newly materialized Tasksrequested
task.due-statusStatus Tasks transition to when dueready
task.auto-transition-to-readyAutomatically move Tasks to due-status when duetrue
task.auto-transition-to-terminalAutomatically transition expired Tasks to terminal statustrue
task.auto-transition-terminal-stateTerminal status for expired Tasks (cancelled, failed, entered-in-error)cancelled
task.default-validity-periodHow long after its due time a Task remains actionablePT4H
task.task-validity-periodsMap of scheduling interval → validity period (ceiling lookup)PT1H: PT15M, P1D: PT1H, P1W: P1D
task.careplan-validity-extension.enabledHonor per-CarePlan validity overridesfalse

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.

  • 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.