Skip to main content

Healthcare Prior Authorization - HITL + Idempotency

Prior authorization (PA) is the perfect case for human-in-the-loop AI. An agent can gather the documentation, build the clinical justification, and route the request to a payer - but the decision to commit a patient to a $4,200 procedure is exactly where an automated workflow has to pause and wait for a human with the license to sign off. This tutorial walks through that agent on AxonFlow's Workflow Control Plane, with two runtime properties that matter for clinical use:

  • The approval cannot be retargeted. If the medical director approves procedure J0135 for patient P-88120, the agent physically cannot resume that approved step against a different patient or a different procedure - the idempotency_key match check rejects the call with 409 IDEMPOTENCY_KEY_MISMATCH before the PA submission fires.
  • The agent can tell, on any retry, whether the approval already landed or whether the PA submission already succeeded. retry_context surfaces attempt counts, the prior decision, and whether /complete ever fired - so a resumed agent doesn't re-submit an already-approved PA and doesn't re-trigger an already-granted approval.
Scope

This tutorial combines three capabilities:

  • retry_context and idempotency_key on step gates - new in platform v7.3.0, wire-level, available on every tier (including Community)
  • An approval requirement that holds the step for a reviewer - Enterprise. It is a typed policy published in the customer portal; Community and Evaluation cannot publish one

Run the walkthrough on an Enterprise deployment. The retry_context and idempotency_key parts work on every tier.

The scenario​

Your PA agent is processing a request for patient P-88120, CPT code J0135 (adalimumab, a biologic that typically needs PA). The agent:

  1. Gathers the patient's clinical record and formulary notes
  2. Builds the PA request
  3. Pauses at a gate that returns require_approval: the organization's approval requirement holds the PA submission step for the medical director
  4. Medical director reviews and approves
  5. Agent submits the PA to the payer
  6. /complete marks the step done

Along the way, the agent may retry - a WebSocket drop during reviewer polling, an orchestrator redelivery after a timeout, a resumed workflow after an agent restart. Every retry has to leave the approval pinned to patient P-88120 / procedure J0135. That's what idempotency_key buys you.

Flow​

The critical moments are bolded in the diagram: the approval is bound to the pa:P-88120:J0135 key, and AxonFlow refuses any later gate or complete with a different key before the PA submission fires.

Prerequisites​

  • AxonFlow running locally (Getting Started)
  • An Enterprise deployment with the customer portal, and two portal users with the policy:write permission: one publishes the approval requirement and the other promotes it
  • One AxonFlow SDK (Python, TypeScript, Go, Java) or curl
  • An approval requirement that holds the PA submission step (Step 0 below publishes one)

Step 0 - Publish the approval requirement​

On v11 a step is held for a reviewer by a typed requirement carrying the approval_challenge obligation. The legacy /api/v1/policies route this step used before v11 cannot create a hold.

A requirement selects a step by the action its step type presents as, by who is calling, and, from v11.1.0, by the step's name (args.context.step__name, read as a positive eq with on_absent: no_match; see the step-name recipe). It cannot read the step's step_input, so it cannot express "biologics over $2,000". Two consequences for this workflow:

  • The PA submission gate uses step_type: "tool_call", which presents as the tool.call action. The requirement below holds every tool_call step your organization gates. To hold only the PA submission step, add a condition on args.context.step__name equal to Submit prior authorization, or give the steps that must not wait a different step type.
  • The medication-class and cost check stays in your application, which decides which requests take the reviewed step.

Start from the complete document in A requirement that holds a step for approval and adapt it. For this tutorial, save it as pa-approval.json with:

  • metadata.document_id: prior-auth-approvals
  • policies[0].id and source_policy: pa.approval.tool_call
  • actions: tool.call
  • params.eligible: your medical directors' group, for example Group::axonflow-trusted-header:medical-directors, and params.quorum: "1"
  • the fixture's expect: {"pa.approval.tool_call": "MATCH"}

Then publish it in the customer portal, naming the medical director who will promote it, and have them promote it:

# Author: publish, naming a different person as the approver
jq '. + {approvers: [{"kind":"principal","type":"User","qualifier":"portal","local":"[email protected]"}]}' \
pa-approval.json > publish.json
DIGEST=$(curl -sS -X POST "$PORTAL_URL/api/v1/typed-policies/publish" \
-b "axonflow_session=$AUTHOR_SESSION" -H 'Content-Type: application/json' \
--data @publish.json | jq -r '.artifact.digest')

# Approver: promote the published digest
curl -sS -X POST "$PORTAL_URL/api/v1/typed-policies/promote" \
-b "axonflow_session=$APPROVER_SESSION" -H 'Content-Type: application/json' \
--data "{\"digest\":\"$DIGEST\",\"reason\":\"PA submissions need a medical director\"}"

A publication that names only its author is refused with APPROVER_IS_AUTHOR, and the author cannot promote their own version. An organization with a single person who can approve can use the self-approval exception instead.

Step 1 - Create the workflow and first gate​

The idempotency key pa:P-88120:J0135 encodes the exact business identity of this PA: patient P-88120, procedure J0135. The key format is up to you - a hash of the full clinical request is also reasonable - but it must uniquely identify the transaction.

from axonflow import AxonFlow
from axonflow.workflow import (
CreateWorkflowRequest,
StepGateRequest,
StepType,
WorkflowSource,
)

IDEMPOTENCY_KEY = "pa:P-88120:J0135"

async with AxonFlow(
endpoint="http://localhost:8080",
client_id="pa-agent",
client_secret="your-secret",
) as client:
workflow = await client.create_workflow(
CreateWorkflowRequest(
workflow_name="prior-auth-request",
source=WorkflowSource.EXTERNAL,
trace_id="case-P-88120-J0135",
)
)

gate = await client.step_gate(
workflow_id=workflow.workflow_id,
step_id="submit-pa",
request=StepGateRequest(
step_name="Submit prior authorization",
step_type=StepType.TOOL_CALL,
step_input={
"patient_id": "P-88120",
"cpt_code": "J0135",
"medication_class": "biologic",
"estimated_cost_usd": 4200,
},
idempotency_key=IDEMPOTENCY_KEY,
),
)

# gate.decision == "require_approval"
# gate.approval_id is set (e.g. "app_318a270f...")
# gate.retry_context.gate_count == 1
# gate.retry_context.last_decision == "require_approval"

The agent is now paused. It does not submit anything to the payer.

Step 2 - Poll /gate while approval is pending​

Your agent (or orchestrator, or scheduler) retries /gate periodically to check whether the reviewer has acted. Every retry returns the same require_approval decision with an incrementing gate_count, so the agent always knows how long it has been waiting and can decide to time out or escalate.

gate = await client.step_gate(
workflow_id=workflow.workflow_id,
step_id="submit-pa",
request=StepGateRequest(
step_name="Submit prior authorization",
step_type=StepType.TOOL_CALL,
idempotency_key=IDEMPOTENCY_KEY,
),
)

if gate.decision == "require_approval":
rc = gate.retry_context
print(f"Still pending. Attempt {rc.gate_count}, first_attempt_at={rc.first_attempt_at}.")
# prior_completion_status stays "gated_not_completed"
# last_decision stays "require_approval"
# idempotency_key stays "pa:P-88120:J0135"

The retry loop is intentionally boring. retry_context gives the agent everything it needs to decide "keep waiting" vs "escalate" without guessing - no side-channel state, no external coordination.

Step 3 - Medical director approves​

The reviewer acts through the approval API (or the Enterprise Customer Portal UI in production deployments - see Customer Portal).

curl -X POST "http://localhost:8080/api/v1/workflows/$WORKFLOW_ID/steps/submit-pa/approve" \
-H "Authorization: Basic $(echo -n 'pa-agent:your-secret' | base64)" \
-H "X-User-ID: [email protected]"

Response:

{
"workflow_id": "wf_abc123",
"step_id": "submit-pa",
"approval_status": "approved",
"approved_by": "[email protected]",
"message": "Step approved"
}

The reviewer is identified from the X-User-ID header and recorded in the audit log. On rejection, the workflow aborts and the agent sees a rejected state on the next gate call. On expiry (24h by default), the audit log records the auto-rejection and the workflow is aborted - same observable behavior as a manual reject.

Step 4 - Next gate sees allow, idempotency_key preserved​

After the reviewer approves, the next /gate call with the same key returns allow. Use the default retry policy, exactly as in Step 2: the gate answers from the step's recorded decision, and an approved step's recorded require_approval is answered as allow, with cached: true and a reason that starts Previously approved:.

gate = await client.step_gate(
workflow_id=workflow.workflow_id,
step_id="submit-pa",
request=StepGateRequest(
step_name="Submit prior authorization",
step_type=StepType.TOOL_CALL,
idempotency_key=IDEMPOTENCY_KEY,
),
)

# gate.decision == "allow"
# gate.cached is True, gate.reason starts with "Previously approved:"
# gate.retry_context.idempotency_key == "pa:P-88120:J0135" (still)
A reevaluate gate on a held step is refused

From v11.1.0 a step gate that would re-evaluate a step whose approval is pending, rejected or expired is refused with 409 APPROVAL_HOLD, which names the step and its hold; the hold itself is untouched. The refusal keys on the stored row, so a gate call carrying an override refuses the same way, and a checkpoint resume is refused while any step holds. An approved step still re-evaluates, a step with no hold is unaffected, and an idempotent retry still returns the recorded decision. A gate for a different step is refused while any step holds a pending approval.

Before v11.1.0 reevaluate skipped the step's recorded decision, and with it the step's approval state, and ran a fresh evaluation — which is why it was never the way to ask whether an approval landed. To read approval state, re-gate with the default policy, which answers from the recorded decision, or read the step's approval_status from GET /api/v1/workflows/{workflow_id}, then carry on with the step.

Step 5 - Submit to the payer, /complete with matching key​

payer_response = payer_client.submit_prior_auth(
patient_id="P-88120",
cpt_code="J0135",
justification=clinical_note,
idempotency_key=IDEMPOTENCY_KEY, # same key across every layer
)

await client.mark_step_completed(
workflow_id=workflow.workflow_id,
step_id="submit-pa",
request=MarkStepCompletedRequest(
output={
"pa_reference": payer_response.reference,
"approved_by": "[email protected]",
"submitted_at": payer_response.submitted_at.isoformat(),
},
idempotency_key=IDEMPOTENCY_KEY,
),
)

await client.complete_workflow(workflow.workflow_id)

Using the same idempotency key at the payer layer is doubled protection - if the payer's response also times out, the next gate retry will still see prior_completion_status: "gated_not_completed" and the agent can ask the payer "did a PA request with key pa:P-88120:J0135 already land?" before re-submitting.

The safety story - approvals cannot be retargeted​

Imagine a bug in your agent's record-handling code causes a state mix-up: the agent starts acting on patient P-91234 / procedure J9999 but tries to complete the submit-pa step from patient P-88120's workflow. Without idempotency discipline, that agent would submit the wrong procedure under an approval that was granted for a different patient. With idempotency_key pinned on both ends:

from axonflow.exceptions import IdempotencyKeyMismatchError

try:
await client.mark_step_completed(
workflow_id=workflow.workflow_id,
step_id="submit-pa",
request=MarkStepCompletedRequest(
output={"pa_reference": "PA-WRONG"},
idempotency_key="pa:P-91234:J9999", # wrong patient + procedure
),
)
except IdempotencyKeyMismatchError as e:
# Refuses to complete. e.expected_idempotency_key == "pa:P-88120:J0135"
# e.received_idempotency_key == "pa:P-91234:J9999"
alert_clinical_safety_team(e)

The call fails with 409 IDEMPOTENCY_KEY_MISMATCH before the PA submission, the audit log records the attempted mismatch, and the bug surfaces as a clear alert instead of a miscategorized claim submission.

What this protects you from​

  1. Approved decisions being retargeted to a different patient/procedure - the key mismatch check refuses before the payer ever sees the wrong request
  2. Duplicate PA submissions on orchestrator retry - prior_completion_status tells the agent whether the payer already received the submission; reconcile with the payer's own idempotency store before re-submitting
  3. Lost ack mid-submission - same reconciliation pattern as Payment Agent - Retry & Reconciliation

What this does not cover​

  • Cross-workflow replay - if a workflow expires (24h by default) and your application creates a new one for the same patient + procedure, AxonFlow does not enforce key match across workflow IDs. Keep your own idempotency store on the PA reference and check it before creating the replacement workflow. Cross-workflow enforcement is a planned future enhancement.
  • Clinical-record identity checks - AxonFlow enforces key-level identity on the workflow step. Patient identity verification against the EHR is your application's responsibility.
  • PHI redaction in audit logs - use AxonFlow's PII detection to ensure idempotency keys and step inputs don't carry raw PHI.