AI Claim Denial Appeal Letters: The Letter Is the Easy Part

AI drafts a denial appeal in seconds. What wins: parse the 835, route by reason code, quote the payer's policy, cite the chart, and let a human sign.

Clint JohnsonPublished 10 min read

Key takeaways

  • Parse the 835 and route by a payer-keyed CARC table; eligibility and timely filing denials go to work queues, not LLM letters.
  • The model only finds evidence: each policy criterion gets a FHIR resource ID and text span that code verifies, and unsupported criteria are flagged for staff.
  • A human signs and sends every appeal, and success is overturn rate per category per payer plus staff minutes per appeal.

I wouldn't build an AI appeal letter generator. Yes, an LLM can draft a claim denial appeal in seconds. But persuasive prose is the cheapest part of an appeal. A letter wins when it answers the payer's actual reason code, quotes the payer's own coverage criteria, and points to chart evidence for each criterion, and a human signs it. Everything except the prose is deterministic code, and that code is the product.

MedScrub sits close to the revenue cycle, where AI output turns into money or into a compliance finding. This post is how I'd wire denial appeals into that path. The rule is the one I use everywhere in healthcare: the model finds, code decides. The ICD-10 post applied it to coding. The CMS-0057-F post applied it to prior auth before service. This is the step after adjudication. The claim came back denied, and someone has to decide what to do about it.

Why an AI appeal letter template isn't enough

Search for this topic and you get templates: a prompt, some blanks, a letter. A template doesn't know which reason code the payer used, which policy version was in force on the date of service, or which note in the chart proves the criterion. It will produce a confident letter for a denial that should never get a letter at all.

A pipeline decides whether there should be a letter, what it has to prove, and whether the chart can prove it. Only then does it write.

One scope note: a claim rejected at the front door, before adjudication, gets corrected and resubmitted, not appealed, so it's out of scope here.

How to read CARC and RARC denial codes on an 835

The denial reason already arrives in a structured file. The X12 835 remittance advice explains why each payment was adjusted or denied. Payers have to give that reason with Claim Adjustment Reason Codes (CARCs), Remittance Advice Remark Codes (RARCs) and Claim Adjustment Group Codes (CAQH CORE). There are exactly four group codes: PR, CO, PI and OA. A CARC is always paired with one of them.

Parse CAS at both loops

The CAS segment is positional. CAS01 is the group code, which says who bears the cost. CAS02 is the CARC, which says why. CAS03 is the amount. RARCs add explanation but never move money (Saga IT). CAS shows up at claim level (loop 2100) and service-line level (loop 2110) (EDI Paisan). Parse both. A claim can be paid on one line and denied on the next.

Vendor write-ups disagree on where RARCs live, and their examples don't always match each other. Write the parser from the 005010X221A1 implementation guide, not from blog posts, and test it against real remits from each payer.

from decimal import Decimal

def parse_cas(elements: list[str]) -> list[dict]:
    # elements = one CAS segment, split on the separator from the ISA header.
    # Assumes one group code (CAS01), then reason/amount/quantity triplets.
    # Confirm against the 005010X221A1 TR3 before production use.
    group = elements[1]                       # PR / CO / PI / OA
    rest = elements[2:]
    out = []
    for i in range(0, len(rest), 3):
        carc = rest[i]
        if not carc:
            continue
        amt = rest[i + 1] if i + 1 < len(rest) and rest[i + 1] else "0"
        qty = rest[i + 2] if i + 2 < len(rest) and rest[i + 2] else None
        out.append({"group": group, "carc": carc,
                    "amount": Decimal(amt),
                    "quantity": Decimal(qty) if qty else None})
    return out

Money goes in Decimal, never float. If your appeal tracking is off by a cent, finance will stop trusting everything else it shows.

Build the payer-keyed CARC table

The classifier behind the parser is smaller than you'd expect. CAQH cites analysis showing the 10 most-reported CARCs make up 75% of annual CARC usage, and CMS reported that the top 25 make up 85% (CAQH CORE). A lookup table covers most of your volume. You don't need a model to read a reason code.

You can't use one global table, though. The same CORE document describes "extensive confusion" across the industry: different payers send different CARC/RARC/group combinations for the same business scenario. Key the mapping by payer first and fall back to a default second.

Seed it from your own remits. Count (payer, group, CARC) across a few months of 835s, map the top 25 by hand, and send everything else to triage. The rows look like this:

payer_idgroupCARCcategory
payer_aCO<code A>authorization
payer_bCO<code B>authorization
*CO<code C>medical_necessity
*PR<code D>eligibility

Fill the codes from your own counts and check each definition against the live X12 code list. Two payers saying "no auth" with different codes is exactly the case the payer key exists for. Staff extend the table as unmapped codes show up. That's a spreadsheet job, not a model job.

Which claim denials should (and shouldn't) get an appeal letter

Use five categories: medical necessity, coding, authorization, timely filing and eligibility. That taxonomy is a design choice, not a standard. CORE defines four minimum business scenarios, and CMS transparency data uses yet another set. Pick the one that matches how your staff actually fix things.

Some categories never get an LLM letter.

An eligibility denial means the patient wasn't covered, or you billed the wrong plan. A letter for it is a letter to the wrong address. Re-verifying and rebilling the right payer fixes it. A timely filing denial is a dated fact. Either you have proof of timely submission or you don't, and a clerk with the clearinghouse acknowledgment closes it faster than any letter. Both go to a work queue.

LETTER_ELIGIBLE = {"medical_necessity", "authorization", "coding"}

def route(payer_id: str, adj: dict, mapping: dict) -> tuple[str, str]:
    key = (adj["group"], adj["carc"])
    category = mapping.get((payer_id, *key)) or mapping.get(("*", *key))
    if category is None:
        return ("triage_queue", "unmapped")    # humans extend the table
    if category not in LETTER_ELIGIBLE:
        return ("work_queue", category)        # eligibility, timely filing
    return ("appeal_pipeline", category)

Coding stays letter-eligible only when the dispute is whether the code was right. If the code was wrong, the fix is a corrected claim, and that belongs in your coding workflow.

This routing matters more than the model does. In KFF's analysis of 2024 HealthCare.gov data, lack of medical necessity was only 5% of in-network denials, and the biggest bucket was "Other" at 36%, which no lookup table can route. That's why the triage queue exists. The rest: administrative 25%, excluded services 13%, missing prior auth or referral 9% (KFF). It's Marketplace-only data, so treat it as an illustration, not your mix.

Measure your own mix from your own 835s. I'd bet the direction holds: the denials that make the news are a thin slice of the volume. Generate a letter for every denial and most of your tokens go to arguing with administrative denials that a phone call would have closed.

Medical necessity appeals: quote the payer's coverage criteria (NCD/LCD)

A medical necessity appeal that doesn't quote the payer's own policy is an opinion. For Medicare, the policy is the NCD or LCD for the denied service. For commercial payers, it's their published medical policy or coverage criteria.

Three rules for this step:

  1. Retrieve by code pair, not by semantic similarity. The CPT and ICD codes are on the claim. Use them as exact keys into your policy store. Embeddings are fine for finding the right section inside a long policy. They don't get to pick which policy applies.
  2. Version policies by effective date. The criteria that matter are the ones in force on the date of service, not today's. Store each version with its effective range and select on DOS.
  3. Break the policy into a criteria list. Each criterion gets an ID and an evidence window. Everything downstream works criterion by criterion.

Here's the shape, with illustrative criteria, not quoted from any real policy:

IDCriterionEvidence window
C1Diagnosis on the claim is documented by the treating clinicianDOS encounter
C2Prior conservative treatment was tried and failedBefore DOS
C3Ordering note states why the service is neededOn or before DOS

This is where letters win. A practitioner breakdown of Medicare redeterminations says first-level wins cluster around documentation that existed but was never reviewed, clerical errors, signature problems fixed by attestation, and plain misapplication of the coverage policy, where "a direct citation to the policy language can resolve the matter." It also says contested medical-necessity judgments rarely win at that level (DoctorsManagement). That's Original Medicare, not Medicare Advantage, so don't generalize it to every payer. It still tells you where to spend effort. The policy quote and the evidence citation carry the appeal. The prose is packaging.

How to stop an AI appeal letter from making up clinical evidence

This is where the model earns its place. It reads FHIR resources and clinical notes through narrow, typed tools, the way I described in the FHIR MCP server post. For each criterion, it returns a resource ID and the exact text span it believes satisfies that criterion. It doesn't write the argument yet. It only finds evidence.

Then code checks every citation. The resource has to exist, the span has to be in it, and the date has to fall inside the criterion's window. That window comes from the policy, not only from the date of service. C2 above needs evidence dated before the service.

def verify(criteria, citations, fetch):
    for c in criteria:
        cite = citations.get(c.id)
        if cite is None:
            c.status = "no_evidence"; continue
        res = fetch(cite.resource_id)
        if res is None or cite.span not in res.text:
            c.status = "bad_citation"
        elif not c.window.contains(res.effective_date):
            c.status = "out_of_window"
        else:
            c.status = "supported"

If the model can't point to it, the letter can't say it. Any criterion without supported evidence is flagged for staff, not argued. The flag becomes a task: get the missing therapy note, get the signed attestation. It's the same claim-by-claim citation check from catching hallucinations in AI clinical notes, applied to a different document.

Flags matter more as the appeal climbs. In Original Medicare, evidence not submitted before the QIC reconsideration decision is generally excluded at later levels unless you show good cause (42 CFR 405.966, via DoctorsManagement). If the evidence package has a hole, find it at Level 1 or 2. Finding it at the ALJ is too late.

The model never submits. A human signs every appeal.

The drafted letter goes to a queue. A person reads it, edits it, signs it and sends it. The model has no path to the payer portal, the fax line or the clearinghouse.

These deadlines have money attached. In Original Medicare, a redetermination must be filed within 120 days of the initial determination (Novitas), with receipt of a notice presumed 5 days after its date (DoctorsManagement). Late requests without good cause are dismissed (Novitas). A system that sends things on its own will eventually send the wrong thing on the last day.

Every human edit is data. Capture the diff between the draft and the signed letter. Tag it with payer, category and criterion, and turn it into a regression case. When a reviewer deletes a sentence because it argued something the chart didn't say, that sentence becomes a failing test before your next prompt change ships. That's how you get an eval suite that does its job without a separate labeling project. Your billers are already labeling. You just have to keep what they do.

How to measure denial appeal overturn rates by payer and category

Don't score the letter. Score the result. A beautiful letter that loses is still a loss.

Track two numbers. The first is overturn rate per denial category per payer. The second is staff minutes per appeal.

A blended overturn rate hides everything. HHS OIG found Medicare Advantage organizations overturned 95% of appealed SNF admission denials at the plan's own internal appeal (Healthcare Finance News). Insurers on HealthCare.gov upheld 66% of appeals in 2024 (KFF). Those are different programs, appeal levels and denial types, so they aren't a payer-to-payer comparison. That's the point. Overturn rate depends on program, payer and category, and an average across them means nothing. Break it out, or you'll tune prompts against noise.

Staff minutes tells you whether the pipeline helps at all. If reviewers spend longer fixing drafts than they used to spend writing letters, the system is a cost, however good the letters look. If minutes drop but a category's overturn rate drops with them, you're just sending faster losers. You want both numbers moving the right way, per payer, before you call it working.

Questions people ask

Can AI write a claim denial appeal letter?

Yes, an LLM can draft one in seconds. A letter that wins answers the 835 reason code, quotes the payer's coverage criteria for the denied service, and cites verified chart evidence for each criterion, and a human signs and sends it.

Which denials should not get an AI appeal letter?

Eligibility and timely filing denials should go to a work queue. Eligibility is fixed by re-verifying and rebilling the right payer, and timely filing depends on proof of timely submission, which a letter doesn't change.

How do you stop an AI appeal letter from making up clinical evidence?

Have the model return a FHIR resource ID and exact text span for each policy criterion, then check in code that the resource exists, contains the span, and falls in the criterion's date window. Any criterion without supported evidence is flagged for staff instead of argued in the letter.

How do you measure whether an AI denial appeal pipeline works?

Track overturn rate per denial category per payer, plus staff minutes per appeal. Blended overturn rates hide differences between programs, payers and categories, and staff minutes show whether the drafts actually save time.