Ecommerce Spec
One canonical shape for every ecommerce event — GA4-style names, an items[] array, value and currency — so Meta, Google Ads, GA4 and OpenAI activation is entering an id rather than mapping fields.
Why there is a spec
Ad platforms do not fail loudly. Meta accepts a Purchase with no value and reports a conversion worth nothing. Google accepts a conversion whose cart_data has no items and reports it with no Shopping signal. Both look healthy in a delivery log.
They also disagree about names for the same facts. Meta wants contents[].item_price, Google wants items[].unitPrice, GA4 wants items[].price. Every one of those means the unit price. So the only thing that makes activation cheap is a single input shape they can all be projected from.
That shape is the Mythic ecommerce spec. Send it, and connecting a platform is entering your pixel id — the browser tag and the server destination are both already mapped.
On Shopify this is automatic. The Web Pixel and the order webhook both emit this spec, so the only thing left is the platform id. This page is for everyone sending events through the SDK or the server-side ingestion API directly.
Event names
Snake_case, matching GA4. Not a style preference: the managed Meta pixel's default mapping, the Google Ads and GA4 destinations, and the OpenAI event taxonomy all key on these names already.
| Event | When | Carries value |
|---|---|---|
view_item_list | A collection, category or recommendation list was seen | No |
view_item | One product page | Yes |
add_to_cart | A line was added | Yes |
remove_from_cart | A line was removed | Yes |
view_cart | The cart was seen | Yes |
begin_checkout | Checkout started | Yes |
checkout_step_completed | A checkout step with no GA4 equivalent (contact info, address) | Yes |
add_shipping_info | Shipping selected | Yes |
add_payment_info | Payment entered | Yes |
purchase | The order was placed | Yes |
refund | The order was refunded | Yes |
order_cancelled | The order was cancelled | Yes |
search | A search was run | No |
view_item_list and search deliberately carry no value. Their items[] is a catalogue, not a basket — deriving a value from it would report the sum of everything on the page as revenue. A 24-product collection view would reach Meta as a conversion worth the whole collection.
Event properties
items and item_count are always present, even when empty. Everything else is omitted when unknown rather than sent as null.
| Property | Type | Meaning |
|---|---|---|
items | array | Line items. Always present. See the item shape. |
item_count | number | Sum of quantity across items. Present because an array cannot be aggregated in a trend — this can. |
value | number | The monetary value of this event. See what value means. |
currency | string | ISO 4217, upper case. |
transaction_id | string | The order id. Google's dedup key and Meta's order_id. Only on purchase, refund and order_cancelled. |
checkout_id | string | The checkout token. Never the order id. |
cart_id | string | The cart id. |
affiliation | string | The store the transaction happened in. |
coupon | string | Order-level discount code(s). |
subtotal | number | Item total before shipping and tax. |
shipping | number | Shipping cost. |
tax | number | Total tax. |
discount | number | Total discount. |
search_term | string | The query, on search. A plain string — never buried in a filters array, because no destination mapping can reach into one. |
list_id / list_name | string | The list, on view_item_list. |
payment_method | string | Payment gateway used. Only known once payment is taken, so it appears on purchase. |
shipping_method | string | Shipping tier chosen. |
checkout_step | number | 1-based step index within checkout. |
new_customer | boolean | First-ever order for this customer. Absent when the producer cannot tell — never guessed. purchase only. |
customer_order_number | number | 1-based lifetime order count including this order, so a first order is 1. purchase only. |
customer_lifetime_value | number | Lifetime spend including this order, in currency. purchase only. |
purchase_type | string | one_time or subscription. A mixed cart is subscription. purchase only. |
Customer lifecycle properties
The last four are how you segment new versus returning buyers, bid differently on a second order than a fifth, and separate subscription revenue from one-off revenue.
Two things to know before you build on them.
Absent means unknown, not false. new_customer is only sent when the producer actually knows. A guest checkout has no customer record, so the property is absent rather than false — because false asserts "this is a returning buyer", and a value-based bidding strategy will act on that assertion.
Availability depends on the producer. For Shopify specifically:
| property | Web Pixel (browser) | Order webhook (server) |
|---|---|---|
new_customer | yes — isFirstOrder | yes |
customer_order_number | logged-in customers only | yes |
customer_lifetime_value | no source | yes |
purchase_type | yes | yes |
One sale reaches Mythic from both producers and purchase dedupe keeps one copy — usually the browser's, because it arrives first. Measured on a live order, that surviving copy carried new_customer and purchase_type and neither counter.
So the counters are also written to the person profile as orders_count and total_spent, from a path that is never deduped. Two consequences worth knowing:
- Filtering and segmenting on the profile always works, whichever purchase copy survived.
- Activation works too: when the purchase event lacks them, Mythic recovers them from the profile before sending to Meta and GA4. A value the producer did send always wins — the profile is only a fallback.
These reach GA4 automatically as event parameters — it forwards every non-$-prefixed property. Meta needs them listed in custom_data_mapping, which the managed integration and migration 98 do for you. If you configured a Meta destination by hand, add them there.
transaction_id and checkout_id are different things
checkout_id is the checkout session. transaction_id is the order, and it does not exist until the order does — so begin_checkout has a checkout_id and no transaction_id.
Keeping them apart is what lets transaction_id be a dedup key. It is the value Google matches browser and server conversions on, and both halves of a Mythic purchase send the same one.
The item shape
Key names are GA4's, so the GA4 projection is nearly an identity and every other platform is a rename.
| Property | Type | Meaning |
|---|---|---|
item_id | string | Product id. |
item_variant_id | string | Variant id, when it differs from the product. |
sku | string | SKU. Preferred as the catalogue key when projecting to Meta. |
item_name | string | Product name. |
item_brand | string | Brand / vendor. |
item_category | string | Category / product type. |
item_variant | string | Variant label ("Large / Blue"), not the variant id. |
price | number | Unit price. Never a line total. |
quantity | number | Units on this line. A missing quantity is 1, never 0. |
discount | number | Per-unit discount, GA4 semantics. |
coupon | string | Line-level discount code. |
index | number | 1-based position in the list this item was seen in. |
item_url / item_image_url | string | Product page and image, as absolute https:// URLs. |
price is the unit price on every event. This is the single rule most worth getting right: Meta's item_price and Google's unitPrice both mean unit, and a line total sent here multiplies the reported item value by the quantity with nothing rejecting it. If your platform gives you a line total, divide it by quantity before sending.
What value means per event
| Event | value |
|---|---|
view_item | Unit price of the product |
add_to_cart / remove_from_cart | Unit price × quantity of the line, not the cart |
view_cart | Cart total |
begin_checkout, checkout_step_completed, add_shipping_info, add_payment_info | Checkout total |
purchase | The total charged, including shipping and tax. subtotal is the pre-shipping figure. |
refund | Amount refunded, positive |
order_cancelled | Cancelled order total, positive |
refund and order_cancelled are positive on purpose. The event name carries the direction; a negative value subtracts from a platform's conversion value in some integrations and adds to it in others.
When value is omitted on an event that should have one, Mythic derives it as sum(price × quantity) — so a single-item view_item or add_to_cart carries revenue without you computing it.
Example
mythic.capture('purchase', {
transaction_id: '4581201',
checkout_id: 'chk_9f2a1c',
affiliation: 'shop.example.com',
currency: 'USD',
value: 107.5, // total charged
subtotal: 95,
shipping: 5,
tax: 7.5,
discount: 10,
coupon: 'SUMMER',
item_count: 3,
items: [
{
item_id: '7734115',
item_variant_id: '42901337',
sku: 'SNOW-01',
item_name: 'The Collection Snowboard',
item_brand: 'Hydrogen',
item_category: 'Snowboards',
item_variant: 'Large',
price: 30, // UNIT price
quantity: 3,
index: 1
}
]
});
What each platform receives
You send the spec once. Mythic projects it per platform, in both the browser and the server half.
| Platform | Where the projection runs | What it gets |
|---|---|---|
| Meta Pixel | Browser, inside the managed tag | value, currency, contents[{id, quantity, item_price}], content_ids, content_type, num_items, content_category, order_id, search_string |
| Meta CAPI | Server destination | The same fields, built by the same projection, with the same event_id — so Meta collapses the pair into one conversion |
| Google Ads | Server destination (Data Manager) | conversionValue, currency, transaction_id, and cartData.items[{itemId, merchantProductId, quantity, unitPrice}] |
| Google Ads | Browser, when a conversion label is set | value, currency, transaction_id, items[] |
| GA4 | Server destination (Data Manager, tagless) | Event params, conversionValue, currency, transactionId, and the same cartData as Google Ads |
| OpenAI Ads | Server destination | The event translated to their taxonomy, with the amount in the currency's minor unit |
Meta's contents[].id prefers your sku, then item_variant_id, then item_id — a Shopify catalogue feed keys on the SKU. An item with none of the three is dropped rather than sent with an empty id, because an unmatched content counts against the pixel's match rate.
The Meta projection runs twice, once per half, from one shared implementation. That matters because Meta keeps only one of the pair: if the browser sent line items and the server did not, which payload survived would depend on which arrived first.
Connecting a platform
Each managed platform is one call, and the commerce mapping is filled in for you:
POST /client/v1/destinations/integrations/meta— pixel idPOST /client/v1/destinations/integrations/google_ads— customer id + conversion actionsPOST /client/v1/destinations/integrations/google_analytics— measurement id + property idPOST /client/v1/destinations/integrations/openai_ads— pixel id
GA4 and OpenAI Ads are server-only: there is no browser tag to install, which is the point — every event arrives with resolved identity and full line items even when the visitor blocks scripts.
Where you pass your own event_mapping, it is merged over the commerce default, so renaming one event never stops the others being delivered. cart_data is the opposite: passing it replaces the default outright, because a partial cart config is a deliberate statement about where your items live.
OpenAI Ads has a closed event taxonomy. Events with no counterpart there — view_item_list, view_cart, remove_from_cart, add_shipping_info, add_payment_info, checkout_step_completed, refund, order_cancelled — are deliberately left unmapped, and an unmapped event is skipped rather than rejected. Everything else still reaches Meta, Google Ads and GA4.
Checking that you actually send it
The spec ships as an Event Contracts pack. Applying it makes drift visible instead of silent:
curl -X POST "https://mythic-analytics.gulp.workers.dev/client/v1/contracts/presets/commerce?location_id=YOUR_LOCATION_ID" \
-H "Authorization: Bearer ak_YOUR_AGENCY_KEY"
A producer that stops sending value, or starts sending items as an object, shows up in the violation report the next day rather than reaching your ad platforms as a conversion worth zero. Add ?dry_run=true first to see exactly what would be stored.
Contracts stay read-only: nothing is rejected, quarantined or dropped at ingestion because of one.