Ecommerce Reporting API: One Data Layer for Shopify and WooCommerce

· 17 min read · Ecommerce

Build a unified reporting API layer that pulls data from Shopify, WooCommerce, and other platforms — normalise metrics across stores, automate collection, and feed dashboards without manual exports.

Ecommerce Reporting API: One Data Layer for Shopify and WooCommerce

Every ecommerce platform has an API. Most teams still export CSV files manually.

The gap between available data and how teams actually access it is enormous. Shopify, WooCommerce, BigCommerce — they all expose orders, products, customers, and inventory through REST or GraphQL APIs. Yet the reporting workflow in most organisations is still someone downloading a spreadsheet every Monday morning.

This guide builds an ecommerce reporting API: one Python module that connects to any storefront platform, normalises the data into a consistent schema, and feeds it into dashboards and reports automatically.

An ecommerce reporting API is worth building the moment you run more than one store, or one store on more than one platform. A single-platform script is simpler — if that is your situation, automating Shopify reports directly is the shorter path. The work below only pays off when you need Shopify and WooCommerce orders to mean the same thing in the same table.

Who This Is For

  • Ecommerce managers who want real-time access to sales and inventory data without logging into multiple admin panels
  • Data engineers building reporting pipelines across multiple storefronts or platforms
  • Vibe coders who want a clean abstraction over messy ecommerce APIs
  • Agencies managing multiple client stores who need a single data model for all of them

Basic Python and REST API familiarity is enough. If you have used requests to call an API, you can follow this guide.

The Reporting Architecture

flowchart TD
  subgraph Sources["Ecommerce Platforms"]
    S1[Shopify API]
    S2[WooCommerce API]
    S3[BigCommerce API]
  end
  subgraph Adapter["Normalisation Layer"]
    A[Platform Adapter]
  end
  subgraph Output["Reporting Layer"]
    DB[(Data Warehouse)]
    D[Dashboard]
    R[Scheduled Reports]
  end
  S1 --> A
  S2 --> A
  S3 --> A
  A --> DB
  DB --> D
  DB --> R

Each platform adapter handles authentication and pagination. The normalisation layer maps platform-specific fields to a shared schema. Downstream consumers never know which platform the data came from.

What You Will Need

Bash
pip install requests pandas sqlalchemy
  • API credentials for your ecommerce platform (Shopify Admin API token, WooCommerce REST API key, etc.)
  • A database for historical storage (SQLite for development, PostgreSQL for production)

Step 1: Define the Shared Data Schema

The key insight: every ecommerce platform has the same core entities. Orders have line items, products have variants, customers have contact details. The field names differ but the structure is identical.

Python
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional

@dataclass
class NormalisedOrder:
    """Platform-agnostic order representation."""

    order_id: str
    platform: str
    created_at: datetime
    customer_email: str
    total: float
    currency: str
    line_items: list = field(default_factory=list)
    status: str = "unknown"
    source: Optional[str] = None

    def to_dict(self):
        """Convert to dictionary for DataFrame creation."""
        return {
            "order_id": self.order_id,
            "platform": self.platform,
            "created_at": self.created_at.isoformat(),
            "customer_email": self.customer_email,
            "total": self.total,
            "currency": self.currency,
            "status": self.status,
            "item_count": len(self.line_items),
            "source": self.source,
        }


@dataclass
class NormalisedProduct:
    """Platform-agnostic product representation."""

    product_id: str
    platform: str
    title: str
    price: float
    inventory_quantity: int
    status: str = "active"
    category: Optional[str] = None

This schema is the contract. Every platform adapter must produce these objects regardless of what the source API returns.

Step 2: Build Platform Adapters

Each adapter inherits from a base class and implements the platform-specific logic. The base class enforces the interface.

Python
import requests
from abc import ABC, abstractmethod
from datetime import timezone

class EcommerceAdapter(ABC):
    """Base adapter for ecommerce platform APIs."""

    def __init__(self, config: dict):
        """Initialise with platform-specific configuration."""
        self.config = config
        self.session = requests.Session()

    @abstractmethod
    def fetch_orders(self, since_date: datetime) -> list[NormalisedOrder]:
        """Fetch orders created after the given date."""
        pass

    @abstractmethod
    def fetch_products(self) -> list[NormalisedProduct]:
        """Fetch all active products."""
        pass

    def _paginate(self, url: str, params: dict = None) -> list:
        """Handle API pagination. Override for platform-specific patterns."""
        results = []
        params = params or {}
        while url:
            response = self._request(url, params)
            data = response.json()
            results.extend(self._extract_items(data))
            url = self._next_page_url(data, response)
            params = {}
        return results

    @abstractmethod
    def _extract_items(self, response_data: dict) -> list:
        """Extract the item list from a response payload."""
        pass

    @abstractmethod
    def _next_page_url(self, response_data: dict, response) -> str | None:
        """Extract the next page URL from pagination metadata."""
        pass

_request is the single place every call passes through, which is what makes rate limiting and retries a property of the whole layer rather than something each adapter has to remember. Its implementation is in Where the Platforms Actually Differ below, once the reason it needs to exist is on the table.

Shopify Adapter

Python
class ShopifyAdapter(EcommerceAdapter):
    """Adapter for the Shopify Admin REST API."""

    def __init__(self, shop_url: str, api_token: str, api_version: str = "2026-01"):
        """Set up Shopify API connection."""
        config = {
            "base_url": f"https://{shop_url}/admin/api/{api_version}",
            "shop_url": shop_url,
        }
        super().__init__(config)
        self.session.headers["X-Shopify-Access-Token"] = api_token

    def fetch_orders(self, since_date: datetime) -> list[NormalisedOrder]:
        """Fetch orders from Shopify since the given date."""
        url = f"{self.config['base_url']}/orders.json"
        params = {
            "created_at_min": since_date.isoformat(),
            "status": "any",
            "limit": 250,
        }
        raw_orders = self._paginate(url, params)
        print(f"Fetched {len(raw_orders)} orders from Shopify")
        return [self._normalise_order(o) for o in raw_orders]

    def _normalise_order(self, raw: dict) -> NormalisedOrder:
        """Map Shopify order fields to normalised schema."""
        return NormalisedOrder(
            order_id=str(raw["id"]),
            platform="shopify",
            created_at=datetime.fromisoformat(raw["created_at"].replace("Z", "+00:00")),
            customer_email=raw.get("email", ""),
            total=float(raw["total_price"]),
            currency=raw["currency"],
            line_items=[item["title"] for item in raw.get("line_items", [])],
            status=raw.get("financial_status", "unknown"),
            source=raw.get("source_name", None),
        )

    def fetch_products(self) -> list[NormalisedProduct]:
        """Fetch all products from Shopify."""
        url = f"{self.config['base_url']}/products.json"
        raw_products = self._paginate(url, {"limit": 250})
        print(f"Fetched {len(raw_products)} products from Shopify")
        return [self._normalise_product(p) for p in raw_products]

    def _normalise_product(self, raw: dict) -> NormalisedProduct:
        """Map Shopify product fields to normalised schema."""
        variant = raw.get("variants", [{}])[0]
        return NormalisedProduct(
            product_id=str(raw["id"]),
            platform="shopify",
            title=raw["title"],
            price=float(variant.get("price", 0)),
            inventory_quantity=variant.get("inventory_quantity", 0),
            status=raw.get("status", "active"),
            category=raw.get("product_type", None),
        )

    def _extract_items(self, response_data: dict) -> list:
        """Extract items from Shopify response."""
        key = "orders" if "orders" in response_data else "products"
        return response_data.get(key, [])

    def _next_page_url(self, response_data: dict, response) -> str | None:
        """Extract next page URL from Shopify Link header."""
        link_header = response.headers.get("Link", "")
        if 'rel="next"' in link_header:
            for part in link_header.split(","):
                if 'rel="next"' in part:
                    return part.split("<")[1].split(">")[0]
        return None

WooCommerce Adapter

Python
class WooCommerceAdapter(EcommerceAdapter):
    """Adapter for the WooCommerce REST API."""

    def __init__(self, store_url: str, consumer_key: str, consumer_secret: str):
        """Set up WooCommerce API connection."""
        config = {"base_url": f"{store_url}/wp-json/wc/v3"}
        super().__init__(config)
        self.session.auth = (consumer_key, consumer_secret)

    def fetch_orders(self, since_date: datetime) -> list[NormalisedOrder]:
        """Fetch orders from WooCommerce since the given date."""
        url = f"{self.config['base_url']}/orders"
        params = {
            "after": since_date.isoformat(),
            "per_page": 100,
            "page": 1,
        }
        raw_orders = self._paginate(url, params)
        print(f"Fetched {len(raw_orders)} orders from WooCommerce")
        return [self._normalise_order(o) for o in raw_orders]

    def _normalise_order(self, raw: dict) -> NormalisedOrder:
        """Map WooCommerce order fields to normalised schema."""
        return NormalisedOrder(
            order_id=str(raw["id"]),
            platform="woocommerce",
            created_at=datetime.fromisoformat(raw["date_created_gmt"]).replace(
                tzinfo=timezone.utc
            ),
            customer_email=raw.get("billing", {}).get("email", ""),
            total=float(raw["total"]),
            currency=raw["currency"],
            line_items=[item["name"] for item in raw.get("line_items", [])],
            status=raw.get("status", "unknown"),
        )

    def fetch_products(self) -> list[NormalisedProduct]:
        """Fetch all products from WooCommerce."""
        url = f"{self.config['base_url']}/products"
        raw_products = self._paginate(url, {"per_page": 100, "page": 1})
        print(f"Fetched {len(raw_products)} products from WooCommerce")
        return [self._normalise_product(p) for p in raw_products]

    def _normalise_product(self, raw: dict) -> NormalisedProduct:
        """Map WooCommerce product fields to normalised schema."""
        return NormalisedProduct(
            product_id=str(raw["id"]),
            platform="woocommerce",
            title=raw["name"],
            price=float(raw.get("price", 0) or 0),
            inventory_quantity=raw.get("stock_quantity", 0) or 0,
            status=raw.get("status", "active"),
            category=raw.get("categories", [{}])[0].get("name") if raw.get("categories") else None,
        )

    def _extract_items(self, response_data) -> list:
        """WooCommerce returns a list directly."""
        return response_data if isinstance(response_data, list) else []

    def _next_page_url(self, response_data, response) -> str | None:
        """WooCommerce uses X-WP-TotalPages header."""
        total_pages = int(response.headers.get("X-WP-TotalPages", 1))
        current_page = int(response.request.path_url.split("page=")[-1].split("&")[0]) if "page=" in response.request.path_url else 1
        if current_page < total_pages:
            return response.request.url.replace(f"page={current_page}", f"page={current_page + 1}")
        return None

Where the Platforms Actually Differ

The two adapters above look symmetrical. They are not. Hiding the differences from your reporting code is the entire point of the schema — but you still have to get them right inside each adapter, and this is where multi-platform pipelines actually break.

Shopify Admin RESTWooCommerce v3BigCommerce v3
PaginationCursor, in the Link headerPage number, X-WP-TotalPagesPage number, meta.pagination
Max page size250100250
Date filtercreated_at_minaftermin_date_created
Rate limitLeaky bucket, 2 req/s sustained on standard plansNone from Woo itself — the host throttlesPlan-dependent window
Limit visibilityX-Shopify-Shop-Api-Call-Limit: 32/40NothingX-Rate-Limit-Requests-Left
Order timestampISO 8601 with UTC offsetSite local time, no offsetISO 8601, UTC
AuthAccess token headerBasic auth, key + secretToken header

The pagination and auth rows are the ones people expect, and they fail loudly — you get an exception, you fix it. The rows worth your attention are the rate limit and the order timestamp, because they fail silently: they produce a report that is wrong rather than a pipeline that is broken. So does a third difference the table cannot show, which is what the word total actually means on each platform.

The timezone trap

WooCommerce returns two timestamps for every order. date_created is in the store’s configured local time with no offset marker. date_created_gmt is UTC. Python’s datetime.fromisoformat will happily parse the first one into a naive datetime, and pandas will happily group it by date alongside Shopify’s timezone-aware values.

The result is a daily revenue table where the WooCommerce column is shifted by the store’s UTC offset. For a European store that is one or two hours, so a slice of every day’s orders lands on the wrong date. Nothing raises. The numbers just quietly disagree with the platform’s own admin reports, usually noticed weeks later by someone reconciling a month-end figure.

The WooCommerceAdapter above already takes date_created_gmt for exactly this reason. In production, harden it further — the field can be absent on orders written by older plugins:

Python
def parse_woo_timestamp(raw: dict) -> datetime:
    """WooCommerce date_created is site-local; date_created_gmt is UTC."""
    gmt = raw.get("date_created_gmt")
    if gmt:
        return datetime.fromisoformat(gmt).replace(tzinfo=timezone.utc)
    # The store did not return the GMT field. Flag rather than guess.
    raise ValueError(f"Order {raw['id']} has no date_created_gmt — cannot place it on a date")

Raising is deliberate. A missing UTC timestamp is a data quality problem, and guessing an offset here is how you end up with a warehouse that cannot be audited.

Rate limits are not symmetrical

Shopify uses a leaky bucket: 40 requests of capacity refilling at 2 per second on standard plans, 80 at 4 per second on Plus. Every response tells you where you stand in the X-Shopify-Shop-Api-Call-Limit header. Exceed it and you get a 429 with a Retry-After.

WooCommerce imposes no limit of its own. That sounds easier and is worse. The constraint is the hosting, and shared hosting will start returning 503s or simply hang under a backfill — with no header telling you why, and no Retry-After telling you when to come back.

So the base _paginate needs to handle both shapes: honour explicit backpressure where it exists, and self-limit where it does not.

Python
import time

class EcommerceAdapter(ABC):
    # Requests per second this platform tolerates. Override per adapter.
    rate_limit_rps = 2.0
    max_retries = 5

    def _request(self, url: str, params: dict = None):
        """GET with backoff. Handles both explicit 429s and silent throttling."""
        for attempt in range(self.max_retries):
            response = self.session.get(url, params=params, timeout=30)

            if response.status_code == 429:
                # Explicit backpressure — the platform told us when to return.
                wait = float(response.headers.get("Retry-After", 2 ** attempt))
                print(f"Rate limited, waiting {wait}s")
                time.sleep(wait)
                continue

            if response.status_code in (502, 503, 504):
                # Silent throttling, typically shared hosting under load.
                wait = 2 ** attempt
                print(f"Upstream {response.status_code}, backing off {wait}s")
                time.sleep(wait)
                continue

            response.raise_for_status()
            time.sleep(1.0 / self.rate_limit_rps)
            return response

        raise RuntimeError(f"Gave up on {url} after {self.max_retries} attempts")

ShopifyAdapter keeps rate_limit_rps = 2.0. WooCommerceAdapter should set it lower than you think — 1.0 is a reasonable default against shared hosting, and the backfill will still finish overnight.

”Total” is not the same number

Both platforms have a field called total on an order. They do not mean the same thing in the ways that matter for reporting:

  • Refunds. Shopify keeps the original order with financial_status: "refunded" and its total_price unchanged — the refund lives in a separate refunds array. WooCommerce moves the order to status: "refunded". If you sum total across both without handling this, Shopify over-reports revenue and WooCommerce under-reports it, in the same table.
  • Cancellations. status=any on Shopify includes cancelled orders. They have a non-null cancelled_at and a total_price that still looks like revenue.
  • Tax and shipping. Both include them in total, but the tax breakdown lives in different fields, so any net-of-tax metric needs platform-specific handling.

The schema should carry enough to make this decidable downstream rather than baking one interpretation into the adapter:

Python
@dataclass
class NormalisedOrder:
    # ... existing fields ...
    is_cancelled: bool = False
    refunded_total: float = 0.0

    @property
    def net_revenue(self) -> float:
        """Revenue after refunds, excluding cancelled orders."""
        if self.is_cancelled:
            return 0.0
        return round(self.total - self.refunded_total, 2)

Aggregate on net_revenue, not total, and the two platforms finally agree.

Step 3: Build the Aggregation Layer

Raw orders are useful for audit trails. For reporting, you need aggregations: daily revenue, conversion rates, top products.

Python
import pandas as pd

class ReportingAggregator:
    """Aggregate normalised ecommerce data into reporting metrics."""

    def __init__(self, orders: list[NormalisedOrder]):
        """Initialise with a list of normalised orders."""
        self.df = pd.DataFrame([o.to_dict() for o in orders])
        self.df["created_at"] = pd.to_datetime(self.df["created_at"])
        self.df["date"] = self.df["created_at"].dt.date
        print(f"Loaded {len(self.df)} orders for aggregation")

    def daily_revenue(self) -> pd.DataFrame:
        """Calculate daily revenue totals."""
        return (
            self.df.groupby("date")
            .agg(
                orders=("order_id", "count"),
                revenue=("total", "sum"),
                avg_order_value=("total", "mean"),
            )
            .round(2)
            .reset_index()
        )

    def platform_breakdown(self) -> pd.DataFrame:
        """Revenue breakdown by platform."""
        return (
            self.df.groupby("platform")
            .agg(
                orders=("order_id", "count"),
                revenue=("total", "sum"),
                avg_order_value=("total", "mean"),
            )
            .round(2)
            .reset_index()
        )

    def top_sources(self, n: int = 10) -> pd.DataFrame:
        """Top traffic sources by order count."""
        return (
            self.df[self.df["source"].notna()]
            .groupby("source")
            .agg(orders=("order_id", "count"), revenue=("total", "sum"))
            .sort_values("orders", ascending=False)
            .head(n)
            .reset_index()
        )

Sample Output

Text
Daily Revenue Report
─────────────────────
Date        │ Orders │ Revenue   │ AOV
2026-05-01  │ 47     │ £3,241.50 │ £68.97
2026-05-02  │ 52     │ £3,890.00 │ £74.81
2026-05-03  │ 39     │ £2,710.25 │ £69.49
2026-05-04  │ 61     │ £4,502.75 │ £73.82

Step 4: Store Historical Data

Aggregated snapshots are useful for trends. Store each run in a database so you can track week-over-week changes.

Python
from sqlalchemy import create_engine

class DataWarehouse:
    """Store normalised ecommerce data for historical analysis."""

    def __init__(self, connection_string: str = "sqlite:///ecommerce_warehouse.db"):
        """Connect to the data warehouse."""
        self.engine = create_engine(connection_string)
        print(f"Connected to warehouse: {connection_string}")

    def store_orders(self, orders: list[NormalisedOrder]):
        """Append new orders to the warehouse."""
        df = pd.DataFrame([o.to_dict() for o in orders])
        df.to_sql("orders", self.engine, if_exists="append", index=False)
        print(f"Stored {len(df)} orders in warehouse")

    def store_daily_snapshot(self, metrics: pd.DataFrame, snapshot_date: str):
        """Store a daily metrics snapshot."""
        metrics["snapshot_date"] = snapshot_date
        metrics.to_sql("daily_snapshots", self.engine, if_exists="append", index=False)
        print(f"Stored daily snapshot for {snapshot_date}")

    def get_trend(self, days: int = 30) -> pd.DataFrame:
        """Get revenue trend for the last N days."""
        query = f"""
            SELECT date, SUM(revenue) as revenue, SUM(orders) as orders
            FROM daily_snapshots
            WHERE date >= date('now', '-{days} days')
            GROUP BY date
            ORDER BY date
        """
        return pd.read_sql(query, self.engine)

Step 5: Guard Against Schema Drift

A single-platform script breaks when the platform changes. A multi-platform layer breaks when any of them changes, and the more stores you add the more often that is. This is the running cost people underestimate when they decide to build one.

The two failure shapes are different:

  • Versioned deprecation (Shopify). API versions ship quarterly and are supported for roughly a year. Deprecation is announced, dated, and visible in response headers — so it is a scheduling problem, not a surprise. Pin api_version explicitly, as the adapter above does, and treat the upgrade as planned work.
  • Unversioned change (WooCommerce). The REST API is v3, but the payload is assembled by WordPress, WooCommerce core, and any plugin that hooks into it. A plugin update on a Tuesday can add, rename, or drop a field with no version bump and no announcement.

You cannot prevent either. You can make sure the pipeline tells you rather than silently writing nulls into the warehouse.

flowchart TD
  R[Raw API payload] --> V{Required fields present?}
  V -- No --> Q[Quarantine + alert]
  V -- Yes --> N[Normalise]
  N --> D{New unmapped fields?}
  D -- Yes --> L[Log drift, keep going]
  D -- No --> W[(Warehouse)]
  L --> W

Two rules make that work. A missing required field stops the record — writing a null revenue figure is worse than writing nothing. An unexpected new field is only logged: it is usually harmless, and halting a nightly pipeline because a plugin added a metadata key would make the guard the outage.

Python
SHOPIFY_REQUIRED = {"id", "created_at", "total_price", "currency"}
WOO_REQUIRED = {"id", "date_created_gmt", "total", "currency"}

class DriftDetector:
    """Validate payloads against the fields the adapter depends on."""

    def __init__(self, platform: str, required: set[str]):
        self.platform = platform
        self.required = required
        self.seen_fields: set[str] = set()
        self.quarantined: list[dict] = []

    def check(self, raw: dict) -> bool:
        """Return True if the record is safe to normalise."""
        missing = self.required - raw.keys()
        if missing:
            self.quarantined.append({"id": raw.get("id"), "missing": sorted(missing)})
            return False

        new_fields = raw.keys() - self.seen_fields - self.required
        if self.seen_fields and new_fields:
            print(f"[drift] {self.platform} added fields: {sorted(new_fields)}")
        self.seen_fields |= raw.keys()
        return True

    def report(self) -> str:
        """Summary to send with the nightly run."""
        if not self.quarantined:
            return f"{self.platform}: schema OK, {len(self.seen_fields)} fields seen"
        return (
            f"{self.platform}: {len(self.quarantined)} records quarantined — "
            f"{self.quarantined[:3]}"
        )

Note what report() does when nothing is wrong: it still prints the passing case, with a field count. A check that is only visible when it fires teaches you nothing about the nights it did not, and you will not trust it when it eventually does.

Wire it into the adapter’s fetch:

Python
def fetch_orders(self, since_date: datetime) -> list[NormalisedOrder]:
    """Fetch orders, skipping any that fail the schema contract."""
    raw_orders = self._paginate(url, params)
    orders = [self._normalise_order(o) for o in raw_orders if self.drift.check(o)]
    print(self.drift.report())
    return orders

Send report() with the pipeline’s own output. A drift line in the nightly email is how you find out that a plugin update three weeks ago has been quietly dropping your tax field.

Step 6: Wire It All Together

The final piece connects adapters, aggregation, and storage into a single reporting pipeline.

Python
from datetime import datetime, timedelta

def run_reporting_pipeline(adapters: list[EcommerceAdapter], warehouse: DataWarehouse):
    """Execute the full reporting pipeline across all platforms."""
    since = datetime.now() - timedelta(days=7)
    all_orders = []

    for adapter in adapters:
        orders = adapter.fetch_orders(since)
        all_orders.extend(orders)

    print(f"\nTotal orders across all platforms: {len(all_orders)}")

    warehouse.store_orders(all_orders)

    aggregator = ReportingAggregator(all_orders)
    daily = aggregator.daily_revenue()
    warehouse.store_daily_snapshot(daily, datetime.now().strftime("%Y-%m-%d"))

    platform = aggregator.platform_breakdown()
    sources = aggregator.top_sources()

    print("\n--- Platform Breakdown ---")
    print(platform.to_string(index=False))
    print("\n--- Top Sources ---")
    print(sources.to_string(index=False))

    return {"daily": daily, "platform": platform, "sources": sources}


# Usage
if __name__ == "__main__":
    adapters = [
        ShopifyAdapter("my-store.myshopify.com", "shpat_xxxx"),
        WooCommerceAdapter("https://mysite.com", "ck_xxxx", "cs_xxxx"),
    ]
    warehouse = DataWarehouse()
    results = run_reporting_pipeline(adapters, warehouse)

Failure Modes to Avoid

  • Summing total across platforms. Refunds and cancellations are represented differently on each. Aggregate net_revenue, or your two stores will disagree with their own admin panels in opposite directions.
  • Parsing WooCommerce’s date_created. It is site-local with no offset. Take date_created_gmt and keep everything in the pipeline timezone-aware.
  • Assuming no rate limit means no limit. WooCommerce does not throttle you; the hosting does, with 503s and hangs rather than a 429 and a Retry-After. Self-limit against it.
  • Letting the adapter decide business rules. If the adapter silently excludes cancelled orders, no downstream query can ever count them. Normalise the facts, decide policy in the aggregation layer.
  • Backfilling in one run. A first import of three years of orders across two platforms will hit every limit at once. Chunk by month, checkpoint after each, and make the run resumable.
  • Trusting currency to be uniform. A total of 68.97 means nothing until you know whether it is GBP or EUR. Multi-store almost always means multi-currency; either store the currency with every row and convert at query time, or convert on ingest and record the rate you used.
  • No idempotency on re-runs. if_exists="append" will duplicate every order if the pipeline runs twice. Use the (platform, order_id) pair as a natural key and upsert.

When Not to Build This

Being honest about this matters more than the code above, because the wrong answer here costs months.

Do not build a unified layer if you run one store on one platform. A direct script is shorter, faster, and has one failure mode instead of three. Every abstraction here exists to reconcile differences, and with one platform there are none to reconcile — you are paying the cost and getting none of the benefit. Automating Shopify reports directly is the right shape for that.

Do not build it to future-proof a migration. “We might move to Shopify next year” is not a reason to carry an adapter layer for a year. Write the direct version, and write the layer when the second platform actually exists.

Do not build it if the platforms never need to appear in the same number. Two brands with separate P&Ls, separate teams, and separate reporting are two pipelines, not one abstraction. The layer earns its cost only when someone genuinely needs to ask “what did we sell yesterday” and get one answer spanning both.

Consider buying instead if your requirements are standard. If you want revenue, AOV, and top products across Shopify and WooCommerce with no custom metrics and no other data sources, a connector tool will do it for a monthly fee and no maintenance. Building wins when you need calculations the tools do not support, when the data has to join against something outside ecommerce entirely, or when the data cannot leave your infrastructure.

Then it is worth building when you run genuine multi-platform or multi-store operations, need custom metrics, and want the warehouse to be yours. That is a real situation — it is just narrower than the number of teams who start building one.

What This Replaces

Manual ProcessAutomated Equivalent
Log into Shopify admin, export CSVAPI pulls data automatically
Log into WooCommerce, download ordersSame normalised schema, zero manual work
Copy data into master spreadsheetWarehouse stores all data with history
Manually calculate daily revenueAggregation runs in seconds
Email spreadsheet to teamDashboard updates in real time
Compare last week manuallyHistorical snapshots enable trend queries

Next Steps

For a comparison of every Shopify reporting approach — email reports, dashboards, API data collection, and performance monitoring — see Choosing the Right Shopify Reporting Approach.

For automating the Excel report output from this pipeline, see Automate Excel Reports with Python. To build a visual dashboard on top of the warehouse data, see How to Build a Data Dashboard Without Manual Excel Work.

If you are running a Shopify store specifically, see How to Automate Shopify Reports with Python for a deep dive on the Shopify API and Shopify Reporting API: Pull Sales and Inventory Data for advanced data extraction patterns.

For scheduling this pipeline to run automatically, see Schedule and Orchestrate Workflows with Prefect.

Ecommerce optimisation services include building custom reporting APIs and data pipelines tailored to your store.

Get in touch to discuss automating your ecommerce reporting.

ecommerce reporting api ecommerce reporting api python multi-platform ecommerce reporting unified ecommerce api shopify woocommerce data api cross-platform ecommerce analytics ecommerce data aggregation api normalise ecommerce metrics multi-store reporting api ecommerce data pipeline automate ecommerce data collection ecommerce api integration

Enjoyed this article?

Get notified when I publish new articles on automation, ecommerce, and data engineering.

Get in touch

Related Articles