Skip to main content
Back to Blog
shopifyxeroautomationecommercepython

How to Connect Shopify to Xero Without Paying for a Connector

Drakon Systems··12 min read
Share:

If you run an e-commerce business on Shopify and use Xero for accounting, you've probably looked at the connector options. They're not cheap:

  • Tradebox — around £150/month
  • A2X — starts at £50/month and climbs from there
  • Synkro / Amaka / Zapier — somewhere in between, usually with limitations

For a small business processing a few hundred orders a month, that's £600–£1,800/year just to move invoice data from one system to another.

We replaced all of it with a Python script that runs on a schedule. It costs nothing. It took an afternoon to build. And it's been running in production for months without issues.

Here's how.

The Architecture

The sync is conceptually simple:

  1. Pull paid orders from Shopify via the Admin API
  2. Map each order to a Xero sales invoice — handling line items, VAT, shipping, and discounts
  3. Create the invoice in Xero via the Accounting API
  4. Track what's been synced to prevent duplicates
  5. Run on a schedule — we use cron, twice daily

No middleware. No webhook dependency (though you can add one). No third-party platform sitting between your data and your accounts.

┌─────────┐     Admin API      ┌──────────────┐     Accounting API     ┌──────┐
│ Shopify  │ ──────────────────>│  Python Sync │ ──────────────────────>│ Xero │
└─────────┘   GET /orders.json  │    Script    │   PUT /Invoices        └──────┘
                                └──────────────┘
                                      │
                                      ▼
                                ┌──────────┐
                                │ State File│  (last synced timestamp)
                                └──────────┘

Step 1: Pulling Orders from Shopify

Shopify's Admin API is straightforward. You need a Custom App with read_orders scope, which gives you an access token.

import requests
from datetime import datetime, timezone

SHOPIFY_STORE = "yourstore.myshopify.com"
SHOPIFY_TOKEN = "shpat_your_token_here"
SHOPIFY_API = f"https://{SHOPIFY_STORE}/admin/api/2024-01"

def fetch_paid_orders(since_date: str, limit: int = 100) -> list:
    """Fetch paid, unfulfilled/fulfilled orders since a given date."""
    orders = []
    url = f"{SHOPIFY_API}/orders.json"
    params = {
        "status": "any",
        "financial_status": "paid",
        "created_at_min": since_date,
        "limit": min(limit, 250),
        "order": "created_at asc",
    }

    while url and len(orders) < limit:
        r = requests.get(url, headers={"X-Shopify-Access-Token": SHOPIFY_TOKEN}, params=params)
        r.raise_for_status()
        batch = r.json().get("orders", [])
        orders.extend(batch)

        # Handle pagination via Link header
        link = r.headers.get("Link", "")
        url = None
        if 'rel="next"' in link:
            for part in link.split(","):
                if 'rel="next"' in part:
                    url = part.split("<")[1].split(">")[0]
        params = None  # params are baked into the next URL

    return orders[:limit]

Key decisions here:

  • Filter by financial_status: paid — you only want orders where money has actually arrived. No point invoicing for abandoned checkouts.
  • Paginate properly — Shopify caps at 250 per page and uses cursor-based pagination via the Link header. If you ignore this, you'll miss orders.
  • Sort ascending by date — so if the sync is interrupted, you can pick up where you left off.

Step 2: Mapping Orders to Xero Invoices

This is where the real decisions live. Shopify and Xero think about commerce differently, and the mapping choices you make here determine whether your accounts are clean or a mess.

Contact Strategy

You have two options:

  1. One contact per customer — creates a Xero contact for each Shopify buyer. Gives you per-customer reporting but clutters your contact list with thousands of one-time buyers.
  2. One consolidated contact — all online sales go to a single Xero contact like "Online Sales - Shopify". Cleaner for businesses where individual customer tracking in Xero doesn't matter.

We use option 2. Most e-commerce businesses don't need per-customer AR tracking in Xero — Shopify handles the customer relationship. Xero is for the accounts.

# All Shopify orders go to one consolidated contact
ONLINE_SALES_CONTACT_ID = "ced9055c-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

Line Items

Each Shopify order has line items (products), and potentially shipping and discounts. Here's how we map them:

def build_line_items(order: dict) -> list:
    """Convert Shopify order line items to Xero invoice lines."""
    lines = []

    for item in order.get("line_items", []):
        quantity = int(item["quantity"])
        unit_price = float(item["price"])

        # Apply any line-level discounts
        discount_pct = 0.0
        for discount in item.get("discount_allocations", []):
            total_discount = float(discount["amount"])
            line_total = unit_price * quantity
            if line_total > 0:
                discount_pct = round((total_discount / line_total) * 100, 2)

        lines.append({
            "Description": item.get("title", "Product"),
            "Quantity": quantity,
            "UnitAmount": unit_price,
            "AccountCode": "4004",  # Sales account
            "TaxType": determine_tax_type(order),
            "DiscountRate": discount_pct,
        })

    # Add shipping as a line item
    for shipping in order.get("shipping_lines", []):
        shipping_price = float(shipping.get("price", 0))
        if shipping_price > 0:
            lines.append({
                "Description": f"Shipping: {shipping.get('title', 'Standard')}",
                "Quantity": 1,
                "UnitAmount": shipping_price,
                "AccountCode": "4004",
                "TaxType": determine_tax_type(order),
            })

    return lines

VAT Handling

This is the part where most DIY integrations get it wrong. Shopify gives you prices that may or may not include VAT depending on your store settings. Xero needs to know the tax treatment.

def determine_tax_type(order: dict) -> str:
    """Determine the correct Xero tax type based on the order."""
    # Check if the order has any tax lines
    total_tax = float(order.get("total_tax", 0))

    if total_tax == 0:
        return "ZERORATEDOUTPUT"  # No VAT (international, exempt, etc.)

    return "OUTPUT2"  # Standard 20% UK VAT

def get_line_amounts_type(order: dict) -> str:
    """Tell Xero whether prices include or exclude tax."""
    if order.get("taxes_included", False):
        return "Inclusive"
    return "Exclusive"

The critical setting is LineAmountTypes on the Xero invoice:

  • If your Shopify prices include VAT (common for UK B2C stores), set it to "Inclusive"
  • If they exclude VAT, set it to "Exclusive"

Get this wrong and every invoice will be off by 20%. We've seen third-party connectors get this wrong too, so don't assume paid tools handle it correctly.

Building the Invoice

def create_xero_invoice(order: dict, access_token: str) -> dict:
    """Create a Xero sales invoice from a Shopify order."""
    order_number = order["order_number"]
    order_date = order["created_at"][:10]  # YYYY-MM-DD

    invoice = {
        "Type": "ACCREC",  # Accounts Receivable (sales invoice)
        "Contact": {"ContactID": ONLINE_SALES_CONTACT_ID},
        "InvoiceNumber": f"SH-B{order_number}",
        "Reference": f"Shopify #{order_number}",
        "Date": order_date,
        "DueDate": order_date,  # Already paid
        "Status": "AUTHORISED",
        "LineAmountTypes": get_line_amounts_type(order),
        "LineItems": build_line_items(order),
    }

    r = requests.put(
        "https://api.xero.com/api.xro/2.0/Invoices",
        headers={
            "Authorization": f"Bearer {access_token}",
            "Xero-Tenant-Id": XERO_TENANT_ID,
            "Content-Type": "application/json",
        },
        json={"Invoices": [invoice]},
    )
    r.raise_for_status()
    return r.json()["Invoices"][0]

A few things to note:

  • Invoice numbering — we prefix with SH-B (Shopify - Beauty Hair) followed by the Shopify order number. This makes invoices instantly identifiable and prevents collisions with manually-created invoices.
  • Status: AUTHORISED — these are already-paid orders, so they go straight to authorised. You could use DRAFT if you want a review step.
  • DueDate = Date — since payment has already been collected via Shopify/Stripe, the due date equals the order date.

Step 3: Duplicate Prevention

This is non-negotiable. If your sync runs twice and creates duplicate invoices, your accounts are wrong and your accountant will hate you.

We use two layers of protection:

Layer 1: State Tracking

After each successful sync, we save the timestamp of the last processed order:

import json
from pathlib import Path

STATE_FILE = "shopify_xero_state.json"

def load_state() -> dict:
    try:
        return json.loads(Path(STATE_FILE).read_text())
    except FileNotFoundError:
        return {"last_synced_at": "2026-01-01T00:00:00+00:00"}

def save_state(last_order_date: str):
    Path(STATE_FILE).write_text(json.dumps({
        "last_synced_at": last_order_date,
        "updated": datetime.now(timezone.utc).isoformat(),
    }, indent=2))

Each run only fetches orders created after last_synced_at. Simple and effective.

Layer 2: Invoice Number Check

Even with state tracking, edge cases exist — interrupted runs, timezone quirks, manual re-runs. So before creating an invoice, we check if the invoice number already exists in Xero:

def invoice_exists(invoice_number: str, access_token: str) -> bool:
    """Check if an invoice with this number already exists in Xero."""
    r = requests.get(
        f"https://api.xero.com/api.xro/2.0/Invoices",
        headers={
            "Authorization": f"Bearer {access_token}",
            "Xero-Tenant-Id": XERO_TENANT_ID,
        },
        params={"InvoiceNumbers": invoice_number},
    )
    if r.status_code == 200:
        invoices = r.json().get("Invoices", [])
        return len(invoices) > 0
    return False

This is a belt-and-braces approach. The state file handles 99% of cases. The invoice number check catches the rest.

Step 4: Xero OAuth2 Token Management

Xero uses OAuth2 with refresh tokens that expire after 60 days. Your sync needs to handle token refresh automatically:

def refresh_xero_token(client_id: str, client_secret: str) -> str:
    """Refresh the Xero access token."""
    tokens = json.loads(Path("xero_tokens.json").read_text())

    r = requests.post("https://identity.xero.com/connect/token", data={
        "grant_type": "refresh_token",
        "refresh_token": tokens["refresh_token"],
        "client_id": client_id,
        "client_secret": client_secret,
    })

    if r.status_code != 200:
        raise Exception(f"Token refresh failed: {r.text}")

    new_tokens = r.json()
    Path("xero_tokens.json").write_text(json.dumps(new_tokens, indent=2))
    return new_tokens["access_token"]

Important: Every time you refresh, you get a new refresh token. Save it. If you lose it or use the old one twice, you'll need to re-authorise through the browser flow.

We store tokens in a JSON file with chmod 600 permissions and keep the client secret in a secrets manager (1Password in our case). Don't hardcode credentials.

Step 5: Scheduling

We run the sync via cron, twice daily:

# Shopify → Xero sync (8am and 6pm)
0 8 * * * cd /home/user/sync && python3 shopify_xero_sync.py --sync --limit 100
0 18 * * * cd /home/user/sync && python3 shopify_xero_sync.py --sync --limit 100

The --limit 100 flag caps each run. With the state-tracking approach, it picks up where it left off, so even if you have a backlog it'll work through it over multiple runs.

For higher-volume stores, you could:

  • Run more frequently (every hour, every 15 minutes)
  • Use a Shopify webhook on orders/paid to trigger real-time syncing
  • Add a backfill command for historical orders

We have all three in our production implementation.

The Full Sync Loop

Putting it all together:

def sync_orders(limit: int = 100):
    """Main sync: pull new Shopify orders, create Xero invoices."""
    state = load_state()
    access_token = refresh_xero_token(CLIENT_ID, CLIENT_SECRET)

    orders = fetch_paid_orders(since_date=state["last_synced_at"], limit=limit)
    print(f"Found {len(orders)} paid orders since {state['last_synced_at']}")

    created, skipped, errors = 0, 0, 0

    for order in orders:
        order_num = order["order_number"]
        invoice_num = f"SH-B{order_num}"

        # Duplicate check
        if invoice_exists(invoice_num, access_token):
            print(f"  Skipped {invoice_num} (already exists)")
            skipped += 1
            continue

        try:
            result = create_xero_invoice(order, access_token)
            print(f"  Created {invoice_num} -> {result['InvoiceID']}")
            created += 1
        except Exception as e:
            print(f"  ERROR {invoice_num}: {e}")
            errors += 1

    # Update state with the latest order timestamp
    if orders:
        save_state(orders[-1]["created_at"])

    print(f"Done: {created} created, {skipped} skipped, {errors} errors")

What About Payments?

If you want Xero to show invoices as "Paid" rather than just "Authorised", you need an extra step: create a payment against each invoice after creation.

def create_payment(invoice_id: str, amount: float, date: str, access_token: str):
    """Mark a Xero invoice as paid."""
    payment = {
        "Invoice": {"InvoiceID": invoice_id},
        "Account": {"Code": "090"},  # Your Stripe/payment account
        "Amount": amount,
        "Date": date,
    }

    r = requests.put(
        "https://api.xero.com/api.xro/2.0/Payments",
        headers={
            "Authorization": f"Bearer {access_token}",
            "Xero-Tenant-Id": XERO_TENANT_ID,
            "Content-Type": "application/json",
        },
        json={"Payments": [payment]},
    )
    r.raise_for_status()

We actually skip this step. Our bank feed in Xero handles reconciliation — the Stripe payouts appear automatically, and we match them against the invoices. Adding payments via API would double up and cause reconciliation headaches.

The right approach depends on your payment flow. If you receive per-order payments directly (not batched through Stripe), API-created payments make more sense.

Handling Edge Cases

A few things we learned the hard way:

Refunds

Shopify refunds don't automatically create Xero credit notes. If you need that, you'll want to poll Shopify's refund endpoint or listen for refunds/create webhooks and create credit notes in Xero.

Currency

If you sell in multiple currencies, you need to set the CurrencyCode field on the Xero invoice and ensure the currency is enabled in your Xero organisation. For single-currency UK stores, you can ignore this.

Rate Limits

Xero allows 60 API calls per minute. Shopify allows 40 requests per app per store. For most sync volumes this is fine, but if you're backfilling thousands of orders, add a delay:

import time

for order in orders:
    create_xero_invoice(order, access_token)
    time.sleep(1)  # Stay well within rate limits

Deleted or Cancelled Orders

Filter these out before syncing. A cancelled order that becomes a Xero invoice is an accounting mess.

Why Not Just Use A2X or Tradebox?

They work. They're battle-tested. If you want something that's zero maintenance and you don't mind the monthly fee, they're fine choices.

But consider:

| | DIY (Python) | A2X | Tradebox | |---|---|---|---| | Monthly cost | £0 | £50–£80 | £150 | | Annual cost | £0 | £600–£960 | £1,800 | | Setup time | 3–4 hours | 30 minutes | 1 hour | | Customisation | Total | Limited | Limited | | VAT control | Full | Template-based | Template-based | | Runs on | Your infrastructure | Their cloud | Their cloud | | Dependencies | Just Python + APIs | A2X staying in business | Tradebox staying in business |

For us, the deciding factor was control. We wanted to choose exactly how orders map to invoices, which account codes to use, how to handle edge cases, and how to structure the invoice numbers for our accountant. With a third-party connector, you get their mapping logic and hope it matches your needs.

Getting Started

If you want to build this yourself:

  1. Shopify: Create a Custom App in your store admin (Settings → Apps → Develop apps). Give it read_orders scope. Copy the Admin API access token.
  2. Xero: Register an app at developer.xero.com. Choose "Web app" type. Complete the OAuth2 flow once to get your initial tokens. Store them securely.
  3. Code: Start with the snippets above. The full sync loop is about 200 lines of Python.
  4. Test: Run against a few orders manually. Check the invoices in Xero. Verify VAT amounts match.
  5. Schedule: Add a cron job. Monitor the logs.

The whole thing can be production-ready in an afternoon. We know, because that's how long ours took.


We built this for one of our e-commerce clients and saved them over £4,000/year. If you're running Shopify + Xero and want help building a custom integration — or anything else that removes expensive middleware from your stack — get in touch.

See how the wider Drakon Systems portfolio fits together

Explore finance automation, AI security infrastructure, education products, and implementation services from one product portfolio.

View Products