Skip to content

Receiving HL7v2 messages

Stand up a Glion MLLP server, register routes, run middleware, and respond with the right ACK.

MLLP is the default protocol for sending and receiving HL7v2 messages over TCP. Glion ships an out-of-the-box MLLP server framework that handles framing, parsing, dispatch, and acknowledgment construction — so handler code only deals with the business outcome of each message.

You can learn more about the runtime architecture for running MLLP in Glion in the MLLP runtime model.

Getting started

  1. Create a new app from the mllp-server example.
  2. Move into the new project folder, my-mllp-server.
  3. Run the dev script — glion dev listens on 127.0.0.1:2575 and reloads on file changes.
Terminal
npm create glion@latest my-mllp-server -- --example mllp-server
cd my-mllp-server
npm run dev
  1. In a second terminal, send a test ADT^A01 message with nc to the MLLP server listening at 127.0.0.1:2575.
Terminal
printf '\x0bMSH|^~\&|SENDER|SENDER|GLION|NODE|20260510120000||ADT^A01|MSG001|P|2.5.1\rEVN||20260510120000\rPID|||123456^^^MRN||Doe^John\r\x1c\x0d' \
  | nc 127.0.0.1 2575

The terminal logs the inbound message and nc prints back the AA acknowledgment.

Congratulations, you have a working MLLP server! For a typed client with NAK handling, TLS, and Cloudflare Workers support, see Send an HL7v2 message.

Project structure

An MLLP server project looks like any other TypeScript Node project, with two Glion-specific files: a router (the app's entry point) and a config file the CLI reads at startup.

app.ts
glion.config.ts
package.json
tsconfig.json

Two files do the work specific to Glion:

File
glion.config.tsDeclares the entry module and the listener port. The CLI loads this file at startup and uses it to find your router.
src/app.tsThe router. Its default export is the Mllp instance the runtime serves. Parser, middleware, and route registrations all live here (see Write the router).

Everything else is standard Node tooling: package.json for dependencies and the dev / start scripts, tsconfig.json for TypeScript. The path under src/ is a convention, not a requirement — entry in glion.config.ts resolves any path you give it.

Manual installation

  1. Install dependencies.
Terminal
npm install @glion/cli @glion/mllp @glion/mllp-ack @glion/hl7v2 @glion/ack
  1. Add the dev and start scripts.
package.json
{
  "scripts": {
    "dev": "glion dev",
    "start": "glion start"
  }
}

glion dev runs the server in development mode with file-watching, hot reload, and the terminal UI. glion start runs the same server in production mode with structured logs and no watcher.

  1. Create the configuration at glion.config.ts.

glion.config.ts at the project root is the single source of truth for the server. The CLI loads it, resolves entry to your router module, and starts the listener on the configured port.

glion.config.ts
import { defineConfig } from "@glion/cli/config";

export default defineConfig({
  entry: "./src/app.ts",
  port: 2575,
});
  1. Create the router at src/app.ts.
src/app.ts
import { Mllp } from "@glion/mllp";
import { parseHL7v2 } from "@glion/hl7v2";
import { ackMiddleware } from "@glion/mllp-ack";

const app = new Mllp().parser(parseHL7v2);

app.use(ackMiddleware());

app.on("ADT^A01", (ctx) => {
  console.log(`Patient admit ${ctx.controlId}`);
});

export default app;

The four pieces:

  • parser(parseHL7v2) — wires the default HL7v2 parser.
  • app.use(ackMiddleware()) — registers the middleware that turns handler outcomes into wire ACKs (see Middleware).
  • app.on("ADT^A01", ...) — registers a handler for one trigger event (see Routing).
  • export default app — the CLI imports this from your entry path.
  1. Run the server.

Run npm run dev (or your package manager's equivalent) and the server is live on 127.0.0.1:2575.

Middleware

Use middleware for cross-cutting concerns like logging, metrics, authentication, or rate-limiting. Glion also ships built-in middleware such as ackMiddleware() from @glion/mllp-ack.

Middleware wraps handlers using the onion pattern: each middleware runs before await next(), hands control downward to the next layer, then runs again after next() resolves. They register in declaration order with app.use(fn), so a logging middleware registered first sees every message before validation has rejected it; a metrics middleware registered last sees only messages that reached a handler.

Custom middleware

You can also create your own custom middleware. In the example below, the middleware times every message and logs success or failure once the handler completes:

src/app.ts
import { Mllp } from "@glion/mllp";

const app = new Mllp();

app.use(async (ctx, next) => {
  const start = Date.now();
  try {
    // ... pre-processing logic like auth checks or rate-limiting...
    await next(); // Pass control to the next middleware or handler.
    // ... post-processing logic like logging or metrics...
    ctx.log.info(`ok ${Date.now() - start}ms ${ctx.controlId}`);
  } catch (error) {
    ctx.log.error(`fail ${Date.now() - start}ms ${String(error)}`);
    throw error;
  }
});

app.on("*", (ctx) => {
  console.log(`HL7v2 message received ${ctx.controlId}`);
});

export default app;

Good to know

A middleware can short-circuit by calling ctx.respond(ack) and returning instead of awaiting next() — useful for rate-limiters, circuit breakers, or auth checks that should respond AR before any downstream work runs.

Routing

Glion's MLLP server uses a route-based model to dispatch messages to handlers.

src/app.ts
import { Mllp } from "@glion/mllp";
import { parseHL7v2 } from "@glion/hl7v2";
import { ackMiddleware } from "@glion/mllp-ack";
import { UnsupportedMessageTypeReject } from "@glion/ack";

const app = new Mllp().parser(parseHL7v2);

app.use(ackMiddleware());

app.on("ADT^A01", handler); // exact match
app.on("ADT^*",   handler); // any ADT trigger event
app.on("*^A01",   handler); // any message type with the A01 event
app.on("ADT",     handler); // any ADT (same as ADT^*)
app.on("*",       handler); // catch-all

export default app;

The pattern shapes the router accepts:

PatternMatches
"ADT^A01"Exactly that trigger event.
"ADT^*"Any trigger event under the ADT message type.
"*^A01"Any message type with the A01 trigger event.
"ADT"Any ADT message, regardless of trigger event.
"*"Catch-all — runs only if nothing else matched.

When several patterns could match the same message, the first registered route wins — register specific routes before catch-alls.

Custom routing

For dispatch logic that doesn't map to trigger events — routing by sending facility, by partner ID, or by message version — app.on also accepts a custom matcher function instead of a pattern string. The matcher receives the context and returns a boolean for whether the handler should run.

src/app.ts
import { Mllp } from "@glion/mllp";
import { parseHL7v2 } from "@glion/hl7v2";

const app = new Mllp();
app.parser(parseHL7v2);

const matcher = (ctx) => ctx.message.get("MSH-4") === "SOME_FACILITY";

app.on(matcher, (ctx) => {
  const facility = ctx.message.get("MSH-4");
  return handlers[facility](ctx);
});

export default app

Handlers

Handlers are where your business logic lives. They run after the middleware chain when a route matches.

A handler is an async function — it can hit a database, call an API, emit a domain event. Handlers are agnostic to the transport and protocol; they only know about the message and the business logic. Each one receives a ctx object and signals its outcome by returning normally or throwing an exception.

The ctx exposes:

  • the parsed message,
  • the resolved profile, if one is attached,
  • the source vfile (with any diagnostics),
  • helpers like ctx.respond and ctx.log.

Good to know

If a message matches no handler, Glion sends back an AR rejection with a diagnostic that names the unrouted trigger event. The catch-all in the Routing example shows the explicit form.

src/app.ts
app.on("ADT^A01", async (ctx) => {
  const patientId = ctx.message.get("PID-3");
  const patientName = ctx.message.get("PID-5");
  
  // Business logic here, e.g. database calls, API requests, etc.
  const results = await database.savePatientAdmit({ patientId, patientName });

  // Returning normally produces an AA (Application Accept) ACK.
  return results;
});

Error handling

@glion/ack ships built-in exception types that map one-to-one to MSA-1 NAK codes. Throw one from a handler and ackMiddleware() reads its fields, translating them into the right MSA-1, MSA-3, ERR-3, and ERR-4 fields in the wire ACK. A plain throw new Error(...) still works — it becomes AE — but loses the diagnostic detail.

Standard exceptions

The following exceptions correspond one-to-one with the MSA-1 codes carried in an HL7v2 ACK message:

Exception classCodeWhen to throw
AckApplicationErrorAEThe message was understood but cannot be processed (validation, missing record, downstream timeout).
AckApplicationRejectARThe message is rejected outright at the application level (unknown partner, unsupported version, semantic invalidity).
AckCommitErrorCEEnhanced mode — the message could not be persisted (database down, storage full).
AckCommitRejectCREnhanced mode — the message will not be persisted (commit policy violation).

Each exception accepts a message and an optional object with errorCode (ERR-3) and severity (ERR-4) for more detailed diagnostics in the ACK. For example:

src/app.ts
import { AckApplicationError, Hl7ErrorCode, Severity } from "@glion/ack";

app.on("ORU^R01", (ctx) => {
  if (!ctx.message.has("OBR")) {
    throw new AckApplicationError("Missing OBR segment", {
      errorCode: Hl7ErrorCode.RequiredFieldMissing,
      severity: Severity.Error,
    });
  }
});

Built-in exceptions

@glion/ack ships convenience subclasses of the standard exceptions, pre-filled with errorCode and severity for common cases.

Exception classCodeWhen to throw
UnsupportedMessageTypeRejectARThe message's trigger event is not supported by the receiver. Useful in catch-all routes.
ApplicationInternalErrorAEA catch-all for unexpected errors — the message is understood but something went wrong processing it.

For the full list of built-in exceptions, see the source code.

Custom exceptions

Extend the standard exceptions to capture domain-specific failures while keeping the right NAK code and diagnostics on the wire:

src/errors.ts
import { AckApplicationError, Hl7ErrorCode, Severity } from "@glion/ack";

export class PatientNotFoundError extends AckApplicationError {
  constructor(mrn: string) {
    super(`Patient not found: ${mrn}`, {
      errorCode: Hl7ErrorCode.UnknownKeyIdentifier,
      severity: Severity.Error,
    });
    this.name = "PatientNotFoundError";
  }
}

Throw PatientNotFoundError from your handlers and the client receives an AE with MSA-3 set to Patient not found: <mrn> and ERR-3 set to UnknownKeyIdentifier.

src/app.ts
import { Mllp } from "@glion/mllp";
import { parseHL7v2 } from "@glion/hl7v2";
import { ackMiddleware } from "@glion/mllp-ack";
import { PatientNotFoundError } from "./errors";

const app = new Mllp();
app.parser(parseHL7v2);
app.use(ackMiddleware());

app.on("ADT^A01", (ctx) => {
  const mrn = ctx.message.get("PID-3");
  const patient = database.findPatientByMrn(mrn);
  if (!patient) {
    throw new PatientNotFoundError(mrn);
  }
});

export default app;

Acknowledgments

HL7v2 acknowledgments (ACKs) are the standard way for a receiver and a sender to communicate about the processing outcome of a message. An ACK is an HL7v2 message that the receiver sends back to the sender over MLLP after processing an inbound message. By default, a receiver must wait for an ACK response before confirming receipt of the message. This is called "synchronous ACK mode" and is the most common pattern for MLLP integrations. For more on the ACK message itself, see Acknowledgments.

Glion MLLP server provides a middleware, ackMiddleware(), that automatically turns handler response into the right ACK response on the wire. This way, the handler only needs to return its business outcome or throw exceptions — it never constructs ACK strings or sets MSA-1 codes directly. The middleware also fills MSA-2 (control id), MSA-3 (textual reason), and the optional ERR segment from the exception fields. Application code never constructs ACK strings to make it fully compliant with the HL7v2 standard and to ensure that ACKs always reflect the true processing outcome of each message.

The basic mapping from handler outcome to MSA-1 ACK code is:

Handler outcomeACK code
Returns normally, no fatal vfile diagnosticsAA
ctx.respond(ack) called explicitlyThe supplied ACK
Throws AckApplicationError (or a subclass)AE
Throws AckApplicationRejectAR
Throws AckCommitErrorCE
Throws AckCommitRejectCR
Throws any other errorAE

See also