In this guide we walk through, step by step, how to set up a complete revenue tracking system using Google Tag Manager (web) and sending data through a Server-Side GTM container, so that every purchase lands in a MySQL database and you know your e-shop revenue in real time. The goal is not to build yet another report — it is to have an independent purchase record in your own database, so the real revenue stays visible even if GA4, Google Ads, Meta Ads, or any other tool runs into a problem.
Why MySQL and not GA4 or BigQuery?
GA4 is excellent for analytics, but it has one fundamental drawback: it is slow. During high-volume periods like Black Friday, reporting lag can drive the wrong decisions. When ad spend needs to react in real time, an independent logging system stops being a nice-to-have and becomes essential.
The Web GTM → Server-Side GTM → MySQL pipeline:
- captures every purchase the moment it happens
- stores the data on your own server
- operates independently of any analytics tool
- is ideal for real-time dashboards and immediate budget decisions
Storing orders in MySQL gives you several advantages:
- Real revenue in milliseconds from the moment the purchase happens
- No dependency on GA4 latency or sampling
- Your own dashboards in Power BI, Looker Studio, or Metabase
- Easy integration with custom tools, scripts, or alerts
- Reconciliation against ERP or site orders
- Free — entire pipeline runs on open-source + your own infrastructure
The outcome: the marketing and performance team gets revenue as it actually happens, with no delays and no dependencies.
Architecture overview
The system is built on three layers, each with a clear role:
- Browser / dataLayer — emits the purchase event with all order details
- GTM Web Container — reads the dataLayer and forwards data to Server-Side GTM
- Server-Side GTM → MySQL API — processes, enriches, and stores purchases in the database
Data flow:
Browser (dataLayer.purchase)
↓
GTM Web (Tag: Send to Server)
↓
Server-Side GTM (Client + Tag)
↓
REST API Endpoint (Flask / Node / PHP)
↓
MySQL (orders table)
Every layer does exactly what it should — nothing more, nothing less. When the architecture is clean, management, debugging, and extension all become much simpler.
The dataLayer is the foundation
Before any GTM work (web or server-side), the e-shop must emit a well-structured purchase event into the dataLayer. Without a clean and complete event, the entire pipeline is built on half-truths — which ends in wrong revenue numbers and hard-to-debug errors.
Recommended purchase event structure (GA4 Enhanced E-commerce format):
dataLayer.push({
event: "purchase",
ecommerce: {
transaction_id: "ORDER12345",
value: 74.90,
currency: "EUR",
tax: 0,
shipping: 3.90,
discount: 5.00,
coupon: "BF2025",
items: [
{
item_id: "SKU-00123",
item_name: "Oversized Hoodie",
item_brand: "BrandX",
item_category: "Apparel > Women > Hoodies",
item_variant: "Black / Medium",
price: 39.95,
quantity: 1
}
]
}
});
Common dataLayer problems to fix at the e-shop level first:
- missing
order_id - missing
coupon - no separation of
subtotal,discount, andtax - missing item-level fields (brand, category, variant)
- event fires at the wrong moment (refresh, back navigation, SPA transitions)
Fix these upstream. The dataLayer is the "data contract" that feeds every downstream layer.
Web GTM → Server-Side GTM
In Web GTM we deliberately avoid forwarding the entire dataLayer to Server-Side GTM — that inflates processing and transfer cost for no benefit. Instead, we create Data Layer Variables for the specific fields we need (transaction_id, value, currency, shipping, tax, discount, items) and send only those via a Stape Data Tag.
The tag triggers only on the purchase event, so every order is sent once to Server-Side GTM — no duplication, no wasted calls.
On the server side, the Data Client receives the incoming variables and converts them into an event inside the Server-Side GTM container. In Debug Mode you see one new event per purchase, containing exactly the fields we forwarded — transaction_id, value, currency, shipping, tax, discount, and the full items list. No transformations needed; values arrive as-is.
Server-Side GTM → Flask endpoint
Server-Side GTM forwards the purchase to our own backend via a JSON HTTP Request tag pointing at something like https://api.mydomain.gr/gtm/purchase-log. The payload carries only the fields we need — transaction_id, value, currency, shipping, tax, discount, items list, and basic context (timestamp, domain).
Minimal Flask app that receives and writes to MySQL:
from flask import Flask, request, jsonify
import mysql.connector
from datetime import datetime
import json
app = Flask(__name__)
db_config = {
"host": "localhost",
"user": "my_user",
"password": "my_password",
"database": "my_database",
"charset": "utf8mb4"
}
@app.route("/gtm/purchase-log", methods=["POST"])
def purchase_log():
data = request.get_json(silent=True) or {}
order_id = data.get("transaction_id")
value = data.get("value")
if not order_id or value is None:
return jsonify({"status": "error", "message": "Missing order_id or value"}), 400
conn = mysql.connector.connect(**db_config)
cursor = conn.cursor()
cursor.execute("""
INSERT INTO orders_log
(order_id, order_value, currency, shipping, tax, discount, items_json, created_at, source)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
order_id, value,
data.get("currency", "EUR"),
data.get("shipping", 0), data.get("tax", 0), data.get("discount", 0),
json.dumps(data.get("items", []), ensure_ascii=False),
datetime.utcnow(),
"gtm-ss"
))
conn.commit()
cursor.close()
conn.close()
return jsonify({"status": "ok"}), 200
MySQL table schema:
orders_log
----------
id AUTO_INCREMENT PRIMARY KEY
order_id VARCHAR
order_value DECIMAL
currency VARCHAR
shipping DECIMAL
tax DECIMAL
discount DECIMAL
items_json JSON or TEXT
created_at DATETIME
source VARCHAR -- e.g. "gtm-ss"
For a first implementation, storing items as JSON is enough. A normalized order_items table can come later.
Apache reverse proxy + HTTPS
Server-Side GTM needs a public HTTPS URL to call, but Flask normally runs on 127.0.0.1:5000. An Apache reverse proxy terminates SSL on a public host and forwards the request internally to Flask.
Enable the needed modules and configure a Virtual Host on port 443 with Let's Encrypt certificates, ProxyPass to the Flask instance, and Header always set directives for CORS if the Server-Side GTM domain differs from the API. With that in place, Server-Side GTM can POST purchases to https://api.mysite.gr/gtm/purchase-log — and every order drops into MySQL within milliseconds of the customer clicking "confirm".