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.

  • items is required, with at least one item.

Attributes #

AttributeTypeDescriptionRequired
itemslist[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 #

MemberValueMenu 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, or component is required; more than one, or none, raises an error.
  • A component widget supports only the EXPANDED size.

Attributes #

AttributeTypeDescriptionRequired
urlstrA URL to load within the widget.Exactly one of url, content, or component
contentstrContent to display directly within the widget.Exactly one of url, content, or component
componentComponentA ready-made Canvas widget: APPOINTMENTS (upcoming appointments) or MESSAGING (quick messaging).Exactly one of url, content, or component
sizeSizeWidget width on the grid: EXPANDED (12 columns), MEDIUM (8 columns), or COMPACT (4 columns). All are 300px tall. Defaults to EXPANDED.No
priorityintOrders 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 result replaces that one-day rule. True lets the patient act within the final day, and False refuses 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_identifier is set, it must match an installed application, or the effect raises a validation error.

Attributes #

AttributeTypeDescriptionRequired
can_schedule_appointmentsboolIf 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_identifierstrThe 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_css contains a < character, or anything that makes the browser fetch: url(), @import, image-set(), @font-face, or a backslash escape.
  • font_source is set without font_family.

Attributes #

AttributeTypeDescriptionRequired
brand_colorstrThe brand color, as a six-digit hex color such as "#0F766E".No
menu_background_colorstrThe background of the left navigation menu, as a six-digit hex color.No
menu_selected_background_colorstrThe background of the selected menu item, as a six-digit hex color.No
menu_text_colorstrThe 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_colorstrThe page background, as a six-digit hex color.No
corner_radiusintThe corner radius, in pixels, from 0 to 24.No
font_familystrA 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_sourcestrThe 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_wordstrThe word the portal uses for the person, such as in the “Patient Login” heading: "patient", "member", or "client".No
custom_cssstrYour 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 on menu_selected_background_color.
  • Body text on page_background_color.
  • brand_color links and section titles on page_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_color text on menu_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_appointments in 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_methodHow 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:

OrderPortal URLPDF the patient gets
Lab/app/laborder/<order id>/requisitionThe 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>/requisitionThe “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_STATES to 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:

documentRecord keyRecord
"after_visit_summary"note_idThe id of the Note
"lab_requisition"order_idThe id of the LabOrder
"imaging_requisition"order_idThe 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_id is required.

Attributes #

AttributeTypeDescriptionRequired
questionnaire_idstr | UUIDThe unique ID of the Questionnaire to show.Yes
create_commandboolStrongly 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_idstr | UUID | NoneThe 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 Interview responses before assigning a form again. Because the same intake form is typically expected at every visit, scope that check to the current appointment’s note_id rather 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=True so each submission is committed as a Questionnaire Command; pass a note_id to target a specific note, or omit it to let Canvas create one. Without create_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_dbid is required, and must be the dbid of an existing user; otherwise the effect returns a User does not exist error.
  • Only the fields you set are changed, and setting one to None clears it. With neither set, nothing changes.

Attributes #

AttributeTypeDescriptionRequired
user_dbidintThe dbid of the CanvasUser to update.Yes
phone_numberstrThe phone number to store.No
emailstrThe 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_dbid is required, and must be the dbid of an existing user; otherwise the effect returns a User does not exist error.

Attributes #

AttributeTypeDescriptionRequired
user_dbidintThe 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.