CDS Hooks Medication Safety Testing — Integration Guide
This guide describes how to connect your medication safety system (System Under Test, SUT) to the VO4 self-test platform. The platform sends standardized CDS Hooks requests to your system and evaluates the response cards against expected outcomes.
Two APIs, two roles:
- CDS Hooks API — this is what you implement. VO4 calls your endpoints with test scenarios. See the CDS Hooks Swagger UI for the full specification.
- VO4 Test Runner API — this is what you call. Use it to start test runs, poll status, and retrieve results (e.g. from your CI/CD pipeline). See the Test Runner Swagger UI for the full specification.
Overview
VO4 Self-Test Platform Your System (SUT)
┌──────────────┐ ┌──────────────────┐
│ │ POST /cds-services/ │ │
│ Test Runner │ ───────────────────> │ CDS Hooks │
│ │ (FHIR patient data │ Server │
│ Scenarios │ + draft medication) │ │
│ │ │ Medication │
│ Evaluator │ <─────────────────── │ Safety Engine │
│ │ (cards with signals) │ │
└──────────────┘ └──────────────────┘
The platform:
- Loads a test scenario (patient, medications, clinical data)
- Transforms it into a CDS Hooks request with FHIR resources
- Sends the request to your CDS Hooks endpoint
- Evaluates the returned cards against expected signals
- Reports PASS / FAIL / ERROR per scenario
What You Need to Implement
Your system must expose CDS Hooks 3.0 compliant HTTP endpoints:
Discovery Endpoint
GET {your-base-url}/cds-services
Returns the list of CDS services your system supports. The VO4 platform calls this to discover available hooks.
{
"services": [
{
"id": "order-sign",
"hook": "order-sign",
"title": "Medicatiebewaking",
"description": "Controleert medicatievoorschriften op veiligheid",
"prefetch": {
"patient": "Patient/{{context.patientId}}",
"medicationData": "MedicationRequest?patient={{context.patientId}}&status=active"
}
}
]
}
Note: The
prefetchfield in the discovery response advertises which FHIR queries your system needs. The VO4 platform does not use these templates — it always sends the full set of prefetch resources defined in the test scenario. However, implementing the discovery endpoint is required by the CDS Hooks specification.
Hook Endpoints
POST {your-base-url}/cds-services/{serviceId}
Where serviceId is one of:
order-sign— Medication check at prescription time (primary hook)medication-review— Scheduled medication review (no draft orders)run-protocol— Execute a scheduled MFB protocol
Authentication
Bearer token authentication is optional. When configured, the platform sends a Bearer token in the Authorization header:
Authorization: Bearer {token}
The token is configured when setting up the test run. Your system may validate this token, or accept unauthenticated requests.
Two tokens, two purposes: There are two separate authentication tokens involved when using VO4:
Token Purpose Required? SUT Bearer token Sent by VO4 to your system with each CDS Hooks request. Configured as bearer_tokenwhen creating a test run.Optional — only if your system requires authentication VO4 API token Sent by you to VO4 when calling the Test Runner API (e.g. starting test runs from CI/CD). A Sanctum token created in your VO4 account settings. Required for all Test Runner API calls
Request Format
Headers
POST /cds-services/order-sign HTTP/1.1
Content-Type: application/json
Accept: application/json
Authorization: Bearer {token}
Note: The
Authorizationheader is only present when a bearer token is configured for the test run.
Body
{
"hook": "order-sign",
"hookInstance": "d1577c69-dfbe-44ad-938d-e3d3953cfc04",
"context": {
"patientId": "patient-uuid",
"draftOrders": {
"resourceType": "Bundle",
"type": "collection",
"entry": [
{
"resource": {
"resourceType": "MedicationRequest",
"id": "draft-med-uuid",
"status": "draft",
"intent": "order",
"medicationCodeableConcept": {
"coding": [
{
"system": "urn:oid:2.16.840.1.113883.2.4.4.10",
"code": "46027",
"display": "Paracetamol tablet 500mg"
}
],
"text": "Paracetamol tablet 500mg"
},
"subject": {
"reference": "Patient/patient-uuid"
},
"dosageInstruction": [
{
"text": "3 maal per dag 2 tabletten",
"timing": {
"repeat": {
"frequency": 3,
"period": 1,
"periodUnit": "d"
}
},
"route": {
"coding": [
{
"system": "urn:oid:2.16.840.1.113883.2.4.4.9",
"code": "9",
"display": "Oraal"
}
]
},
"doseAndRate": [
{
"doseQuantity": {
"value": 2,
"unit": "stuk",
"system": "urn:oid:2.16.840.1.113883.2.4.4.1.900.2",
"code": "245"
}
}
]
}
]
}
}
]
},
"extensions": {
"nl.medicatiebewaking.scenarioContext": {
"hospitalized": false,
"practitionerInPharmacy": false
}
}
},
"prefetch": {
"patient": {
"resourceType": "Patient",
"id": "patient-uuid",
"gender": "female",
"birthDate": "1991-04-07",
"name": [
{
"given": ["Anna"],
"family": "Testpatient"
}
]
},
"medicationData": {
"resourceType": "Bundle",
"type": "searchset",
"entry": []
},
"conditions": {
"resourceType": "Bundle",
"type": "searchset",
"entry": []
},
"observations": {
"resourceType": "Bundle",
"type": "searchset",
"entry": []
}
}
}
Context Fields
| Field | Type | Description |
|---|---|---|
patientId |
string | FHIR Patient resource ID |
draftOrders |
Bundle | New/changed medications to check (for order-sign) |
protocolNumber |
integer | MFB protocol number (for run-protocol only) |
triggerMedicationId |
string | ID of trigger medication in prefetch (for run-protocol, optional) |
extensions |
object | Scenario context extensions (see below) |
Context Extensions
The context.extensions object may contain a nl.medicatiebewaking.scenarioContext object with the following fields:
| Field | Type | Default | Description |
|---|---|---|---|
hospitalized |
boolean | false |
Indicates whether the patient is currently hospitalized. When true, your system should apply inpatient medication safety rules (e.g., different dosage limits for intensive care). |
practitionerInPharmacy |
boolean | false |
Indicates whether the prescriber is operating within a pharmacy context. This may affect which medication safety checks are applicable (e.g., OTC vs. prescription-only rules). |
Prefetch Resources
| Key | FHIR Resource | Description |
|---|---|---|
patient |
Patient | Demographics: gender, birthDate, name |
medicationData |
Bundle of MP9 medication resources | All medication resources: MedicationRequest (MA, VV), MedicationDispense (TA, MVE), MedicationStatement (MGB), MedicationAdministration, Medication |
conditions |
Bundle of Condition | Active conditions and contra-indications |
observations |
Bundle of Observation | Lab values, vital signs (weight, height, eGFR, etc.) |
allergies |
Bundle of AllergyIntolerance | Known allergies and intolerances |
All FHIR resources follow the Dutch Medication Process 9 (MP9) profiles. See the Nictiz MP9 FHIR IG for detailed profile specifications.
Key Coding Systems
| System OID | Description | Example |
|---|---|---|
urn:oid:2.16.840.1.113883.2.4.4.1 |
G-Standaard GPK (Generiek Product) | 3891 (Paracetamol) |
urn:oid:2.16.840.1.113883.2.4.4.10 |
G-Standaard PRK (Prescriptie Product) | 46027 |
urn:oid:2.16.840.1.113883.2.4.4.7 |
G-Standaard HPK (Handels Product) | 1034537 |
http://www.whocc.no/atc |
ATC code | N02BE01 |
urn:oid:2.16.840.1.113883.2.4.4.9 |
Toedieningsweg (Route) | 9 (Oraal) |
urn:oid:2.16.840.1.113883.2.4.4.1.900.2 |
Eenheden (Units) | 245 (Stuk) |
http://hl7.org/fhir/sid/icpc-1-nl |
ICPC-1 NL | L88 |
http://snomed.info/sct |
SNOMED CT | 38341003 |
Response Format
Your system must return a JSON response with CDS Hooks cards:
{
"cards": [
{
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"summary": "Dosering is hoger dan gebruikelijk.\nPas zo nodig de dosering aan.",
"indicator": "warning",
"detail": "**Ernst:** Hoog",
"source": {
"label": "zindex.dosage-check"
}
}
]
}
Card Fields
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
uuid |
string | Recommended | UUID v4 format | Unique card identifier. If omitted, the platform generates one. Providing a stable UUID helps with tracking and debugging. |
summary |
string | Required | Non-empty | Brief signal description. For MFB protocol signals, include the action text from the G-Standaard — the platform matches cards to expected signals based on this text. |
indicator |
string | Required | Enum: info, warning, critical |
Severity level (see below) |
detail |
string | Optional | Markdown supported | Extended description |
source |
object | Required | Source attribution | |
source.label |
string | Optional | Rule identifier for your own reference. See Signal Types for recommended values. | |
source.protocolNumber |
integer | Optional | Integer > 0 | MFB protocol number, for your own reference. |
extensions |
object | Optional | Custom metadata. You may include your own metadata under extensions['nl.medicatiebewaking.metadata'] for debugging and traceability. |
How card matching works: The VO4 platform matches returned cards to scenario assertions primarily via the signal text in
summaryanddetail. For MFB protocol signals, the platform looks up the expected action text from the G-Standaard and matches it against your card text. You do not need to populatesource.labelorsource.protocolNumberfor the matching to work — these fields are optional and serve as useful metadata for your own system's logging and debugging.
Signal Types (source.label)
The source.label field is optional. When provided, it helps identify the signal type and can be used for filtering and debugging. Recommended values:
| Label | Description |
|---|---|
zindex.dosage-check |
Dosage limit exceeded |
zindex.mfb-protocol |
MFB protocol signal |
zindex.allergy |
Allergy or intolerance detected |
zindex.duplicate-medication |
Duplicate medication detected |
zindex.derived-contra-indication |
Derived contra-indication |
zindex.special-characteristic |
Special patient characteristic |
CDS Hooks Indicator
The indicator field is required by the CDS Hooks specification and must be one of:
| Indicator | Meaning |
|---|---|
info |
Informational — no immediate action required |
warning |
Warning — attention or review required |
critical |
Critical — urgent, potentially dangerous situation |
Note: The VO4 self-test platform does not currently validate the
indicatorvalue in its test assertions. However, your system must include this field with a valid value as it is required by the CDS Hooks standard. Downstream consumers of your cards may rely on this field for visual presentation and clinical decision support workflows.
Test Evaluation
The VO4 self-test platform evaluates your response cards using three assertion types:
Required Signals
Cards that must be present. The test fails if any required signal is missing.
Optional Signals
Cards that may be present. These are not counted as unexpected.
Forbidden Signals
Cards that must not be present. The test fails if any forbidden signal appears.
Unexpected Cards
Any card not matched to a required or optional signal is considered unexpected, causing the test to fail.
Hook Types
order-sign (Primary)
Used when a new medication is being prescribed. The draft medication(s) are in context.draftOrders, existing medications in prefetch.medicationData.
Per the CDS Hooks 3.0 specification, draftOrders is a FHIR Bundle that may contain multiple MedicationRequests. This occurs for example when prescribing a tapering schedule (afbouwschema) with separate sequential medication agreements:
{
"context": {
"draftOrders": {
"resourceType": "Bundle",
"type": "collection",
"entry": [
{ "resource": { "resourceType": "MedicationRequest", "status": "draft", "...": "MA1 - day 1: 4x daily" } },
{ "resource": { "resourceType": "MedicationRequest", "status": "draft", "...": "MA2 - day 2: 3x daily" } },
{ "resource": { "resourceType": "MedicationRequest", "status": "draft", "...": "MA3 - day 3: 2x daily" } },
{ "resource": { "resourceType": "MedicationRequest", "status": "draft", "...": "MA4 - day 4: 1x daily" } }
]
}
}
}
When your system receives multiple draft MedicationRequests, the recommended evaluation strategy is sequential accumulation:
- Evaluate draft-1 as trigger, with only prefetch medications as context
- Evaluate draft-2 as trigger, with prefetch medications plus draft-1 as context
- Evaluate draft-3 as trigger, with prefetch medications plus draft-1 and draft-2 as context
- Continue for all remaining drafts
This ensures each medication is checked with awareness of the preceding drafts in the sequence. All results are merged into a single CDS Cards response.
medication-review
Used for periodic medication review. No draft orders — all medications are in prefetch.medicationData.
run-protocol
Used to execute a specific MFB protocol. Includes context.protocolNumber (integer) and optionally context.triggerMedicationId.
Error Handling
The VO4 platform enforces a 30-second timeout per request. Your system must respond within this window.
| Scenario | Expected behavior |
|---|---|
| Request timeout (30s) | Test marked as ERROR |
| Non-2xx HTTP response | Test marked as ERROR |
| Invalid JSON response | Test marked as ERROR |
| Empty cards array | Valid response (no signals) |
When your system has no signals to return, respond with an empty cards array:
{
"cards": []
}
Note: The VO4 platform sends test requests sequentially (one at a time). There are no concurrent requests to your system during a test run.
Example Scenarios
Note: The
source.labelandsource.protocolNumberfields shown in these examples are optional. They are included for illustration but are not required for the platform to match your cards to scenario assertions. The platform matches based on the signal text insummaryanddetail.
Scenario 1: Overdose Detection
Input: Metoprolol 100mg, 1x daily 10 tablets (1000mg/day)
Expected card:
{
"summary": "Dosering is hoger dan gebruikelijk.",
"indicator": "warning",
"detail": "<p>Pas zo nodig de dosering aan.</p>",
"source": {
"label": "zindex.dosage-check"
}
}
Scenario 2: Allergy Check
Input: Doxycycline for patient with tetracycline allergy
Expected card:
{
"summary": "Allergie: Doxycycline behoort tot ongewenste groep(en): TETRACYCLINES",
"indicator": "critical",
"source": {
"label": "zindex.allergy"
}
}
Scenario 3: MFB Protocol Signal
Input: RAAS-remmer + kaliumsparend diureticum
Expected card:
{
"summary": "MFB: RAAS-remmers + Kaliumsparende diuretica",
"indicator": "warning",
"source": {
"label": "zindex.mfb-protocol",
"protocolNumber": 363
}
}
Getting Started
- Implement the CDS Hooks endpoint:
POST /cds-services/order-sign - Parse the incoming FHIR resources from
contextandprefetch - Run your medication safety checks
- Return cards in the CDS Hooks response format
- Contact us to configure a test run with your endpoint URL and optional bearer token
Test Suites
The available test suites and their scenarios can be browsed on the Scenario's page.
Pipeline Integration (CI/CD)
The VO4 self-test platform exposes a REST API that you can call from your CI/CD pipeline to automatically run tests against your system after each deployment. See the VO4 Test Runner API Swagger UI for the full specification.
Making Your SUT Externally Reachable
The VO4 platform needs to send HTTP requests to your CDS Hooks endpoint. If your SUT runs in a local or private environment (e.g., behind a firewall or on localhost), the platform cannot reach it directly. Options to expose your SUT:
-
Tunnel service — Use a tool like ngrok, Cloudflare Tunnel, or localhost.run to create a temporary public URL for your local server:
# Example with ngrok ngrok http 8080 # Outputs: https://abc123.ngrok-free.app -> http://localhost:8080Use the generated URL as
sutUrlin your test run. These URLs are temporary and change on restart. -
Staging environment — Deploy your SUT to a publicly reachable staging server with a stable URL. This is recommended for CI/CD pipelines where a tunnel is impractical.
-
VPN / network peering — If both the VO4 platform and your SUT are within the same private network or VPN, direct internal URLs can be used.
Base URL
{vo4-platform-url}/api/cds-tests
Endpoints
| Method | Path | Description |
|---|---|---|
GET |
/health |
Health check (no auth) |
GET |
/suites |
List available test suites |
GET |
/test-cases |
List test cases (filterable) |
GET |
/test-cases/{id} |
Get single test case details |
POST |
/run |
Run tests against your system |
Authentication
API requests require a Bearer token (Sanctum). Obtain a token from the platform administrator.
Authorization: Bearer {api-token}
The /health endpoint does not require authentication.
Running Tests
curl -X POST {vo4-platform-url}/api/cds-tests/runs \
-H "Authorization: Bearer {api-token}" \
-H "Content-Type: application/json" \
-d '{
"sut_base_url": "https://abc123.ngrok-free.app/api/cds-services",
"name": "My test run",
"directories": ["knmp-test-set"],
"bearer_token": "optional-sut-auth-token"
}'
Request Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
sut_base_url |
string (URL) | Yes | Your CDS Hooks base URL (see URL requirements below) |
sut_query_params |
string | No | Query parameters appended to every request to the SUT, without leading ? (e.g. "api_key=abc&mode=test") |
name |
string | No | Human-readable run name |
directories |
string[] | No | Test suites to run (e.g. ["knmp-test-set"]). Omit to run all. |
filter |
string | No | Filter test cases by ID pattern |
bearer_token |
string | No | Bearer token sent with each request to the SUT |
rate_limit |
integer | No | Maximum requests per minute to the SUT |
SUT URL Requirements
The sut_base_url must meet the following requirements:
- Must use
http://orhttps://scheme - Must be publicly reachable from the VO4 platform (no private/internal IP addresses such as
10.x.x.x,172.16.x.x,192.168.x.x, or127.0.0.1) - Must not point to
localhostor loopback addresses - Use a tunnel service (e.g. ngrok) or a staging server if your system is not publicly accessible (see Making Your SUT Externally Reachable)
Response
{
"id": 42,
"name": "My test run",
"status": "completed",
"total_tests": 20,
"passed_tests": 18,
"failed_tests": 1,
"error_tests": 1,
"skipped_tests": 0,
"results": [
{
"id": 100,
"testcase_id": "knmp-casus-01",
"testcase_name": "Interactie RAAS-remmer + NSAID",
"status": "pass",
"duration": 0.45,
"error": null
}
]
}
CI/CD Example (GitHub Actions)
The recommended approach is to start your Guard/SUT as a Docker container in the CI runner, expose it via a tunnel service like ngrok, and let the VO4 platform send test requests to it.
ngrok rate limit: The free ngrok tier allows 100 connections per minute. Use the
rate_limitparameter to throttle VO4's request rate (e.g.,90requests per minute to stay safely within the limit).
Prerequisites
Create the following GitHub Actions secrets:
| Secret | Description |
|---|---|
VO4_PLATFORM_URL |
VO4 platform URL (e.g., https://vo4.mp9.nl) |
VO4_API_TOKEN |
Your Sanctum API token for the VO4 platform |
NGROK_AUTH_TOKEN |
Free ngrok auth token from dashboard.ngrok.com |
SUT_BEARER_TOKEN |
(Optional) Bearer token if your SUT requires authentication |
Workflow
name: Medication Safety Tests
on:
push:
branches: [main]
workflow_dispatch:
jobs:
cds-tests:
runs-on: ubuntu-latest
steps:
# Step 1: Start your SUT (replace with your own Docker image / startup command)
- name: Start SUT
run: |
docker run -d --name sut -p 8080:80 your-org/your-guard:latest
# Wait for SUT to be ready
for i in $(seq 1 30); do
curl -sf http://localhost:8080/api/cds-services/health > /dev/null 2>&1 && break
echo "Waiting for SUT... ($i/30)"
sleep 5
done
# Step 2: Expose SUT via ngrok
- name: Start ngrok tunnel
id: ngrok
run: |
curl -sf https://bin.equinox.io/c/bNyj1mQVY4c/ngrok-v3-stable-linux-amd64.tgz | tar xz
./ngrok config add-authtoken ${{ secrets.NGROK_AUTH_TOKEN }}
./ngrok http 8080 --log=stdout > /tmp/ngrok.log 2>&1 &
for i in $(seq 1 15); do
NGROK_URL=$(curl -sf http://localhost:4040/api/tunnels 2>/dev/null \
| jq -r '.tunnels[0].public_url // empty')
if [ -n "$NGROK_URL" ]; then
echo "Tunnel: $NGROK_URL"
echo "url=$NGROK_URL" >> "$GITHUB_OUTPUT"
break
fi
sleep 2
done
# Step 3: Start test run on VO4
- name: Run tests
id: run
run: |
RESPONSE=$(curl -sf -X POST "${{ secrets.VO4_PLATFORM_URL }}/api/cds-tests/runs" \
-H "Authorization: Bearer ${{ secrets.VO4_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{
\"sut_base_url\": \"${{ steps.ngrok.outputs.url }}/api\",
\"name\": \"CI #${{ github.run_number }}\",
\"bearer_token\": \"${{ secrets.SUT_BEARER_TOKEN }}\",
\"rate_limit\": 90
}")
RUN_ID=$(echo "$RESPONSE" | jq -r '.id')
echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT"
# Step 4: Poll for completion
- name: Wait for results
id: results
run: |
for i in $(seq 1 120); do
RESPONSE=$(curl -sf \
"${{ secrets.VO4_PLATFORM_URL }}/api/cds-tests/runs/${{ steps.run.outputs.run_id }}" \
-H "Authorization: Bearer ${{ secrets.VO4_API_TOKEN }}")
STATUS=$(echo "$RESPONSE" | jq -r '.status')
PASSED=$(echo "$RESPONSE" | jq -r '.passed_tests')
TOTAL=$(echo "$RESPONSE" | jq -r '.total_tests')
FAILED=$(echo "$RESPONSE" | jq -r '.failed_tests')
ERRORS=$(echo "$RESPONSE" | jq -r '.error_tests')
echo "[$i] $STATUS — $PASSED/$TOTAL passed (failed: $FAILED, errors: $ERRORS)"
if [ "$STATUS" = "completed" ]; then
echo "passed=$PASSED" >> "$GITHUB_OUTPUT"
echo "failed=$FAILED" >> "$GITHUB_OUTPUT"
echo "errors=$ERRORS" >> "$GITHUB_OUTPUT"
break
fi
sleep 5
done
# Step 5: Report
- name: Summary
if: always() && steps.run.outputs.run_id
run: |
echo "## Medication Safety Test Results" >> "$GITHUB_STEP_SUMMARY"
echo "| Passed | Failed | Errors |" >> "$GITHUB_STEP_SUMMARY"
echo "|--------|--------|--------|" >> "$GITHUB_STEP_SUMMARY"
echo "| ${{ steps.results.outputs.passed }} | ${{ steps.results.outputs.failed }} | ${{ steps.results.outputs.errors }} |" >> "$GITHUB_STEP_SUMMARY"
echo "[View details](${{ secrets.VO4_PLATFORM_URL }}/cds-reports/${{ steps.run.outputs.run_id }})" >> "$GITHUB_STEP_SUMMARY"
Alternative: Fixed SUT URL (no ngrok)
If your SUT is deployed to a publicly reachable staging server, you can skip the ngrok step and pass the URL directly:
- name: Run tests
run: |
curl -sf -X POST "${{ secrets.VO4_PLATFORM_URL }}/api/cds-tests/runs" \
-H "Authorization: Bearer ${{ secrets.VO4_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{
"sut_base_url": "https://staging.your-system.com/api",
"bearer_token": "${{ secrets.SUT_BEARER_TOKEN }}"
}'
Listing Available Suites
curl {vo4-platform-url}/api/cds-tests/suites \
-H "Authorization: Bearer {api-token}"
Health Check
Use the health endpoint for monitoring or pipeline readiness checks:
curl {vo4-platform-url}/api/cds-tests/health
{
"status": "ok",
"version": "1.0.0",
"suites": 3
}
Support
For questions about the VO4 self-test platform or scenario format, contact the development team.