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
InvoiceNumberbefore creating. Xero'sIdempotency-Keyis remembered for 6 minutes, shorter than a retry schedule that runs for 15. - Send only
ContactIDin the invoice'sContactelement. 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 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 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.
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 is OAuth2, and n8n refreshes it before each call.
/ 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 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 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 |
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.
/ 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, 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: 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 argues for a retried, logged delivery over a fire-once webhook. The same pattern feeds other systems: the Zoho CRM article covers an OAuth destination handled from WordPress code instead.