Nirmitee.io
Mirth Connect

Mirth Connect REST API Gotchas : 7 Undocumented Issues That Break Deployments

May 27, 202615 min readUpdated Oct 19, 2026
Written by
Jitendra Choudhary
Jitendra Choudhary

CTO & Co-Founder

CTO & Co-Founder at Nirmitee.io. Architects healthcare integrations across FHIR, SMART on FHIR, ABDM and NHCX, writing from production experience taking hospital software from sandbox to go-live.

Mirth Connect REST API Gotchas : 7 Undocumented Issues That Break Deployments

Channel management via API sounds simple. The docs make it look like five curl commands. Here is what actually happens when you try to automate Mirth Connect channel deployment — and the working pattern we built after eight hours of silent failures.

Quick answer: Mirth Connect REST API has 7 undocumented gotchas. The pattern that works: (1) create in GUI, (2) export via GET /api/channels/{id}, (3) modify with sed only, (4) delete old channel, (5) POST /api/channels, (6) PUT /api/server/channelMetadata with enabled=true, (7) deploy then verify with GET /api/channels/statuses. The 204 response from the deploy endpoint means nothing on its own — always verify state is STARTED.

Watch: CI/CD for Mirth Connect: Channels in Git, Tests, and Automatic Rollback (Live) (2:14).

The Promise

Mirth Connect's REST API at port 8443 exposes clean endpoints for channel CRUD and deployment. The reality after eight hours of debugging: the API accepts invalid XML without complaint, confirms deploy operations that did nothing, hides all errors behind a default log level of ERROR, and has an undocumented server metadata gate that blocks deployment entirely. Every endpoint in this blog returns a success status code even when it silently failed.

Mirth Connect exposes a REST API on the same port as the Administrator GUI (default: 8443). The Swagger documentation lists clean endpoints:

  • POST /api/channels — create a channel
  • PUT /api/channels/{channelId} — update a channel
  • POST /api/channels/_deploy — deploy channels
  • GET /api/channels/{channelId} — export a channel

On paper, you could build a CI/CD pipeline in an afternoon: version your channel XML in Git, push changes, have a deployment script call these endpoints, and you are done. Infrastructure as Code for integration engines.

That is the promise. Here is the reality.

We spent the better part of a day building a Mirth Connect 4.5.2 automation pipeline. Every endpoint returned success status codes. Channels appeared in the dashboard. Deploy calls completed without errors. And nothing worked. The channels were invalid, the deploys were phantom operations, and the logs showed nothing because logging was off by default.

This post documents every gotcha we hit, in the order we hit them, with the exact error messages, API responses, and workarounds. If you are building CI/CD for Mirth Connect, this will save you a full day.

Before You Start: Authentication and Required Headers

Every Mirth REST API call requires two things: (1) -H X-Requested-With: curl on every request — omitting it returns 403 with no explanation (CSRF protection, Mirth checks for existence not value); (2) authentication via basic auth (-u admin:admin) or session cookie from POST /api/users/_login. For CI/CD, use basic auth — one less piece of state to manage. These headers apply to every single curl command in this guide.

Every Mirth REST API call requires two things that the docs gloss over.

The X-Requested-With Header

This header is mandatory on every single API call. Without it, Mirth returns 403 Forbidden with no explanation. The value can be anything — XMLHttpRequest, curl, your app name — but it must be present.

# This fails silently with 403:
curl -k https://localhost:8443/api/channels

# This works:
curl -k -H "X-Requested-With: curl" https://localhost:8443/api/channels

This is a CSRF protection mechanism. Mirth checks for the header's existence, not its value. Every curl command in this post includes it.

Authentication: Two Methods, Both Work Differently

Method 1: Basic Auth (simpler, works everywhere)

curl -k -u admin:admin \
  -H "X-Requested-With: curl" \
  https://localhost:8443/api/channels

Method 2: Session Cookie (from the login endpoint)

# Login and capture the session cookie
curl -k -c cookies.txt \
  -H "X-Requested-With: curl" \
  -X POST https://localhost:8443/api/users/_login \
  -d "username=admin&password=admin"

# Use the session cookie on subsequent calls
curl -k -b cookies.txt \
  -H "X-Requested-With: curl" \
  https://localhost:8443/api/channels

Basic auth is simpler for scripts. Session cookies are what the Administrator GUI uses internally. For CI/CD, stick with basic auth — one less piece of state to manage.

Gotcha #1: Channel Creation Silently Produces Invalid Channels

POST /api/channels returns true even when the XML produces an invalid channel. The channel appears in the dashboard with a name and ID, but Mirth Administrator shows This channel is invalid and it cannot be deployed. The API performs basic XML parsing, stores whatever you sent, and returns true. The only fix: never author channel XML from scratch — always export a working channel from Mirth via GET /api/channels/{id} and modify that.

This is the big one. The one that wastes the most time because everything looks like it worked.

What You Expect

You craft a channel XML payload (or use JSON), POST it to /api/channels, get a success response, and your channel is ready to deploy.

What Actually Happens

curl -k -u admin:admin \
  -H "X-Requested-With: curl" \
  -H "Content-Type: application/xml" \
  -X POST https://localhost:8443/api/channels \
  -d @my-channel.xml

Response:

{"boolean":true}

Looks great. The API accepted the channel and returned true. You check the channel list and the channel appears. It has a name, an ID, a description. It exists.

Except when you open it in the Mirth Administrator (the desktop Java app), you see this:

This channel is invalid. Verify all required extensions are loaded correctly.

The channel is there, but it is broken. Mirth accepted the XML, stored it, returned success, and created something that cannot be deployed, cannot process messages, and cannot even be edited in the GUI.

Why This Happens

Mirth's channel XML format is extremely specific. The class attributes on properties elements, the version attributes, the exact nesting of data type properties — all of it must match precisely what Mirth's internal serializer produces. The API does not validate the XML against the expected schema. It performs basic XML parsing, stores whatever you sent, and returns true.

The channel XML that Mirth itself exports contains elements like:

<properties class="com.mirth.connect.connectors.tcp.TcpReceiverProperties" version="4.5.2">

If your XML has the wrong class name, a missing version attribute, or elements in the wrong order, the channel is created but flagged as invalid internally. The API gives you zero indication that anything went wrong.

The Fix

Do not author channel XML from scratch. The only reliable source of valid channel XML is Mirth itself. Create your base channel in the Mirth Administrator GUI, then export it via the API:

curl -k -u admin:admin \
  -H "X-Requested-With: curl" \
  -H "Accept: application/xml" \
  https://localhost:8443/api/channels/b5bda28f-7c29-4773-8bea-2eb5f17aa710 \
  -o base-channel.xml

This exported XML is the gold standard. It contains every property, every default, every version tag exactly as Mirth expects it. Use this as your template.

Gotcha #2: The Channel Metadata Gate (The Hidden Deploy Blocker)

Creating a channel via the API does not automatically enable it in Mirth's server-level channel metadata registry. Only channels with enabled=true in this registry can be deployed. After importing a channel, you must PUT to /api/server/channelMetadata with enabled=true for your channel's ID. Without this step, the deploy endpoint returns 204 and nothing happens. This undocumented gate is the most common reason automated deployments silently fail.

You have a valid channel. You call the deploy endpoint. It succeeds. The channel does not start.

The Symptom

# Deploy the channel
curl -k -u admin:admin \
  -H "X-Requested-With: curl" \
  -H "Content-Type: application/json" \
  -X POST "https://localhost:8443/api/channels/_deploy" \
  -d '{"channelIds":["b5bda28f-7c29-4773-8bea-2eb5f17aa710"],"returnErrors":true}'

Response: 204 No Content

You check the dashboard. The channel exists but shows as "Undeployed." You call deploy again. Another 204. Still undeployed.

The Root Cause

Mirth maintains a server-level channel metadata registry that is separate from the channel definitions themselves. This metadata controls which channels are "enabled" — and only enabled channels can be deployed. Creating a channel via the API does not automatically enable it in this registry.

The Fix

After creating or importing a channel, you must explicitly enable it in the server metadata:

# Step 1: Get current channel metadata
curl -k -u admin:admin \
  -H "X-Requested-With: curl" \
  -H "Accept: application/xml" \
  https://localhost:8443/api/server/channelMetadata \
  -o metadata.xml

The metadata XML looks like this:

<map>
  <entry>
    <string>b5bda28f-7c29-4773-8bea-2eb5f17aa710</string>
    <channelMetadata>
      <enabled>false</enabled>
      <lastModified>
        <time>1716700000000</time>
        <timezone>America/New_York</timezone>
      </lastModified>
      <pruningSettings>
        <archiveEnabled>true</archiveEnabled>
      </pruningSettings>
    </channelMetadata>
  </entry>
</map>

Notice <enabled>false</enabled>. That flag is the gate. You need to set it to true:

# Step 2: Update metadata with enabled=true
curl -k -u admin:admin \
  -H "X-Requested-With: curl" \
  -H "Content-Type: application/xml" \
  -X PUT https://localhost:8443/api/server/channelMetadata \
  -d '<map>
  <entry>
    <string>b5bda28f-7c29-4773-8bea-2eb5f17aa710</string>
    <channelMetadata>
      <enabled>true</enabled>
      <lastModified>
        <time>1716700000000</time>
        <timezone>America/New_York</timezone>
      </lastModified>
      <pruningSettings>
        <archiveEnabled>true</archiveEnabled>
      </pruningSettings>
    </channelMetadata>
  </entry>
</map>'

Only after this step will the deploy endpoint actually do anything.

Gotcha #3: Silent Deploy Success (204 Means Nothing)

POST /api/channels/_deploy and POST /api/channels/{id}/_deploy return 204 No Content in every case: successful deploy, disabled channel, non-existent channel ID, and invalid channel. There is no response body and no error indication. Always follow every deploy call with GET /api/channels/statuses and verify state is STARTED for your channel ID. Without this verification step, you have no way to know if anything actually happened.

This compounds Gotcha #2 and makes debugging a nightmare.

The Problem

The deploy endpoint — both the bulk POST /api/channels/_deploy and the single-channel POST /api/channels/{channelId}/_deploy — returns 204 No Content in all of the following cases:

  1. The channel was successfully deployed and started
  2. The channel was disabled in metadata (nothing happened)
  3. The channel ID does not exist (nothing happened)
  4. The channel is invalid (nothing happened)

204 No Content is the HTTP equivalent of a shrug. There is no response body to inspect. No error. Nothing tells you what happened.

How to Verify Deployment Actually Worked

After calling deploy, check the channel status explicitly:

# Check channel status after deploy
curl -k -u admin:admin \
  -H "X-Requested-With: curl" \
  -H "Accept: application/json" \
  https://localhost:8443/api/channels/statuses

The response includes the deployment state for each channel:

{
  "list": {
    "dashboardStatus": [
      {
        "channelId": "b5bda28f-7c29-4773-8bea-2eb5f17aa710",
        "name": "HL7v2 ADT to FHIR Patient",
        "state": "STARTED",
        "deployedRevisionDelta": 0,
        "statistics": { ... }
      }
    ]
  }
}

If the channel is not in this list, it was never deployed. If state is not STARTED, something went wrong. Build this verification step into your CI/CD pipeline — without it, you are deploying into a void.

The Single-Channel Deploy Endpoint

One small mercy: there is a per-channel deploy endpoint that is easier to work with than the bulk deploy:

# Deploy a single channel
curl -k -u admin:admin \
  -H "X-Requested-With: curl" \
  -X POST "https://localhost:8443/api/channels/b5bda28f-7c29-4773-8bea-2eb5f17aa710/_deploy"

Same 204 response, same silent behavior, but at least you are targeting one channel and can immediately verify its status.

Gotcha #4: XML Format Sensitivity (Python ElementTree Breaks Mirth Channels)

Python ElementTree, Java DOM, lxml, and most XML libraries break Mirth channel XML when writing it back out — normalizing self-closing tags, reordering attributes, and changing whitespace in ways Mirth's XStream deserializer rejects. The result: a channel stored as invalid with no error from the API. The only safe approach: use sed or bash string replacement for literal text substitution. Never re-parse and re-emit the XML tree.

Once you have a valid channel XML exported from Mirth, you naturally want to modify it programmatically — change the port, update the transformer code, swap out a destination URL. This is where standard XML tools fail you.

The Trap

You write a Python script using xml.etree.ElementTree:

import xml.etree.ElementTree as ET

tree = ET.parse('channel.xml')
root = tree.getroot()

# Change the port
port_elem = root.find('.//listenerConnectorProperties/port')
port_elem.text = '6662'

tree.write('modified-channel.xml', xml_declaration=True, encoding='UTF-8')

You POST the modified XML. Mirth returns {"boolean":true}. The channel shows up. And it is invalid.

What Causes This

Python's ElementTree (and most XML libraries) normalize the XML when writing it back:

  • Self-closing tags change: <elements /> becomes <elements/> or vice versa
  • Namespace handling shifts: attributes may be reordered
  • Whitespace changes: indentation and line endings are modified
  • Attribute ordering: class and version attributes may swap positions

Mirth's XML deserializer is sensitive to some of these differences. A channel XML that is semantically identical but syntactically reformatted by an XML library will be accepted by the API and stored as an invalid channel.

Here is the exact difference that breaks things. Mirth exports:

<pluginProperties/>
<listenerConnectorProperties version="4.5.2">

After round-tripping through Python ET:

<pluginProperties />
<listenerConnectorProperties version="4.5.2">

That space before the /> should not matter in any sane XML parser. But Mirth uses XStream with specific class-mapping expectations, and the deserialized object graph must match exactly.

The Fix: Use String Replacement

Crude, effective, and the only approach that reliably preserves the XML format:

# Change the port from 6661 to 6662
sed 's|<port>6661</port>|<port>6662</port>|g' base-channel.xml > modified-channel.xml

# Change the channel name
sed 's|<name>HL7v2 ADT to FHIR Patient</name>|<name>HL7v2 ADT to FHIR Patient - Staging</name>|g' \
  modified-channel.xml > final-channel.xml

Or in a bash script:

CHANNEL_XML=$(cat base-channel.xml)
CHANNEL_XML="${CHANNEL_XML//<port>6661<\/port>/<port>6662<\/port>}"
CHANNEL_XML="${CHANNEL_XML//<name>HL7v2 ADT to FHIR Patient<\/name>/<name>HL7v2 ADT to FHIR Patient - Staging<\/name>}"
echo "$CHANNEL_XML" > modified-channel.xml

Yes, this is string manipulation on XML. Yes, it feels wrong. It is the only thing that works consistently.

If you absolutely must use a proper XML library, the workaround is to never write the full XML back out. Instead, read the original file as a string, use regex or string replacement for your specific changes, and write the string back. Keep the XML library for reading and validation only.

Gotcha #5: Dollar Signs in URLs (FHIR + Mirth Variable Syntax Collision)

FHIR uses $ in operation URLs (/CodeSystem/$lookup, /Patient/$match). Mirth uses $ for variable interpolation. When a FHIR URL with $ appears in an HTTP Sender destination field, Mirth treats $lookup as a variable reference, resolves it to nothing, and the HTTP call goes to an invalid endpoint. Fix: build the full URL in a JavaScript transformer using String.fromCharCode(36) for the dollar sign, store in channelMap, and reference it via the variable in the HTTP Sender.

If your Mirth channel calls FHIR APIs, you will hit this within the first hour.

The Problem

FHIR uses $ in operation URLs:

https://tx.fhir.org/r4/CodeSystem/$lookup?system=http://hl7.org/fhir/sid/icd-10-cm&code=I21.0

Mirth uses $ for variable interpolation in connector fields:

${channelMap.get('myVariable')}

When you put a FHIR $lookup URL in the HTTP Sender's Host field, Mirth tries to resolve $lookup as a variable reference. It finds nothing, replaces it with empty string or null, and your HTTP call goes to a completely different (and invalid) endpoint.

The error you see in the logs (if logging is even enabled — see Gotcha #7):

ERROR  - HTTP request failed: 404 Not Found

Or worse, no error at all — just a 400 from the FHIR server that you cannot see because the response is swallowed.

The Fix

Build the URL in a JavaScript transformer step and put it in a channel map variable:

// In the source transformer (JavaScript step), build the full URL
var fhirBase = 'https://tx.fhir.org/r4';
var operation = 'CodeSystem/' + String.fromCharCode(36) + 'lookup';  // $ = char 36
var system = encodeURIComponent('http://hl7.org/fhir/sid/icd-10-cm');
var code = msg['DG1']['DG1.3']['DG1.3.1'].toString();

var fullUrl = fhirBase + '/' + operation + '?system=' + system + '&code=' + code;
channelMap.put('fhirLookupUrl', fullUrl);

Then in the HTTP Sender destination, use the variable in the Host field:

${fhirLookupUrl}

Now Mirth resolves the variable, which contains the literal $lookup string, and the HTTP call goes to the correct FHIR endpoint. The key is that the dollar sign in the resolved value is not processed again for variable substitution.

Alternatively, for simpler cases, you can escape the dollar sign with a backslash in some Mirth contexts, but this behavior is inconsistent across versions. The JavaScript approach is bulletproof.

Gotcha #6: The Web Admin is Read-Only

The Mirth web admin at /webadmin has exactly three features: login, monitoring dashboard, and logout. No channel creation, no editing, no deploying. It is a read-only monitoring interface. For channel management you need either the desktop Mirth Administrator Java app or the REST API. This is not a version limitation — it is by design.

The Expectation

Mirth Connect 4.x ships with a web-based administrator at https://localhost:8443/webadmin. Surely this modern web interface can manage channels, right?

The Reality

The web admin (/webadmin) has exactly three features:

  1. Login — enter your credentials
  2. Dashboard — view channel status, message counts, and statistics
  3. Logout — end your session

That is it. No channel creation. No channel editing. No transformer editing. No deploying. No undeploying. It is a monitoring dashboard, not an administration interface.

For actual channel management, you need either:

  • Mirth Administrator — the desktop Java application (requires Java runtime, launches via JNLP or direct JAR)
  • The REST API — what this entire blog post is about

This matters for CI/CD because you cannot tell your ops team "just fix it in the web admin." Any channel change requires either the desktop app or API access.

Gotcha #7: Logging Is Off by Default

Mirth's default log4j2 configuration sets root level to ERROR. Transformer exceptions, HTTP 4xx/5xx responses, routing decisions — all silently discarded. When a channel processes messages and nothing appears to happen, disabled logging is the first thing to check. Fix: change logger.root.level = ERROR to INFO in /opt/connect/conf/log4j2.properties and restart Mirth. Enable this on day one before you build anything.

The Problem

You deploy a channel, send a test message, and nothing happens. No output. No error. No log entry. You check the Mirth server logs:

docker exec mirth-connect cat /opt/connect/appdata/logs/mirth.log

The log file exists but contains only startup messages. Your channel processing, transformer errors, HTTP call failures — none of it is logged.

The Root Cause

Mirth's default log configuration (log4j2.properties) sets the root log level to ERROR. Only fatal errors appear. Transformer exceptions, HTTP response codes, message routing decisions — all of it happens at INFO or DEBUG level and is silently discarded.

The Fix

Edit the log4j2.properties file inside the container (or mount it via Docker volume):

# Find the log config
docker exec mirth-connect find /opt/connect -name "log4j2.properties"
# Usually at: /opt/connect/conf/log4j2.properties

# Change the root log level
docker exec mirth-connect sed -i \
  's/logger.root.level = ERROR/logger.root.level = INFO/' \
  /opt/connect/conf/log4j2.properties

For a Docker setup, mount a custom config:

# docker-compose.yml
services:
  mirth-connect:
    image: nextgenhealthcare/connect:4.5.2
    volumes:
      - ./custom-log4j2.properties:/opt/connect/conf/log4j2.properties

Your custom-log4j2.properties should include:

logger.root.level = INFO

After this change, restart Mirth (or the container). Now you will actually see what is happening when channels process messages:

INFO  - Channel "HL7v2 ADT to FHIR Patient" (b5bda28f) deployed successfully
INFO  - Received message on channel b5bda28f, source connector 0
INFO  - Transformer executed successfully, 6 FHIR resources generated

Without this fix, you are debugging blind. Enable it on day one.

The Working Pattern: API-Based Channel Deployment That Actually Works

The 7-step API deployment pattern that works reliably: (1) create base channel in GUI, (2) export via GET /api/channels/{id}, (3) modify with sed/string replacement only, (4) undeploy and delete existing channel, (5) POST /api/channels with modified XML, (6) PUT /api/server/channelMetadata with enabled=true, (7) deploy and verify with GET /api/channels/statuses confirming state is STARTED. Skipping any step produces a silent failure.

After hitting all seven gotchas, here is the step-by-step process that reliably deploys Mirth channels via the API. This is the pattern we use in production.

Step 1: Create the Base Channel in Mirth Administrator

Use the desktop Java app to create your channel. Configure the source connector, destination connectors, transformer steps, and filters. Validate and save. It is the only step that requires the GUI — you do it once per channel type.

Step 2: Export the Channel via API

MIRTH_URL="https://localhost:8443"
CHANNEL_ID="b5bda28f-7c29-4773-8bea-2eb5f17aa710"
AUTH="admin:admin"

curl -k -u "$AUTH" \
  -H "X-Requested-With: curl" \
  -H "Accept: application/xml" \
  "$MIRTH_URL/api/channels/$CHANNEL_ID" \
  -o base-channel.xml

Commit this XML to your Git repository. This is your source of truth.

Step 3: Modify the XML (String Replacement Only)

# Create environment-specific channel
cp base-channel.xml staging-channel.xml

# Generate a new unique channel ID
NEW_ID=$(uuidgen | tr '[:upper:]' '[:lower:]')

# Replace channel ID, name, and port
sed -i '' "s|<id>$CHANNEL_ID</id>|<id>$NEW_ID</id>|g" staging-channel.xml
sed -i '' "s|<name>HL7v2 ADT to FHIR Patient</name>|<name>HL7v2 ADT to FHIR Patient - Staging</name>|g" staging-channel.xml
sed -i '' "s|<port>6661</port>|<port>7661</port>|g" staging-channel.xml

Step 4: Delete the Old Channel (If Updating)

# Undeploy first (safe to call even if not deployed)
curl -k -u "$AUTH" \
  -H "X-Requested-With: curl" \
  -X POST "$MIRTH_URL/api/channels/$CHANNEL_ID/_undeploy"

# Delete the channel
curl -k -u "$AUTH" \
  -H "X-Requested-With: curl" \
  -X DELETE "$MIRTH_URL/api/channels/$CHANNEL_ID"

Why delete instead of update with PUT? Because PUT /api/channels/{channelId} returns false on validation failures with no error details. The delete-and-recreate pattern gives you a clean slate and avoids versioning conflicts.

Step 5: Import the Modified Channel

curl -k -u "$AUTH" \
  -H "X-Requested-With: curl" \
  -H "Content-Type: application/xml" \
  -X POST "$MIRTH_URL/api/channels" \
  -d @staging-channel.xml

Expected response: {"boolean":true}

Step 6: Enable the Channel in Server Metadata

curl -k -u "$AUTH" \
  -H "X-Requested-With: curl" \
  -H "Content-Type: application/xml" \
  -X PUT "$MIRTH_URL/api/server/channelMetadata" \
  -d "<map>
  <entry>
    <string>$NEW_ID</string>
    <channelMetadata>
      <enabled>true</enabled>
      <lastModified>
        <time>$(date +%s)000</time>
        <timezone>UTC</timezone>
      </lastModified>
      <pruningSettings>
        <archiveEnabled>true</archiveEnabled>
      </pruningSettings>
    </channelMetadata>
  </entry>
</map>"

Step 7: Deploy and Verify

# Deploy
curl -k -u "$AUTH" \
  -H "X-Requested-With: curl" \
  -X POST "$MIRTH_URL/api/channels/$NEW_ID/_deploy"

# Wait for deployment to complete
sleep 3

# Verify
STATUS=$(curl -sk -u "$AUTH" \
  -H "X-Requested-With: curl" \
  -H "Accept: application/json" \
  "$MIRTH_URL/api/channels/statuses" | \
  python3 -c "
import sys, json
data = json.load(sys.stdin)
statuses = data.get('list', {}).get('dashboardStatus', [])
if not isinstance(statuses, list):
    statuses = [statuses]
for s in statuses:
    if s.get('channelId') == '$NEW_ID':
        print(s.get('state', 'NOT_FOUND'))
        sys.exit(0)
print('NOT_DEPLOYED')
")

echo "Channel status: $STATUS"
if [ "$STATUS" != "STARTED" ]; then
  echo "ERROR: Channel failed to deploy. Status: $STATUS"
  exit 1
fi

The Full Automation Script

Here is a complete bash script you can drop into a CI/CD pipeline. It handles the entire lifecycle: export, modify, import, enable, deploy, and verify.

#!/usr/bin/env bash
set -euo pipefail

# =============================================================================
# mirth-deploy.sh -- Deploy a Mirth Connect channel via REST API
#
# Usage:
#   ./mirth-deploy.sh <channel-xml> [--env staging|production]
#
# Prerequisites:
#   - curl, python3, uuidgen
#   - MIRTH_URL, MIRTH_USER, MIRTH_PASS environment variables
# =============================================================================

CHANNEL_XML="${1:?Usage: $0 <channel-xml> [--env staging|production]}"
ENVIRONMENT="${3:-staging}"

MIRTH_URL="${MIRTH_URL:-https://localhost:8443}"
MIRTH_USER="${MIRTH_USER:-admin}"
MIRTH_PASS="${MIRTH_PASS:-admin}"
AUTH="$MIRTH_USER:$MIRTH_PASS"

CURL_OPTS="-sk -u $AUTH -H X-Requested-With:mirth-deploy"

echo "=== Mirth Channel Deployment ==="
echo "Target: $MIRTH_URL"
echo "Channel: $CHANNEL_XML"
echo "Environment: $ENVIRONMENT"
echo ""

# --- Step 1: Parse channel ID and name from the XML ---
CHANNEL_ID=$(grep -oP '(?<=<id>)[^<]+' "$CHANNEL_XML" | head -1)
CHANNEL_NAME=$(grep -oP '(?<=<name>)[^<]+' "$CHANNEL_XML" | head -1)
echo "[1/7] Channel: $CHANNEL_NAME ($CHANNEL_ID)"

# --- Step 2: Check if channel already exists ---
EXISTING=$(curl $CURL_OPTS \
  -H "Accept: application/json" \
  -o /dev/null -w "%{http_code}" \
  "$MIRTH_URL/api/channels/$CHANNEL_ID" 2>/dev/null || true)

if [ "$EXISTING" = "200" ]; then
  echo "[2/7] Channel exists -- undeploying and deleting..."

  # Undeploy (ignore errors)
  curl $CURL_OPTS -X POST \
    "$MIRTH_URL/api/channels/$CHANNEL_ID/_undeploy" 2>/dev/null || true
  sleep 1

  # Delete
  curl $CURL_OPTS -X DELETE \
    "$MIRTH_URL/api/channels/$CHANNEL_ID" 2>/dev/null
  echo "  Deleted existing channel."
else
  echo "[2/7] Channel does not exist -- fresh install."
fi

# --- Step 3: Import the channel ---
echo "[3/7] Importing channel XML..."
IMPORT_RESULT=$(curl $CURL_OPTS \
  -H "Content-Type: application/xml" \
  -X POST "$MIRTH_URL/api/channels" \
  -d @"$CHANNEL_XML" 2>/dev/null)

if echo "$IMPORT_RESULT" | grep -q '"boolean":true\|true'; then
  echo "  Import accepted."
else
  echo "  ERROR: Import failed. Response: $IMPORT_RESULT"
  exit 1
fi

# --- Step 4: Enable in channel metadata ---
echo "[4/7] Enabling channel in server metadata..."
TIMESTAMP=$(date +%s)000
METADATA_XML="<map>
  <entry>
    <string>$CHANNEL_ID</string>
    <channelMetadata>
      <enabled>true</enabled>
      <lastModified>
        <time>$TIMESTAMP</time>
        <timezone>UTC</timezone>
      </lastModified>
      <pruningSettings>
        <archiveEnabled>true</archiveEnabled>
      </pruningSettings>
    </channelMetadata>
  </entry>
</map>"

curl $CURL_OPTS \
  -H "Content-Type: application/xml" \
  -X PUT "$MIRTH_URL/api/server/channelMetadata" \
  -d "$METADATA_XML" 2>/dev/null
echo "  Metadata updated."

# --- Step 5: Deploy ---
echo "[5/7] Deploying channel..."
curl $CURL_OPTS \
  -X POST "$MIRTH_URL/api/channels/$CHANNEL_ID/_deploy" 2>/dev/null
echo "  Deploy command sent."

# --- Step 6: Wait and verify ---
echo "[6/7] Verifying deployment (waiting 5s)..."
sleep 5

STATUS=$(curl $CURL_OPTS \
  -H "Accept: application/json" \
  "$MIRTH_URL/api/channels/statuses" 2>/dev/null | \
  python3 -c "
import sys, json
data = json.load(sys.stdin)
statuses = data.get('list', {}).get('dashboardStatus', [])
if not isinstance(statuses, list):
    statuses = [statuses]
for s in statuses:
    if s.get('channelId') == '$CHANNEL_ID':
        print(s.get('state', 'UNKNOWN'))
        sys.exit(0)
print('NOT_DEPLOYED')
" 2>/dev/null)

echo "  Channel state: $STATUS"

# --- Step 7: Report ---
echo ""
echo "[7/7] Deployment result:"
if [ "$STATUS" = "STARTED" ]; then
  echo "  SUCCESS -- Channel '$CHANNEL_NAME' is deployed and running."
  exit 0
elif [ "$STATUS" = "STOPPED" ]; then
  echo "  WARNING -- Channel deployed but stopped."
  exit 0
elif [ "$STATUS" = "NOT_DEPLOYED" ]; then
  echo "  FAILURE -- Channel was not deployed."
  echo "  Troubleshooting:"
  echo "    1. Check if channel is valid (open in Mirth Administrator)"
  echo "    2. Verify metadata has enabled=true"
  echo "    3. Check server logs"
  exit 1
else
  echo "  FAILURE -- Unexpected state: $STATUS"
  exit 1
fi

Save this as mirth-deploy.sh, make it executable, and call it from your CI/CD pipeline:

chmod +x mirth-deploy.sh
MIRTH_URL=https://mirth.internal:8443 \
MIRTH_USER=deploy \
MIRTH_PASS='s3cure-p@ss' \
./mirth-deploy.sh channels/adt-to-fhir.xml --env production

Bonus: Environment-Specific Channel Modification

For multi-environment deployments (dev, staging, production), create a simple modifier script that uses sed to swap environment-specific values:

#!/usr/bin/env bash
# modify-channel.sh -- Create environment-specific channel XML
# Usage: ./modify-channel.sh <base-channel.xml> <env-config.env> > output.xml

BASE_XML="$1"
ENV_FILE="$2"

# Load environment config
source "$ENV_FILE"

# Start with the base channel (preserving exact XML format)
CHANNEL=$(cat "$BASE_XML")

# Replace values using exact string matching
CHANNEL="${CHANNEL//<port>${BASE_PORT}<\/port>/<port>${TARGET_PORT}<\/port>}"
CHANNEL="${CHANNEL//<name>${BASE_NAME}<\/name>/<name>${TARGET_NAME}<\/name>}"

# If destination URL needs changing
if [ -n "${TARGET_DEST_URL:-}" ]; then
  CHANNEL="${CHANNEL//$BASE_DEST_URL/$TARGET_DEST_URL}"
fi

echo "$CHANNEL"

With an environment config file:

# staging.env
BASE_PORT=6661
TARGET_PORT=7661
BASE_NAME="HL7v2 ADT to FHIR Patient"
TARGET_NAME="HL7v2 ADT to FHIR Patient - Staging"
BASE_DEST_URL="https://fhir.production.internal"
TARGET_DEST_URL="https://fhir.staging.internal"

Quick Reference: Every Endpoint You Need

The 11 endpoints for complete Mirth channel lifecycle: POST /api/users/_login (auth), GET /api/channels (list), GET /api/channels/{id} (export as XML), POST /api/channels (create), PUT /api/channels/{id} (update), DELETE /api/channels/{id} (delete), GET /api/server/channelMetadata (check enabled), PUT /api/server/channelMetadata (enable), POST /api/channels/{id}/_deploy (deploy), POST /api/channels/{id}/_undeploy (undeploy), GET /api/channels/statuses (the only reliable verification). All require the X-Requested-With header.

OperationMethodEndpointNotes
LoginPOST/api/users/_loginReturns the session cookie
List channelsGET/api/channelsAdd Accept: application/xml
Get channelGET/api/channels/{id}Use XML format for re-import
Create channelPOST/api/channelsReturns {"boolean":true} even on invalid XML
Update channelPUT/api/channels/{id}Returns false On failure, no details
Delete channelDELETE/api/channels/{id}Undeploy first
Get metadataGET/api/server/channelMetadataCheck enabled status
Set metadataPUT/api/server/channelMetadataMust set enabled=true
Deploy (bulk)POST/api/channels/_deployJSON body with channel IDs
Deploy (single)POST/api/channels/{id}/_deploySimpler, same 204 response
UndeployPOST/api/channels/{id}/_undeploySafe to call if not deployed
Channel statusesGET/api/channels/statusesThe only way to verify the deployment

All endpoints require -k (skip TLS verify for self-signed cert), -H "X-Requested-With: curl", and authentication.

The Bottom Line

Mirth Connect's REST API is functional but adversarial. It accepts bad input without complaint, confirms operations that did not happen, and hides the information you need to debug behind a disabled logging configuration. The documentation covers the happy path and stops.

The pattern that works: create in the GUI, export via API, modify with string replacement, deploy via the 7-step process, and always verify with the statuses endpoint. Anything that skips a step in this sequence will eventually produce a deployment that looks successful and is not.

We built three production channels this way — HL7v2 to FHIR R4 Bundle, a smart ADT router, and an HL7v2 to openEHR transformer — all deployed and updated exclusively through CI/CD after the initial GUI creation. The automation works. It just does not work the way the docs suggest.

This post is part of our Mirth Connect integration series. We build healthcare integration pipelines — HL7v2, FHIR, openEHR — and write about the parts the documentation leaves out. 

Related guides

This article is part of our complete guide to Mirth Connect: what is Mirth Connect. Related deep-dives:

Need production-grade Mirth Connect help? See our Mirth Connect integration services.

Ready to scale?

Talk to our healthcare engineering team about building, integrating, and shipping faster.

Frequently Asked Questions

Why does POST /api/channels return true but my channel shows as invalid?

The API validates XML syntax but not channel schema correctness. Mirth stores whatever XML you send and marks it invalid during deserialization if XStream class mappings fail. The fix: never author channel XML from scratch — always export from Mirth via GET /api/channels/{id} and use that as your base template.

I called the deploy endpoint and got 204 but the channel is not running. What happened?

204 is returned regardless of outcome — successful deploy, disabled channel, non-existent channel ID, and invalid channel all return 204. Check: (1) Is the channel enabled in server metadata? GET /api/server/channelMetadata and verify enabled=true. (2) GET /api/channels/statuses and confirm state is STARTED. Build this verification into every CI/CD pipeline.

Can I use the Mirth web admin at /webadmin to manage channels?

No. The /webadmin UI provides login, a monitoring dashboard, and logout only. No channel creation, editing, or deployment. For channel management you need either the desktop Mirth Administrator (Java app) or the REST API.

Why does my FHIR dollar sign URL fail in a Mirth HTTP Sender?

Mirth interprets $ as a variable reference. FHIR URLs like /CodeSystem/$lookup have $lookup treated as a variable that resolves to nothing. Fix: build the URL in a JavaScript transformer using String.fromCharCode(36) for the dollar sign, store in channelMap, then reference the variable in the HTTP Sender field.

Can I use Python XML libraries to modify Mirth channel XML?

Not safely. XML libraries normalize self-closing tags and whitespace when re-emitting, which breaks Mirth's XStream deserializer — storing the result as an invalid channel. Use sed or bash string replacement for literal text substitution without re-parsing the XML tree.

How do I see transformer errors in Mirth logs?

Change logger.root.level from ERROR to INFO in /opt/connect/conf/log4j2.properties and restart Mirth. The default configuration silently drops everything below error severity — transformer failures, HTTP responses, and routing decisions are all invisible. Enable this on day one.

What is the difference between bulk and single-channel deploy endpoints?

POST /api/channels/_deploy takes a JSON body with an array of channel IDs. POST /api/channels/{id}/_deploy deploys one channel with no request body. Both return 204 No Content regardless of outcome. For CI/CD, the single-channel endpoint is simpler and easier to verify with an immediate status check.

What Mirth Connect version does this REST API guide apply to?

All findings verified on Mirth Connect 4.5.2 on Docker (nextgenhealthcare/connect:4.5.2) with PostgreSQL 16 and Java 17. The REST API is largely stable across 4.x versions but header requirements and response formats may differ in 3.x.
Share