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
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)- 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:
- Gathers the patient's clinical record and formulary notes
- Builds the PA request
- Pauses at a gate that returns
require_approval: the organization's approval requirement holds the PA submission step for the 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)
- An Enterprise deployment with the customer portal, and two portal users with the
policy:writepermission: 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 thetool.callaction. The requirement below holds everytool_callstep your organization gates. To hold only the PA submission step, add a condition onargs.context.step__nameequal toSubmit 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-approvalspolicies[0].idandsource_policy:pa.approval.tool_callactions:tool.callparams.eligible: your medical directors' group, for exampleGroup::axonflow-trusted-header:medical-directors, andparams.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.
- 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/v9"
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": "Step requires human 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",
"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:.
- 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,
),
)
# gate.decision == "allow"
# gate.cached is True, gate.reason starts with "Previously approved:"
# 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,
});
// gate.decision === "allow"
gate, err := client.StepGate(workflow.WorkflowID, "submit-pa", axonflow.StepGateRequest{
StepName: "Submit prior authorization",
StepType: axonflow.StepTypeToolCall,
IdempotencyKey: idempotencyKey,
})
// 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)
.build()
);
// gate.getDecision() == GateDecision.ALLOW
reevaluate gate on a held step is refusedFrom 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,
"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.
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
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.
Related
- Retry Semantics & Idempotency - full wire-shape reference
- Human-in-the-Loop - tier comparison for the approval queue, the reviewer API, and the 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
