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 guarantees that matter for clinical use:
- The approval cannot be retargeted. If the medical director approves procedure
J0135for patientP-88120, the agent physically cannot resume that approved step against a different patient or a different procedure — theidempotency_keymatch check rejects the call with409 IDEMPOTENCY_KEY_MISMATCHbefore the PA submission fires. - The agent can tell, on any retry, whether the approval already landed or whether the PA submission already succeeded.
retry_contextsurfaces attempt counts, the prior decision, and whether/completeever fired — so a resumed agent doesn't re-submit an already-approved PA and doesn't re-trigger an already-granted approval.
This tutorial combines three capabilities:
retry_contextandidempotency_keyon step gates — new in platform v7.3.0, wire-level, available on every tier (including Community)- HITL approval queue for
require_approval— available on Evaluation tier and above. Community can triggerrequire_approvaldecisions but cannot act on them (no queue API) - Retry-aware dynamic policies (optional extension at the end) — Evaluation tier and above
Run the core walkthrough on an Evaluation license. You can get a free one at getaxonflow.com/evaluation-license.
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:
- Gathers the patient's clinical record and formulary notes
- Builds the PA request
- Pauses at a gate that triggers
require_approval— the policy says "any biologic over $2,000 goes to medical director" - Medical director reviews and approves
- Agent submits the PA to the payer
/completemarks 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)
- Evaluation license set via
AXONFLOW_LICENSE_KEY— free registration. Needed for the HITL queue API to work; Community can returnrequire_approvaldecisions but cannot enqueue them for reviewer action. - One AxonFlow SDK (Python, TypeScript, Go, Java) or
curl - A policy named something like
biologics-require-md-approvalthat returnsrequire_approvalon high-cost biologics (Step 0 below creates one)
Step 0 — Create the policy
On an Evaluation license, policies returning require_approval create HITL queue entries that the reviewer API can act on.
curl -X POST http://localhost:8080/api/v1/policies \
-H "Content-Type: application/json" \
-H "Authorization: Basic $(echo -n 'pa-agent:your-secret' | base64)" \
-d '{
"name": "biologics-require-md-approval",
"description": "Biologic medication over $2,000 requires medical director approval",
"type": "context_aware",
"category": "dynamic-compliance",
"priority": 900,
"enabled": true,
"conditions": [
{"field": "step_input.medication_class", "operator": "equals", "value": "biologic"},
{"field": "step_input.estimated_cost_usd", "operator": "greater_than", "value": 2000}
],
"actions": [
{"type": "require_approval", "config": {"reason": "biologic medication over $2,000 requires medical director approval", "severity": "high"}}
]
}'
Note the policy shape: conditions is a flat array ANDed implicitly, actions is a plural array with each action wrapped in {type, config}, and the reason and severity live inside config. Operators are equals, greater_than, less_than, contains, not_equals, regex, in. The type must be context_aware (one of a fixed set) and category must start with dynamic- or media-. See WCP Policy Configuration for the full field reference.
This is a standard HITL policy — no retry-aware conditions yet. We'll stack one on top in the Evaluation-tier extension at the end.
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.
- Python
- TypeScript
- Go
- Java
- curl
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"
import { AxonFlow } from "@axonflow/sdk";
const IDEMPOTENCY_KEY = "pa:P-88120:J0135";
const client = new AxonFlow({
endpoint: "http://localhost:8080",
clientId: "pa-agent",
clientSecret: "your-secret",
});
const workflow = await client.createWorkflow({
workflow_name: "prior-auth-request",
source: "external",
trace_id: "case-P-88120-J0135",
});
const gate = await client.stepGate(workflow.workflow_id, "submit-pa", {
step_name: "Submit prior authorization",
step_type: "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
// gate.retry_context.gate_count === 1
// gate.retry_context.last_decision === "require_approval"
import axonflow "github.com/getaxonflow/axonflow-sdk-go/v5"
const idempotencyKey = "pa:P-88120:J0135"
client := axonflow.NewClient(axonflow.AxonFlowConfig{
Endpoint: "http://localhost:8080",
ClientID: "pa-agent",
ClientSecret: "your-secret",
})
workflow, err := client.CreateWorkflow(axonflow.CreateWorkflowRequest{
WorkflowName: "prior-auth-request",
Source: axonflow.WorkflowSourceExternal,
TraceID: "case-P-88120-J0135",
})
if err != nil {
log.Fatal(err)
}
gate, err := client.StepGate(workflow.WorkflowID, "submit-pa", axonflow.StepGateRequest{
StepName: "Submit prior authorization",
StepType: axonflow.StepTypeToolCall,
StepInput: map[string]interface{}{
"patient_id": "P-88120",
"cpt_code": "J0135",
"medication_class": "biologic",
"estimated_cost_usd": 4200,
},
IdempotencyKey: idempotencyKey,
})
// gate.Decision == "require_approval"
// gate.ApprovalID is set
// gate.RetryContext.GateCount == 1
// gate.RetryContext.LastDecision == axonflow.GateDecisionRequireApproval
import com.getaxonflow.sdk.AxonFlow;
import com.getaxonflow.sdk.AxonFlowConfig;
import com.getaxonflow.sdk.types.workflow.WorkflowTypes.*;
final String IDEMPOTENCY_KEY = "pa:P-88120:J0135";
AxonFlow client = AxonFlow.create(AxonFlowConfig.builder()
.endpoint("http://localhost:8080")
.clientId("pa-agent")
.clientSecret("your-secret")
.build());
CreateWorkflowResponse workflow = client.createWorkflow(
CreateWorkflowRequest.builder()
.workflowName("prior-auth-request")
.source(WorkflowSource.EXTERNAL)
.traceId("case-P-88120-J0135")
.build()
);
StepGateResponse gate = client.stepGate(workflow.getWorkflowId(), "submit-pa",
StepGateRequest.builder()
.stepName("Submit prior authorization")
.stepType(StepType.TOOL_CALL)
.stepInput(Map.of(
"patient_id", "P-88120",
"cpt_code", "J0135",
"medication_class", "biologic",
"estimated_cost_usd", 4200))
.idempotencyKey(IDEMPOTENCY_KEY)
.build()
);
// gate.getDecision() == GateDecision.REQUIRE_APPROVAL
// gate.getApprovalId() is set
// gate.getRetryContext().getGateCount() == 1
// gate.getRetryContext().getLastDecision() == GateDecision.REQUIRE_APPROVAL
IDEMPOTENCY_KEY="pa:P-88120:J0135"
WORKFLOW_ID=$(curl -s -X POST http://localhost:8080/api/v1/workflows \
-H "Content-Type: application/json" \
-H "Authorization: Basic $(echo -n 'pa-agent:your-secret' | base64)" \
-d '{
"workflow_name": "prior-auth-request",
"source": "external",
"trace_id": "case-P-88120-J0135"
}' | jq -r '.workflow_id')
curl -X POST "http://localhost:8080/api/v1/workflows/$WORKFLOW_ID/steps/submit-pa/gate" \
-H "Content-Type: application/json" \
-H "Authorization: Basic $(echo -n 'pa-agent:your-secret' | base64)" \
-d @- <<EOF
{
"step_name": "Submit prior authorization",
"step_type": "tool_call",
"step_input": {
"patient_id": "P-88120",
"cpt_code": "J0135",
"medication_class": "biologic",
"estimated_cost_usd": 4200
},
"idempotency_key": "$IDEMPOTENCY_KEY"
}
EOF
Response:
{
"decision": "require_approval",
"step_id": "submit-pa",
"approval_id": "app_318a270f-...",
"approval_url": null,
"reason": "biologic medication over $2,000 requires medical director approval",
"cached": false,
"decision_source": "fresh",
"retry_context": {
"gate_count": 1,
"completion_count": 0,
"prior_completion_status": "none",
"prior_output_available": false,
"prior_output": null,
"prior_completion_at": null,
"first_attempt_at": "2026-04-21T16:00:00.000Z",
"last_attempt_at": "2026-04-21T16:00:00.000Z",
"last_decision": "require_approval",
"idempotency_key": "pa:P-88120:J0135"
}
}
approval_url is only populated when the orchestrator is deployed with the PORTAL_BASE_URL environment variable set — typical for Enterprise installs bundled with the Customer Portal. On a local Community install it stays null. When populated, the format is {PORTAL_BASE_URL}/workflows/{workflow_id}/steps/{step_id}/approve — the reviewer-side action URL, not a queue-item URL. Your agent and your reviewer UI should drive off approval_id + workflow_id + step_id rather than depending on approval_url being set.
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.
- Python
- TypeScript
- Go
- Java
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"
const gate = await client.stepGate(workflow.workflow_id, "submit-pa", {
step_name: "Submit prior authorization",
step_type: "tool_call",
idempotency_key: IDEMPOTENCY_KEY,
});
if (gate.decision === "require_approval") {
const rc = gate.retry_context;
console.log(`Still pending. Attempt ${rc.gate_count}, first_attempt_at=${rc.first_attempt_at}.`);
}
gate, err := client.StepGate(workflow.WorkflowID, "submit-pa", axonflow.StepGateRequest{
StepName: "Submit prior authorization",
StepType: axonflow.StepTypeToolCall,
IdempotencyKey: idempotencyKey,
})
if gate.Decision == axonflow.GateDecisionRequireApproval {
rc := gate.RetryContext
log.Printf("Still pending. Attempt %d, first_attempt_at=%s.",
rc.GateCount, rc.FirstAttemptAt.Format(time.RFC3339))
}
StepGateResponse gate = client.stepGate(workflow.getWorkflowId(), "submit-pa",
StepGateRequest.builder()
.stepName("Submit prior authorization")
.stepType(StepType.TOOL_CALL)
.idempotencyKey(IDEMPOTENCY_KEY)
.build()
);
if (gate.getDecision() == GateDecision.REQUIRE_APPROVAL) {
RetryContext rc = gate.getRetryContext();
log.info("Still pending. Attempt {}, first_attempt_at={}.",
rc.getGateCount(), rc.getFirstAttemptAt());
}
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 default on Evaluation), 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 goes through. Pass retry_policy: "reevaluate" so the policy engine re-runs against the freshly-approved step and returns allow:
- Python
- TypeScript
- Go
- Java
from axonflow.workflow import RetryPolicy
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,
retry_policy=RetryPolicy.REEVALUATE,
),
)
# gate.decision == "allow"
# gate.retry_context.idempotency_key == "pa:P-88120:J0135" (still)
const gate = await client.stepGate(workflow.workflow_id, "submit-pa", {
step_name: "Submit prior authorization",
step_type: "tool_call",
idempotency_key: IDEMPOTENCY_KEY,
retry_policy: "reevaluate",
});
// gate.decision === "allow"
gate, err := client.StepGate(workflow.WorkflowID, "submit-pa", axonflow.StepGateRequest{
StepName: "Submit prior authorization",
StepType: axonflow.StepTypeToolCall,
IdempotencyKey: idempotencyKey,
RetryPolicy: axonflow.RetryPolicyReevaluate,
})
// gate.Decision == axonflow.GateDecisionAllow
StepGateResponse gate = client.stepGate(workflow.getWorkflowId(), "submit-pa",
StepGateRequest.builder()
.stepName("Submit prior authorization")
.stepType(StepType.TOOL_CALL)
.idempotencyKey(IDEMPOTENCY_KEY)
.retryPolicy(RetryPolicy.REEVALUATE)
.build()
);
// gate.getDecision() == GateDecision.ALLOW
retry_policy: "reevaluate"Default-idempotent retries return the cached decision without consulting the policy engine. Right after an approval, the cached decision on the database row is still require_approval — the step was evaluated once and memoized. Passing retry_policy: "reevaluate" forces a fresh policy evaluation that sees the approved state and returns allow. This matches the pattern documented in Execution Boundary Semantics.
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:
- Python
- TypeScript
- Go
- Java
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)
import { IdempotencyKeyMismatchError } from "@axonflow/sdk";
try {
await client.markStepCompleted(workflow.workflow_id, "submit-pa", {
output: { pa_reference: "PA-WRONG" },
idempotency_key: "pa:P-91234:J9999", // wrong
});
} catch (e) {
if (e instanceof IdempotencyKeyMismatchError) {
alertClinicalSafetyTeam(e);
} else {
throw e;
}
}
err := client.MarkStepCompleted(workflow.WorkflowID, "submit-pa",
&axonflow.MarkStepCompletedRequest{
Output: map[string]interface{}{"pa_reference": "PA-WRONG"},
IdempotencyKey: "pa:P-91234:J9999", // wrong
})
var mismatch *axonflow.IdempotencyKeyMismatchError
if errors.As(err, &mismatch) {
alertClinicalSafetyTeam(mismatch)
}
import com.getaxonflow.sdk.exceptions.IdempotencyKeyMismatchException;
try {
client.markStepCompleted(workflow.getWorkflowId(), "submit-pa",
MarkStepCompletedRequest.builder()
.output(Map.of("pa_reference", "PA-WRONG"))
.idempotencyKey("pa:P-91234:J9999")
.build()
);
} catch (IdempotencyKeyMismatchException e) {
alertClinicalSafetyTeam(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.
Evaluation-tier extension — retry-aware escalation
Core flow above uses only the standard HITL queue. The Evaluation-tier extension is a retry-aware policy that layers additional governance on top: if an agent spends too long cycling through retries on a gated_not_completed step, escalate severity or require a second approver.
curl -X POST http://localhost:8080/api/v1/policies \
-H "Content-Type: application/json" \
-H "Authorization: Basic $(echo -n 'pa-agent:your-secret' | base64)" \
-d '{
"name": "stuck-pa-escalates-severity",
"description": "PA requests stuck in gated_not_completed for 30+ minutes escalate to critical severity",
"type": "context_aware",
"category": "dynamic-compliance",
"priority": 900,
"enabled": true,
"conditions": [
{"field": "step.prior_completion_status", "operator": "equals", "value": "gated_not_completed"},
{"field": "step.first_attempt_age_seconds", "operator": "greater_than", "value": 1800}
],
"actions": [
{"type": "require_approval", "config": {"reason": "Prior authorization stuck in uncertain state for 30+ minutes — escalating for clinical safety review", "severity": "critical"}}
]
}'
Paired with retry_policy: "reevaluate" on polling gate calls, this policy upgrades a half-hour-stuck PA request to severity: "critical", which on Enterprise tier triggers SLA escalation and in-portal highlighting for the reviewer queue. On Evaluation tier it surfaces the escalation via the severity-filterable queue listing.
Any step.* condition on a Community license is rejected with 403 FEATURE_REQUIRES_EVALUATION_LICENSE at create time. See Retry Semantics & Idempotency — Retry-aware policies for the full rejection envelope and the list of step.* fields.
What this protects you from
- Approved decisions being retargeted to a different patient/procedure — the key mismatch check refuses before the payer ever sees the wrong request
- Duplicate PA submissions on orchestrator retry —
prior_completion_statustells the agent whether the payer already received the submission; reconcile with the payer's own idempotency store before re-submitting - Lost ack mid-submission — same reconciliation pattern as Payment Agent — Retry & Reconciliation
- Stuck approvals consuming reviewer attention silently — the Evaluation-tier retry-aware policy above escalates stuck PAs rather than letting them age out invisibly
What this does not cover
- Cross-workflow replay — if a workflow expires (24h on Evaluation) 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.
Related
- Retry Semantics & Idempotency — full wire-shape reference
- HITL Approval Gates — tier comparison for the approval queue and the reviewer API
- Human-in-the-Loop — conceptual overview and operating model
- Payment Agent — Retry & Reconciliation — same primitives applied to a no-HITL payment flow
- Approvals and Exception Handling Patterns — design guidance for expiry, escalation, and re-trigger
- Customer Portal — Enterprise Approval Dashboard UI for reviewer operations at scale
