Prior Authorization Routing Automation
A claim that comes back with NCPDP reject 75 (Prior Authorization Required) has not failed adjudication — it has been handed off. Prior authorization routing is the subsystem that catches that reject, opens a governed PA case for the exact member and drug that triggered it, and then drives that case through a bounded state machine — pended, approved, denied, overridden, appealed, or expired — until a terminal outcome flows back into the adjudication engine and the original claim can be re-priced or finally rejected. This area specifies that handoff as a deterministic, idempotent, auditable subsystem rather than a queue of manual work, extending the rule surface defined in Formulary Validation & Rule Engine Design. The failure that automation exists to prevent is the silent one: a member standing at a pharmacy counter whose PA was approved in one system but never synced back to the engine that rejected them.
Problem Framing
Within the rule engine, prior authorization is the branch that fires when a formulary edit determines a drug is coverable conditionally rather than outright. The upstream decision — whether this 407-D7 Product/Service ID (NDC), resolved through the NDC-to-GPI Crosswalk Automation to a therapeutic class, actually requires authorization for this member and plan — belongs to Step Therapy & Prior Auth Trigger Rules. Routing begins the moment that determination returns “PA required” and the adjudicator emits reject 75. From there, the claim is no longer a point-of-sale pricing problem; it is a case with a lifecycle that can span seconds (an auto-override) or weeks (a clinical appeal). The engineering problem is to model that lifecycle as a state machine whose transitions are deterministic, idempotent, and replayable, so that a PA outcome is never lost, never double-created, and never stale when it reaches the claim. Everything in this area treats the PA case as an event-sourced entity keyed on (member_token, GPI) and version-stamped against the formulary snapshot that raised the reject.
Prerequisites
PA routing is a downstream consumer of the adjudication path, not an entry point. Before a case is opened, the following must already hold:
- A PA-required determination with its reason. Routing does not decide whether a drug needs authorization; it consumes the boolean plus the triggering rule id from the trigger-rule evaluation. A reject
75with no attached rule reference is a defect, not a case — it means the trigger layer emitted the code without recording why, and the case cannot be adjudicated or audited later. - Normalized member and drug context. The case is keyed on the tokenized member identifier and the GPI, never on the raw
407-D7NDC. Package-level NDC variance must already be collapsed to a therapeutic class upstream, or two fills of the same drug open two unrelated PA cases. - PHI already tokenized. By the time a case is opened,
302-C2Cardholder ID and310-CAPatient First Name have been stripped and replaced with an opaquemember_token, exactly as required by Security & Compliance Boundaries for Claims Data. The PA engine reasons over tokens and taxonomy identifiers only; the clinical narrative that a reviewer sees lives behind a separate PHI-scoped service, never in the routing engine’s event log. - A version-stamped formulary snapshot. The PA policy that governs a case — which overrides auto-approve, how long an approval is valid — is read from the dated snapshot active at the dispensing timestamp, so a case opened in June is adjudicated and later replayed against June’s policy even if July’s policy differs.
- An append-only event store. Every transition is an immutable event. The current state of a case is a fold over its event history, not a mutable row that later transitions overwrite. This is what makes the whole subsystem replayable during a payer examination.
- Library baseline. Python 3.11+,
pydantic>=2.6for strict transition contracts,structlogor stdlibloggingfor JSON telemetry, anddecimal.Decimalfor the copay and cost-share figures an approval carries. See the Pydantic documentation and the decimal module for the exact APIs used below.
PA State Specification
A PA case is a finite state machine with six states and a fixed transition table. Modeling it as an explicit table — rather than as scattered if branches across the codebase — is what keeps the transitions total, auditable, and safe to replay. Every transition is triggered by a named event, guarded by a precondition, and produces exactly one appended event.
| State | Meaning | Entered by | Legal next states | Terminal? |
|---|---|---|---|---|
PENDING |
Case opened, awaiting a decision | reject 75 from trigger rules |
APPROVED, DENIED, OVERRIDDEN, EXPIRED |
no |
APPROVED |
Clinical approval on file | reviewer decision or upheld appeal | EXPIRED |
no (time-bounded) |
OVERRIDDEN |
Auto-approved by a policy-gated override | valid 420-DK / 461-EU override |
EXPIRED |
no (time-bounded) |
DENIED |
Authorization refused | reviewer decision | APPEALED |
no |
APPEALED |
Denial under formal appeal | member/prescriber appeal filed | APPROVED, DENIED |
no |
EXPIRED |
Approval window elapsed | authorization end-date passed | PENDING (resubmission) |
effectively terminal |
Two invariants wrap the table. First, every transition is idempotent under an idempotency key: replaying the same (case_id, event_id) must leave the case in the identical state and append no second event. Second, no transition is legal that the table does not name: an event that would drive APPROVED → DENIED directly, without an intervening EXPIRED and a fresh PENDING, is rejected as an illegal transition and logged as a conflict rather than silently applied. The transitions that carry money — an approval that authorizes a specific quantity at a specific cost share — recompute copay through Tier Mapping & Copay Calculation Logic using Decimal, never float.
The reject codes the routing layer reads and writes are load-bearing:
| Code | Field | Meaning in routing |
|---|---|---|
75 |
511-FB Reject Code |
Prior authorization required — opens or re-asserts a case |
569 |
511-FB Reject Code |
Provide beneficiary with notice — attached on a denial |
76 |
511-FB Reject Code |
Plan limitations exceeded — a distinct edit, not a PA path |
Reference Python Implementation
The routing engine below models the case as an event-sourced entity. It exposes a single apply method that takes the current case and a transition event, validates the transition against the table, and returns the next case plus the event to append — never mutating in place. Idempotency is enforced by carrying the last applied event_id; a replayed event is a no-op. The engine accepts only tokenized identifiers and logs only tokens, states, and reject codes.
import json
import logging
from decimal import Decimal, ROUND_HALF_UP
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
from pydantic import BaseModel, field_validator, ConfigDict
# Structured JSON logging for SIEM ingestion. PHI GUARDRAIL: only the opaque
# member_token, GPI, case_id and outcome ever reach this logger -- never
# 302-C2 Cardholder ID, 310-CA Patient Name, or raw claim bytes.
logging.basicConfig(format="%(message)s", level=logging.INFO)
logger = logging.getLogger("pa_routing")
REJ_PA_REQUIRED = "75" # 511-FB Reject Code: Prior Authorization Required
REJ_NOTICE = "569" # 511-FB Reject Code: Provide beneficiary with notice
CENTS = Decimal("0.01")
class PAState(str, Enum):
PENDING = "PENDING"
APPROVED = "APPROVED"
OVERRIDDEN = "OVERRIDDEN"
DENIED = "DENIED"
APPEALED = "APPEALED"
EXPIRED = "EXPIRED"
# The transition table IS the policy. Any event not named here is illegal.
LEGAL: dict[PAState, set[PAState]] = {
PAState.PENDING: {PAState.APPROVED, PAState.DENIED, PAState.OVERRIDDEN, PAState.EXPIRED},
PAState.APPROVED: {PAState.EXPIRED},
PAState.OVERRIDDEN: {PAState.EXPIRED},
PAState.DENIED: {PAState.APPEALED},
PAState.APPEALED: {PAState.APPROVED, PAState.DENIED},
PAState.EXPIRED: {PAState.PENDING},
}
class PACase(BaseModel):
"""Current folded state of one PA case. Non-PHI by construction."""
model_config = ConfigDict(extra="forbid")
case_id: str # deterministic hash of (member_token, GPI, request_window)
member_token: str # opaque; 302-C2 already stripped upstream
gpi: str # 14-digit therapeutic class, not raw 407-D7 NDC
state: PAState
last_event_id: Optional[str] = None
authorized_copay: Optional[Decimal] = None # Decimal money, never float
auth_expires_at: Optional[str] = None
snapshot_version: str
class TransitionEvent(BaseModel):
model_config = ConfigDict(extra="forbid")
event_id: str # idempotency key; unique per intended transition
case_id: str
to_state: PAState
occurred_at: str
override_461eu: Optional[str] = None # 461-EU Prior Authorization Type Code
override_number_462ev: Optional[str] = None # 462-EV PA Number Submitted
authorized_copay: Optional[Decimal] = None
@field_validator("authorized_copay")
@classmethod
def _quantize(cls, v: Optional[Decimal]) -> Optional[Decimal]:
return None if v is None else v.quantize(CENTS, rounding=ROUND_HALF_UP)
class TransitionRejected(Exception):
"""Raised when an event would drive an illegal or conflicting transition."""
def apply(case: PACase, event: TransitionEvent) -> PACase:
"""Deterministic, idempotent transition. Returns the next case; never mutates."""
if event.case_id != case.case_id:
raise TransitionRejected("event/case mismatch")
# Idempotency: a replayed event is a no-op that returns the identical state.
if event.event_id == case.last_event_id:
_audit(case, event, outcome="idempotent_replay")
return case
if event.to_state not in LEGAL[case.state]:
_audit(case, event, outcome="illegal_transition")
raise TransitionRejected(f"{case.state} -> {event.to_state} not permitted")
next_case = case.model_copy(update={
"state": event.to_state,
"last_event_id": event.event_id,
"authorized_copay": event.authorized_copay or case.authorized_copay,
})
_audit(next_case, event, outcome="applied")
return next_case
def _audit(case: PACase, event: TransitionEvent, outcome: str) -> None:
# Append-only audit event: tokens + taxonomy + outcome only, no PHI.
logger.info(json.dumps({
"event": "pa_transition",
"case_id": case.case_id,
"member_token": case.member_token, # opaque, non-PHI
"gpi": case.gpi,
"from_state": case.state.value,
"to_state": event.to_state.value,
"event_id": event.event_id,
"outcome": outcome,
"reject_code": REJ_PA_REQUIRED if case.state == PAState.PENDING else None,
"snapshot_version": case.snapshot_version,
"timestamp": datetime.now(timezone.utc).isoformat(),
}))The engine is a pure function of (case, event): given the same case and event it returns the same next case and appends the same audit line, which is what makes failover and re-adjudication safe. The case_id is a deterministic hash of (member_token, GPI, request_window) so that two concurrent claims for the same member and drug in the same window resolve to the same case rather than two — the concurrency guarantee that Resolving PA Pend Race Conditions hardens. Money on an approval crosses the wire as a quantized Decimal string; a float copay would silently break cost-share rounding downstream.
The full lifecycle the engine enforces is shown below. Each edge is a named, guarded transition; the terminal band on the right is where a resolved case flows back into adjudication.
Figure: The PA case lifecycle — reject 75 opens a PENDING case that resolves to APPROVED, OVERRIDDEN, DENIED, or EXPIRED; denials route to APPEALED, and every terminal path resumes adjudication.
Engineering Constraints & Known Failure Modes
PA routing fails in specific, recurring ways. Each has a deterministic rule rather than an ad-hoc except:
- Pend races. Two point-of-sale claims for the same member and drug arrive milliseconds apart; both see “no open case” and both create a
PENDINGcase, producing duplicate reviews and later conflicting decisions. Thecase_idis derived deterministically from(member_token, GPI, request_window)and enforced by an atomic upsert so the second writer joins the existing case instead of creating a rival. The full treatment — locking strategy, idempotency-key dedup, and the DB unique constraint — is in Resolving PA Pend Race Conditions. - Duplicate PA requests across channels. A prescriber portal submission and a pharmacy reject can both open a case for the same
(member, GPI). Deduplication keys on the deterministiccase_id, not on the submission channel, so cross-channel duplicates converge. - Stale approvals. An
APPROVEDcase has anauth_expires_at. A claim that arrives after that date must not silently re-use the approval; the case transitions toEXPIREDand the incoming claim re-asserts reject75. Expiry is evaluated against the dispensing timestamp, never wall-clock at read time, so a replayed historical claim sees the approval that was valid then. - Override conflicts. An automated
420-DKoverride and a concurrent clinical denial can race on aPENDINGcase. The transition table plus the idempotency key make the outcome deterministic — whichever event the store commits first wins, and the loser hits an illegal-transition guard rather than overwriting. Which overrides may auto-apply at all, versus which must route to a human, is decided in Automating PA Override Approvals. - Split-brain status across systems. The PA system, the adjudication engine, and member/prescriber portals can hold divergent views of the same case. Reconciliation on
case_idwith version vectors, and idempotent status ingestion, are covered in Syncing PA Appeal Status Across Systems. - Backend outage on the PA path. If the PA store is unreachable, the router must not default to
APPROVE. It emits reject75(case unresolved) and degrades through Fallback Routing Logic Design rather than authorizing an unreviewed fill. - PHI leakage in error paths. The tempting
exceptthat logs the offending claim is the classic HIPAA exposure. Every audit line carriesmember_token,gpi,case_id, and the outcome — never302-C2Cardholder ID,310-CAPatient Name, or the transaction body. Validation errors log the failure shape, not the payload, consistent with NCPDP Reject Code Reference.
Performance & Correctness Tuning
- Idempotency keys everywhere. Every transition carries an
event_idunique to the intended change. Retries, at-least-once queue redeliveries, and failover re-applies all collapse to a single applied event, so exactly-once semantics hold without distributed locks on the hot path. - Event ordering. Transitions are applied in the order the event store commits them, not the order they arrive over the wire. Each event carries
occurred_atplus a monotonic sequence per case so an out-of-order delivery is reordered or rejected, never applied blindly — the same discipline the sync page formalizes with version vectors. - Snapshotting the fold. A long-lived case (one that expires and resubmits repeatedly) accumulates events. Periodically materialize a snapshot of the folded
PACaseand truncate replay to “latest snapshot + subsequent events” so state reconstruction stays O(recent events), not O(all history), while the full log remains for audit. - Version-stamped policy. Every case records the
snapshot_versionof the formulary/PA policy that governs it, so an approval’s validity window and the set of auto-approvable overrides are those that were live when the case opened — the versioned-snapshot replay guarantee this whole engine depends on. - Decimal money, always. Any copay or cost-share an approval authorizes uses
decimal.Decimal, quantized to cents withROUND_HALF_UP, and crosses the wire as a string. A float here silently corrupts member cost-share. - Audit before acknowledge. Serialize the transition event to the append-only store before acknowledging the triggering message, so a crash between decision and acknowledgement replays the decision rather than losing it.
In This Section
- Resolving PA Pend Race Conditions — preventing duplicate case creation and lost updates when concurrent claims pend the same member and GPI, using idempotency keys, atomic upserts, and a database unique constraint.
- Automating PA Override Approvals — which
420-DK,461-EU, and462-EVoverride submissions can be auto-approved safely versus which must route to clinical review, with the policy gate and copay recompute. - Syncing PA Appeal Status Across Systems — keeping the adjudication engine, the PA system, and portals eventually consistent through idempotent, out-of-order-tolerant status ingestion.
Related
- Step Therapy & Prior Auth Trigger Rules — the upstream evaluation that decides a claim needs authorization and emits the reject
75this area routes. - NCPDP Reject Code Reference — the canonical meaning of
75,569, and76in the511-FBfield every transition reads and writes. - Fallback Routing Logic Design — how the router degrades safely when the PA store is unreachable instead of authorizing an unreviewed fill.
- Security & Compliance Boundaries for Claims Data — the tokenization and audit obligations every PA event must satisfy.
- Tier Mapping & Copay Calculation Logic — the
Decimalcopay recompute an approval or override triggers before the claim re-prices.