Waystar is one of the largest clearinghouses in the United States, connecting to thousands of payer endpoints. If you build healthcare software that bills insurance, there is a good chance you will integrate with it — and a good chance the first attempt takes twice as long as planned.
📌 Updated guide: we have published a newer, step-by-step version of this walkthrough — see the Waystar clearinghouse integration guide and the seven claims-integration traps that break dev teams.
Not because the technology is hard. Because the project has two halves that run on completely different clocks. One half is engineering. The other half is contracts, enrolment forms and network approvals that no amount of good code will accelerate.
This guide covers both, in order, end to end. What to settle commercially, what to request and when, how the connection is configured, what to build, how to go live, and how to operate it afterwards.
Part 1 — Decide
Where Waystar sits
There are thousands of payers in the US, each with its own connectivity, enrolment process and rejection behaviour. A clearinghouse collapses that into one connection: you send claims to Waystar, they validate, route to the correct payer, and return responses in standard formats.
Waystar absorbed ZirMed, which is why the platform's artifacts still carry ZirMed identifiers years later — a detail that matters when you configure the connection, as we will see.
The four commercial questions
Settle these before any code is written. Each one constrains the architecture, and each is expensive to unwind later.
1. Is the account per-platform or per-practice? Clearinghouse contracts are typically negotiated per software platform, with one account submitting on behalf of many practices. The billing entity is identified by tax ID inside the claim payload, not at the transport layer. Teams that assume per-practice credentials build a secrets and tenancy model they do not need.
2. SFTP batch, API, or both? Waystar offers batch file exchange over SFTP, and separately an API surface. Real-time API access is frequently a separate product line that not every contract includes. The default EHR agreement is very often SFTP-only. Confirm entitlement before scoping any real-time feature.
3. Which payers does this cover, and how well? Reach is not uniform. If a meaningful share of your customers' volume goes to payers handled poorly, you feel it as rejection rates rather than as a line in the contract.
4. Who owns enrolment? Waystar provides enrolment support, pre-populating forms and tracking payer status through a dashboard. Whether your team, the practice, or Waystar drives it changes your timeline materially. Establish it explicitly.
The timeline nobody plans for
Two prerequisites sit entirely outside engineering, and both are the usual reason a "six-week integration" becomes four months:
- Payer enrolment. Each provider must be enrolled with each payer — separately for claims, for electronic remittance, and for eligibility. It is paperwork, it takes weeks, and a technically flawless integration cannot bill a payer the provider is not enrolled with.
- IP allowlisting. Connections are restricted to registered addresses. Your servers must be added before you can send anything, including your first test. Multi-day lead time.
Both should start in week one, before a line of code exists. They cost nothing to begin, and they gate everything.
Part 2 — Connect
Credentials
You will receive SFTP host, port, username, and either a password or a key. Two things surprise teams:
- SFTP credentials are distinct from the web portal login. They are separately issued and typically machine-generated strings — copy and paste them rather than retyping.
- They arrive through a secure channel, not email. If someone offers to send them in a chat message, push back. These credentials can submit claims on behalf of every practice on your platform.
Store them as environment variables or in a Secrets Manager, never in committed configuration. Validate at application startup and fail loudly:
# Startup validation - refuse to boot in production with placeholders
required = ["SFTP_HOST", "SFTP_USERNAME", "SFTP_PASSWORD", "RECEIVER_ID", "HOST_KEY_SHA256"]
missing = [k for k in required if not config.get(k) or config[k].startswith("<")]
if missing and env != "development":
raise ConfigError(f"Refusing to start. Unset: {', '.join(missing)}")Pin the host key
Accepting any host key defeats the purpose of SSH. Capture the fingerprint once during your first verified connection, pin it, and refuse to connect without a match in production. This protects against interception and against a silent key rotation on their side.
Identifiers — the ones people get wrong
Three identifiers must be exactly right, or files are rejected or misrouted:
| Element | Value | Note |
|---|---|---|
ISA05 / ISA06 | Qualifier 30 plus your Federal Tax ID | Qualifier 30 is "U.S. Federal Tax Identification Number" |
ISA07 / ISA08 | Receiver ID — commonly ZIRMED | The ZirMed heritage. Confirm yours in writing. |
ISA15 | T test / P production | The single most consequential character in the file |
The application sender and receiver codes in GS02 / GS03 must match their ISA counterparts. A mismatch here is a common first-submission rejection, and the error message rarely says so plainly.
Make the usage indicator a configuration value, never a constant. Production cutover should be a config flip, and the value should be logged at startup so anyone can see which mode a running system is in.
Directories and file naming
Two directories matter: an outbound path you write claims to, and an inbound path where responses appear, usually with an archive subdirectory holding processed history.
The outbound directory is frequently write-only. You cannot list it to check whether a filename is already taken, so uniqueness cannot depend on a lookup — embed a timestamp or batch ID in every filename.
The file extension determines which processing pipeline ingests the file. Use the wrong one and the file is rejected, or worse, processed by the wrong engine:
.CLP— professional claims (837P). Outpatient and behavioural health..CLI— institutional claims (837I).CLD— dental claims (837D).ELG— eligibility inquiries (270).835— remittance
And a rule that catches people: no periods in the base filename. claims_20260819T1830Z_001.CLP is fine; claims.2026.08.19.CLP is not.
Part 3 — Build
The conversation
Submission is a conversation across hours and days, not a request/response call:
- 837P — the claim, outbound
- 999 — functional acknowledgement, minutes. Was the file syntactically valid? Says nothing about claim merit.
- 277CA — claim acknowledgement, hours. Did the claim pass intake, or was it rejected before reaching the payer?
- 277 — claim status response. Where the claim stands with the payer.
- 276 — claim status inquiry, outbound. You asking.
- 835 — remittance. Payment, adjustments, patient responsibility.
⚠️ The 60-day retention limit
This is the constraint most integrations discover too late. 277CA files are typically retained for 60 calendar days only. After that, they are gone from the server.
The implication is straightforward and non-negotiable: download and persist your own copy of every response file. Do not treat the clearinghouse as your archive. If a payer disputes a claim eight months from now, the acknowledgement proving you filed on time must exist in your own storage.
The same discipline applies generally — retrieve promptly, store durably, and remove processed files from the inbox so it does not grow unbounded.
Building the outbound 837
Generation is the well-documented part; the operational wrapper around it is where teams struggle. A submission that survives production looks like this:
def submit_claim(claim_id):
# 1. Gate BEFORE generating. Never transmit a claim you know will reject.
blockers = validate_submission_readiness(claim_id)
if blockers:
return Blocked(blockers[0])
# 2. Idempotency - refuse a second live submission for the same claim
if submissions.exists(claim_id=claim_id,
status_in=["pending", "transmitted", "failed"]):
return Blocked("A live submission already exists")
# 3. PHASE ONE - persist first, with control numbers, before sending
submission = submissions.create(
claim_id = claim_id,
isa13 = next_interchange_control_number(),
gs06 = next_group_control_number(),
pcn = f"PCN{claim_id}",
status = "pending",
)
# 4. PHASE TWO - generate and transmit
edi = build_837p(claim_id, submission.isa13, submission.gs06)
filename = f"claims_{utc_now():%Y%m%dT%H%M%S}_{submission.id}.CLP"
transport.put(edi, filename)
submission.mark_transmitted()Three things that matter more than they look:
- Gate before generating. Missing demographics, inactive coverage, absent NPI, missing diagnosis — all are cheaper to catch now than as a rejection in three days. If eligibility is not yet automated, that is the highest-return thing to fix first; we covered it in automating eligibility verification.
- Persist before transmitting. If the process dies mid-send, the record exists and is recoverable. Without it, you have a claim that may or may not have been filed.
- Generate control numbers once, at persist time. Not at send time, and never again on retry.
Correlation
Responses arrive later, out of order, batched with other claims. Matching them is the hardest part of the integration and must be designed before the first submission.
The patient control number is the workhorse — your identifier, echoed back by everyone downstream. A predictable convention such as a stable prefix plus your internal claim ID makes matching trivial, while letting foreign control numbers from a prior system fall out for manual triage rather than silently mismatching.
Three rules learned expensively:
- Reuse control numbers across retries. Regenerating them breaks correlation and makes a retry indistinguishable from a duplicate claim.
- Never rewrite the payer's control number to force a match. Record manual matches as a separate join record. The remittance is payer truth and must stay immutable.
- Never discard an unmatched response. Park it for triage. Unmatched usually means a correlation bug, and dropping it hides the bug while losing the money.
Processing inbound files
Route by content, not filename. Naming conventions are not contractually stable; the X12 envelope structure is.
def route(raw):
# ISA is fixed-width: element separator at index 3, segment terminator at 105
if not raw.startswith("ISA") or len(raw) < 106:
return handle_non_edi(raw) # portal reports arrive here too
sep, term = raw[3], raw[105]
segments = raw.split(term)
st01 = first(segments, "ST", sep)[1]
if st01 != "277":
return HANDLERS[st01] # 999, 835, 271
# 277 and 277CA share ST01. BHT06 tells them apart.
bht06 = element(first(segments, "BHT", sep), 6)
return handle_payer_status if bht06 == "DG" else handle_claim_ackThat last branch is the single most valuable detail in this guide. A payer claim status response (277) and a clearinghouse claim acknowledgement (277CA) both carry ST01 = 277, and are distinguished only by BHT06 — DG for a payer status response, TH for an acknowledgement.
Route on ST01 alone and payer status responses are silently parsed as acknowledgements. Nothing crashes. Files are consumed, rows are written, logs look healthy — and claims quietly hold stale statuses while the clearinghouse portal shows something different. It is the classic "our system says rejected but Waystar says it is fine" bug.
Non-EDI files in your inbox
Waystar deposits portal reports alongside the X12 — XML claims-edit digests carrying ZirMed report stylesheets. Your router must recognise them, or they accumulate forever as unrecognised.
Better: treat them as signal. These reports frequently indicate that a human edited a claim inside the Waystar portal — which is exactly the event that makes your copy of the claim go stale. Raising a work item saying "this claim was edited at the clearinghouse, reconcile it" closes a blind spot most integrations never notice they have.
Retry without duplicates
A duplicate claim is worse than a failed one. Transmission failures are routine, so treat them as a recoverable state:
MAX_ATTEMPTS = 8
STALE_AFTER = timedelta(minutes=15) # pending this long means the sender died
def retry_sweep():
for s in submissions.due_for_retry(MAX_ATTEMPTS, STALE_AFTER):
try:
# SAME control numbers - correlation must hold across attempts
edi = build_837p(s.claim_id, s.isa13, s.gs06)
transport.put(edi, s.filename)
s.mark_transmitted()
except TransportError as e:
s.attempts += 1
s.last_error = str(e)[:1024]
if s.attempts >= MAX_ATTEMPTS:
s.abandon() # visible state, not silence
claims.revert_to_ready(s.claim_id)
else:
s.backoff(min(2 ** s.attempts * 10, 240)) # minutes, capped at 4hSeparate delivery state from claim state. A claim can be "submitted" in your workflow while its file has not reached the clearinghouse. Conflating the two makes an undelivered claim indistinguishable from a filed one — and the operator only finds out when the payer has no record of it.
Part 4 — The money
The 835 does not simply say "we paid you X". It decomposes the charge, and the decomposition is where the expensive errors live.
A $200 session with a $150 contracted rate: insurance pays $120, the patient owes $30, and $50 is a contractual adjustment — the discount you accepted by joining the network.
The collectible balance is $30, not $80. The naive formula billed − paid = outstanding overstates receivables on every in-network claim. It is the most common defect in home-grown billing systems, and because the number looks plausible, it can survive for years. The business consequences are covered in the real cost of getting claims integration wrong.
Parsing CAS segments
Adjustments arrive in CAS segments. Group codes: CO contractual, PR patient responsibility, PI payer initiated, OA other.
There is a parsing trap that silently corrupts the result. A CAS segment repeats as (reason, amount, quantity) triplets, but the group code appears only once:
CAS*CO*45*50*1*253*2.5
| | | | | |
| | | | | +-- quantity (2nd adjustment)
| | | | +------ amount (2nd adjustment)
| | | +--------- reason (2nd adjustment)
| | +------------ amount (1st adjustment)
| +--------------- reason (1st adjustment)
+------------------ GROUP CODE - once, applies to all
# Correct: read the group ONCE, outside the triplet loop
group, i = elements[1], 2
while i + 1 < len(elements):
adjustments.append((group, elements[i], elements[i + 1]))
i += 3 # step over the quantityA loop that reads every third element as a group code picks up the quantity from the second adjustment onward. Reasons and amounts stay correct, so totals look sane, and nothing throws — but the CO versus PR distinction becomes noise, and that distinction is precisely what a correct collectible balance depends on.
Part 5 — Go live
Before the first production claim
- Payer enrolment confirmed for the providers you are about to bill
- IP allowlisting confirmed from the servers that will actually connect
- Host key fingerprint pinned; unknown-host acceptance disabled outside development
- Usage indicator driven by configuration and logged at startup
- Sender and receiver identifiers confirmed in writing, ISA and GS matching
- Response files downloaded and persisted to your own storage
- Correlation proven end to end on a test claim — 837 out, 999, 277CA and 835 all matched back
- Duplicate protection tested by deliberately double-submitting
- Credentials absent from logs — verify by inspecting a real log line, not by assuming
Sequence the first live week
Submit one claim. Follow it through every response. Then a small batch from a single payer. Then widen.
The temptation after a clean test file is to switch the whole book of business over. Resist it — the first production claims surface enrolment gaps and payer-specific rules that no sandbox reproduces.
Part 6 — Operate
A healthy claims operation is visible in a handful of numbers. If your team cannot produce these on demand, the integration is not finished regardless of what the roadmap says:
- Claims awaiting transmission — anything sitting here is unbilled care
- Transmission failures and abandonments — claims that never reached the clearinghouse at all
- First-pass acceptance rate — accepted without rework. The best single indicator of upstream data quality
- Claims with no payer response past a threshold — these go quietly missing
- Denial rate by payer and reason — denials cluster, and clusters are fixable. Many trace back to eligibility, which we covered in why claims get denied before they are even sent
- Unreconciled remittances — money received not yet matched to a claim
- Days in A/R, on genuinely collectible balances
Most of these are about knowing rather than processing. The expensive failures in claims are rarely dramatic — they are claims sitting in a state nobody watches until the filing deadline passes.
The failure modes worth knowing in advance
| Symptom | Usual cause |
|---|---|
| Connection refused from production but works locally | IP allowlisting not applied to that server |
| Files uploaded but nothing happens | Wrong extension, so it routed to another pipeline |
| Whole file rejected at 999 | ISA/GS identifier mismatch, or wrong usage indicator |
| Claims accepted then denied by payer | Enrolment incomplete for that provider and payer |
| Statuses stale; portal disagrees with your system | Routing 277 on ST01 without checking BHT06 |
| Duplicate claims at the payer | Regenerated control numbers on retry |
| Receivables higher than anything ever collected | Contractual adjustments counted as outstanding |
| Acknowledgements missing for older claims | Never persisted locally; 60-day retention elapsed |
The short version
A Waystar integration is two projects. The paperwork project — contract scope, payer enrolment, IP allowlisting — takes weeks, is outside engineering's control, and gates everything. Start it on day one.
The engineering project is tractable if you get five things right: gate claims before generating them, persist before transmitting, reuse control numbers across retries, distinguish 277 from 277CA by BHT06, and never treat a contractual adjustment as money owed.
Everything else is detail — and the detail is well documented. It is the sequencing that decides whether this takes six weeks or six months.
For the vendor-neutral version of these patterns, our clearinghouse integration guide covers the same ground without Waystar specifics.



