Generating Appeal Letters with Jinja2

Problem: you have a parsed denial — a CLP claim-status loop, a CAS adjustment carrying a CARC, maybe an LQ remark — and you need to turn it into a formatted, payer-addressed appeal letter that a biller can sign and submit, without re-keying a single field by hand. This guide shows the exact Jinja2 setup to render an appeal letter from a template and a typed denial record: autoescaped for injection safety, PHI-safe in its logging, and switchable per payer. It implements the render stage of Automated Appeal Letter Generation.

Prerequisites

Spec Reference: Template Variables

The template contract is the set of variables the render context must supply. Keep it small and typed — every variable maps to a named 835 element or a stored 837 claim attribute, so nothing in the letter is free-typed.

Template variable Source element Example (de-identified) Notes
appeal.payer_claim_control_number CLP07 CTRL0000000 Payer’s own claim ID; must appear for the payer to locate the claim
appeal.patient_control_number CLP01 PCN-SAMPLE-01 Your internal claim ID
appeal.total_charge CLP03 450.00 Billed amount under appeal
appeal.denials[].carc CAS reason code 197 Denial reason driving the argument
appeal.denials[].rarc LQ02 (LQ01=HE) N657 Optional supplemental remark
appeal.rendering_npi 837 NM109 1999999992 Placeholder NPI, not a real provider
appeal.service_date 837 DTP*472 2026-05-04 Anchors the timely-filing argument
appeal.appeal_level config redetermination Selects the template file
appeal.filing_deadline computed 2026-09-01 Deadline printed on the letter

Step-by-Step Implementation

Step 1 — Author the payer template

Store one template per payer and appeal level, named so selection is a formatting operation: {payer_id}_{appeal_level}.html.j2. Use only placeholder data in examples. The template below is deliberately minimal — a real payer template carries that payer’s mandatory cover-form headings and appeal address.


Re: Request for Redetermination
Payer Claim Control Number:
Patient Control Number:
Date of Service:

This is a first-level appeal of the denial reflected on the remittance advice for the claim above. The claim was denied under the following adjustment reason code(s):

Rendering provider NPI furnished the billed service. Supporting documentation is enclosed. We respectfully request that the determination be reversed.

This appeal must be received by .

Step 2 — Define the typed context model

Reuse the AppealRecord/DenialLine dataclasses from the parent guide. Passing a typed object — not a loose dict — means a missing field fails at construction, never as a silent blank in a mailed letter.

from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal


@dataclass(frozen=True)
class DenialLine:
    carc: str                 # CAS reason code
    amount: Decimal           # CAS03
    rarc: str | None = None   # LQ02 when LQ01 == "HE"


@dataclass(frozen=True)
class AppealRecord:
    payer_claim_control_number: str   # CLP07
    patient_control_number: str       # CLP01
    total_charge: Decimal             # CLP03
    denials: list[DenialLine]         # CAS/LQ rows
    rendering_npi: str                # 837 NM109
    service_date: date                # 837 DTP*472
    payer_id: str
    appeal_level: str                 # selects the template file
    filing_deadline: date             # computed elsewhere
    evidence: list[str] = field(default_factory=list)

Step 3 — Configure the environment and render

Build the environment once and cache it — compiling templates per render is the most common performance mistake. Autoescape must be on: the render context carries PHI (patient identifiers, free-text remarks) that could otherwise inject markup into an HTML or XML output path.

import logging
from datetime import date

from jinja2 import Environment, FileSystemLoader, select_autoescape, TemplateNotFound

logger = logging.getLogger("appeal.render")


def build_env(template_dir: str) -> Environment:
    return Environment(
        loader=FileSystemLoader(template_dir),
        autoescape=select_autoescape(("html", "xml")),  # injection safety
        trim_blocks=True,
        lstrip_blocks=True,
    )


def render_letter(record: AppealRecord, env: Environment) -> str:
    """Render a payer-specific appeal letter to an HTML string."""
    template_name = f"{record.payer_id}_{record.appeal_level}.html.j2"
    try:
        template = env.get_template(template_name)
    except TemplateNotFound:
        # Never fall back to a generic template — route to manual review.
        logger.error(
            "No template for payer/level | payer=%s | level=%s | clp07=%s",
            record.payer_id, record.appeal_level, record.payer_claim_control_number,
        )
        raise

    # Log ONLY control numbers and codes — never the rendered body (it is PHI).
    logger.info(
        "Rendered appeal | clp07=%s | payer=%s | level=%s | carcs=%s",
        record.payer_claim_control_number, record.payer_id, record.appeal_level,
        ",".join(d.carc for d in record.denials),
    )
    return template.render(appeal=record, today=date.today().isoformat())

Step 4 — Render to PDF (optional)

Most payers require a mailed or portal-uploaded PDF. Render the HTML first, then convert — WeasyPrint keeps the CSS layout intact. Stage the PDF to an encrypted, access-controlled path under HIPAA §164.312(a), and delete it once submission is confirmed.

def render_pdf(record: AppealRecord, env: Environment, out_path: str) -> None:
    from weasyprint import HTML  # optional dependency

    html = render_letter(record, env)
    HTML(string=html).write_pdf(out_path)   # out_path: encrypted-at-rest volume
    logger.info(
        "Appeal PDF written | clp07=%s", record.payer_claim_control_number
    )

Verification

Render the sample record and confirm the control number, CARC, and deadline land in the output. Use only de-identified placeholders — never real member IDs, names, or NPIs in a test fixture.

env = build_env("templates/")
record = AppealRecord(
    payer_claim_control_number="CTRL0000000",
    patient_control_number="PCN-SAMPLE-01",
    total_charge=Decimal("450.00"),
    denials=[DenialLine(carc="197", amount=Decimal("450.00"), rarc="N657")],
    rendering_npi="1999999992",              # placeholder NPI
    service_date=date(2026, 5, 4),
    payer_id="medicare",
    appeal_level="redetermination",
    filing_deadline=date(2026, 9, 1),
)
html = render_letter(record, env)
assert "CTRL0000000" in html
assert "CARC 197 / RARC N657" in html
assert "must be received by 2026-09-01" in html

Expected rendered fragment (de-identified):

<li>CARC 197 / RARC N657 — adjustment 450.00</li>
...
<p><strong>This appeal must be received by 2026-09-01.</strong></p>

Common Gotchas

  • Autoescape off invites injection. A payer-supplied free-text remark or a patient name containing < or & will corrupt an HTML render — or worse, inject markup — if autoescape is disabled. Always pass autoescape=select_autoescape(("html", "xml")); only mark a value | safe when you have sanitized it yourself.
  • Rendered body in the logs. logger.debug(template.render(...)) writes the full PHI-bearing letter to a log sink, breaching §164.312(b). Log the CLP07 control number and CARC list, never the rendered text.
  • Wrong per-payer template silently used. A default get_template("appeal.html.j2") fallback ships a letter missing the payer’s mandatory cover form and address, and the appeal is rejected on procedure. Select strictly on payer_id/appeal_level and route a TemplateNotFound to manual review.
  • Uncompiled templates per render. Rebuilding the Environment inside the render function recompiles every template on every call. Build it once at startup and reuse it across the batch.

Up: Automated Appeal Letter Generation