Blog
E-commerce events without a shop platform: making a custom checkout report to GA4
Published · 5 min read
A shop platform at least gives you a plugin to argue with. A checkout you built yourself — a React app, a booking flow, a SaaS sign-up with Stripe behind it — gives you nothing: no event fires until your own code pushes it. The good news is that the contract is small: a handful of names, one object shape, and a few rules about where each push belongs. This is all of it, including the part most guides leave out — the purchases that happen when nobody is on your page.
The contract: a name and an ecommerce object
Everything here reaches Analytics the same way. Your page pushes an event to window.dataLayer under its GA4 name, carrying an ecommerce object in GA4's shape. A Tag Manager trigger matches the name, and a GA4 event tag reads the object and sends it on to your server container. If you told SignalHost your site is a shop — or pressed Set up under Conversion events on the container page — those triggers and tags already exist in your web container, named with an SH prefix. What is left is the push.
Two rules catch most mistakes. Clear the previous ecommerce object first, or a view_item from three pages ago rides along inside the next event. And on every event that carries money, send value as a number and currency as an ISO code: a string value or a missing currency is accepted without complaint and quietly left out of revenue.
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ ecommerce: null });
window.dataLayer.push({
event: 'begin_checkout',
ecommerce: {
currency: 'EUR',
value: 49.00,
items: [
{ item_id: 'plan-pro', item_name: 'Pro', price: 49.00, quantity: 1 }
]
}
});Which event, and where it belongs
A custom flow has the same moments a shop platform has. You just have to find them in your own code.
- view_item_list — a page or component that shows several products or plans. Once when the list is shown, not on every scroll.
- view_item — a single product or plan page.
- add_to_cart and remove_from_cart — in the button handler, after your cart has actually changed.
- begin_checkout — the moment the visitor enters checkout: the checkout page's first render, or the click that opens a checkout dialog.
- add_payment_info — when payment details are accepted, not when the form appears.
- purchase — once, when the order is confirmed. Most of this article is about getting this one right.
- sign_up and start_trial — for a subscription business, the conversions before any money changes hands. SignalHost builds tags for both alongside the shop events.
Push from the handler that caused the change, not from a render. A single-page app re-renders freely, and React runs effects twice in development: a push inside an effect doubles in the dev build and repeats every time a component remounts.
The purchase on your confirmation page: read the order from your server
When the visitor comes back to a page of yours after paying, that page is where purchase belongs — with two conditions. Take the order from your own backend, not from the URL: query parameters can be edited, get stripped by redirects, and arrive on failed payments too. Stripe, for one, returns to the same address whether the payment went through or not, and says which in a parameter of its own. And push it once: a reload, the back button or a bookmarked receipt page all come back to it.
// On your confirmation page. The order comes from YOUR server, by an id the
// page already knows — never from price or status parameters in the URL.
const order = await fetch(`/api/orders/${orderId}`).then((r) => r.json());
const key = `purchase-sent-${order.id}`;
if (order.status === 'paid' && !localStorage.getItem(key)) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ ecommerce: null });
window.dataLayer.push({
event: 'purchase',
ecommerce: {
transaction_id: order.id,
value: order.total,
tax: order.tax,
currency: order.currency,
items: order.items.map((i) => ({
item_id: i.sku, item_name: i.name, price: i.price, quantity: i.quantity
}))
}
});
localStorage.setItem(key, '1');
}transaction_id is a safety net, not a plan. Analytics drops a repeated transaction_id within one session, but not a reload of the receipt page the next morning.
Payment on someone else's page: Stripe Checkout, PayPal, a bank redirect
A hosted payment page is the usual choice for a custom build, and it has one sharp edge: the visitor may never come back. They pay, close the tab, and your confirmation page never renders. With cards that is a few percent of purchases. With bank transfers, invoices and pay-later methods it can be most of them, because the money arrives hours or days afterwards.
So decide, per payment method, where the truth lives. If your backend learns about the payment from a webhook, the webhook is the reliable place to report it — see the next section — and the confirmation page must not push purchase as well, or every card payment is counted twice. If the only place you learn it is the return page, report it there and accept the gap.
Purchases no browser is there for: send them from your server
Subscriptions make this unavoidable. A trial converts after seven days, a renewal happens every month, an invoice is paid by transfer next week. Each is a purchase, and none of them happens in front of a browser. The answer is to send the event server-to-server, from the webhook that learns about the payment, to the same tagging endpoint your pages use — your SignalHost hostname or your own domain — so it passes through your server container like every other hit.
It needs one thing from the checkout: the visitor's Analytics client id, so the purchase lands on the person whose visits led to it. It is in the _ga cookie, which your checkout request already carries when it goes to your own domain. Store it with the order. No _ga cookie means the visitor did not grant analytics consent: send the hit as consent-denied with a throwaway id, or not at all — never invent an identity for them.
// In your checkout request handler: keep the visitor's Analytics id with the order.
const ga = req.cookies._ga; // "GA1.1.1234567890.1700000000"
order.gaClientId = ga ? ga.split('.').slice(-2).join('.') : null;
// Later, in your payment webhook — a trial converting, an invoice paid:
const params = new URLSearchParams({
v: '2',
tid: 'G-XXXXXXXXXX', // your GA4 measurement ID
cid: order.gaClientId ?? `${Date.now()}.${Math.floor(Math.random() * 1e9)}`,
gcs: order.gaClientId ? 'G101' : 'G100', // no _ga cookie = no consent
en: 'purchase',
'ep.transaction_id': invoice.id,
'epn.value': '49.00',
cu: 'EUR',
pr1: 'idplan-pro~nmPro~pr49.00~qt1',
});
await fetch(`https://metrics.example.com/g/collect?${params}`, { method: 'POST' });Pick one source per event and keep to it. This is exactly how signalhost.io measures its own sales: the browser pushes sign_up, begin_checkout and start_trial; the Stripe webhook sends purchase when the first invoice is paid; nothing sends both. Google's Measurement Protocol would also work, but it goes straight to Google and bypasses your server container, so none of your server-side tags ever see the sale.
Consent asks nothing extra of you
Push the events whatever the visitor chose. The tags obey Consent Mode: with consent they send normally; without it they send cookieless pings that Analytics models rather than reports. Holding back the pushes yourself only hides conversions from that modelling. What does matter is order — the Tag Manager snippet goes after your consent banner's script, so the consent defaults exist before the first event is sent.
Checking that it works
- Open the container in SignalHost. The events card lists what arrived, by name, with the share that carried consent. A name you pushed and do not see there never reached your tagging server.
- Walk your checkout in Tag Manager's Preview mode. It shows each push, which tag fired, and the ecommerce object that was sent.
- In Analytics, DebugView shows events as they arrive. Standard reports can take a day.
- Reload the confirmation page twice and count the purchases. It should still be one.
The failures here are all quiet ones: a name in the wrong case, value sent as a string, items nested one level too deep. None of them produces an error anywhere. Every one of them produces a report with a hole in it.
