Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions extensions/note_lock_webhook/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Canvas Medical, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
89 changes: 89 additions & 0 deletions extensions/note_lock_webhook/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Note Lock Webhook

## What it does

Note Lock Webhook watches note state changes in Canvas and pushes a small payload to an endpoint of your choice the moment a note is **signed**.

Canvas emits a note state change event for every transition a note goes through — created, locked, unlocked, deleted, signed. This plugin listens to all of them but only acts on `SGN` (signed). When that transition happens, it POSTs:

```json
{
"state": "SGN",
"note_id": "<note id>",
"patient_id": "<patient id>"
}
```

That's deliberately minimal. The note id and patient id are the two keys you need to call the Canvas FHIR API for whatever else you want — the full note body, the encounter, the patient's chart — so the webhook stays fast and carries no PHI beyond identifiers.

## Problem it solves

A signed note is the moment clinical documentation becomes final, and it's the natural trigger for everything downstream: billing and coding review, care-coordination handoffs, quality reporting, syncing the note into a data warehouse, kicking off a patient follow-up.

Without a push signal, external systems have to poll the FHIR API on a timer and diff results to notice a note was signed. That's wasteful when nothing changed, and it adds latency exactly when you don't want it. This plugin inverts it: Canvas tells your system the instant a note is signed, and your system decides what to fetch.

## Who it's for

| Role | Primary use |
|---|---|
| Engineering / integrations team | Trigger downstream jobs off signed documentation instead of polling FHIR |
| Revenue cycle / billing ops | Start coding and claim review as soon as a note is final |
| Data / analytics team | Stream signed notes into a warehouse or pipeline in near real time |
| Care coordination | Fire handoffs, referrals, or patient outreach when documentation completes |

**Specialty:** not specialty-specific. Any Canvas instance where notes get signed will emit these events.

## How to install

1. Install the plugin into your Canvas instance:

```bash
canvas install note_lock_webhook
```

2. Set the plugin secrets in the Canvas admin UI, under the plugin's settings (see [Configuration options](#configuration-options)). `WEBHOOK_URL` is required — the plugin will not be able to send anything until it is set.

3. Sign a note in Canvas and confirm your endpoint receives the payload. Plugin logs are visible with:

```bash
canvas logs
```

No SDK feature flags or additional Canvas settings need to be enabled — the note state change event this plugin subscribes to is available by default.

### Requirements

- Canvas SDK `0.1.4` or later
- An HTTPS endpoint that accepts `POST` with a JSON body

## Configuration options

Both options are Canvas **plugin secrets**, set per-instance in the admin UI. Nothing needs to be changed in the source.

| Secret | Required | Description |
|---|---|---|
| `WEBHOOK_URL` | Yes | The endpoint the payload is POSTed to. Receives `Content-Type: application/json`. |
| `AUTH_TOKEN` | No | If set, sent as `Authorization: Bearer <token>`. Leave unset if your endpoint doesn't need authentication. |

To change *which* note state triggers the webhook, edit the `SIGNED_STATE` constant in `note_lock_webhook/protocols/note_lock_webhook.py` — for example, set it to `"LKD"` to fire on lock instead of sign.

## Architecture

```
note_lock_webhook/
├── CANVAS_MANIFEST.json # plugin manifest; declares the protocol and secrets
└── protocols/
└── note_lock_webhook.py # NOTE_STATE_CHANGE_EVENT_UPDATED handler
```

## Behavior notes

- Non-signed state changes return immediately without making a request, so the plugin is inert for the majority of note events.
- A non-2xx response from the webhook is logged as an error; the plugin does not retry. If delivery guarantees matter, have your endpoint enqueue the payload and acknowledge quickly.
- The protocol returns no effects — it does not modify anything in Canvas.

## Running tests

```bash
uv run pytest tests/
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"sdk_version": "0.1.4",
"plugin_version": "0.0.1",
"name": "note_lock_webhook",
"description": "Watches note state changes and POSTs the note id and patient id to an external endpoint when a note is signed.",
"components": {
"protocols": [
{
"class": "note_lock_webhook.protocols.note_lock_webhook:NoteLockWebhookProtocol",
"description": "Sends the note id and patient id to a webhook when a note is signed (state == SGN)",
"data_access": {
"event": "",
"read": [],
"write": []
}
}
],
"commands": [],
"content": [],
"effects": [],
"views": []
},
"secrets": ["WEBHOOK_URL", "AUTH_TOKEN"],
"tags": {},
"references": [],
"license": "MIT",
"diagram": false,
"readme": "../README.md"
}
Empty file.
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from canvas_sdk.effects import Effect
from canvas_sdk.events import EventType
from canvas_sdk.protocols import BaseProtocol
from canvas_sdk.utils import Http

from logger import log

# Note state that triggers the webhook. Canvas emits a note state change event for
# every transition (NEW, LKD, ULK, ...); we only care about a note being signed.
SIGNED_STATE = "SGN"


class NoteLockWebhookProtocol(BaseProtocol):
"""
When a note is signed, POST its note id and patient id to an external endpoint.
"""

RESPONDS_TO = EventType.Name(EventType.NOTE_STATE_CHANGE_EVENT_UPDATED)

def compute(self) -> list[Effect]:
"""Send the signed note's identifiers to the configured webhook."""
context = self.event.context
state = context.get("state")

if state != SIGNED_STATE:
return []

payload = {
"state": state,
"note_id": context.get("note_id"),
"patient_id": context.get("patient_id"),
}

url = self.secrets["WEBHOOK_URL"]
headers = {"Content-Type": "application/json"}

auth_token = self.secrets.get("AUTH_TOKEN")
if auth_token:
headers["Authorization"] = f"Bearer {auth_token}"

response = Http().post(url, json=payload, headers=headers)

if response.ok:
log.info(f"Sent signed note {payload['note_id']} to the webhook.")
else:
log.error(
f"Webhook rejected signed note {payload['note_id']}: "
f"{response.status_code}"
)

return []
36 changes: 36 additions & 0 deletions extensions/note_lock_webhook/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# This pyproject.toml is only used for local development and testing.
# The Canvas plugin has its own packaging process that doesn't use this file.

[project]
name = "note-lock-webhook"
version = "0.0.1"
description = "Watches note state changes and POSTs the note id and patient id to an external endpoint when a note is signed."
license = "MIT"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"canvas[test-utils]",
]

[dependency-groups]
dev = [
"pytest>=8.0.0",
"pytest-mock>=3.12.0",
]

[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = "-v"

[tool.coverage.run]
source = ["note_lock_webhook"]
omit = ["tests/*", "*/tests/*", "*/__pycache__/*"]

[tool.coverage.report]
omit = ["tests/*", "*/tests/*"]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
]
Empty file.
23 changes: 23 additions & 0 deletions extensions/note_lock_webhook/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Shared test fixtures for note_lock_webhook tests."""

from unittest.mock import MagicMock

import pytest


@pytest.fixture
def mock_event() -> MagicMock:
"""A note state change event for a signed note."""
event = MagicMock()
event.context = {
"state": "SGN",
"note_id": "note-abc-123",
"patient_id": "patient-xyz-789",
}
return event


@pytest.fixture
def secrets() -> dict:
"""Plugin secrets as configured in a Canvas instance."""
return {"WEBHOOK_URL": "https://example.test/hook", "AUTH_TOKEN": "s3cret"}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Tests for note_lock_webhook.protocols.note_lock_webhook.

Covers the signed-state filter, the payload shape, auth header handling, and
logging of a rejected webhook call.
"""

from unittest.mock import MagicMock, patch

import pytest

from note_lock_webhook.protocols.note_lock_webhook import NoteLockWebhookProtocol


def _make_protocol(event: MagicMock, secrets: dict) -> NoteLockWebhookProtocol:
protocol = NoteLockWebhookProtocol.__new__(NoteLockWebhookProtocol)
protocol.event = event
protocol.secrets = secrets
return protocol


@pytest.fixture
def mock_http():
"""Patch Http so no real request is made; yields the post() mock."""
with patch(
"note_lock_webhook.protocols.note_lock_webhook.Http"
) as http_class:
post = http_class.return_value.post
post.return_value = MagicMock(ok=True, status_code=200)
yield post


class TestSignedNote:
def test_posts_note_and_patient_id(self, mock_event, secrets, mock_http):
effects = _make_protocol(mock_event, secrets).compute()

assert effects == []
mock_http.assert_called_once()
_, kwargs = mock_http.call_args
assert kwargs["json"] == {
"state": "SGN",
"note_id": "note-abc-123",
"patient_id": "patient-xyz-789",
}

def test_posts_to_the_configured_url(self, mock_event, secrets, mock_http):
_make_protocol(mock_event, secrets).compute()

args, _ = mock_http.call_args
assert args[0] == "https://example.test/hook"

def test_sends_bearer_token_when_configured(
self, mock_event, secrets, mock_http
):
_make_protocol(mock_event, secrets).compute()

_, kwargs = mock_http.call_args
assert kwargs["headers"]["Authorization"] == "Bearer s3cret"

def test_omits_auth_header_when_token_is_unset(self, mock_event, mock_http):
secrets = {"WEBHOOK_URL": "https://example.test/hook"}

_make_protocol(mock_event, secrets).compute()

_, kwargs = mock_http.call_args
assert "Authorization" not in kwargs["headers"]

def test_logs_error_when_webhook_rejects(self, mock_event, secrets, mock_http):
mock_http.return_value = MagicMock(ok=False, status_code=500)

with patch(
"note_lock_webhook.protocols.note_lock_webhook.log"
) as mock_log:
_make_protocol(mock_event, secrets).compute()

mock_log.error.assert_called_once()
assert "note-abc-123" in mock_log.error.call_args[0][0]


class TestOtherStates:
@pytest.mark.parametrize("state", ["NEW", "LKD", "ULK", "DEL", None])
def test_does_not_post(self, mock_event, secrets, mock_http, state):
mock_event.context["state"] = state

effects = _make_protocol(mock_event, secrets).compute()

assert effects == []
mock_http.assert_not_called()

def test_does_not_require_secrets_to_be_configured(
self, mock_event, mock_http
):
"""A non-signed state must not raise even with no secrets set."""
mock_event.context["state"] = "LKD"

assert _make_protocol(mock_event, {}).compute() == []
mock_http.assert_not_called()