Redirect
The RedirectEffect tells the Canvas frontend to navigate the browser to a destination. The plugin returns the effect from a handler and the frontend performs a full-page navigation. In the patient portal, portal pages change in place instead (see Redirects in the patient portal). The headline use case is sending a user onward after a note is signed — for example, navigating back to a work queue to pick up the next patient.
A redirect is delivered only to the acting user who triggered the handler — and only to that user’s browser. Because of that, it takes effect only when the handler runs in the context of a real user with an active browser session. Return it from user-initiated handlers — a note state-change (sign/lock) handler, an action-button handler, an application handler, or an authenticated SimpleAPI call. If a handler has no user actor — for example a CronTask, other background processing, or any event whose actor defaults to canvas-bot — there is no browser to navigate and the redirect is silently ignored. See Event Actor for which events carry an actor.
Provide exactly one destination:
url— a full URL string the plugin composes in Python (it may include patient/note ids). Either an external URL (https://...) or an internal Canvas path (/panel,/patient/{id}?noteId=...).application_id— the identifier of a Canvas application to open.
By default the navigation replaces the current tab. Set target to RedirectEffect.TargetType.NEW_TAB to open a url destination in a new tab instead.
Internal navigation must be a root-relative path that starts with
/— e.g.url="/schedule"(or/panel,/patient/{id}?noteId=...). A leading-slash path is the only form treated as internal navigation. A bare page name likeschedulewill not work: anything that doesn’t start with/is treated as an external URL and is rejected unless it’s a fullhttps://...URL (protocol-relative//...and backslash/\...values are always rejected). The matchingREDIRECT_ALLOWLIST_INTERNALentries must likewise be leading-slash paths (e.g./schedule).
For staff, an internal url can be any Canvas path, including its query string. The paths staff use most, such as the patient chart, schedule, patient list, and revenue pages, are listed in Pages a pane sees. For patients, internal paths are limited to the portal routes.
Build a RedirectEffect and return its apply() from your handler.
Methods #
apply() → Effect #
Redirects the user.
- Provide exactly one of
urlorapplication_id. Anapplication_idmust identify an existing application. targetdefaults toSAME_TAB.
Attributes #
| Name | Type | Description | Required |
|---|---|---|---|
url | str | A full external URL (https://...) or an internal Canvas path that must start with / (e.g. /schedule, /patient/{id}), composed by the plugin. Non-empty. For common staff paths, see Pages a pane sees. For patients, see the portal routes. | Exactly one of url or application_id |
application_id | str | The identifier of a Canvas application to open. Must exist and be enabled. | Exactly one of url or application_id |
target | TargetType | Where to open a url destination. Defaults to TargetType.SAME_TAB. | No |
TargetType #
A StrEnum of the supported navigation targets. You can also pass the string value.
| Member | Value | Behavior |
|---|---|---|
RedirectEffect.TargetType.SAME_TAB | "same_tab" | Replaces the current EHR view (full-page navigation). The default. |
RedirectEffect.TargetType.NEW_TAB | "new_tab" | Opens the destination in a new browser tab. |
Security & Allowlist #
Every destination is validated on the server before the browser navigates — the frontend is never trusted to decide whether a target is allowed. This blocks open-redirect abuse and accidental leakage of PHI through query parameters. Targets are denied by default.
The allowlist governs only where a plugin may send a user — it does not change what that user is allowed to see, and cannot be used to bypass their permissions. A redirect performs an ordinary browser navigation, so the destination still enforces the user’s own access: redirecting a user to a page or application they lack permission for behaves exactly as if they navigated there themselves (they’re denied by that destination), and never elevates their access.
The allowlist is configured per instance by an administrator via three plugin secrets. Your plugin declares the keys in its manifest variables; the admin sets each value on the Plugin admin page, or from the CLI with canvas config set. (This redirect allowlist is separate from the manifest’s url_permissions field, which allow-lists iframe and script domains for layout effects — the two are unrelated.) Each value is a list with one entry per line — entries are newline-delimited, not comma-separated, because URLs and paths can legitimately contain commas (e.g. ?q=1,2,3):
| Secret key | Value (one entry per line) | Permits |
|---|---|---|
REDIRECT_ALLOWLIST_INTERNAL | /patients/panel/patient | those path roots and anything the plugin composes under them, matched at a path boundary (/patient/{id}?noteId=...). |
REDIRECT_ALLOWLIST_EXTERNAL | https://app.example.com | those origins/prefixes (include the scheme), matched case-insensitively at an origin/path boundary — so it does not match https://app.example.com.evil.com, and a differing port (e.g. https://app.example.com:8443/...) is not a match. |
REDIRECT_ALLOWLIST_APPLICATION | my_plugin.applications.app:MyApp | redirecting to those applications by id (matched exactly; the app must exist and be enabled). |
Declare the keys in your manifest so the admin can fill them:
{
"variables": [
{ "name": "REDIRECT_ALLOWLIST_INTERNAL" },
{ "name": "REDIRECT_ALLOWLIST_EXTERNAL" },
{ "name": "REDIRECT_ALLOWLIST_APPLICATION" }
]
}
Both steps are required, and both default to “blocked”: if you don’t declare a key in the manifest, the admin has no field to fill; if the admin doesn’t set a value, that key’s allowlist is empty. An empty or absent secret allows nothing — so each redirect category (internal / external / application) only works once its key is declared and an admin has given it a value. A freshly installed plugin can therefore redirect nowhere until an admin opts it in.
Set a value from the CLI with your shell’s newline quoting so each entry stays on its own line (see canvas config set):
$ canvas config set acme__my_plugin $'REDIRECT_ALLOWLIST_INTERNAL=/panel\n/patient' --instance acme-staging
Non-allowlisted destinations are dropped, and the platform logs only the plugin name and the blocked host (never the full URL/path). Protocol-relative (//host) and backslash (/\host) targets are always rejected.
Redirects in the patient portal #
A plugin can also redirect a patient who is signed in to the patient portal. Use it to move the patient from a plugin page to their next step, such as their messages, lab results, or another portal application. The redirect works from portal menu item applications, landing page widgets, and other plugin pages shown in the portal. Return the effect from an endpoint authenticated with PatientSessionAuthMixin, using the same pattern as Redirect from an application iframe.
Every portal redirect must first pass the plugin’s allowlist (see Security & Allowlist). When the acting user is a patient, Canvas then checks the destination against the portal’s own rules:
| Destination | Portal rule |
|---|---|
Internal url | Must be /app or a portal route that is turned on for the instance (see the table below). Any other internal path, including /app/application/..., is blocked. |
application_id | Must identify a portal_menu_item application. Open portal applications this way, not by URL. |
External url | No extra rule. A URL that passes REDIRECT_ALLOWLIST_EXTERNAL opens in the same tab, or in a new tab with TargetType.NEW_TAB. |
These are the portal routes a plugin can redirect to. Each one works only while its portal page is turned on through its PATIENT_APP_* setting, managed by Canvas Support (see Managing the Patient Portal):
| Route | Setting |
|---|---|
/app/appointments | PATIENT_APP_APPOINTMENTS |
/app/messaging | PATIENT_APP_MESSAGING |
/app/payments | PATIENT_APP_PAYMENTS |
/app/labs | PATIENT_APP_LABS |
/app/contact | PATIENT_APP_CONTACT |
/app/records | PATIENT_APP_RECORDS |
/app/my-health | PATIENT_APP_MY_HEALTH |
The plugin’s REDIRECT_ALLOWLIST_INTERNAL secret must also include the path, for example /app/messaging or /app.
The portal changes routes and opens applications in place, without reloading the page. The browser’s Back button returns the patient to the plugin page. target has no effect on these destinations. An application opens with its portal menu item selected.
When the portal rules block a redirect, the patient stays on the current page. The plugin logs record the plugin name and either the application identifier or <internal> for a blocked path. The path itself is never logged.
Staff users, including staff who also have a patient record, use the Canvas EHR. Their redirects follow the staff rules described earlier on this page, not the portal rules.
Example Usage #
Redirect to a work queue after a note is signed #
Requires /panel in the plugin’s REDIRECT_ALLOWLIST_INTERNAL secret.
from canvas_sdk.effects.redirect import RedirectEffect
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
from canvas_sdk.v1.data.note import CurrentNoteStateEvent, NoteStates
class RedirectAfterSign(BaseHandler):
RESPONDS_TO = EventType.Name(EventType.NOTE_STATE_CHANGE_EVENT_CREATED)
def compute(self):
state = CurrentNoteStateEvent.objects.values_list("state", flat=True).get(
id=self.event.target.id
)
if state != NoteStates.LOCKED:
return []
# Send the provider back to their work queue to grab the next patient.
return [RedirectEffect(url="/panel").apply()]
Open an external URL in a new tab from an action button #
Requires https://app.example.com in the plugin’s REDIRECT_ALLOWLIST_EXTERNAL secret.
return [
RedirectEffect(
url="https://app.example.com/orders/next",
target=RedirectEffect.TargetType.NEW_TAB,
).apply()
]
Redirect to an application by id #
Requires the identifier in the plugin’s REDIRECT_ALLOWLIST_APPLICATION secret.
return [RedirectEffect(application_id="my_plugin.applications.app:MyApp").apply()]
Send a patient to their messages from a portal page #
Requires /app/messaging in the plugin’s REDIRECT_ALLOWLIST_INTERNAL secret, and the portal’s messaging page turned on.
from canvas_sdk.effects import Effect
from canvas_sdk.effects.redirect import RedirectEffect
from canvas_sdk.effects.simple_api import JSONResponse, Response
from canvas_sdk.handlers.simple_api import PatientSessionAuthMixin, SimpleAPI, api
class PortalPageAPI(PatientSessionAuthMixin, SimpleAPI):
@api.post("/go-to-messages")
def go_to_messages(self) -> list[Response | Effect]:
return [
RedirectEffect(url="/app/messaging").apply(),
JSONResponse({"ok": True}),
]
Redirect from an application iframe #
An application iframe can’t return an effect directly. The clean pattern is to expose a SimpleAPI endpoint on your plugin that returns a RedirectEffect, and have the iframe fetch() it. Because a SimpleAPI request is authenticated as the acting user, the returned effect is validated and delivered through the exact same interpreter → allowlist → per-user path as an action-button or note-sign redirect — there is no iframe-specific code path to reason about.
Why an API call and not
postMessage? An iframe couldpostMessageits parent window to request a redirect (the way the close-modal workflow does), but we recommend against it here. A redirect already has to make a server round-trip for allowlist validation, so routing the request through the parent window and a dedicated mutation would add a second mechanism that buys nothing. Having the iframe call your own API that returns the effect is cleaner:
- One mechanism, one mental model. The iframe reuses the same effect pipeline as every other redirect — no separate frontend bridge, no dedicated mutation, and target validation lives in exactly one place (the interpreter).
- Secure by construction. The plugin whose allowlist is checked is intrinsic: it’s the plugin that owns the API endpoint. Nothing frontend-supplied has to be trusted or proven un-spoofable — a
postMessagebridge would first have to attribute the message to an owning application before it could even pick which allowlist to apply.- Composable. Your endpoint can do real work first — persist state, branch on the patient/note, decide where to send the user — and then return the redirect alongside a normal JSON response.
The endpoint returns the RedirectEffect (optionally with a response body for the fetch):
from canvas_sdk.effects import Effect
from canvas_sdk.effects.redirect import RedirectEffect
from canvas_sdk.effects.simple_api import JSONResponse, Response
from canvas_sdk.handlers.simple_api import SimpleAPI, StaffSessionAuthMixin, api
class MyAppAPI(StaffSessionAuthMixin, SimpleAPI):
@api.post("/redirect")
def redirect(self) -> list[Response | Effect]:
# ...optionally do work first (persist data, decide the destination)...
return [
RedirectEffect(url="/panel").apply(), # or application_id="my_plugin.applications.app:MyApp"
JSONResponse({"ok": True}),
]
The iframe calls it with a credentialed, same-origin request:
// inside the plugin application iframe
fetch('/plugin-io/api/my_plugin/redirect', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ destination: 'panel' })
});
How the redirect arrives. The navigation does not come back in the fetch() response body. The effect is broadcast to the acting user and applied by the frontend’s redirect subscription, exactly as for any other RedirectEffect — so the fetch response is just your endpoint’s acknowledgement, and the browser navigates a moment later when the effect is delivered.
Requirements & gotchas
- The endpoint must authenticate the request as a specific Canvas user — that user is the actor the redirect targets. Use
StaffSessionAuthMixin(as above) orPatientSessionAuthMixin, which resolve the acting user from the Canvas session, or another scheme that identifies a specific user (for example an OAuth token tied to a user). A shared-secret scheme —BasicAuthMixinorAPIKeyAuthMixin— authenticates the request but establishes no acting user, so a redirect returned from it has no browser to target and is silently dropped. - For the session mixins, the request must be same-origin and credentialed (
credentials: 'same-origin') so the session is sent and the server can identify the acting user. Plugin-served iframes — rendered fromLaunchModalEffectcontent or a plugin-served URL — are same-origin. An unauthenticated request has no acting user either, so the redirect is silently dropped. - The target still has to be allowlisted (see Security & Allowlist); the API path enforces the identical gate.
target(new tab) applies only tourldestinations; anapplication_idalways opens in-app.
Validation #
Construction is validated by Pydantic and will raise a ValidationError for:
- Providing neither
urlnorapplication_id, or providing both. - An empty
url. - A
targetthat is not a member ofTargetType. - An
application_idthat does not resolve to an existing application.