Sync invoices to QuickBooks or Xero
Push Northwind accounting data automatically — invoices created in Sheets flow to your books.
Published Nov 16, 2025
Northwind raises invoices in a spreadsheet because that is where the project data already lives — client, retainer amount, dates. But the accountant needs those invoices in Xero, and retyping each one is slow and error-prone.
This script closes that gap. It reads the invoices sheet, finds the rows that have not been pushed yet, creates each one in Xero through the accounting API, and writes the returned Xero invoice ID back into the row. That ID is both the record of success and the guard that stops the same invoice being created twice.
What you’ll need
- A Google Sheet with one row per invoice and a header in row 1, with these
columns:
client,amount,date,dueDate, andxeroId(left blank — the script fills it). - The spreadsheet’s ID for the
INVOICES_SHEET_IDconfig value. - A Xero account with the Accounting API enabled and an OAuth 2.0 app set up.
- A valid Xero access token saved as
XERO_TOKENin Script Properties — see Store API keys and secrets securely. - The account code your Xero chart of accounts uses for retainer revenue — the
example uses
200.
The script
// The spreadsheet that holds your invoices.
const INVOICES_SHEET_ID = '1abcInvoicesId';
// Xero account code for retainer revenue — match this to your chart
// of accounts.
const REVENUE_ACCOUNT_CODE = '200';
// New invoices are created as drafts so the accountant can review
// before approving. Use 'AUTHORISED' to push them straight through.
const INVOICE_STATUS = 'DRAFT';
/**
* Reads the invoices sheet, creates every not-yet-synced row in Xero,
* and writes the returned Xero ID back so it is never pushed twice.
*/
function pushToXero() {
const sheet = SpreadsheetApp.openById(INVOICES_SHEET_ID).getSheets()[0];
// 1. Read the whole sheet, splitting the header off the data rows.
const values = sheet.getDataRange().getValues();
const [header, ...rows] = values;
if (!rows.length) {
Logger.log('No invoices to sync — nothing to do.');
return;
}
// 2. Map column names to indexes so column order does not matter.
const col = Object.fromEntries(header.map((name, i) => [name, i]));
// 3. The OAuth token is read once and reused for every request.
const token = PropertiesService.getScriptProperties()
.getProperty('XERO_TOKEN');
if (!token) {
Logger.log('No XERO_TOKEN in Script Properties — aborting.');
return;
}
let synced = 0;
// 4. Walk every row. Skip any invoice that already carries a xeroId.
rows.forEach((row, i) => {
if (row[col.xeroId]) return;
// 5. Create the invoice in Xero, then store the returned ID on the
// matching row in `values` (i + 1 accounts for the header row).
const xeroId = createXeroInvoice(token, row, col);
values[i + 1][col.xeroId] = xeroId;
synced++;
});
// 6. Write the whole grid back in one call.
sheet.getDataRange().setValues(values);
Logger.log('Synced ' + synced + ' invoice(s) to Xero.');
}
/**
* Creates a single ACCREC (accounts-receivable) invoice in Xero and
* returns its InvoiceID. PUT creates a new invoice each call.
*/
function createXeroInvoice(token, row, col) {
const res = UrlFetchApp.fetch('https://api.xero.com/api.xro/2.0/Invoices', {
method: 'put',
headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },
contentType: 'application/json',
payload: JSON.stringify({
Invoices: [{
Type: 'ACCREC',
Contact: { Name: row[col.client] },
LineItems: [{
Description: 'Retainer',
Quantity: 1,
UnitAmount: row[col.amount],
AccountCode: REVENUE_ACCOUNT_CODE,
}],
Date: row[col.date],
DueDate: row[col.dueDate],
Status: INVOICE_STATUS,
}],
}),
muteHttpExceptions: true,
});
// A non-2xx response means the invoice was not created — surface the
// body so the cause (bad token, unknown contact) is visible.
const code = res.getResponseCode();
if (code < 200 || code >= 300) {
throw new Error('Xero returned ' + code + ': ' + res.getContentText());
}
return JSON.parse(res.getContentText()).Invoices[0].InvoiceID;
}
How it works
pushToXeroopens the invoices spreadsheet and reads the entire grid, splitting row 1 off as the header.- If there are no data rows, it logs a message and stops.
- It builds a
collookup from header text to column index, so the script does not break if the columns are reordered. - It reads the
XERO_TOKENonce and reuses it for every request. If the token is missing it aborts early rather than firing failing calls. - It walks every row and skips any invoice that already has a value in
xeroId— that is how rows already synced are left alone. - For each remaining row it calls
createXeroInvoice, which PUTs an ACCREC invoice to the Xero API and returns the newInvoiceID. A non-2xx response throws with the response body so the failure is easy to diagnose. - The returned ID is written back into the row, and after the loop the whole
grid is saved in a single
setValuescall.
Example run
The invoices sheet before a run:
| client | amount | date | dueDate | xeroId |
|---|---|---|---|---|
| Acme Co | 2400 | 2025-11-01 | 2025-11-15 | 220a8c-…-91f |
| Bluefin Ltd | 1800 | 2025-11-01 | 2025-11-15 | |
| Cedar Studio | 3200 | 2025-11-01 | 2025-11-15 |
The first row already has a xeroId, so it is skipped. The other two are
created in Xero as draft invoices and their IDs are written back:
| client | amount | date | dueDate | xeroId |
|---|---|---|---|---|
| Acme Co | 2400 | 2025-11-01 | 2025-11-15 | 220a8c-…-91f |
| Bluefin Ltd | 1800 | 2025-11-01 | 2025-11-15 | 7f3e10-…-4ad |
| Cedar Studio | 3200 | 2025-11-01 | 2025-11-15 | 9b21d4-…-c08 |
The log reads Synced 2 invoice(s) to Xero. In Xero, two draft invoices appear
under accounts receivable, ready for the accountant to review and approve.
Run it
Run it on demand after a billing batch, or on a timer:
- In the Apps Script editor, select
pushToXeroand click Run, approving the authorisation prompt the first time. - To automate it, open Triggers (the clock icon), click Add Trigger,
choose
pushToXero, and set a Time-driven schedule — daily is plenty for most billing cycles.
The xeroId guard makes the script safe to re-run: already-synced rows are
always skipped.
Watch out for
- Xero access tokens expire after 30 minutes. A production setup needs the
OAuth 2.0 refresh-token flow to mint a fresh
XERO_TOKENbefore each run — the Apps ScriptOAuth2library handles this. The script as written assumes the token in Script Properties is currently valid. Contact: { Name: ... }matches an existing Xero contact by name, or creates a new one. A typo in theclientcolumn will create a duplicate contact, so keep client names consistent with what is already in Xero.- The
AccountCodemust exist in your chart of accounts. IfREVENUE_ACCOUNT_CODEis wrong, Xero rejects the whole invoice — check the thrown error body. - Invoices are created as
DRAFTso nothing is finalised without review. SwitchINVOICE_STATUStoAUTHORISEDonly if you trust the sheet data completely. - Xero rate-limits to 60 calls per minute. One call per invoice is fine for a
normal batch, but if you sync hundreds at once, add a short
Utilities.sleepbetween rows. - For QuickBooks, the structure is the same — read the sheet, skip synced rows,
POST to the QuickBooks
invoiceendpoint, store the returnedId— only the endpoint URL and JSON field names differ.
Related
Sync calendar bookings with Calendly
Bridge Google Calendar and Calendly — Northwind bookings on either side appear on both.
Updated Jan 7, 2026
Connect to an air-quality and weather feed
Build a Northwind environmental dashboard — current London AQI plus 5-day forecast.
Updated Dec 30, 2025
Build a podcast and media stats tracker
Pull Northwind's podcast download numbers across platforms into a single sheet.
Updated Dec 10, 2025
Track real-estate listings for new matches
Monitor property feeds for Northwind office hunts — alert when a match appears.
Updated Nov 28, 2025
Translate columns with a translation API
Localise Northwind text in bulk without manual work — via Google Translate or DeepL.
Updated Nov 24, 2025