DEV Community

Cover image for After the Upload: A Results and Re-lodge Workflow for NDIS Bulk Claims (Claim References, Correction Files and Claim Age, 2026)
Rostery Team
Rostery Team

Posted on

After the Upload: A Results and Re-lodge Workflow for NDIS Bulk Claims (Claim References, Correction Files and Claim Age, 2026)

Originally based on our step-by-step guide NDIS Bulk Upload 2026: How to Make a Bulk Payment Request in 6 Steps on rostery.com.au. This version is rewritten for the people who look after a claim file once it has left the building.

Most advice about NDIS bulk claiming stops at the Upload button. That's understandable, because the upload is where the visible errors happen. But for a finance or operations team, the harder work starts afterwards. A file of 300 rows turns into 300 separate payment requests, each with its own outcome, and the bank deposit that follows is one number. If nobody ties those back together, short payments and rejected rows simply vanish.

This post is about that second half: reading the results, re-lodging failed rows without double claiming, and keeping an eye on how old each claim is. We wrote about the pre-upload side last week in a five-gate check workflow on dev.to, so we won't repeat the column rules here.

Everything about the portal comes from the NDIA's Bulk payment requests: self-help guide for providers (December 2025) and its payment pages on ndis.gov.au, read on 28 October 2026. When we say "the guide", that's the document we mean.

General information only, not claiming, financial or legal advice. Check the current NDIA guidance before you change a process.

After you press Upload: the portal checks the file, you find it under Uploaded Bulk Payment File, read each row, download the results with the Capped column, then reconcile by claim reference

The one idea that makes everything else work: one row, one request

The guide is explicit that the number of lines under the header equals the number of payment requests created. So 100 data rows means 100 requests. Each one is checked, paid, capped or rejected on its own.

That has a design consequence for whoever builds the file. Your system needs a stable key for every row, one that survives the trip to the NDIA and back. The only field in the template that does that job is ClaimReference. It's optional in the template, but if you leave it empty you lose the thread between what you claimed and what came back.

Step 1: design claim references you can match later

The guide allows up to 50 characters in ClaimReference, using letters, numbers and a few separators. It also has a rejection code, P05, for when a single claim reference has been used on too many claims. Our explainer for rejection code P04 sits alongside P05 in the same family of payment codes, and both point at the same habit: give every row its own reference.

A simple pattern that works well is invoice number plus line number. It's readable by a person, unique per row, and easy to split back apart when you reconcile.

import re

def claim_reference(invoice_no: str, line_no: int) -> str:
    """Invoice number plus line number, letters and digits only, max 50 chars."""
    ref = f"{invoice_no}L{line_no}"
    ref = re.sub(r"[^A-Za-z0-9]", "", ref)
    if len(ref) > 50:
        raise ValueError(f"reference too long: {ref}")
    return ref

def check_unique(refs: list[str]) -> list[str]:
    seen, dupes = set(), []
    for r in refs:
        if r in seen:
            dupes.append(r)
        seen.add(r)
    return dupes
Enter fullscreen mode Exit fullscreen mode

We strip everything except letters and digits on purpose. The template accepts a few symbols, but references often end up in other systems too, such as your accounting package or a remittance spreadsheet, and plain alphanumerics travel more safely.

Step 2: generate a file name you have never used

The portal remembers every file name it has seen. The guide says a corrected file can't share a name with any earlier upload, whether that earlier upload worked or failed. Names are also capped at 20 characters, counting the dot and the extension, and some characters are banned outright.

So the file name is another thing worth generating rather than typing. A date plus a short run counter fits comfortably.

from datetime import date

BANNED = set('\\?/:*<>|"')

def next_file_name(used: set[str], run_date: date) -> str:
    for n in range(1, 100):
        name = f"BP{run_date:%y%m%d}R{n:02d}.CSV"   # e.g. BP261028R01.CSV, 15 chars
        if name.upper() not in {u.upper() for u in used}:
            assert len(name) <= 20 and not (BANNED & set(name))
            return name
    raise RuntimeError("ran out of run numbers for today")
Enter fullscreen mode Exit fullscreen mode

Keep the used set somewhere permanent, such as a small table in your database. A spreadsheet tab works too, as long as everyone who uploads writes to it.

Step 3: read the outcome of every row

After a successful upload, the portal says the file has been submitted and is pending payment validation. The guide warns that processing may not be immediate, so this is a next-day job rather than a same-minute one.

To see the outcomes, go to the Payment Request tile, then View Payment Request, set View By to Uploaded Bulk Payment File and select Get Files. Each row shows one of two statuses:

  • SUCCESSFUL: the payment request was created and moves on towards payment.
  • ERROR: it wasn't. Select its Payment Request Number to read the Reject Reason.

The Download Results option on the same screen gives you the file back with an outcome against every row. That's the file your script should read. It also carries a Capped column. Where a row asked for more than the maximum catalogue price, the NDIA pays up to the limit and marks Capped as YES. Nothing is rejected, so a capped row looks like a success unless you compare amounts.

Here is a minimal reconciler. We haven't hard-coded the results file's column names, because you should read them from your own download. Set the four constants once and the rest follows.

import csv

REF_COL = "ClaimReference"   # check these against your own results file
STATUS_COL = "Status"
CAPPED_COL = "Capped"
REASON_COL = "RejectReason"

def triage(results_path: str, invoices: dict[str, dict]) -> dict[str, list]:
    out = {"paid_in_full": [], "capped": [], "rejected": [], "unknown_ref": []}
    with open(results_path, newline="", encoding="utf-8-sig") as f:
        for row in csv.DictReader(f):
            ref = row.get(REF_COL, "").strip()
            if ref not in invoices:
                out["unknown_ref"].append(row)
            elif row.get(STATUS_COL, "").upper().startswith("ERROR"):
                out["rejected"].append((ref, row.get(REASON_COL, "")))
            elif row.get(CAPPED_COL, "").upper() == "YES":
                out["capped"].append(ref)
            else:
                out["paid_in_full"].append(ref)
    return out
Enter fullscreen mode Exit fullscreen mode

The unknown_ref bucket is the one people forget. A row that comes back with a reference your system doesn't recognise usually means someone edited the file by hand after it was generated. That's worth knowing about early.

If several things are wrong on one row, the NDIA may return a single catch-all code instead of a specific one. Our page on MB1, the multiple-fields rejection, explains it: more than one field needs checking, so look at the whole row rather than hunting for one typo.

Step 4: build the correction file, and only the correction file

This is the step where well-meaning people create duplicates. The guide is firm about three things.

  1. A payment request marked Rejected can't be fixed inside the portal. It stays that way permanently, and a fresh request is needed.
  2. Don't reload the whole original file. Rows that succeeded the first time would be claimed again, and they'll come back flagged as already existing.
  3. The new file holds the header row plus only the rows that failed, corrected. You may keep the same ClaimReference on a corrected row.

Re-lodging failed rows without creating duplicates: fix the cause, build a new file with the header and only failed rows, keep the claim reference if you like, use a new name; never reload the whole file or reuse a file name

In code, that's a filter over the original rows using the rejected references from Step 3, plus a new file name from Step 2.

def write_correction_file(original_path, rejected_refs, out_path):
    with open(original_path, newline="", encoding="utf-8-sig") as src, \
         open(out_path, "w", newline="", encoding="utf-8") as dst:
        reader = csv.reader(src)
        writer = csv.writer(dst)
        header = next(reader)
        writer.writerow(header)              # header always goes in
        ref_i = header.index("ClaimReference")
        kept = 0
        for row in reader:
            if row[ref_i] in rejected_refs:
                writer.writerow(row)          # only the failed rows
                kept += 1
    return kept
Enter fullscreen mode Exit fullscreen mode

One caution. The script copies the failed rows, it doesn't fix them. Someone still has to deal with the cause, and many causes live outside the file: not enough funds left in the budget, service dates outside the plan, or the provider not being recorded as one of the participant's my providers. Re-lodging a row without changing anything just earns the same code again. Run the corrected file through our free NDIS bulk claim file checker before it goes back up. It reads the CSV in your browser, so the file stays on your machine, and flags format problems before the portal does.

Step 5: watch the age of every unpaid row

Timing has become a real part of the workflow, and the rules are moving.

How old is too old for an NDIS claim: the NDIA asks for claims within 90 days, older claims may be held for review up to 28 days, the current limit is 2 years, and from December 2026 the NDIA says claims must be within 90 days

Here's what the NDIA says today, on its payment pages:

  • The outer limit is 2 years from the date the support was delivered.
  • It asks for claims within 90 days of a support being delivered in full, and does extra checks on claims made later than that.
  • A claim made more than 90 days after delivery may be held for review for up to 28 days. If it was part of a bulk upload, only that claim waits; the others are paid on the usual terms.
  • Its Securing the NDIS for future generations page lists a change for December 2026: a 90-day deadline for lodging a claim after the support.

For plans still in the old system, there's also C09: a request can only be made until 90 days after the service booking end date.

That makes a daily "claim age" check worth adding. Anything unclaimed or rejected that's creeping towards 90 days should go to the top of someone's list.

from datetime import date

def age_flags(rows, today=None, warn_at=60, limit=90):
    today = today or date.today()
    for r in rows:   # rows not yet paid: (ref, support_end_date)
        age = (today - r["support_end"]).days
        if age >= limit:
            yield r["ref"], age, "over 90 days: expect extra checks"
        elif age >= warn_at:
            yield r["ref"], age, "claim or fix this week"
Enter fullscreen mode Exit fullscreen mode

The thresholds are your choice, not the NDIA's. We'd suggest a warning well before day 90, because a rejected row still needs time to be fixed, re-lodged and paid.

Step 6: close the loop when money lands

Per the guide, a request sitting at Pending is settled by whichever payment run comes next, and there's at least one run every weekday. Anything created after the final Friday run rolls to the next business day, normally Monday. On turnaround, the NDIA's payment page puts it at 2 to 3 business days for a valid claim when you're a recorded my provider, stretching to around 10 business days when you aren't, or when the NDIA wants to check the claim.

The NDIA also says it won't email you while a claim is open or pending. So the only way to know a row is late is to look. For bulk claims, View Payment Request has a search by submission date with a Pending Payment status filter, which is a good weekly query to run.

When the deposit does arrive, match it line by line, not as a total. A remittance advice helps you see what a payment covers, but the results file is the line-level truth for NDIA-managed claims.

A weekly rhythm that holds it together

When Task
Upload day Generate references and a new file name, run the checker, upload
Next business day Download Results, triage into paid, capped, rejected and unknown
Same day Fix causes for rejected rows, build and upload the correction file
Weekly Search Pending Payment by submission date, chase anything overdue
Daily Run the claim age check on everything not yet paid

Where Rostery fits

We build Rostery, so here's what it does, in the words of its own pages. For plan managers, Rostery's NDIA claim files feature writes the bulk file from approved, validated invoices and reads the results file back, recording paid, part-paid and rejected against individual invoice lines with the reason code, and lets corrected lines be re-claimed without disturbing the ones already paid. Payment reconciliation then matches the NDIA payment against the claim line by line and raises anything that doesn't match as an exception.

If you'd rather build this yourself, the steps above are the core of it. The full portal walkthrough, including all 16 columns and the six steps, is in the original Rostery guide to the NDIS bulk upload.

The Rostery team

General information only. Portal steps and timeframes checked on 28 October 2026 against ndis.gov.au and the NDIA bulk payment guide dated December 2025. The code samples are illustrations, not a complete claiming system.

Top comments (0)