---
title: "WooCommerce Xero Integration: Invoices via the Xero API"
description: "WooCommerce to Xero: 30-minute OAuth tokens, an n8n workflow that creates the invoice, a 6-minute idempotency window, and the 1,000-call daily limit."
url: "https://wpwebhooks.org/blog/woocommerce-xero-integration/"
date: "2026-10-22"
---

# WooCommerce Xero Integration: Invoices via the Xero API

**TL;DR:** The official extension creates an invoice for every order. Build your own only when the rules about which orders become which invoices are yours.

-   Xero speaks OAuth 2.0 only: **access tokens last 30 minutes**, refresh tokens 60 days and rotate on every use. A static API key in WordPress is not an option.
-   Let n8n hold the Xero credential and refresh it. WordPress sends the paid order to an n8n webhook and never sees a Xero token.
-   Look up `InvoiceNumber` before creating. Xero's `Idempotency-Key` is remembered for **6 minutes**, shorter than a retry schedule that runs for 15.
-   Send only `ContactID` in the invoice's `Contact` element. Anything else rewrites the contact record.
-   A new, uncertified Xero app gets **1,000 calls a day** per organisation, about 333 orders at three calls each.

/ Official extension

## Is there an official Xero integration for WooCommerce?

Yes. [Xero for WooCommerce](https://woocommerce.com/products/xero/) is built by Woo, costs **$109 a year**, and lists 10K+ active installs.¹ It creates an invoice in Xero for each order with the product, shipping, discount and tax data, and applies payments to those invoices as they are made.

That last part, payments matched to invoices, is most of the bookkeeping value, and this article does not build it. If one invoice per order with payments applied is what your accountant wants, buy the extension.

Build it yourself when the rules are specific: invoices only for business customers with a VAT number, only for orders paid by bank transfer, a different revenue account per product category, or the same order also going to a CRM and a fulfilment service with one log for all three.

/ OAuth

## Why can't WordPress just send an API key to Xero?

Because Xero has no API keys. Every connection is OAuth 2.0, and the [token lifetimes](https://developer.xero.com/documentation/guides/oauth2/auth-flow/) are short: an **access token expires after 30 minutes** and a refresh token after 60 days. Each refresh returns a new refresh token as well as a new access token, and you must save both. If your code misses the response, the old refresh token keeps working for a 30-minute grace period, and after that someone has to authorise the app again.

Worked through for a store with an integration that is always on:

-   24 hours × 60 minutes ÷ 30 minutes = **48 refreshes a day**, each one rewriting a stored secret.
-   A store that takes no orders for 60 days, such as a seasonal shop in its off season, comes back to a dead refresh token if nothing refreshed in the meantime.

![Cyberpunk illustration of an android caught in mid-air inside the works of a giant tower clock at night, one hand just letting go of the rocking escapement arm and the other stretched toward the next tooth of a huge escape wheel, still out of reach above a dark drop.](https://wpwebhooks.org/blog/woocommerce-xero-integration/og_image.jpg)

[Custom Connections](https://developer.xero.com/documentation/guides/oauth2/custom-connections/) remove the browser step. They use the client credentials grant against a single organisation, but they are a premium option with a monthly subscription, available only for organisations in Australia, New Zealand, the UK and the US. They still return a 30-minute access token, so the token exchange does not go away.

So something has to run an OAuth client. Writing one inside WordPress means storing rotating secrets in the database and refreshing them from WP-Cron, which runs only when the site gets traffic. An automation tool already does this. n8n's [Xero credential](https://docs.n8n.io/integrations/builtin/credentials/xero) is OAuth2, and n8n refreshes it before each call.

FIG 01 — WordPress never holds a Xero token: n8n refreshes OAuth, and the lookup makes a retry harmless

/ Workflow

## What does the n8n workflow for a WooCommerce invoice look like?

Five nodes. A **Webhook** node receives the order. A **Xero** node, operation _Get Many_ invoices, checks for an existing invoice numbered `WC-` plus the order id. An **IF** node stops if one exists. A second **Xero** node finds the contact by email, or creates one. An **HTTP Request** node creates the invoice.

The last step uses HTTP Request instead of the Xero node's _Create_ invoice operation because line items have to come from the order. The Xero node takes them as a fixed list of fields in its UI, and an order has as many lines as the customer bought. The [Xero node docs](https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.xero) describe the route: in HTTP Request, set _Authentication_ to _Predefined Credential Type_ and pick the same Xero credential, so the refresh still happens for you.

A Code node builds the lines from WooCommerce's own fields. Each order item carries `subtotal`, the price before coupons, and `total`, the price after them. Both exclude tax:

JavaScript — n8n Code node that builds Xero line items

```
const o = $('Webhook').first().json.body;

const lines = Object.values(o.items).map(i => ({
  Description: i.name,
  Quantity: i.quantity,
  UnitAmount: Number(i.subtotal) / i.quantity,        // before coupons
  DiscountAmount: Number(i.subtotal) - Number(i.total),
  TaxAmount: Number(i.total_tax),                      // WooCommerce's figure, not Xero's
  AccountCode: '200',                                  // your sales account
}));

if (Number(o.shipping_total) > 0) {
  lines.push({ Description: 'Shipping', Quantity: 1,
    UnitAmount: Number(o.shipping_total), TaxAmount: Number(o.shipping_tax), AccountCode: '200' });
}

return [{ json: { lines } }];
```

Splitting the discount out matters because of rounding. Xero rounds `UnitAmount` to two decimal places unless you add `unitdp=4` to the URL. Three mugs at 10.00 with a 5.00 coupon have a line total of 25.00. Sent as a unit price, that is 25.00 ÷ 3 = 8.3333, rounded to 8.33, and 8.33 × 3 = **24.99**: one cent short. Sent as 10.00 × 3 = 30.00 with a `DiscountAmount` of 30.00 − 25.00 = 5.00, the line is exactly 25.00. `TaxAmount` overrides Xero's own calculation for the same reason: the invoice should carry the tax WooCommerce charged, not Xero's recomputation of it.

The HTTP Request node then sends a single invoice:

JSON — PUT https://api.xero.com/api.xro/2.0/Invoices

```
{
  "Invoices": [
    {
      "Type": "ACCREC",
      "Contact": { "ContactID": "025867f1-d741-4d6b-b1af-9ac774b59ba7" },
      "InvoiceNumber": "WC-1234",
      "Reference": "WooCommerce order 1234",
      "Date": "2026-10-22",
      "DueDate": "2026-10-22",
      "LineAmountTypes": "Exclusive",
      "Status": "AUTHORISED",
      "LineItems": []
    }
  ]
}
```

Three details in that body. `PUT` on [Invoices](https://developer.xero.com/documentation/api/accounting/invoices/) only creates, while `POST` creates or updates, so PUT cannot quietly overwrite an invoice someone already edited. For sales invoices (`ACCREC`) the `InvoiceNumber` is a unique code, which is what makes the lookup reliable. And the request needs a `Xero-tenant-id` header with the organisation id, which the Xero node finds for you and HTTP Request does not.

An `AUTHORISED` invoice shows as owed until a payment is recorded against it. Recording the payment is a separate call this workflow does not make. If nobody reconciles by hand, create the invoices as `DRAFT` and let the bookkeeper approve them.

/ Contacts

## Why does a Xero invoice overwrite the customer's contact details?

Because anything in the invoice's `Contact` element besides `ContactID` is applied to the contact record. The invoices reference says so in a note: if you send other contact details, they update the contact itself, and **any ContactPersons not included in the request are deleted**. Sending a name and email "to be helpful" can wipe the people an accountant added to a business customer's record.

So resolve the contact in its own step, read the `ContactID`, and send only that. Searching by `EmailAddress` finds returning customers. Decide in advance what a guest checkout with a new email becomes: a new contact per email, or one shared "Web sales" contact for consumer orders. The second keeps the contact list small, and Xero's limits page notes that contact lists over 10,000 can cause performance issues.

| Concern | Xero for WooCommerce | Webhook Actions + n8n |
| --- | --- | --- |
| Payments applied to invoices | Built in | Not built. A separate workflow on the payment event, or reconcile in Xero |
| Line items, shipping, discounts, tax | Built in | Yours to map, as in the Code node above |
| Which orders become invoices | Its settings | Any condition on the order payload: business customers only, one payment method, one country |
| Xero OAuth tokens | Handled by the extension | Held and refreshed by the n8n credential. WordPress stores only the n8n webhook's header secret |
| Xero down, or a 429 | See its documentation | The workflow errors, n8n answers 500, the delivery is retried with backoff, and the lookup prevents a second invoice |
| One order to several systems | Xero only | The same trigger feeds other webhooks, each with its own log |
| Cost | $109 a year | The plugin is free; n8n hosting or an n8n plan is yours to pay |

try\_it

Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards.

[Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip)

/ Duplicates

## Is Xero's Idempotency-Key enough to prevent duplicate invoices?

Not on its own, because of how long Xero remembers it. Per the [idempotency guide](https://developer.xero.com/documentation/guides/idempotent-requests/idempotency/), a request carrying an `Idempotency-Key` header is cached, and a repeat with the same key returns the cached response instead of running again, but **keys are stored for 6 minutes** from the first call. After that, the same key is processed as a new request.

Compare that with a common retry schedule, five attempts with the delay doubling each time: the four retries wait 60, 120, 240 and 480 seconds.

-   They land 1, 1 + 2 = 3, 3 + 4 = 7 and 7 + 8 = **15 minutes** after the first attempt.
-   The first two are inside Xero's 6-minute window. The last two are not.

So a timeout where Xero did create the invoice, followed by a retry at minute 7, makes a second invoice even with the header set. Send the key anyway, using the event's UUID, because it covers n8n's own fast retries. Then rely on the `InvoiceNumber` lookup for everything else. Xero adds two more rules: a key reused with a different body gets a 400, and an internal error is cached under its key and returned again. When the same error keeps coming back, Xero's advice is to GET the resource and check before trying with a new key, which is the lookup again.

/ Limits

## What are the Xero API limits for a WooCommerce store?

Per connected organisation, from the [limits page](https://developer.xero.com/documentation/guides/oauth2/limits/): **5 calls in progress** at once, **60 a minute**, and **1,000 a day for apps in the starter tier**, 5,000 a day above it. A new app starts in the starter tier with 5 connections, and the app you register for a self-hosted n8n credential is a new app. Over a limit you get a 429 with an `X-Rate-Limit-Problem` header naming the limit and, for the minute and daily limits, a `Retry-After`.

At three calls per order (invoice lookup, contact lookup, create) and four when the contact is new:

-   Daily: 1,000 ÷ 3 = **333 orders**, or 1,000 ÷ 4 = **250** if every customer is new.
-   Per minute: 60 ÷ 3 = **20 orders**. A flash sale above that queues up behind 429s.

The organisation's own plan has limits too. On the plan Xero's limits page calls "Early", the 21st approved sales invoice in a month is refused with a 400 reading _"You have reached the limit of invoices you can approve."_ That is a billing fact, not a transient error, so retrying it only spends the daily allowance.

/ Exposure

## What does Xero not protect you from?

**Duplicates.** Two invoices for one order look real in every report. Only your lookup prevents them.

**Tax that does not match.** WooCommerce stores an item's `subtotal` and `total` without tax even when the shop displays prices with tax, so `Exclusive` plus WooCommerce's own `TaxAmount` is the right pairing either way. The mistake is choosing `Inclusive` because the shop shows gross prices: Xero then reads the net figures as gross and books less revenue and less tax than you charged. Test one invoice per tax class against WooCommerce's order screen before switching it on.

**Too many uncertified apps.** One Xero organisation can connect at most five uncertified apps. An accountant's practice with other tools connected may already be close.

**A workflow nobody watches.** When the workflow returns 500, the order stays visible in the delivery log and can be replayed. When n8n itself is down, that log is the only place the order is recorded, which is why [the WooCommerce to n8n article](https://wpwebhooks.org/blog/woocommerce-n8n-webhook/) argues for a retried, logged delivery over a fire-once webhook. The same pattern feeds other systems: the [Zoho CRM](https://wpwebhooks.org/blog/wordpress-zoho-crm-integration/) article covers an OAuth destination handled from WordPress code instead.

/Footnotes

¹ Xero for WooCommerce on the [WooCommerce Marketplace](https://woocommerce.com/products/xero/), read 2026-10-22: version 1.9.15, 10K+ active installs, $109 for a 1-year plan.

² Token lifetimes, Custom Connections, idempotency, rate limits and invoice fields from the [Xero developer documentation](https://developer.xero.com/documentation/guides/oauth2/limits/), read 2026-10-22.

## Structured data

```json
{"@context":"https://schema.org","@type":"Article","headline":"WooCommerce Xero Integration: Invoices via the Xero API","description":"WooCommerce to Xero: 30-minute OAuth tokens, an n8n workflow that creates the invoice, a 6-minute idempotency window, and the 1,000-call daily limit.","datePublished":"2026-10-22","dateModified":"2026-10-22","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/woocommerce-xero-integration/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/woocommerce-xero-integration/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk illustration of an android caught in mid-air inside the works of a giant tower clock at night, one hand just letting go of the rocking escapement arm and the other stretched toward the next tooth of a huge escape wheel, still out of reach above a dark drop."},"keywords":["woocommerce xero integration","xero woocommerce","woocommerce xero","xero api invoice","n8n xero","xero oauth2 token"]}

{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WooCommerce Xero Integration: Invoices via the Xero API","item":"https://wpwebhooks.org/blog/woocommerce-xero-integration/"}]}

{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Is there an official Xero integration for WooCommerce?","acceptedAnswer":{"@type":"Answer","text":"Yes. Xero for WooCommerce is built by Woo and costs $109 a year. It creates a Xero invoice for each order with product, shipping, discount and tax data, and applies payments to the invoices. Build your own only when the rules about which orders become invoices are specific to your business."}},{"@type":"Question","name":"Can I connect WooCommerce to Xero with an API key?","acceptedAnswer":{"@type":"Answer","text":"No. Xero uses OAuth 2.0 only. Access tokens expire after 30 minutes and refresh tokens after 60 days, and each refresh returns a new refresh token you must store. Custom Connections avoid the browser step but are a paid subscription and still issue 30 minute tokens."}},{"@type":"Question","name":"How do I stop duplicate Xero invoices when a request is retried?","acceptedAnswer":{"@type":"Answer","text":"Look the invoice up by InvoiceNumber before creating it, for example WC- plus the order id, and stop if it exists. Xero supports an Idempotency-Key header, but keys are stored for only 6 minutes, so a retry 7 or 15 minutes later is processed as a new request."}},{"@type":"Question","name":"Why did creating a Xero invoice change the customer contact?","acceptedAnswer":{"@type":"Answer","text":"Because details other than ContactID in the invoice Contact element update the contact record, and any ContactPersons not included are deleted. Resolve the contact in its own step and send only the ContactID on the invoice."}},{"@type":"Question","name":"What are the Xero API rate limits?","acceptedAnswer":{"@type":"Answer","text":"Per organisation: 5 concurrent calls, 60 a minute, and 1,000 a day for apps in the starter tier or 5,000 a day above it. Exceeding a limit returns 429 with an X-Rate-Limit-Problem header and, for the minute and daily limits, a Retry-After header."}}]}

{"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/woocommerce-xero-integration.png","caption":"FIG 01 — WordPress never holds a Xero token: n8n refreshes OAuth, and the lookup makes a retry harmless","description":"A paid WooCommerce order is queued and posted to an n8n Webhook node. n8n holds the Xero OAuth 2.0 credential and refreshes the 30 minute access token itself. The workflow first looks up an invoice with the order number; if one exists it stops. Otherwise it finds or creates the contact by email, then creates an ACCREC invoice that sends only the ContactID in the Contact element. A 429 carries Retry-After; a workflow error returns 500 so the queue retries, and the lookup prevents a duplicate.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"}
```
