Xero adapter
Xero is an optional integration (VISION.md). The adapter lives in
server/integrations/xero/ and sits behind the integration port
(server/integrations/port.js). Core code never imports it.
The adapter connects one Xero organisation to each Flow organisation, copies its contacts and items, and sends issued Flow invoices to Xero as draft invoices. The lab checks and approves them in Xero. It also keeps a copy of the organisation's invoices, credit notes and payments, history included ("The mirror"). Bills are not ported yet.
Turn it on
Each lab uses its own Xero app. There is no shared Flow Xero app, so no one else holds the lab's Xero sign-in.
In Flow, an owner opens Integrations, then Xero:
- Create your Xero app at developer.xero.com, My Apps: a Web app, with
this installation's address and the redirect URI the screen shows
(
https://<this installation>/api/xero/oauth/callback, Copy). - Paste the client id and the client secret. Choose the mode, and for live type the Xero organisation's exact name.
- Save and turn Xero on. Flow creates its tables and starts Xero at once. If a setting is missing, the screen says which.
- Connect to Xero, sign in to Xero and allow access. Flow then copies the history in the background ("The mirror").
The refresh token's key (XERO_TOKEN_KEY) comes from Flow's secrets key
(docs/INTEGRATIONS.md, "The secrets key"). A host can instead set
FLOW_INTEGRATIONS=xero, XERO_MODE and the settings below; a host setting
wins over the screen.
Modes
XERO_MODE says which Xero the adapter acts on. It has no default
(issue #10).
| Mode | Acts on | Writes |
|---|---|---|
live |
The lab's own Xero organisation, named by XERO_TENANT_ID or XERO_TENANT_NAME |
Only with XERO_ALLOW_LIVE_WRITES=on |
sandbox |
A Xero Demo Company. Flow refuses an organisation whose class is not DEMO. |
Yes |
replay |
Nothing: recorded, synthetic responses from server/integrations/xero/fixtures/ |
Yes, into the replay |
Development and CI use replay or sandbox. They never hold live
credentials. Tests use replay only and never call the real Xero API.
Settings
| Setting | Kind | What it does |
|---|---|---|
XERO_MODE |
configuration | live, sandbox or replay. Required when FLOW_INTEGRATIONS includes xero. |
XERO_CONNECTION |
configuration | app (the default): a Xero web app, approved in the browser, with a refresh token. custom: a Xero Custom Connection, sandbox only ("Demo"). |
XERO_CLIENT_ID |
configuration | The Xero app's client id. Needed for live and sandbox. |
XERO_CLIENT_SECRET |
secret | The Xero app's client secret. Needed for live and sandbox. |
XERO_TOKEN_KEY |
secret | A 32-byte key (64 hex characters or base64) that encrypts the Xero refresh token at rest. Needed for live and sandbox. Make one with openssl rand -hex 32. |
XERO_TENANT_ID / XERO_TENANT_NAME |
configuration | The Xero organisation this installation may connect. live needs one of them. |
XERO_ALLOW_LIVE_WRITES |
configuration | on lets live mode create draft invoices and contacts. Default off. |
XERO_CREATE_CONTACTS |
configuration | on (the default, including when unset) offers Create on a pending invoice whose client is not in Xero, and on that contact. off: add the client in Xero, then sync. Live mode still needs XERO_ALLOW_LIVE_WRITES. |
XERO_REDIRECT_URI |
configuration | The OAuth callback. Default: FLOW_PUBLIC_ORIGIN + /api/xero/oauth/callback. Register the same address on the Xero app. |
XERO_SCOPE |
configuration | OAuth scopes. Default offline_access accounting.contacts accounting.invoices accounting.payments.read accounting.settings.read (a Custom Connection leaves out offline_access). A connection made before a scope was added keeps its old scopes until a person connects again. |
XERO_DAY_LIMIT |
configuration | The app's daily call limit per organisation until Xero reports its own: 1000 on the Starter plan (the default), 5000 on Core and above. |
XERO_USAGE_WARNING_FRACTION |
configuration | Log a warning when this share of the day's calls is used. Default 0.7. |
XERO_TRACKING_CATEGORY / XERO_TRACKING_OPTION |
configuration | A tracking category and option put on every invoice line. Set both or neither. |
XERO_TAX_TYPE_GST / XERO_TAX_TYPE_GST_FREE |
configuration | Xero tax types for Flow's tax codes. Default OUTPUT and EXEMPTOUTPUT (Australian Xero). |
XERO_DEFAULT_DUE_DAYS |
configuration | Days from the invoice date to the due date when a Flow invoice has none. Default 30. |
XERO_INVOICE_NUMBER |
configuration | xero (default): Xero numbers the invoice. flow: Xero gets Flow's invoice number. |
XERO_REPLAY_FIXTURES |
configuration | Replay mode only: a fixture file in place of the bundled one. |
XERO_KEEP_REFERENCE |
configuration | on (the default) keeps free text from Xero records in the copy: invoice and payment references and line descriptions. The Import matches on them. off stores those fields empty. |
XERO_MIRROR_PAGE_SIZE |
configuration | Records per mirror call. Default 200, at most 1000. |
XERO_MIRROR_RESERVE_FRACTION |
configuration | The share of the day's calls the mirror leaves for drafts and people. Default 0.3. |
XERO_WEBHOOK_KEY |
secret | The webhook key from the Xero app's Webhooks page. Set: Flow takes Xero's change events at /api/xero/webhooks. Unset: Flow reads changes by polling only. |
Errors name a setting, never its value.
Demo
A Xero Demo Company is the test and demo Xero: fictional data, free, and only its owner's Xero login sees it. Xero resets it 28 days after it was made, and its owner can reset it at any time (My Xero, Reset). It cannot invite other users or send invoices.
Flow connects to it with a Xero Custom Connection
(XERO_CONNECTION=custom). Custom Connections are free on the Demo Company
only, so Flow allows one only in sandbox mode. There is no browser step
and no refresh token: Flow asks Xero for a 30-minute access token with the
client id and secret (the client credentials grant). So Flow on localhost, in
a cloud session, or on a host that sleeps connects the same way. Connect to
Xero on the Xero page connects at once.
One time, the Demo Company's owner:
- In My Xero, opens Try the Demo Company (Change Country: Australia).
- In My Apps, presses New app,
type Custom connection, with the scopes
accounting.contacts accounting.invoices accounting.payments.read accounting.settings.read, and themselves as the authorising user. - Presses Connect in Xero's email and picks the Demo Company.
- Copies the client id and generates the secret into host secrets, never a
file:
XERO_DEMO_CLIENT_IDandXERO_DEMO_CLIENT_SECRETfor the demo script.
scripts/demo/xero-demo.mjs then runs the whole flow on synthetic data: the
real Flow on a throwaway database (FLOW_TEST_DATABASE_URL, wiped first),
connected to the Demo Company. It makes a new Flow customer, creates it in
Xero, reads it back from Xero, and saves evidence.json (with the Xero link
to the contact) and screenshots of Flow:
FLOW_TEST_DATABASE_URL=postgres://… XERO_DEMO_CLIENT_ID=… XERO_DEMO_CLIENT_SECRET=… \
node scripts/demo/xero-demo.mjs --out xero-demo
A reset of the Demo Company can end the Custom Connection; then the owner approves it again from My Apps.
Connect Xero
Only an owner can connect or disconnect Xero, with a verified owner sign-in
(docs/SELF_HOSTING.md, "Sign-in (Clerk)"). Flow connects one Xero
organisation to each Flow organisation, and a Xero organisation to at most
one Flow organisation.
- On the Xero app (developer.xero.com, My Apps), add the redirect URI
https://<this installation's address>/api/xero/oauth/callback. On Replit that is the app's*.replit.appaddress. Flow reads the address fromFLOW_PUBLIC_ORIGIN(orXERO_REDIRECT_URI); the two must match exactly. POST /api/xero/connectwith{ "organisationId": "…" }answers withauthorizeUrl. Open it in the browser and approve the organisation in Xero.- Xero sends the browser back to
/api/xero/oauth/callback. Flow checks the one-use state (ten minutes), exchanges the code, and keeps the organisationXERO_TENANT_IDorXERO_TENANT_NAMEnames. If several are approved and the settings name none, it refuses. Insandboxit refuses an organisation that is not a Demo Company. A refused approval is revoked at Xero.
GET /api/xero/status?organisationId=… (anyone signed in) shows the mode,
whether writes are allowed, the redirect URI, the connection and the day's
Xero call budget. POST /api/xero/disconnect (owner) revokes the token at
Xero and deletes Flow's copy.
The refresh token
Xero gives a new refresh token on every refresh and the old one stops
working, so Flow stores the newest one in xero_connections, encrypted with
XERO_TOKEN_KEY (AES-256-GCM) and bound to its Flow organisation. A refresh
holds that row's lock and stores the new token in the same transaction, so
two refreshes never race. Access tokens stay in memory for their 30 minutes.
No token is ever logged or shown.
A connection made in one mode is not used in another: change XERO_MODE and
an owner connects again. So does a refresh token that Xero no longer accepts
(unused for 60 days, or revoked): Flow answers xero_reconnect_needed. If
XERO_TOKEN_KEY changes, the stored token cannot be read and an owner
connects again.
Contacts and items
Flow's own contacts stay in contacts (docs/CONTACTS.md). Xero's contacts
are copied into xero_contacts. A sync replaces that copy and does not
change Flow's contacts. Flow does not change a Xero contact that already
exists, unless Two-way edits is on: then a Flow edit of a linked contact goes
to Xero (docs/CONTACTS.md, "Two-way edits"; xero/contact-sync.js). With
Add Xero clients on, each Xero client becomes a Flow contact.
When Create missing clients is on, an owner or admin creates the missing
Xero contact from a Flow contact (POST /api/xero/contacts with
{ "organisationId", "contactId" }). Flow writes one Xero contact, stores
that copy, and links it (created_by_flow). If Xero already has one active
contact of that name, Flow links that one (exact_name) and creates
nothing. An archived-only name, or more than one active name, is refused.
GET /api/xero/contacts/match?organisationId=…&contactId=… answers whether
Link and Create are offered, from the copy, with no Xero call.
When the names differ, or Xero has the name twice, an owner or admin links
the Flow contact to the right Xero contact (POST /api/xero/contacts/link
with { "organisationId", "contactId", "xeroContactId" }). Flow writes only
its identity map (person); Xero is not called. The Xero contact must be
active in the copy and not linked to another Flow contact. A link to a Xero
contact that is now archived ends first.
POST /api/xero/sync with { "organisationId": "…" } (owner or admin)
reads every Xero contact (100 a page) and item (one call) and replaces
Flow's copy in xero_contacts and xero_items in one transaction. Records
gone from Xero leave the copy. Items with no code are left out, because an
invoice line names an item by code. A failed read keeps the copy as it was.
Each sync is recorded in xero_sync_runs; GET /api/xero/status shows the
last one. Outside replay mode the background worker also syncs each connected
organisation once a day (refreshStaleCatalogues in import.js), so the
pipeline can link customers (docs/JOB_INVOICE_LINKS.md). A failed read is
tried again an hour later, then two, four and so on, at most a day apart. The
first failure is reported with log.error; the same failure again is a
warning.
GET /api/xero/contacts?organisationId=…&q=…&limit=… (up to 1,000) and
GET /api/xero/items?organisationId=… (anyone signed in) list the copy.
For invoice preparation, server/integrations/xero/catalogue.js offers:
| Function | Answers |
|---|---|
matchContact(db, organisationId, name) |
matched with the one active Xero contact whose name is the same (case and doubled spaces ignored), ambiguous with the matches, or missing |
matchContactFor(db, organisationId, { contactId, name }) |
The same, after the identity-map link. A linked contact matches even when the names differ |
itemsByCode(db, organisationId, codes) |
A map from item code to the Xero item (sales price in cents, account code, tax type) |
listContacts, listItems |
The copy |
lastSuccessfulSyncAt(db, organisationId) |
When the copy was last refreshed, or null |
A sync and a batch of drafts for one organisation run one at a time
(runtime.serial), because they share one call budget.
Draft invoices
Flow is the store of record; Xero gets a copy (VISION.md). An issued Flow
invoice version goes to Xero as one sales invoice (ACCREC). The customer's
send mode (docs/INVOICING_RULES.md) decides its status: a draft (DRAFT,
the default) that a person approves in Xero, or an approved invoice
(AUTHORISED) that Flow then asks Xero to email to the customer.
flowchart TD
start[Send issued invoice] --> pushed{Pushed before?}
pushed -- settled --> done[already in Xero: nothing sent]
pushed -- unknown outcome --> resume{Six minutes since the key was sent?}
resume -- no --> same[Same request, same key]
resume -- yes --> look[Look in Xero by reference]
look -- one match --> reconciled[Record it]
look -- none --> newkey[Same request, new key]
pushed -- no --> earlier{Earlier version in Xero, not deleted or voided?}
earlier -- yes --> refuse[Refuse: change it in Xero first]
earlier -- no --> check[Contact, items, live reference check]
check -- same contact, reference and total --> existing[Record the Xero invoice: nothing sent]
check -- same contact and reference, other total, 90 days --> hold[Hold for a person]
check -- clear --> store[Store request and key] --> send[POST] --> verify[Read back and compare]
The rules:
- What is sent is the issued document. Flow sends the latest issued
version's document (
invoice_versions), never live lines. A draft or void invoice is never sent. - A sent invoice is never changed in place. Flow never updates a Xero
invoice's contact, lines, amounts or dates. The one write to an existing
Xero invoice is Delete, below, and it sends
Status: DELETEDonly. A revised invoice (version 2) is sent only after the earlier version's Xero invoice is deleted or voided in Xero; Flow checks that live and keeps the earlier record. - A missing Xero contact. The Flow customer must be linked to one Xero
contact, or match exactly one active Xero contact by name. With Create
missing clients on, a pending invoice offers Create (
docs/CONTACTS.md). With it off, add the client in Xero, then sync. Flow does not change a Xero contact that already exists. - Each line names its item. A line's
itemCodefrom the issued document becomes the XeroItemCode; a code Xero does not have stops the draft. Tax types come fromXERO_TAX_TYPE_GSTandXERO_TAX_TYPE_GST_FREE; amounts are tax-exclusive with each line's amount sent as Flow calculated it. - Reference and dates. The Xero reference is the invoice's reference, or
its number. The date is the issue date; the due date is the invoice's, or
XERO_DEFAULT_DUE_DAYSlater. WithXERO_INVOICE_NUMBER=flowXero gets Flow's number. - Duplicates. For an invoice Flow drafted from a job that its complete,
fresh copy of Xero showed has no invoice, Flow asks Xero nothing more.
Otherwise, before it creates, Flow asks Xero for invoices with the same
reference. One for the same contact and total is the same work: Flow records
it (
existing) and sends nothing. One for the same contact at another total dated within 90 days is held, unless the person sends again with that invoice inallowExistingReference. - Uncertain outcomes. The request and its
Idempotency-Keyare stored inxero_invoice_pushesbefore they leave. If Xero refuses the first request, the record is dropped and the invoice can be sent again once fixed. If the outcome is unknown (timeout, 5xx), the record staysambiguous. Sending again within six minutes repeats the same request with the same key (Xero keeps keys six minutes); after that Flow looks in Xero by reference, settles on the one matching draft it finds, and sends under a new key only when there is none. - Read back. After a create Flow reads the draft and compares contact,
subtotal, tax, total, line count, reference and date with the document. A
difference is recorded as
mismatchfor a person to check in Xero. - Email. In the
approve_and_emailsend mode, after Flow reads back an approved invoice that matches, it calls Xero's email action for it once (POST Invoices/{id}/Email). The push recordsemail_state:sent,failed(Xero refused; sending again retries) orunknown(a person checks Xero; Flow does not retry, because a repeat emails twice). - One at a time. A batch runs on the
writeslane, after any sync for the same organisation. A daily-limit 429 stops the batch and says which invoices were not tried. - Live writes. In
livemode nothing is written unlessXERO_ALLOW_LIVE_WRITES=on. Insandboxthe organisation's class is checked before each batch.
| Route | Who | What |
|---|---|---|
POST /api/xero/invoices/preview { organisationId, invoiceIds } |
anyone signed in | Each invoice's Xero draft, its contact match, and what stops it. No Xero calls. |
POST /api/xero/invoices/push { organisationId, invoiceIds, allowExistingReference? } |
owner or admin | Sends up to 100 issued invoices. Each answers created, reconciled, existing, already_in_xero, mismatch, ambiguous, failed or not_tried. |
GET /api/xero/invoices/<invoiceId>/pushes?organisationId= |
anyone signed in | The push records of one invoice, without the request. |
Through the integration port, pushInvoice({ organisationId, invoiceId, pushedBy }) sends one issued invoice and answers as one entry above.
What invoice preparation can rely on
previewDrafts(db, runtime, organisationId, invoiceIds)and the preview route cost no Xero calls. A previewstateisready,blocked(withproblems),in_xero,ambiguous,dispatchingormismatch. Problem codes:invoice_not_issued,invoice_void,xero_contact_missing,xero_contact_ambiguous,xero_item_missing, andxero_earlier_version_in_xero(shown, not blocking: the push checks Xero live). Warnings:xero_not_synced,xero_sync_stale(over 24 hours).- A push is safe to repeat: the Flow invoice id and version are the key, and a repeat never makes a second Xero invoice.
- A preview never writes; only
pushdoes, and only for issued versions.
Deleting a draft
Delete on an issued Flow invoice whose Xero copy is still a draft also
deletes that Xero draft (deleteInvoice on the integration port,
server/integrations/xero/delete-invoice.js). The core stays free of Xero
statuses. It calls the port and voids in Flow only after the answer is
deleted or gone.
Flow reads the Xero invoice live by its id first (the push, or the Import link). Then:
| Xero status | What Flow does |
|---|---|
DRAFT or SUBMITTED |
POST Invoices/{id} with Status: DELETED only. After Xero confirms DELETED, Flow voids the invoice and frees its job. |
DELETED or VOIDED |
No Xero write. Flow voids the invoice and frees its job. |
AUTHORISED or PAID |
No Xero write, and nothing in Flow changes. The message is Approved in Xero. Void it in Xero first. Flow does not void it in Xero. |
| No connection, or writes off | No Xero call, and nothing in Flow changes. Writes off uses the same message as Send. With no accounting system, an issued invoice answers accounting_unavailable. |
A timeout or a 5xx leaves Flow unchanged. The next Delete reads Xero again and does not post a second delete when Xero already shows it gone.
The push (xero_invoice_pushes) and the Import link (xero_invoice_links)
record who deleted it and when, and the push stores Xero's status. Flow's
copy of the invoice takes that status too. The job's ledger state becomes
deleted (or voided when Xero had already voided it), and the Flow invoice
link is cleared the same way as deleting a draft that was never issued. The
issued versions stay. The invoice's History gains a Void line.
Xero migration 0011_xero_invoice_deleted adds deleted_by and deleted_at
on the push and the link. It is the Xero adapter's own migration.
The mirror
Flow keeps a copy of the connected organisation's invoices, credit notes and
payments (server/integrations/xero/mirror.js, issue #54), so its reports
cover the years before Flow was installed and its records can link to Xero's
without spending calls. Xero owns these records. Flow does not edit their
amounts or lines. Delete of a draft, above, sets the copy's status so the
screen matches, and the next read of Xero replaces that row.
| Table | What it holds |
|---|---|
xero_invoices |
Sales and purchase invoices: number, contact, status, dates, totals, amounts due, paid and credited, lines |
xero_credit_notes |
Credit notes, with what each is allocated to |
xero_payments |
Payments, with the invoice or credit note each pays |
xero_mirror_cursors |
Where the next read of each resource starts |
The history import
Connecting Xero starts the import. Nobody runs a script.
flowchart LR connect[Owner connects Xero] --> job[Job xero.import<br/>in background_jobs] boot[Flow starts: a connected<br/>organisation with no finished copy] --> job job --> slice[Slice: up to 20 pages<br/>of each resource] slice -- day budget used --> later[Next slice in 1 hour] slice -- page limit --> next[Next slice now] later --> slice next --> slice slice -- all resources done --> done[History complete] done -- 15 minutes later --> slice
- The import is a job on the core queue (
server/integrations/xero/import.js). One import per organisation is open at a time. - A Flow that starts with Xero already connected, and with a resource that has no finished pass, queues the import too. So an upgrade imports with no step.
- Connecting another Xero organisation clears the cursors, so its history is read from the start.
- The worker runs outside replay mode. It only reads from Xero. It leaves the
reserve share of the day's calls (
XERO_MIRROR_RESERVE_FRACTION). - A failed slice retries with the queue's back-off. After its last attempt
it opens a review item (
job.dead).
Reading the changes
The copy is fresh only while its last full read of invoices is less than a
day old (mirrorState in lookup.js). A copy that is not fresh proves
nothing, so drafts by rule wait and the matcher does not say "no invoice"
(issue #131). So the import never ends:
- When every resource is read to its end, the same job is queued again for
15 minutes later (
REFRESH_MS). It reads only what changed since each cursor: with few changes, one call per resource. - The worker keeps one read queued for each connected organisation, so a restart or an upgrade needs no step. While the call guard stops Xero calls, no read is queued. A read that gave up waits for a person (its review item), a restart or the next day.
- A copy that goes out of date is reported once with
log.error, and again when it is fresh.health()shows each copy undercopiesand is notokwhile a complete copy is out of date. - About 192 calls a day (invoices and credit notes, every 15 minutes), inside the reserve the mirror leaves for drafts and people.
POST /api/xero/mirror with { "organisationId": "…", "resources"?: [...] }
(owner or admin) reads each resource from its cursor now. GET /api/xero/mirror
(anyone signed in) shows the counts and cursors.
flowchart LR cursor[Cursor: changed since T, page P] --> call[GET Invoices<br/>If-Modified-Since T<br/>oldest change first] call --> store[Upsert the page<br/>and move the cursor<br/>in one transaction] store -- full page --> cursor store -- short page --> done[Pass done]
- The first read copies the whole history. It has no cursor. 7,000 invoices at 200 a page cost 35 calls.
- Later reads copy changes only. Each read starts from the last change it stored.
- It can stop at any page. Each page and the cursor are stored in one transaction, so a failed call, a restart or a spent budget loses nothing.
- It never skips a record. After a full page the cursor moves to the page's last change time, less one second, so a record that changes during a read is read again. Only when one second holds a full page does the page number move on.
- It keeps calls for drafts and people. It stops when the day's
remaining calls fall to
XERO_MIRROR_RESERVE_FRACTIONof the limit. - A row changes only when its content does. Each row keeps a hash of
what Flow stores;
last_changed_atmoves only with it. - References are kept. References and line descriptions are the lab's own
data and are kept, unless
XERO_KEEP_REFERENCE=off.
Webhooks and the daily check
The poll is late by up to 15 minutes, and Xero calls polling its second choice. Xero's webhooks tell Flow which record changed within seconds. Xero, Intuit, Shopify and Stripe all say a webhook can be lost, so the poll stays as the safety net, and a daily check proves the copy is complete (issue #45).
flowchart LR xero[Xero] -- event: id only --> hook[POST /api/xero/webhooks<br/>check signature<br/>save id, answer 200] hook --> inbox[(xero_webhook_events)] daily[Daily check<br/>summary of every invoice] -- rows that differ --> inbox inbox --> read[Read by id<br/>50 invoices a call] read --> copy[(Xero copy)] poll[Changes poll<br/>15 min, or 1 h<br/>while webhooks arrive] --> copy copy --> compare[Sent invoices<br/>compared again]
- Receive. Xero signs each delivery with the webhook key
(
x-xero-signature, HMAC-SHA256 of the raw body, base64). A good signature gets 200 with no body; a bad one gets 401; that is also Xero's "intent to receive" test. WithoutXERO_WEBHOOK_KEYthe route answers 404. - Save, then read. Each event's record id waits in
xero_webhook_events, one row per record, so a repeat event costs nothing. The import worker reads waiting invoices by id, 50 a call, into the copy, then compares the sent invoices again. Credit note events read the credit note changes; contact events read contacts and items again, at most once an hour. Other events need no read. - The poll slows down. While a webhook arrived in the last day, the poll
waits an hour (
WEBHOOK_REFRESH_MS). If webhooks stop, it goes back to 15 minutes by itself. - The daily check. Once a day, for a complete copy, one summary read of every invoice (1,000 a call) finds each copy row whose status, money or change time differs from Xero, each invoice the copy lacks, and each live row Xero no longer lists. Those wait in the inbox and are read by id: at most 250 a day. This catches a part payment that did not move Xero's change time, and a lost webhook.
- Calls. Every read goes through the call guard and the governor's sync lane, and leaves the mirror's reserve. A failed read waits 1 minute, then 2, 4 and up to an hour; it is logged once as an error, then as warnings.
- Set it up. On the Xero app's Webhooks page, set the delivery URL to this
installation's address plus
/api/xero/webhooks, and tick Contacts, Invoices, and Credit notes when Xero offers it. Paste the webhook key into Flow's Xero settings ("Webhook key"), save, then press "Send Intent to receive" in Xero.
Read before draft and send
Just before an automatic draft pass, and before Send all ready, Flow reads
Xero's latest changes into the copy (readChangesFirst, the adapter's
readChanges capability). This is once per pass or press, before the first
draft or send, so a job someone invoiced in Xero a minute ago is not drafted
or sent twice.
- No call while webhooks arrive and none waits in the inbox, or when the
copy read invoices and credit notes in the last 10 minutes
(
READ_FIRST_MS). - Otherwise Flow reads the waiting inbox ids, then the invoice and credit note changes (at most 5 pages), through the call guard and the sync lane.
- A changed copy runs the matcher before the draft or send, so the job links to its Xero invoice and waits.
- A read that cannot run (not connected, calls stopped, the day's calls
kept for people, more changes than one read takes) drafts and sends
nothing. The answer says
accountingRead: 'waiting'orwaiting: N; the next pass tries again.
Sent invoices against the copy
People change invoices in both places after Flow sends them
(server/integrations/xero/reconcile.js, issue #132). The version Flow sent
is the merge base: the document both sides agreed on. After each read of the
copy that changed an invoice, Flow compares every invoice it sent with
Xero's copy of it. No Xero calls.
| Finding | When |
|---|---|
differs |
Xero's copy no longer matches the version Flow sent: client, subtotal, GST, total, line count, item codes or date |
voided_in_xero |
Xero voided or deleted the invoice; the Flow invoice is still issued |
voided_in_flow |
Flow voided the invoice; Xero still holds it live |
- Each finding is one review item (
invoice.accounting_differs) with both sides. A repeat refreshes it. It only alerts: Flow never writes to Xero or to the Flow invoice to fix it. A person fixes it where they choose. - The item closes when a later check finds no difference.
- Mark resolved (
POST /api/xero/invoices/<id>/recheck, owner or admin) reads Xero's invoice changes first, then checks that one invoice again. POST /api/xero/invoices/status(anyone signed in) answers each invoice's Xero status from the copy:not_sent,sending,not_copied,draft,approved,part_paid,paid,voidedordeleted, with any finding. The Invoices page shows it as a tag on issued and void invoices, and the invoice shows the differences, Open in Xero and Mark resolved.- Voiding a sent invoice in Flow warns while Xero still holds it live.
- References and line descriptions are not compared yet.
The billing ledger
server/integrations/xero/ledger.js answers the billing obligation ledger
(server/core/ledger.js) in its own evidence shapes.
- Does Xero hold an invoice for this job?
xeroLiveCheck(db, runtime, organisationId)is theliveCheckfordraftInvoiceForObligation. It answers{ state, accountingRef, accountingUrl, matchedBy, checkedAt }.POST /api/xero/obligations/<id>/check(owner or admin) runs it and records the answer withrecordAccountingEvidence. - Payments.
POST /api/xero/payments/sync(owner or admin) reads Xero sales payments changed since the last read (xero_payment_cursors; the first read goes back 90 days) and records each withrecordPayment. When payments cover the total, the ledger queues the "paid" write-back. The port'spullPayments({ organisationId, since })returns the payments inrecordPayment's shape with a cursor.
matchedBy |
Exact? | How Flow finds the Xero invoice |
|---|---|---|
flow_invoice |
yes | Made by Flow from the job's Flow invoice (xero_invoice_pushes) |
accounting_ref |
yes | The Xero invoice id the ledger already holds |
source_invoice_ref |
yes | A Xero sales invoice whose number or reference is the work system's invoice number |
reference |
no | A Xero invoice for the same customer with the job's case reference |
several |
no | More than one Xero invoice answers |
unsettled_push |
no | A push to Xero whose outcome is still unknown |
The ledger records a match that is not exact as ambiguous, and any answer but
not_found refuses a draft. A Xero error throws, and the ledger refuses the
draft. Xero states map to the ledger's: DRAFT and SUBMITTED to
drafted, AUTHORISED to authorised (or part_paid once money is paid),
PAID to paid, VOIDED to voided, DELETED to deleted.
accountingUrl is a Xero deep link for a person.
A payment is recorded only when its Xero invoice ties to one job by
flow_invoice or accounting_ref. Flow refreshes that job's Xero state
first, so the ledger accepts it. Payments that tie to no job, and deleted
payments, are listed in the answer and not recorded. The cursor moves only
when no payment failed, so a failed one is read again.
Rate limits and retries
Every Xero call goes through server/integrations/xero/governor.js, per
connected organisation:
- at most 4 calls at once and 55 a minute (Xero allows 5 and 60), and 10,000 a minute across organisations;
- a daily budget that starts at
XERO_DAY_LIMITand then follows Xero'sX-DayLimit-Remainingheader; a warning is logged once a day atXERO_USAGE_WARNING_FRACTION; - a 429 is retried once, after the wait Xero's
Retry-AfterandX-Rate-Limit-Problemask for, never more than a minute; - a daily-limit 429 opens a circuit: later calls fail at once with
xero_rate_limiteduntil Xero'sRetry-Afterpasses or the day rolls over; - a 401 drops the cached access token and tries once more;
- a create carries an
Idempotency-Keyand a retry sends the same bytes, so Xero returns the first result instead of a second invoice.
Calls count against a lane (writes, sync, reads, other) so the status
shows where the day's budget went. The lane, and a 429's
X-Rate-Limit-Problem, are stored on the call row. Integrations › Xero reads
that row, the governor's day limit, and the copy's own timestamps
(webhook, poll, nightly read). It does not call Xero. A call from before the
lane column has no lane, and the card does not invent one.
Every call also goes through the call guard (docs/INTEGRATIONS.md,
"Outbound calls"), keyed by the Xero organisation, with its state in
xero_call_guard and one row per call in xero_calls:
- a token refresh Xero refuses for good (
invalid_grant) halts every call until an owner connects Xero again or saves its settings; - a 401 or 403 that a fresh token does not clear stops only that resource
(for example
Paymentswhen the connection lacks its scope, #99). The history import skips a stopped resource asrefusedand reads the rest. After 6 hours one probe call may go (then 12, then 24 hours); a good probe starts the resource again. A connection made beforeaccounting.payments.readwas in the default scope needs one Connect again to read payments; - 5 failed calls in 10 minutes open the circuit (5 minutes, doubling to 6 hours), and 50 failed calls stop calls until the next UTC day;
- a first 401 (an expired access token) is not counted;
health()answers each organisation's guard state undercalls.
Replay fixtures
A fixture file lists interactions: a request (method, URL, and the query
values that must match) and the response Xero would give. A request that no
interaction matches fails with xero_replay_no_match, so replay can never
reach the real Xero. Fixtures hold synthetic data only (CONTRIBUTING.md):
made-up names, example.test addresses and all-zero ids.