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 a restriction.period (planned due window) indicating when it should be performed and how long it remains actionable. By default executionPeriod is not set by the materializer — it is written by your client when the work is actually done (see Completing Tasks). If you want the server to mirror the planned due window into executionPeriod at materialization time — for headless or pull-only clients that read times from executionPeriod — enable auto-propagate-period-start / auto-propagate-period-end.
  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.

Past-due occurrences on synchronous activation

Starting with Fire Arrow Server 1.15.0, when a CarePlan is activated on the synchronous path ($subscribe-due-events with _synchronous=true), occurrences whose due time has already passed are created directly in due-status (ready by default) rather than being created as requested and immediately transitioned (steps 1 and 4 collapse into one). This requires auto-transition-to-ready to be enabled and the CarePlan to have a resolved timezone; local-time-only CarePlans are never auto-transitioned. Future-due occurrences still follow steps 1–4 above.

See Task Expiration and Validity for how to control the validity window and the terminal transition.

Tracking materialization progress (materialized-at-version)

Available since 1.16.0

The materialized-at-version marker was introduced in Fire Arrow Server 1.16.0. Earlier releases do not write it.

When you update a scheduled CarePlan, the server re-materializes the affected Tasks in the background. To let a client tell when that work has caught up with a particular update, the server records the CarePlan version it materialized on the CarePlan's meta:

https://firearrow.io/fhir/StructureDefinition/materialized-at-version

The value is a valueInteger holding the meta.versionId that was materialized. A client that updates a CarePlan can poll GET /CarePlan/{id} and wait until meta.extension[materialized-at-version] reaches the version it wrote, then query the resulting Tasks with Task?based-on=CarePlan/{id}:

{
"resourceType": "CarePlan",
"id": "789",
"meta": {
"versionId": "5",
"extension": [{
"url": "https://firearrow.io/fhir/StructureDefinition/materialized-at-version",
"valueInteger": 4
}]
}
}

This is a lightweight alternative to the synchronous $subscribe-due-events path for clients that already hold the CarePlan and only need to know when its Tasks are current, without opening or renewing a subscription.

note

The marker is server-managed bookkeeping on meta, not clinical content in the CarePlan body. Writing it does not itself trigger another round of re-materialization — the server only re-materializes when an activity, a contained resource, or the plan period changes. Because the marker lives on meta, a scheduled CarePlan gains one additional server-written version each time it is materialized, which appears in the CarePlan's version history.

Timezone and Scheduling

CarePlan activities are scheduled using wall-clock timestimeOfDay: "08:00:00" means "8 AM in the patient's local timezone." To compute the exact UTC instant when a Task becomes due (and thus when the due-task sweeper should transition it to ready), the server needs to know which timezone that is. This section describes how the timezone is resolved and how to set it.

How timezone is resolved

The server resolves timezone via two hierarchies, one for the CarePlan as a whole (used to decide whether auto-transition is enabled) and one per activity (used to compute the exact due instants).

CarePlan-level resolution

Used by the due-task sweeper to decide whether Tasks on this CarePlan should be auto-transitioned at all. First match wins:

  1. CarePlan timezone extension — the HL7 timezone extension on the CarePlan resource itself.
  2. Patient timezone extension — the same extension on the CarePlan's subject (Patient) resource.
  3. No timezone — local-time-only. Tasks still materialize with their due dates, but the due-task sweeper never auto-transitions them (see warning below).

Activity-level resolution

Used to compute the exact due instant for a specific activity occurrence. First match wins:

  1. Activity timing datetime offset — if the activity's scheduledTiming includes a datetime with a timezone offset (e.g., 2026-06-15T08:00:00+02:00), that offset is used.
  2. CarePlan.period.start offset — if period.start carries a timezone offset.
  3. CarePlan timezone extension — the HL7 timezone extension on the CarePlan resource.
  4. Patient timezone extension — the same extension on the CarePlan's subject (Patient) resource.
  5. No timezone — local-time-only.

Extension format

The timezone is stored using the standard HL7 timezone extension, with an valueCode holding an IANA timezone identifier (e.g., Europe/Berlin, America/New_York, Asia/Tokyo):

FieldValue
URLhttp://hl7.org/fhir/StructureDefinition/timezone
Value typecode
ValueIANA timezone identifier

On a CarePlan:

{
"resourceType": "CarePlan",
"extension": [{
"url": "http://hl7.org/fhir/StructureDefinition/timezone",
"valueCode": "Europe/Berlin"
}]
}

On the Patient (a convenient fallback so all of a patient's CarePlans inherit the same timezone without repeating it on each one):

{
"resourceType": "Patient",
"extension": [{
"url": "http://hl7.org/fhir/StructureDefinition/timezone",
"valueCode": "Europe/Berlin"
}]
}

If a CarePlan carries the extension, it takes precedence over the Patient's. If neither has it, the schedule runs in local time only.

Timezone edits re-materialize since 2.0.0

Starting with Fire Arrow Server 2.0.0, editing a scheduled CarePlan's timezone extension triggers rematerialization immediately, so due times move to the new timezone right away. Earlier releases picked up a timezone-only edit only later, when some other change or the background sweeper next reconciled the plan. Changing the timezone on the Patient resource is still picked up on the next reconcile rather than immediately.

DST handling

For recurring schedules, prefer IANA timezone identifiers (Europe/Berlin) over fixed UTC offsets (+01:00). A fixed offset does not automatically follow daylight saving time (DST) transitions, so a daily 8 AM task would drift by one hour after a DST shift. IANA identifiers track the offset for you and correctly handle the transition. See the 1.5.0 release notes for the bugfix that made this the default.

Monthly and yearly schedules since 2.0.0

Starting with Fire Arrow Server 2.0.0, monthly (periodUnit: mo) and yearly (periodUnit: a) recurrences step by calendar month and year rather than by a nominal 30 or 365 days. A monthly activity scheduled for the 15th now stays on the 15th and delivers every occurrence, instead of drifting by roughly five days per year. See the 2.0.0 release notes.

Setting the timezone via PlanDefinition/$apply

If you author CarePlans through PlanDefinition/$apply, pass the _timezone parameter and Fire Arrow Server adds the timezone extension to the resulting CarePlan for you:

curl -X POST http://localhost:8080/fhir/PlanDefinition/123/\$apply \
-H "Content-Type: application/fhir+json" \
-d '{
"resourceType": "Parameters",
"parameter": [
{ "name": "_timezone", "valueString": "Europe/Berlin" }
]
}'

See Custom Operations for the full parameter reference.

No timezone = no auto-transition

No timezone = no auto-transition

If neither the CarePlan nor the Patient has a timezone extension (and no offset is carried on the activity or period.start), Tasks are still created with their due dates, but the due-task sweeper never transitions them from requested to ready. This means:

  • Webhooks will not fire at the correct time — your app will not receive push notification triggers.
  • The client can still work by polling for Tasks and checking their restriction.period.start field manually, but the server-side automation is disabled.
  • Tasks without timezone are tagged only with task but not with auto-transition, which is the tag the scheduler looks for when deciding which tasks to move to ready.

Always set a timezone on the CarePlan (or Patient) to enable automatic reminders.

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. Tasks whose due time has already passed are returned in their configured due-status (ready by default), so they are immediately actionable without waiting for a follow-up transition:

{
"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" }
]
}
]
}

When more than one Task is returned, the task parts are ordered by due time, earliest first, so the first entry is always the most urgent actionable Task.

Ordering available since 1.15.0

Due-time ordering of the synchronous task parts was introduced in Fire Arrow Server 1.15.0. Earlier releases return the Tasks in an unspecified order.

The synchronous wait is bounded (default 30 seconds, configurable via fire-arrow.careplan-events.synchronous-wait-timeout). 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. Calling it more than once is safe — it never duplicates Tasks that already exist — but what a repeat call does has changed across releases.

Behavior changed in 1.16.1

Before 1.16.1, $materialize was intended only to enable scheduling. It added the scheduling tag, and once the tag was present, repeating the call was a no-op that still returned status=success. Starting with 1.16.1, $materialize re-evaluates the materialization horizon on every call and creates any occurrences that have newly entered it (occurrences already materialized are not duplicated). A backend without a Subscription can therefore call $materialize periodically to keep the horizon filled as time advances.

Reconcile vs. additive materialization (_reconcile)

$materialize accepts an optional _reconcile boolean that controls how the call treats Tasks that no longer match the CarePlan's current schedule:

  • Reconcile — the non-terminal Task set is made an exact match for the CarePlan's in-window schedule. Occurrences that are no longer scheduled are deleted, retained occurrences whose due time moved are refreshed in place (keeping their id, status, and any in-flight work), and newly scheduled occurrences are created.
  • Additive (_reconcile=false) — the call only adds Tasks for occurrences that have no Task yet. Tasks left over from a previous schedule are not removed; the caller is responsible for reconciling them.
Default changed in 2.0.0

Up to and including 1.x, $materialize was additive by default and reconcile was opt-in (_reconcile=true). Starting with Fire Arrow Server 2.0.0, $materialize reconciles by default — a call with no _reconcile parameter now removes orphaned Tasks and refreshes drifted due times in addition to adding missing ones. If you run a $materialize tick purely to roll the horizon forward and rely on the previous add-only behavior, pass _reconcile=false. See the 2.0.0 release notes.

Using $materialize with and without a subscription

Two server behaviors — rolling the horizon forward over time, and reacting to edits of a CarePlan's schedule — are driven by an active Subscription, not by the scheduling tag that $materialize sets. As a result, $materialize behaves differently depending on whether a Subscription also exists for the CarePlan.

Without a Subscription (a backend that only ever calls $materialize):

  • The server never rolls the horizon on its own — materialization runs only when something triggers it. To advance the horizon as time passes, call $materialize again on a schedule.
  • Editing a scheduled CarePlan does not re-materialize automatically. After changing an activity's schedule, call $materialize again to pick up the new schedule. Since 2.0.0 the call reconciles by default, so it also removes Tasks for occurrences that are no longer scheduled and refreshes drifted due times (pass _reconcile=false for the add-only behavior).

With a Subscription (created via $subscribe-due-events, whether or not you also call $materialize):

  • Renewing the subscription with $renew-due-events re-materializes up to the current horizon, so the horizon rolls forward as part of the normal renewal cycle.
  • Editing a scheduled CarePlan automatically re-materializes from the new schedule, so no manual $materialize call is needed to pick up the change.
Editing behavior changed in 2.0.0

Before 2.0.0, editing a scheduled CarePlan (and the subscribe/renew paths) deleted all non-terminal Tasks and recreated them, so any Task a user had already started could be discarded and Task ids churned on every edit. Starting with 2.0.0 these paths reconcile at the occurrence level instead: a timing change refreshes the affected Tasks in place, removing an activity deletes only its Tasks, and still-valid Tasks keep their id and in-flight work. The reconcile after a CarePlan edit runs asynchronously just after the update commits, so a stale Task can briefly remain visible before reconcile completes.

In short, $materialize on its own is a plain "materialize up to the horizon now" control; the automatic re-materialization on edit and horizon-on-renewal behaviors come from having a Subscription.

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
auto-propagate-period-start: false # Mirror restriction.period.start into executionPeriod.start
auto-propagate-period-end: false # Mirror restriction.period.end into executionPeriod.end
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.auto-propagate-period-startMirror the planned restriction.period.start (due time) into executionPeriod.start at materialization time. Off by default: executionPeriod is normally written by your client when the work is done (see Completing Tasks). Enable for headless/pull-only clients that read planned times from executionPeriod.false
task.auto-propagate-period-endMirror the planned restriction.period.end (deadline) into executionPeriod.end at materialization time. Same use case as auto-propagate-period-start.false
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.