Connecting WooCommerce and Xero: a WooCommerce Xero integration that posts each order once
Duplicate invoices in Xero are rarely caused by a single bug. They are the result of webhook retries, status churn and unkeyed writes. This is how to design the write path so an order posts once and only once.
- Where duplicates come from in a WooCommerce Xero integration
- Choosing an identity that survives retries
- Designing the write path: decide, reserve, post, confirm
- Recovering missed events without replaying them twice
- Refunds, payments and the duplicates finance actually notices
- Proving it before go-live
- Related platform guides
- Key points
Where duplicates come from in a WooCommerce Xero integration
Almost every duplicated invoice we are asked to investigate in a WooCommerce Xero integration comes from one of four places, and none of them are exotic. The first is webhook repetition. WooCommerce fires order.updated for a wide range of changes — a status transition, an order note, a meta field written by a shipping or subscriptions plugin — so a single order can generate six or seven events on its way from pending to completed. If the handler treats every event as an instruction to create an invoice, the arithmetic is obvious.
The second is retry without identity. If your endpoint times out after WooCommerce has already dispatched the payload, or your worker crashes after the POST to Xero but before you record the returned InvoiceID, the next attempt has no way of knowing the invoice exists. The third is concurrency: a webhook handler and a scheduled catch-up sweep picking up the same order within the same second, each seeing an empty mapping table. The fourth is human — someone re-runs a backfill for a date range that has already been processed.
There is a fifth category that finance teams notice before engineers do. The invoice is posted once correctly, and then the bank feed or a manual entry creates a second document representing the same revenue. That is not an API problem, but it belongs in the same design conversation, because the fix is a chart of accounts decision rather than a code change.
Choosing an identity that survives retries
Deduplication requires a key that is stable, unique and available on every retry. In WooCommerce that key is the canonical order ID — the integer returned in the id field of the REST resource. It is worth being explicit about what not to use. The order number displayed to customers is produced by get_order_number(), which sequential order number plugins routinely override, and which can be reformatted or reset. If you key on the display number and the merchant installs a numbering plugin six months after go-live, the integration will happily create a second invoice for every subsequent order.
Stores running High-Performance Order Storage keep orders in dedicated tables rather than the posts table, but the id remains unique within the installation and remains the correct anchor. Where a merchant runs more than one WooCommerce site into a single Xero organisation, prefix the key with a site code so that order 1041 on the retail site and order 1041 on the trade site do not collide.
On the Xero side, you have two natural places to carry that key and you should use both.
Neither field alone is enough. Xero's uniqueness rules are a safety net, not a design. Your own mapping table — one row per WooCommerce order, with a unique constraint on the order ID — is the actual control, because it is the only place you can record state between the moment you decide to post and the moment Xero confirms.
- InvoiceNumber — Set it deterministically, for example WC-1041 or RETAIL-1041. Xero enforces uniqueness on sales invoice numbers within an organisation, so a repeated create attempt is rejected at source rather than silently duplicated.
- Reference — Free text and not unique, but useful for carrying the customer-facing order number so that finance and customer service are looking at the same identifier during a query.
- ContactNumber — On the contact record, carry the WooCommerce customer ID. Xero treats ContactNumber as a unique external identifier, which gives you a reliable way to match without relying on contact name or email casing.
Designing the write path: decide, reserve, post, confirm
The safe sequence has four steps and they must happen in this order. Decide: evaluate the incoming event against a state machine and determine whether this order is in a state that should produce an invoice. Reserve: insert a row into the mapping table with status pending, relying on the unique constraint to reject a second worker attempting the same order. Post: call Xero. Confirm: write the returned InvoiceID back to the row and set status posted. If the process dies between reserve and confirm, the row is left in pending with a timestamp, and a recovery job can query Xero by InvoiceNumber to establish whether the document exists before doing anything else.
The state machine matters as much as the keying. Map WooCommerce order statuses explicitly to accounting outcomes and post nothing for statuses that do not represent revenue. A common arrangement is to post an authorised sales invoice when an order reaches processing or completed, ignore pending, on-hold and failed entirely, and treat cancelled as either a void — if no payment has been applied — or a credit note if it has. Write that table down during discovery and have the finance owner sign it, because it is the part of the specification most likely to be assumed rather than agreed.
Xero supports an Idempotency-Key header on create operations, and where it is available you should send a key derived from the order ID and the operation, not a random value. It is a genuinely useful second line of defence against a retry that arrives while the original request is still in flight. Treat it as complementary to your mapping table rather than a replacement: the retention window for keys is limited, whereas your own records need to remain authoritative for the life of the ledger.
Error handling deserves its own rule. A 429 with a Retry-After header means back off and try again; Xero applies a limit of sixty calls per minute per tenant, five thousand per day and five concurrent connections, which a Black Friday backlog will reach without difficulty. A 400 validation error means stop and raise an exception for a human — retrying a malformed invoice simply produces the same failure with more noise. A 5xx or a network timeout is the genuinely ambiguous case, and it is precisely why the reserve step exists.
Recovering missed events without replaying them twice
Webhooks are a latency optimisation, not a delivery guarantee. WooCommerce deactivates a webhook after a run of consecutive delivery failures, which means a twenty-minute outage on your endpoint during a deployment can leave the store quietly no longer sending anything at all. Every production integration therefore needs a second path: a scheduled sweep that queries the REST API for orders modified since the last successful run, using GET /wp-json/wc/v3/orders with modified_after, orderby set to modified and per_page at its maximum of one hundred.
Two details cause more trouble here than they should. WooCommerce interprets date filters in the site's local timezone unless you pass dates_are_gmt=true, so a sweep written against UTC will silently skip or re-read an hour of orders twice a year. And a sweep window that starts exactly where the last one finished will lose any order written during the round trip; overlap the window by a few minutes and let the deduplication logic absorb the repeats. That overlap is only safe because the mapping table exists — it is the reason you can be generous with re-reads rather than clever with boundaries.
Verify the webhook signature on every request. WooCommerce sends an X-WC-Webhook-Signature header containing a base64 HMAC-SHA256 of the raw payload computed with the webhook secret, along with a delivery ID you can log. Store the delivery ID, because when someone asks whether an order was received at 14:32 you want an answer from your own records rather than from the store's delivery log, which is pruned.
Finally, respond to the webhook quickly and do the work asynchronously. Acknowledge with a 200, put the order ID on a queue, and let a worker perform the decide-reserve-post-confirm sequence. A handler that calls Xero synchronously will eventually time out under load, WooCommerce will retry, and you are back to the original problem with a slower failure mode.
Refunds, payments and the duplicates finance actually notices
Refunds are the most frequent source of double-counting after orders themselves. A WooCommerce refund is its own object with its own ID, nested under the order, and partial refunds mean an order can carry several. Key credit notes on the refund ID, not the order ID, and allocate each credit note to the original invoice by its Xero InvoiceID. If you key on the order, a second partial refund either overwrites the first or creates a duplicate, depending on which branch of your code runs first.
Payments are the other trap. If the integration applies a payment to the invoice from the gateway record, and the bank feed later matches the same transaction against the same invoice, Xero will happily hold an overpayment. The clean pattern is a clearing account per payment gateway: the integration marks the invoice paid into, say, a Stripe clearing account on the order date, and the gateway's settlement into the bank account is reconciled against that clearing balance. Gateway fees post as a separate expense line, and the clearing account balance becomes a useful control — if it does not trend towards zero, something in the flow is wrong.
This is also where the decision between per-order invoices and daily summary invoices is made. A store shipping 800 consumer orders a day against a Xero organisation with a sixty-call-per-minute limit is a poor fit for individual invoices, and finance rarely needs them at that granularity. A daily summary invoice per sales channel, with the order-level detail retained in the store, reduces the write volume by two orders of magnitude and shrinks the duplication surface accordingly. The trade-off is that a customer query requires looking in WooCommerce rather than Xero, which is acceptable for retail and usually unacceptable for trade accounts on credit terms. The same reasoning applies to a Shopify store, and the answer can legitimately differ per channel within one organisation.
Proving it before go-live
Duplication is a failure mode that only appears under conditions your happy-path testing will not produce, so acceptance testing has to be deliberately hostile. The tests we run before signing off a WooCommerce and Xero build are short, specific and repeatable: deliver the same webhook payload three times in quick succession and confirm one invoice; kill the worker process between the Xero POST and the database commit, restart it, and confirm no second invoice; run the catch-up sweep over a period already processed by webhooks and confirm zero new documents; issue two partial refunds on one order and confirm two correctly allocated credit notes.
Add a rate-limit test. Queue two hundred orders, let the worker hit a 429, and verify that the backoff drains the queue without loss and without duplicates. Add a mutation test: modify an order in WooCommerce after the invoice has been authorised in Xero and confirm the integration does what the agreed specification says it should — which in most cases is to log the divergence for review rather than to amend an authorised document.
These tests, and the status mapping table behind them, are what discovery and data mapping should produce before any code is written. They define the fixed scope, they form the acceptance criteria, and they are handed over with the code and documentation at completion so that whoever maintains the integration afterwards can re-run them. An integration that has never been tested against its own retry behaviour is not finished; it is simply waiting for its first busy week.
If you are working through this on a live store and would prefer a second opinion on the mapping before you commit to a build, we are on 01303 883111 or hello@api-integrations.co.uk.
Related platform guides
Key points
- Key every invoice on the canonical WooCommerce order ID, not the customer-facing order number, and hold the mapping in your own table with a unique constraint rather than relying on Xero's validation alone.
- Reserve the mapping row before calling Xero and confirm it after, so a crash or timeout between the two leaves a recoverable pending state instead of an unrecorded invoice.
- Treat webhooks as an optimisation and back them with an overlapping modified_after sweep, remembering that WooCommerce date filters use the site timezone unless dates_are_gmt is set.
Planning an integration?
Send us the two systems and the record types involved. We will come back with an outline scope and the approach we would recommend, within one working day.