VO4

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:

  1. Loads a test scenario (patient, medications, clinical data)
  2. Transforms it into a CDS Hooks request with FHIR resources
  3. Sends the request to your CDS Hooks endpoint
  4. Evaluates the returned cards against expected signals
  5. 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 prefetch field 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_token when 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 Authorization header 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 summary and detail. 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 populate source.label or source.protocolNumber for 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 indicator value 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:

  1. Evaluate draft-1 as trigger, with only prefetch medications as context
  2. Evaluate draft-2 as trigger, with prefetch medications plus draft-1 as context
  3. Evaluate draft-3 as trigger, with prefetch medications plus draft-1 and draft-2 as context
  4. 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.label and source.protocolNumber fields 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 in summary and detail.

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

  1. Implement the CDS Hooks endpoint: POST /cds-services/order-sign
  2. Parse the incoming FHIR resources from context and prefetch
  3. Run your medication safety checks
  4. Return cards in the CDS Hooks response format
  5. 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:8080
    

    Use the generated URL as sutUrl in 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:// or https:// 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, or 127.0.0.1)
  • Must not point to localhost or 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_limit parameter to throttle VO4's request rate (e.g., 90 requests 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.