Patient Portal
The Canvas SDK allows you to configure and extend the patient portal. This page is the technical reference for the portal’s effects and events. For a decision-oriented overview of how much of the patient experience to build, see Choosing Your Patient Experience.
Configure Portal Menu Items #
Decide which items appear in the patient portal’s menu with the PatientPortalMenuConfiguration effect, returned from a handler on the PATIENT_PORTAL__MENU_CONFIGURATION event. Unlike the account-wide page toggles managed by Canvas Support, this lets you show or hide menu items with your own logic (per patient, program, and so on).
The list you return replaces the built-in menu — include every built-in item you want shown, in the order you want them to appear (the list order is the navigation order), and omit any you want hidden.
An item only appears if its page is also enabled in your portal settings (the PATIENT_APP_* settings managed by Canvas Support — see Managing the Patient Portal). This effect selects and orders among the enabled pages: it can hide or reorder them, but it can’t surface a page that’s turned off. Patient-portal applications aren’t part of this list — they’re appended to the navigation after the built-in items through their own registration.
Methods #
apply() → Effect #
Sets the portal menu for the patient.
itemsis required, with at least one item.
Attributes #
| Attribute | Type | Description | Required |
|---|---|---|---|
items | list[MenuItems] | The built-in menu items to show, in navigation order (at least one). Replaces the default set — omit an item to hide it. | Yes |
MenuItems #
| Member | Value | Menu item |
|---|---|---|
APPOINTMENTS | "appointments" | Appointments |
MESSAGING | "messaging" | Messaging |
MY_HEALTH | "my_health" | My Health |
PAYMENTS | "payments" | Payments |
LABS | "labs" | Lab results |
CONTACT | "contact" | Contact |
RECORDS | "records" | Health Records |
from canvas_sdk.effects import Effect
from canvas_sdk.effects.patient_portal_menu_configuration import PatientPortalMenuConfiguration
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
class MyHandler(BaseHandler):
RESPONDS_TO = EventType.Name(EventType.PATIENT_PORTAL__MENU_CONFIGURATION)
def compute(self) -> list[Effect]:
return [
PatientPortalMenuConfiguration(
items=[
PatientPortalMenuConfiguration.MenuItems.APPOINTMENTS,
PatientPortalMenuConfiguration.MenuItems.MESSAGING,
]
).apply()
]
Portal Landing Page Widgets #
The PortalWidget class adds widgets to the patient portal landing page. Return PortalWidget effects from a handler on the PATIENT_PORTAL__WIDGET_CONFIGURATION event. You can fully customize a widget or use a ready-made one provided by Canvas (Appointments, Messaging).
Methods #
apply() → Effect #
Adds the widget to the landing page.
- Exactly one of
url,content, orcomponentis required; more than one, or none, raises an error. - A
componentwidget supports only theEXPANDEDsize.
Attributes #
| Attribute | Type | Description | Required |
|---|---|---|---|
url | str | A URL to load within the widget. | Exactly one of url, content, or component |
content | str | Content to display directly within the widget. | Exactly one of url, content, or component |
component | Component | A ready-made Canvas widget: APPOINTMENTS (upcoming appointments) or MESSAGING (quick messaging). | Exactly one of url, content, or component |
size | Size | Widget width on the grid: EXPANDED (12 columns), MEDIUM (8 columns), or COMPACT (4 columns). All are 300px tall. Defaults to EXPANDED. | No |
priority | int | Orders widgets within the portal; a lower number is higher priority. Defaults to 100. | No |
Example #
from canvas_sdk.effects import Effect
from canvas_sdk.effects.widgets import PortalWidget
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
class MyHandler(BaseHandler):
RESPONDS_TO = EventType.Name(EventType.PATIENT_PORTAL__WIDGET_CONFIGURATION)
def compute(self) -> list[Effect]:
return [
PortalWidget(
url="https://example.com/info",
size=PortalWidget.Size.COMPACT,
priority=25,
).apply()
]
For a full walkthrough with examples, see the Custom Landing Page guide. Example plugins: patient_portal_plugin, and the MSF urgent-care-self-scheduler (a “Need to be seen today?” widget).
Add a Band to Every Portal Page #
To show content across the top or bottom of every page a signed-in patient visits, such as an announcement or a support bar, use a Portal Docked Application. Unlike a landing page widget, the band stays on screen as the patient moves between portal pages.
Customize Appointment Cards #
Customize your patient appointment cards so patients can easily join their telehealth appointments, cancel or reschedule appointments. Each appointment can be customized individually, allowing each one to have its own unique settings.
Each action is decided per appointment: Canvas fires a PATIENT_PORTAL__APPOINTMENT_CAN_* event for the appointment, and your handler returns the matching PATIENT_PORTAL__APPOINTMENT_IS_* (or PATIENT_PORTAL__APPOINTMENT_SHOW_MEETING_LINK) effect with a {"result": <bool>} payload — False hides the action; for the telehealth Join link, True shows it. Each action’s exact event, effect, and payload are shown below.
The Cancel and Reschedule answers also decide what Canvas accepts, not only which buttons the portal shows. When a patient cancels or reschedules, Canvas checks the appointment again and refuses the request if the button wouldn’t be offered.
- With no plugin answer, a patient can cancel or reschedule an appointment until one day before it starts.
- A plugin’s
resultreplaces that one-day rule.Truelets the patient act within the final day, andFalserefuses the action at any time. - Some appointments never offer either action, and Canvas doesn’t fire the event for them. These are appointments that are canceled, marked as a no-show, or checked in, and appointments whose note is signed, locked, or deleted. A patient also can’t cancel an appointment that has already started.
MSF example: portal_disable_cancel_appts hides the Cancel action.
Hide ‘Cancel’ button #
Respond to PATIENT_PORTAL__APPOINTMENT_CAN_BE_CANCELED and return a PATIENT_PORTAL__APPOINTMENT_IS_CANCELABLE effect with {"result": False} to hide the Cancel button.
import json
from canvas_sdk.effects import Effect, EffectType
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
class MyHandler(BaseHandler):
RESPONDS_TO = EventType.Name(EventType.PATIENT_PORTAL__APPOINTMENT_CAN_BE_CANCELED)
def compute(self) -> list[Effect]:
return [
Effect(
type=EffectType.PATIENT_PORTAL__APPOINTMENT_IS_CANCELABLE,
payload=json.dumps({"result": False}))
]
Hide ‘Reschedule’ button #
Respond to PATIENT_PORTAL__APPOINTMENT_CAN_BE_RESCHEDULED and return a PATIENT_PORTAL__APPOINTMENT_IS_RESCHEDULABLE effect with {"result": False} to hide the Reschedule button.
import json
from canvas_sdk.effects import Effect, EffectType
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
class MyHandler(BaseHandler):
RESPONDS_TO = EventType.Name(EventType.PATIENT_PORTAL__APPOINTMENT_CAN_BE_RESCHEDULED)
def compute(self) -> list[Effect]:
return [
Effect(
type=EffectType.PATIENT_PORTAL__APPOINTMENT_IS_RESCHEDULABLE,
payload=json.dumps({"result": False}))
]
Hide ‘Join’ button #
This button shows on telehealth appointments. Respond to PATIENT_PORTAL__APPOINTMENT_CAN_SHOW_MEETING_LINK and return a PATIENT_PORTAL__APPOINTMENT_SHOW_MEETING_LINK effect with {"result": True} to show the join link.
import json
from canvas_sdk.effects import Effect, EffectType
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
class MyHandler(BaseHandler):
RESPONDS_TO = EventType.Name(EventType.PATIENT_PORTAL__APPOINTMENT_CAN_SHOW_MEETING_LINK)
def compute(self) -> list[Effect]:
return [
Effect(
type=EffectType.PATIENT_PORTAL__APPOINTMENT_SHOW_MEETING_LINK,
payload=json.dumps({"result": True}))
]
Configuring the Patient Portal #
Return a PatientPortalApplicationConfiguration effect from a handler on the PATIENT_PORTAL__GET_APPLICATION_CONFIGURATION event to set application-level portal options:
- Self-scheduling: show or hide the self-scheduling entry points. Pair this with Shape Self-Scheduling to control what patients can book.
- Default homepage: open one of your plugin’s portal applications, instead of the widgets landing page, when a patient lands on the portal home page.
Methods #
apply() → Effect #
Applies the portal options. Every attribute is optional; set only the options you want to change.
- If
default_homepage_application_identifieris set, it must match an installed application, or the effect raises a validation error.
Attributes #
| Attribute | Type | Description | Required |
|---|---|---|---|
can_schedule_appointments | bool | If the patient is allowed to book or reschedule appointments. When omitted, patients can self-schedule if at least one note type is schedulable through the patient portal. | No |
default_homepage_application_identifier | str | The application to open as the patient’s portal homepage. Use the application’s class string from CANVAS_MANIFEST.json, the <module path>:<ClassName> value. When omitted, patients see the widgets landing page. | No |
The portal opens the default homepage application only when that application uses the portal_menu_item scope and its plugin is enabled. Otherwise, patients see the widgets landing page.
While a default homepage application is set, every visit to the portal home page opens it, including the header logo link, so patients don’t see landing page widgets. Portal consents, forms, and modals from PATIENT_PORTAL__POST_LOGIN handlers still appear on top of the application. A sign-in link that names another portal page, such as messages, opens that page instead.
from canvas_sdk.effects import Effect
from canvas_sdk.effects.patient_portal.application_configuration import PatientPortalApplicationConfiguration
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
class MyHandler(BaseHandler):
RESPONDS_TO = EventType.Name(EventType.PATIENT_PORTAL__GET_APPLICATION_CONFIGURATION)
def compute(self) -> list[Effect]:
return [
PatientPortalApplicationConfiguration(
can_schedule_appointments=True,
default_homepage_application_identifier="my_plugin.apps.care_dashboard:CareDashboardApp",
).apply()
]
Theme the Patient Portal #
Brand the patient portal with your practice’s colors, font, and corner radius by returning a PatientPortalTheme effect from a handler on the PATIENT_PORTAL__GET_THEME_CONFIGURATION event. The theme applies to every portal page, including the login and registration pages. The exception is custom_css, which applies only on signed-in pages. For a walkthrough with screenshots of what each attribute changes, see Theming the Patient Portal.
Canvas sends the event when a portal page loads. Its context carries business_line_id: the ID of the business line named in the portal URL’s ?bl= parameter, or null when the URL names no active business line. Return a theme for null to brand the whole instance, or check the ID to give a business line its own theme.
Canvas caches each theme for 60 seconds per business line, so a change can take up to a minute to appear. If several effects are returned, the last one wins. If the handler raises an error, the portal shows its default theme.
Every attribute is optional. An attribute you leave unset keeps the portal’s default, and an effect with no attributes returns the default theme.
Methods #
apply() → Effect #
Applies the theme. The effect raises a validation error that names the attribute at fault when:
- An attribute isn’t one of those listed below. A misspelled attribute is an error rather than being ignored.
- A value doesn’t match its format or range in the Attributes table.
custom_csscontains a<character, or anything that makes the browser fetch:url(),@import,image-set(),@font-face, or a backslash escape.font_sourceis set withoutfont_family.
Attributes #
| Attribute | Type | Description | Required |
|---|---|---|---|
brand_color | str | The brand color, as a six-digit hex color such as "#0F766E". | No |
menu_background_color | str | The background of the left navigation menu, as a six-digit hex color. | No |
menu_selected_background_color | str | The background of the selected menu item, as a six-digit hex color. | No |
menu_text_color | str | The menu label color, as a six-digit hex color. When unset with a custom menu_background_color, the portal picks dark or white text from the menu background’s brightness. | No |
page_background_color | str | The page background, as a six-digit hex color. | No |
corner_radius | int | The corner radius, in pixels, from 0 to 24. | No |
font_family | str | A CSS font family list, such as "Inter, sans-serif". Up to 200 characters of letters, digits, spaces, commas, and hyphens. Quotes are not allowed; write multi-word names unquoted, such as "Open Sans". | No |
font_source | str | The Google Fonts stylesheet that loads font_family, starting with https://fonts.googleapis.com/css2? or https://fonts.googleapis.com/css?. Up to 2,000 characters. Requires font_family. | No |
person_word | str | The word the portal uses for the person, such as in the “Patient Login” heading: "patient", "member", or "client". | No |
custom_css | str | Your own CSS for signed-in portal pages, up to 50,000 characters. It must not make the browser fetch anything: no <, url(), @import, image-set(), @font-face, or backslash escapes. It does not apply on the login, registration, password reset, or verification pages. | No |
Setting font_family replaces the portal’s default webfont, including in questionnaire forms. The theme also applies in pop-up windows, such as questionnaire forms and the Session Expired notice.
Set the look with the theme attributes where you can. The portal’s CSS class names aren’t a public API, so selectors in custom_css can stop matching when the portal changes.
Color contrast #
apply() doesn’t check color contrast, so check that the colors you choose stay readable. For normal text, WCAG 2 AA needs a contrast ratio of at least 4.5:1. The portal draws these colors together:
- Menu text on
menu_background_color, and onmenu_selected_background_color. - Body text on
page_background_color. brand_colorlinks and section titles onpage_background_color, and on the light brand tint behind section and accordion headers.- White text on
brand_color, on primary buttons and sent messages. brand_colortext onmenu_selected_background_color, when the menu keeps its default background.
Example #
from canvas_sdk.effects import Effect
from canvas_sdk.effects.patient_portal.theme import PatientPortalTheme
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
class PortalThemeHandler(BaseHandler):
RESPONDS_TO = EventType.Name(EventType.PATIENT_PORTAL__GET_THEME_CONFIGURATION)
def compute(self) -> list[Effect]:
return [
PatientPortalTheme(
brand_color="#0F766E",
page_background_color="#F0FDFA",
# A dark menu: the portal picks white menu text.
menu_background_color="#1F2937",
corner_radius=8,
font_family="Inter, sans-serif",
font_source="https://fonts.googleapis.com/css2?family=Inter&display=swap",
person_word="member",
).apply()
]
Shape Self-Scheduling #
When patients self-schedule, you can filter or reorder what they’re offered before they see it — the open time slots, and the appointment-type, location, and provider options on the scheduling form. Each search step fires a …PRE_SEARCH event (before Canvas runs the search) and a …POST_SEARCH event (with the results). Respond to the POST_SEARCH event and return an effect of the same name with a _RESULTS suffix, carrying the results you want the patient to see. (A …PRE_SEARCH event is available for each step too, if you need to act before the search runs.)
Canvas applies the same appointment-type, location, and provider filters when a patient books or reschedules. It runs your handlers again for that patient and refuses the request unless each selected option is in the lists they return. A booking or new time also needs:
- Self-scheduling turned on for the patient. See
can_schedule_appointmentsin Configuring the Patient Portal. - A start time in the future.
- A duration no longer than the appointment type’s patient portal duration (the NoteType
online_duration).
Two example plugins put these together: patient_portal_search_appointments_slots_plugin (slots) and patient_app_schedule (appointment types, locations, and providers).
Filter available slots #
Respond to PATIENT_PORTAL__APPOINTMENTS__SLOTS__POST_SEARCH and return PATIENT_PORTAL__APPOINTMENTS__SLOTS__POST_SEARCH_RESULTS.
Target: self.target is the Patient id.
Context: self.context["slots_by_provider"] — a JSON string of {provider: {date: [{"start", "end"}, ...]}}, keyed by Staff id, where each start/end is an ISO-8601 datetime.
Example — enforce a 48-hour minimum lead time so patients can’t self-book last minute:
import json
import arrow
from canvas_sdk.effects import Effect, EffectType
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
# Patients must book at least this far ahead — no last-minute self-scheduling
MIN_LEAD_TIME_HOURS = 48
class SlotFilter(BaseHandler):
RESPONDS_TO = EventType.Name(EventType.PATIENT_PORTAL__APPOINTMENTS__SLOTS__POST_SEARCH)
def compute(self) -> list[Effect]:
# slots_by_provider maps each provider to {date: [{"start": ..., "end": ...}, ...]}
slots_by_provider = json.loads(self.context.get("slots_by_provider") or "{}")
cutoff = arrow.now().shift(hours=MIN_LEAD_TIME_HOURS)
filtered = {}
for provider, dates in slots_by_provider.items():
# drop any slot starting before the cutoff
kept = {
date: [slot for slot in slots if arrow.get(slot["start"]) >= cutoff]
for date, slots in dates.items()
}
# then drop empty dates, and providers left with no slots
kept = {date: slots for date, slots in kept.items() if slots}
if kept:
filtered[provider] = kept
return [
Effect(
type=EffectType.PATIENT_PORTAL__APPOINTMENTS__SLOTS__POST_SEARCH_RESULTS,
payload=json.dumps({"slots_by_provider": filtered}),
)
]
Filter appointment types #
Respond to PATIENT_PORTAL__APPOINTMENTS__FORM_APPOINTMENT_TYPES__POST_SEARCH and return PATIENT_PORTAL__APPOINTMENTS__FORM_APPOINTMENT_TYPES__POST_SEARCH_RESULTS.
Target: self.target is the Patient id.
Context: self.context["appointment_types"] — a list of {"id", "title"} entries, where id is a NoteType id (its unique_identifier) and title is the note type’s name.
Example — only let patients self-book a specific set of visit types (e.g. keep follow-ups and telehealth, hide new-patient intakes):
import json
from canvas_sdk.effects import Effect, EffectType
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
# Visit types patients are allowed to self-book
PATIENT_BOOKABLE_TYPES = {"Follow-up", "Telehealth visit"}
class AppointmentTypeFilter(BaseHandler):
RESPONDS_TO = EventType.Name(
EventType.PATIENT_PORTAL__APPOINTMENTS__FORM_APPOINTMENT_TYPES__POST_SEARCH
)
def compute(self) -> list[Effect]:
# each entry is {"id": ..., "title": ...}
appointment_types = self.context.get("appointment_types", [])
filtered = [t for t in appointment_types if t["title"] in PATIENT_BOOKABLE_TYPES]
return [
Effect(
type=EffectType.PATIENT_PORTAL__APPOINTMENTS__FORM_APPOINTMENT_TYPES__POST_SEARCH_RESULTS,
payload=json.dumps({"appointment_types": filtered}),
)
]
Filter locations #
Respond to PATIENT_PORTAL__APPOINTMENTS__FORM_LOCATIONS__POST_SEARCH and return PATIENT_PORTAL__APPOINTMENTS__FORM_LOCATIONS__POST_SEARCH_RESULTS.
Target: self.target is the Patient id.
Context: self.context["locations"] — a list of {"id", "title"} entries, where id is a PracticeLocation id and title is its full name.
Example — only show locations where the patient has been seen before:
import json
from canvas_sdk.effects import Effect, EffectType
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
from canvas_sdk.v1.data import Appointment
class LocationFilter(BaseHandler):
RESPONDS_TO = EventType.Name(
EventType.PATIENT_PORTAL__APPOINTMENTS__FORM_LOCATIONS__POST_SEARCH
)
def compute(self) -> list[Effect]:
locations = self.context.get("locations", [])
seen_location_ids = {
str(loc_id)
for loc_id in Appointment.objects.filter(patient__id=self.target)
.exclude(location__isnull=True)
.values_list("location__id", flat=True)
}
filtered = [loc for loc in locations if loc["id"] in seen_location_ids]
return [
Effect(
type=EffectType.PATIENT_PORTAL__APPOINTMENTS__FORM_LOCATIONS__POST_SEARCH_RESULTS,
payload=json.dumps({"locations": filtered}),
)
]
Filter providers #
Respond to PATIENT_PORTAL__APPOINTMENTS__FORM_PROVIDERS__POST_SEARCH and return PATIENT_PORTAL__APPOINTMENTS__FORM_PROVIDERS__POST_SEARCH_RESULTS.
Target: self.target is the Patient id.
Context: self.context["providers"] — a list of {"id", "title"} entries, where id is a Staff id and title is the provider’s name and roles.
Example — only offer the patient’s own care-team providers:
import json
from canvas_sdk.effects import Effect, EffectType
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
from canvas_sdk.v1.data import CareTeamMembership
from canvas_sdk.v1.data.care_team import CareTeamMembershipStatus
class ProviderFilter(BaseHandler):
RESPONDS_TO = EventType.Name(
EventType.PATIENT_PORTAL__APPOINTMENTS__FORM_PROVIDERS__POST_SEARCH
)
def compute(self) -> list[Effect]:
providers = self.context.get("providers", [])
care_team_ids = {
str(staff_id)
for staff_id in CareTeamMembership.objects.filter(
patient__id=self.target, status=CareTeamMembershipStatus.ACTIVE
).values_list("staff__id", flat=True)
}
filtered = [p for p in providers if p["id"] in care_team_ids]
return [
Effect(
type=EffectType.PATIENT_PORTAL__APPOINTMENTS__FORM_PROVIDERS__POST_SEARCH_RESULTS,
payload=json.dumps({"providers": filtered}),
)
]
Show a Modal After Login #
Show a patient a consent form, a tour, or an alert as soon as they log in to the portal. Respond to the PATIENT_PORTAL__POST_LOGIN event and return a LaunchModalEffect with the DEFAULT_MODAL target. The portal opens the modal over the page the patient lands on.
The event targets the patient. Its context carries login_method, which tells you how the patient logged in:
login_method | How the patient logged in |
|---|---|
"credentials" | Entered their username and password on the login page, including after setting a password from a portal invite or password reset link |
"access_token" | Followed the sign-in link in a message, lab result, or statement notification |
"registration" | Completed self-registration |
The event fires once per login, in the browser tab where the patient logged in. Refreshing the page or returning to the portal with a remembered session doesn’t fire it. When no handler responds, or the plugin runner fails, the patient lands in the portal with no modal.
from canvas_sdk.effects import Effect
from canvas_sdk.effects.launch_modal import LaunchModalEffect
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
class WelcomeModal(BaseHandler):
RESPONDS_TO = EventType.Name(EventType.PATIENT_PORTAL__POST_LOGIN)
def compute(self) -> list[Effect]:
# Only greet patients who just registered
if self.context.get("login_method") != "registration":
return []
return [
LaunchModalEffect(
content="<h2>Welcome to the portal</h2><p>Here's how to get started.</p>",
target=LaunchModalEffect.TargetType.DEFAULT_MODAL,
title="Welcome",
).apply()
]
Link to After-Visit Summaries #
A patient can download the after-visit summary of their own locked notes from the portal at /app/note/<note id>/aftervisitsummary, where <note id> is the note’s id. The portal has no built-in link to it, so share the link from a plugin, such as a widget or a portal page, or in a message.
- The PDF is the same after-visit summary a clinician prints from the note.
- Only the patient the note belongs to can open it, and only once the note is locked. Any other note id returns a 404 error.
- Each download is recorded in the audit log against the patient, and fires an event your plugin can respond to.
Link to Lab and Imaging Requisitions #
A patient can download the requisition of their own lab and imaging orders from the portal. As with after-visit summaries, the portal has no built-in link, so share the link from a plugin or in a message. Each order type has its own URL and PDF:
| Order | Portal URL | PDF the patient gets |
|---|---|---|
| Lab | /app/laborder/<order id>/requisition | The requisition a clinician prints from the order. For an order sent electronically through Health Gorilla, the patient gets Health Gorilla’s requisition form, as staff do. |
| Imaging | /app/imagingorder/<order id>/requisition | The “Prepare for your imaging test” letter staff print for the patient. |
<order id> is the id of the LabOrder or ImagingOrder.
- Only the patient the order belongs to can open it, and only once the order is committed and not entered in error. Any other order id returns a 404 error.
- By default, patients can download the requisition only while the order is open. A lab order is open until the chart shows all of its reports received. An imaging order is open until it has a result, not counting results deleted in Data Integration. To let patients download the requisitions of completed orders too, contact Canvas Support to set
PATIENT_PORTAL_REQUISITION_ORDER_STATESto Open and completed orders. - Each download is recorded in the audit log against the patient, and fires an event your plugin can respond to.
Track Document Downloads #
Each after-visit summary or requisition download fires the PATIENT_PORTAL__DOCUMENT_DOWNLOADED event, so your plugin can keep its own record of which documents a patient has received. The event targets the patient. It is sent after the PDF is served, so a handler can’t delay or block the download.
The event context names the document and the record it came from:
document | Record key | Record |
|---|---|---|
"after_visit_summary" | note_id | The id of the Note |
"lab_requisition" | order_id | The id of the LabOrder |
"imaging_requisition" | order_id | The id of the ImagingOrder |
from canvas_sdk.effects import Effect
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
from logger import log
class TrackDocumentDownloads(BaseHandler):
RESPONDS_TO = EventType.Name(EventType.PATIENT_PORTAL__DOCUMENT_DOWNLOADED)
def compute(self) -> list[Effect]:
document = self.context.get("document")
if document == "after_visit_summary":
log.info(f"After-visit summary downloaded for note {self.context['note_id']}")
elif document in ("lab_requisition", "imaging_requisition"):
log.info(f"{document} downloaded for order {self.context['order_id']}")
return []
Forms #
Forms let you dynamically display questionnaires to patients in the portal based on your own criteria, and commit each response to the patient’s chart as a Questionnaire Command when they submit. Because your handler runs on every portal page load, return only the forms that should currently appear.
Respond to the PATIENT_PORTAL__GET_FORMS event and return a FormResult effect for each questionnaire to show.
Methods #
apply() → Effect #
Shows the questionnaire to the patient.
questionnaire_idis required.
Attributes #
| Attribute | Type | Description | Required |
|---|---|---|---|
questionnaire_id | str | UUID | The unique ID of the Questionnaire to show. | Yes |
create_command | bool | Strongly recommended True. Commits the response as a Questionnaire Command in a note so it appears in the chart. When False (the default), the response is saved only as an Interview and never appears in a note. | No |
note_id | str | UUID | None | The Note to place the command on. Only used when create_command is True; if omitted, Canvas creates and locks a new note for it. Has no effect on its own. | No |
Example #
Assign intake questionnaires when a patient has a confirmed upcoming visit, skipping any they’ve already completed:
import arrow
from canvas_sdk.effects.patient_portal.form_result import FormResult
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
from canvas_sdk.v1.data import Interview, Patient, Questionnaire
from canvas_sdk.v1.data.appointment import AppointmentProgressStatus
INTAKE_QUESTIONNAIRES = ["Insurance Details", "Preferred Pharmacy Details", "Social History"]
APPOINTMENT_NOTE_TYPES = ["Telehealth", "Office visit"]
class Handler(BaseHandler):
RESPONDS_TO = EventType.Name(EventType.PATIENT_PORTAL__GET_FORMS)
def _upcoming_appointment_note_id(self, appointments):
return appointments.filter(
status=AppointmentProgressStatus.CONFIRMED,
start_time__gt=arrow.now().date(),
note_type__name__in=APPOINTMENT_NOTE_TYPES,
).values_list("note__id", flat=True).first()
def compute(self):
patient = Patient.objects.get(id=self.target)
# Only assign intake forms when there's a confirmed upcoming visit
note_id = self._upcoming_appointment_note_id(patient.appointments)
if not note_id:
return []
# Which intake questionnaires are already completed *on this visit's note*.
# committed() automatically drops entered-in-error and uncommitted interviews.
# The same intake form is expected each visit, so scope the check to this note
# rather than the whole patient.
completed = set(
Interview.objects.for_patient(patient.id)
.committed()
.filter(note_id=note_id, questionnaires__name__in=INTAKE_QUESTIONNAIRES)
.values_list("questionnaires__name", flat=True)
)
missing = [name for name in INTAKE_QUESTIONNAIRES if name not in completed]
missing_ids = Questionnaire.objects.filter(name__in=missing).values_list("id", flat=True)
return [
FormResult(questionnaire_id=qid, create_command=True, note_id=note_id).apply()
for qid in missing_ids
]
Best practices:
- Match questionnaires by name, not ID — it always resolves to the latest version. When a questionnaire is updated, the previous version is archived with a
(v#)suffix on its name (e.g.Social History (v1)) while the current version keeps the clean name. Filtering by the exact name therefore returns only the latest version, and (as a bonus) stays portable across instances, where IDs differ. - Prevent duplicates — but scope the check. This logic runs on every page load, so check existing committed
Interviewresponses before assigning a form again. Because the same intake form is typically expected at every visit, scope that check to the current appointment’snote_idrather than the whole patient (use.committed()so entered-in-error responses are ignored automatically). - Don’t let forms linger. Return a questionnaire only while it should be shown; once it’s completed or no longer relevant, stop returning it.
- Always land responses in the chart. Set
create_command=Trueso each submission is committed as a Questionnaire Command; pass anote_idto target a specific note, or omit it to let Canvas create one. Withoutcreate_command, the response is only an Interview and the care team won’t see it.
Real-world example: the MSF patient-portal-forms extension assigns questionnaires (PHQ-9, GAD-7, ROS…) that patients complete in the portal, posting responses back to the chart.
Writing answers back to the chart #
A submitted form is stored as an Interview, which links the patient to the questionnaire. To use the answers elsewhere in the chart, such as updating the patient’s demographics, respond to the INTERVIEW_CREATED event, and to INTERVIEW_UPDATED as well, since answers can arrive after the Interview is created. The event targets the Interview and carries the patient’s id. Read the answers from the Interview, then:
- Update demographics, contact details, and other patient fields with the patient effects.
- Record insurance coverage through the FHIR Coverage API, using the Canvas FHIR client, since no SDK effect writes coverage.
User Login #
These effects manage a patient’s portal user account — the login they use to access the portal. Invites and password resets are sent to the email or phone linked on the user, so make sure those contact points are actually verified before you invite a patient or rely on an updated value. Trigger a verification with the Send Contact Verification effect.
Update User #
The UpdateUserEffect updates a portal user’s phone number or email. In the future this may support additional attributes, but for now only these two are supported. It isn’t tied to a specific portal event — emit it from whatever handler fits your workflow (a common one is keeping the portal user in sync in response to PATIENT_CONTACT_POINT_UPDATED, as below).
Methods #
apply() → Effect #
Updates the user’s phone number or email.
user_dbidis required, and must be thedbidof an existing user; otherwise the effect returns aUser does not existerror.- Only the fields you set are changed, and setting one to
Noneclears it. With neither set, nothing changes.
Attributes #
| Attribute | Type | Description | Required |
|---|---|---|---|
user_dbid | int | The dbid of the CanvasUser to update. | Yes |
phone_number | str | The phone number to store. | No |
email | str | The email to store. | No |
from canvas_sdk.effects import Effect
from canvas_sdk.effects.update_user import UpdateUserEffect
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
from canvas_sdk.v1.data import PatientContactPoint
from canvas_sdk.v1.data.common import ContactPointSystem
class SyncPortalUser(BaseHandler):
RESPONDS_TO = EventType.Name(EventType.PATIENT_CONTACT_POINT_UPDATED)
def compute(self) -> list[Effect]:
contact_point = PatientContactPoint.objects.get(id=self.target)
# Only sync verified contact points. last_verified is set once the patient
# completes the verification challenge (see Send Contact Verification); until
# then the value shouldn't be trusted as the account's login channel.
if not contact_point.last_verified:
return []
user = contact_point.patient.user
# Only update when the value actually changed. Emitting an effect that sets the
# user to what it already is is a no-op — and can retrigger
# PATIENT_CONTACT_POINT_UPDATED, causing an update loop.
if contact_point.system == ContactPointSystem.EMAIL and user.email != contact_point.value:
return [UpdateUserEffect(user_dbid=user.dbid, email=contact_point.value).apply()]
if contact_point.system == ContactPointSystem.PHONE and user.phone_number != contact_point.value:
return [UpdateUserEffect(user_dbid=user.dbid, phone_number=contact_point.value).apply()]
return []
Send Invite #
The SendInviteEffect triggers a portal invitation that lets the patient register or activate their portal account. Because the invite is delivered to the patient’s email or phone, only send it once that contact point is verified (PatientContactPoint.last_verified is set — see Send Contact Verification) and the patient hasn’t already registered (CanvasUser.is_portal_registered). Verification completes with a save that fires PATIENT_CONTACT_POINT_UPDATED, which makes it a natural trigger for the invite. Re-inviting someone who has already activated their account just sends a redundant email/SMS.
Methods #
apply() → Effect #
Sends the portal invitation.
user_dbidis required, and must be thedbidof an existing user; otherwise the effect returns aUser does not existerror.
Attributes #
| Attribute | Type | Description | Required |
|---|---|---|---|
user_dbid | int | The dbid of the CanvasUser to invite. | Yes |
from canvas_sdk.effects import Effect
from canvas_sdk.effects.send_invite import SendInviteEffect
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
from canvas_sdk.v1.data import PatientContactPoint
class InviteVerifiedPatient(BaseHandler):
# Verification completes with a save that fires this event
RESPONDS_TO = EventType.Name(EventType.PATIENT_CONTACT_POINT_UPDATED)
def compute(self) -> list[Effect]:
contact_point = PatientContactPoint.objects.get(id=self.target)
# Only invite once the contact point is actually verified — that's the
# channel the invitation will be delivered to.
if not contact_point.last_verified:
return []
user = contact_point.patient.user
# Only invite once: skip if there's no portal user yet, or they've already registered
if user is None or user.is_portal_registered:
return []
return [SendInviteEffect(user_dbid=user.dbid).apply()]
Sending versus minting a link. SendInviteEffect lets Canvas send the invite over the patient’s verified contact point, and it records the send on CanvasUser.last_invite_date_time. To deliver the invite or password reset over your own transport instead, mint a link with patient_portal_http.get_login_url() and send it yourself. Minting a link does not update last_invite_date_time; only SendInviteEffect does. Canvas has no record that you delivered the link, so the patient’s Portal section is unchanged and staff may send a second invite to a patient who already holds a live link.
What the patient does with the link is still visible in that section. Setting up the account sets is_portal_registered on the CanvasUser data module, which replaces the pending username with theirs and changes the button to Reset password. Signing in is a separate step that sets last_login, which is what replaces “The patient has never logged in to the patient portal.” with a last login time. Opening the link and setting a password does not sign the patient in, so a registered patient who has not yet signed in shows both their username and the never-logged-in line.