If you sync Xero — a bookkeeping integration, a payments reconciliation tool, an e-commerce-to-ledger bridge, or an in-house script that's been matching customer prepayments to invoices since forever — there's a one-line changelog entry with your name on it, and it doesn't throw an error.
For as long as most integrators can remember, the Reference field on Xero's Bank Transaction, Prepayment, and Payment endpoints has returned the invoice number for prepayment transactions. Not the reference you typed on the transaction — the invoice number Xero generated. That was never quite what the field name promised, but it was stable, and a lot of code came to depend on it: "to find the invoice this prepayment belongs to, read Reference." On July 13, 2026, that stops being true.
What actually changes
Xero is making the prepayment endpoints internally consistent with the rest of the API and with the web app. The rollout has two dates:
| Date | Change |
|---|---|
| April 13, 2026 | A new explicit InvoiceNumber field becomes available on these responses. It is the new source of truth for the RECEIVE-PREPAYMENT invoice number. Reference is unchanged — still returns the invoice number. |
| July 13, 2026 |
Reference stops returning the invoice number for RECEIVE-PREPAYMENTs and switches to returning the transaction's actual reference data — the free-text reference, which is usually different and often empty. |
So there's a three-month window where both behaviours coexist: the new field is present, the old field hasn't flipped yet, and everything keeps working. That window is exactly what makes this dangerous. Nothing breaks in April. Nothing breaks in May or June. Code reviews pass. Then on July 13 the meaning of a field changes underneath running integrations that were "tested and working" a quarter earlier.
Why this is a silent failure, not a loud one
Every property of the response that a client checks stays the same:
-
HTTP status: still
200 OK. -
JSON shape:
Referenceis still there, still a string. No field added or removed from the perspective of old code. -
Type: still a string. No deserialization error, no
nullwhere an object was expected. -
The value: silently changes from
"INV-0042"to whatever someone typed in the reference box —"Deposit","Stripe payout", or"".
A schema validator that checks "is Reference a present string?" passes on both sides of July 13. The only thing that changed is the semantics of the field — and semantics don't show up in a status code.
The three ways this bites
1. Invoice matching silently misses
The canonical prepayment-reconciliation loop reads Reference to find the invoice:
// Match a RECEIVE-PREPAYMENT bank transaction back to its invoice
const invoiceNo = txn.Reference; // "INV-0042" … until July 13
const invoice = await findInvoiceByNumber(invoiceNo);
if (invoice) applyPrepayment(invoice, txn.Total);
Before July 13, txn.Reference is "INV-0042" and the lookup hits. After July 13, txn.Reference is the free-text reference — often empty, sometimes a memo like "June deposit". findInvoiceByNumber("") returns nothing, applyPrepayment never runs, and the prepayment sits unreconciled. No exception, no log line, no failed request. Your books just quietly stop matching prepayments to invoices, and someone notices weeks later when the aged-receivables report doesn't foot.
2. The worse case: it matches the wrong invoice
Missing matches are the lucky outcome. The unlucky one is a collision. If a user happened to type something into the reference box that looks like another invoice number — or your findInvoiceByNumber does fuzzy/partial matching — the post-July-13 Reference value can resolve to a different, real invoice. Now a prepayment is applied against the wrong customer's invoice, two ledgers are wrong, and the only evidence is a number that matched something it shouldn't have. Silent-wrong is always more expensive than silent-missing, because you trust the result.
3. Filtering by reference changes what comes back
The same change makes filtering "optimised" to operate on real reference data. Code that filters or searches bank transactions and prepayments by Reference expecting to query on invoice numbers will, after July 13, be filtering on a different field's worth of data. Queries that returned the prepayment for INV-0042 yesterday return nothing today — or return a different set — and a paginated sync that keys off those results silently drops rows it used to pick up.
The fix is small — if you make it before July 13
Xero gave you the escape hatch in April: the explicit InvoiceNumber field. The migration is a one-line read change, and the safe window to do it is now, while Reference still agrees with InvoiceNumber so you can verify the swap against live data before the flip:
// Source of truth for the invoice number is InvoiceNumber, not Reference
const invoiceNo = txn.InvoiceNumber ?? txn.Reference; // belt-and-suspenders during the window
const invoice = await findInvoiceByNumber(invoiceNo);
What to audit in every Xero integration you own:
-
Grep for
Referenceon prepayment/payment paths. Anything that reads.Referenceoff a Bank Transaction, Prepayment, or Payment response and treats it as an invoice number is on the clock. Searchgrep -rn "\.Reference" .and triage every hit on these endpoints. -
Switch invoice-number reads to
InvoiceNumber. It's available now (since April 13). Make the swap, deploy, and confirm it matches the currentReferencevalue before July 13 — that's your free regression test. -
Re-check anything that filters by reference. If you query BankTransactions or Prepayments using
Referenceto pull by invoice number, move that filter to the new field and re-validate the result set. -
Don't assume
Referenceis now useless — assume it's now correct. After July 13 it carries the real user reference. If you have a separate need for that (display, search by user memo), this is an upgrade. The danger is only where the old meaning was load-bearing.
The cutoff date here is loud in the changelog and silent in production. April's new field is the gift; July 13 is the cliff. The whole point of the three-month overlap is to let you migrate while you can still see both values side by side — which only helps if you know the flip is coming.
FlareCanary monitors API responses for schema drift, silent field-semantics changes, and quiet removals across upstream providers. If you run integrations against Xero, Stripe, Shopify, PayPal, or anywhere else that ships breaking changes through a changelog post and a 200 OK, flarecanary.com catches the drift before your reconciliation does.
Top comments (2)
This is a really precise breakdown of the silent-failure class. The three-month coexistence window is the sharpest part — it's not that the migration is undocumented, it's that nothing exercises the new path until the old one is gone.
Bank transaction APIs hit the same pattern in a different costume. A PSD2 provider returns
transactionId,bookingDate, andremittanceInformationUnstructuredon every transaction. Those fields are present, typed, non-null — and their meaning drifts across providers. One bank puts the SEPA end-to-end ID intransactionId; another puts an internal reference that changes on the pending→booked transition. Same JSON shape, different semantics, same silent reconciliation breakage you describe here.The wrong-match collision (#2) is the one that keeps people up. We've seen free-text remittance fields that happen to contain substrings matching invoice numbers — if your
findInvoiceByNumberdoes any kind of fuzzy orLIKE '%...%'resolution, the post-migrationReferencevalue can resolve to a real but wrong invoice. The defensive pattern that survives both: capture a metadata snapshot at fetch time — record which field you sourced the match key from, so when semantics drift you get a detectable mismatch (field wasReference/invoice-number, now returnsReference/free-text) rather than a silent degradation.The "schema validator checks present-string, passes on both sides" observation is exactly why integration tests that assert on field presence are necessary but not sufficient. You need semantic assertions: "this field contains a value matching
^INV-\d+$" catches the drift; "this field is a non-empty string" does not.Good extension of the thesis — the pending→booked flip is a perfect example because it's not a schema change at all, just a meaning change on a field that looks identical before and after. That's the failure mode that's hardest to catch with normal API monitoring (status codes, response shape) since nothing actually breaks, it just silently means something different. Metadata-snapshot + semantic assertions is exactly the right defensive pattern — glad it's resonating, that's basically what we're building toward with FlareCanary.