Conversions & attribution
Track revenue with UTM attribution and automatic deduplication. No extra configuration - UTM params from the landing URL are stored for the visitor's device and credited to its conversions automatically.
01UTM capture
UTM parameters on the landing URL are captured when a visit starts a new session.
The SDK reads the standard five UTM parameters from the landing URL when a visit starts a new session, and stores them with that session and in the device's campaign history:
utm_source- where the click came from (google, newsletter, partner-x)utm_medium- channel class (cpc, email, social)utm_campaign- campaign identifier (spring-sale-2026)utm_term- keyword or audience sliceutm_content- creative / ad variant
02Tracking a conversion
Record an order with Sumidata.purchase() in the browser, or send it to the ingest API from your server - with orderId (and totalAmount) on the order. UTM fills in from the user's or device's latest campaign visit.
In the browser, record an order with Sumidata.purchase(order) (SDK 0.10.0 or later). The SDK sends it at once, in its own request, with the visitor's device and session - so the order is linked to the session and its replay - and the buyer: the id you passed to identify(), or an externalUserId on the order, which wins. A server sends the order to the ingest API as an event with the order fields at the top level of the event, not in payload - the event name is not checked; use purchase.
On both paths an order needs a non-empty orderId (a string or a number): with 0 or "", purchase() sends nothing and the ingest API records an ordinary event. Send totalAmount with it: without it the order is recorded with revenue 0. Put orderId only on the call that records the purchase: every purchase() call and every ingest event with an orderId counts as a purchase and reserves that order number, so a later one with the same orderId is skipped as a duplicate. Everything else is optional - but the richer the payload, the deeper the reports.
Sumidata.event() and Sumidata.push('event', ['purchase', { … }]) cannot record a purchase. The SDK keeps only _category / _feature / _surface at the top level and moves every other property into payload - so orderId and totalAmount would land in payload: the event is stored as an ordinary event, with no error, and never registers as a conversion. Use Sumidata.purchase(order) - with the npm package, a call made before init() resolves waits in the SDK queue. With the CDN <script> snippet use the command form Sumidata.push('purchase', [order]): the snippet's stub has only push, and the command waits in the queue until the SDK has loaded. Hand-built requests to POST /sdk/ingest are for your backend.Sumidata.purchase({
orderId: 'ord_01HW…',
totalAmount: 79.99, // net, after discount
currency: 'USD',
isPrimarySale: true,
items: [{ productId: 'plan_pro_monthly', quantity: 1, unitPrice: 79.99 }],
_category: 'revenue', _feature: 'checkout', _surface: 'app'
})totalAmount as the net after discount, and put the discount in discountAmount (and the code in couponCode). Reports use totalAmount only - revenue and average order value are net; the discount and coupon are stored with the order and shown on the event card.Multi-item orders - make one purchase() call per order (a server sends one purchase event) and list its products in items. The order counts as one purchase; its revenue is totalAmount, and item prices never change it. An order holds at most 200 items. currency is stored as sent - it is not validated or converted, and reports add up totalAmount across currencies - so send all orders of a project in one currency.
Sumidata.purchase({
orderId: order.id,
totalAmount: order.total, // net, after discount
currency: 'USD',
items: cart.items.map(item => ({
productId: item.productId, // required, non-empty string - a numeric id drops the item
productName: item.name,
productCategory: item.category,
quantity: item.quantity, // whole number 1–4294967295; omitted → 1, 0 drops the item
unitPrice: item.unitPrice, // a number, not a string; 0 ≤ price < 10^16; omitted → 0
})),
})productId is missing, empty or not a string, its quantity is not a whole number from 1 to 4294967295 (omitted = 1), or its unitPrice is not a number from 0 to below 10^16 (omitted = 0) - warning code item_invalid. Valid items beyond the first 200 are dropped too (items_over_limit). The order is still recorded with its full totalAmount, and the response lists what was dropped in warnings. Items on an event without orderId are dropped (items_without_order) and the event is recorded as an ordinary event, not a purchase. A purchase that carries productId, productName, productCategory, quantity or unitPrice at the top level is not recorded: it comes back in skipped with Send products inside items[]: one event per order. With purchase() the SDK reads the response for you: an order recorded in full is silent, and every other outcome is logged to the browser console with the [Sumidata] prefix - Purchase <id>: <n> item(s) dropped, Purchase <id> rejected — <field>: <reason>, … was already recorded — not counted again, … not accepted — the project's monthly session quota is used up, … not delivered — call purchase() again to resend, and Purchase not sent: … when the order or its orderId is invalid. A server reads warnings and skipped from the response body.03Attribution across visits
Visits before the conversion feed one UTM snapshot per order: the latest campaign seen for that user or device.
When a user is identified (via identify()), the device's campaign history is copied to that user, so conversions sent with the same externalUserId are credited even when they come from your server. Each conversion stores one UTM snapshot - utmSource, utmMedium, utmCampaign, utmTerm, utmContent - taken from the event itself; every field the event leaves out is filled from the latest campaign visit of that user or, if the user has none, of that device, with no time window. There is no first-touch, last-touch or multi-touch data per order; orders without any UTM tag form one No UTM tag row in the Campaigns report.
04Campaigns report
Revenue by channel, source, campaign and creative, with costs, payback and customer cohorts.
Analytics → Campaigns groups purchases by their UTM snapshot: channel → source (with its medium) → campaign → creative (utm_content). Table shows any period, optionally compared with the previous period or the same period last year; By month shows any metric per calendar month; Cohorts follows customers after their first purchase. Days and months follow the report's time zone - one per project, UTC by default, changed right in the report.
Channels
The channel comes only from the purchase's own utm_source and utm_medium (plus utm_campaign for shopping and cross-network campaigns), using the GA4 default channel names: Paid Search, Paid Social, Email, Affiliates, Organic Search, Organic Social, Referral, AI Assistant, Display and the rest. Tags that match no rule land in Unassigned - expand it to see which links to fix. Purchases without any UTM tag are one No UTM tag row under the channels; they are never guessed from the referrer. Matching ignores case and works for past purchases too.
Costs, ROAS, CPA and ROI
Enter what a campaign (source · medium · campaign) cost in a calendar month: in By month pick the Cost metric and click a cell (Enter saves, Esc cancels, an empty value deletes), or Upload CSV with the columns month, source, medium, campaign, cost - download the template from the report. Uploading the same campaign and month again replaces its cost, it never adds up. Costs are in the currency your revenue is recorded in; nothing is converted.
- ROAS = revenue ÷ cost, CPA = cost ÷ conversions, ROI = (revenue − cost) ÷ cost - revenue-based, before margin.
- Totals count only campaigns that have a cost, so organic revenue does not inflate ROAS.
- A period that cuts a month takes that month's cost in proportion to its days; a cost with no purchases shows as cost only.
Cohorts
Customers are grouped by the month of their first purchase, or by the campaign of that purchase. Cells show cumulative revenue per customer, or the share who paid again in a later month, for Month 0 to Month 11. A customer is the identified user (the externalUserId from identify() or the event), otherwise the device - send a buyer id with each order so a repeat purchase from another device is not counted as a new customer.
05Deduplication
A conversion fired twice (e.g., the thank-you page loads on refresh) counts once.
The dedup key is orderId: an order number is unique within a project. A purchase whose orderId was already accepted - or that appears twice in the same request - is skipped: the duplicate never reaches storage, the first order stays unchanged even if the repeat carries a different amount or items, and the repeat is reported in the skipped array of the response. So there is no double-counting if the thank-you page reloads or your backend retries.
The key is the exact orderId string - case and spaces matter; 1001 and "1001" are the same order - and it is shared by every event name in the project. An order number stays reserved as long as the order is kept (36 months after it was received). Send retries one at a time: two simultaneous requests with the same new orderId are both recorded. If a request fails part-way with a 5xx, part of it may already be stored - a retry can then be skipped as a duplicate of an order saved without its items, or record the order a second time. A {"status":"quota_exceeded"} response means nothing from the request was stored: resend it later - it is not a duplicate.
In the browser, call purchase() once per order - two calls with the same new orderId at the same moment can both be stored. The SDK sends each call once and does not resend on its own: when the request does not get through, the console says Purchase <id> not delivered — call purchase() again to resend, and your site may call purchase() again with the same order. A repeat of an order that was already stored is logged as Purchase <id> was already recorded — not counted again.
06A/B experiments
Register A/B variants per session; the Experiments report credits a device's purchases to the variant that device saw.
Register the variants the user is seeing - once per session, as soon as your experiment framework hands you the assignments. All purchases of that device in the report window (browser or server, sent with the same deviceId) count for its variant in the lift analysis. For the full guide - stickiness rules, GrowthBook / Statsig / LaunchDarkly / Optimizely recipes, query patterns, limitations - see Experiments.
// Called once per session, after your A/B framework resolves assignments
Sumidata.push('identifyExperiment', [[
{ id: 'pricing-v3', variant: 'control' },
{ id: 'onboarding-flow', variant: 'short' },
{ id: 'hero-copy', variant: 'v2' },
]])Sticky assignments
First assignment per (session, experimentId) wins. Calling identifyExperiment again with a different variant for the same experiment is a no-op - the original variant stays. This protects your analysis from flicker-reassignment bugs.
What you get
- Revenue and conversion-rate splits by variant, per experiment.
- Event contrast by variant - how often each group fires every event, to spot where a variant behaves differently.
- Session replays filterable by variant - each variant row links to that variant's replays.
deviceId: a purchase counts for a variant when it carries the same deviceId as the browser that registered the assignment. purchase() attaches the browser's deviceId and sessionId itself. For an order your server sends, read the device id in the browser from localStorage.getItem('sumidata_device_id') and pass it along. Devices that saw more than one variant of an experiment are left out. This report also counts an ingest event without orderId as a purchase when its totalAmount is above 0. When the server does not know the device id, send the zero-UUID 00000000-0000-0000-0000-000000000000: the order is stored but is not counted in any variant, even with a sessionId. The sessionId link powers the Error Impact report - server-sent purchases without it still count in totals, but can't be matched against the errors of the session that produced them. See Linking browser session to server.07Event metadata (AAARRR)
Tag events with AAARRR stage, feature, and surface so funnel reports stay coherent.
Three optional properties turn raw events into analyzable funnels. Set them once per event and Sumidata auto-maintains a dictionary for query-time joins.
| Field | Allowed values |
|---|---|
_category | awareness · acquisition · activation · revenue · retention · referral |
_surface | marketing_site · app · pdp · checkout · … (98 total - see the Product events guide) |
_feature | free-form, [a-z0-9_]+ |
// A signup - acquisition stage, on the marketing site, auth feature
Sumidata.push('event', ['signup', {
_category: 'acquisition',
_feature: 'auth',
_surface: 'marketing_site'
}])
// Checkout started - revenue stage, checkout feature (a product event, not a purchase)
Sumidata.push('event', ['checkout_started', {
cartValue: cart.total,
_category: 'revenue', _feature: 'checkout', _surface: 'checkout'
}])
// The order itself - a purchase command, not an event (section 02)
Sumidata.push('purchase', [{
orderId: order.id, totalAmount: order.total, currency: 'USD',
_category: 'revenue', _feature: 'checkout', _surface: 'checkout'
}])skipped array of the response, while every valid event in the same batch still stores. Keep your taxonomy consistent - the latest metadata sent for an event name wins, and an event that sends only some of the three fields clears the others, so send all three the same way every time.08Where to send conversions
A browser records orders with Sumidata.purchase(), a backend with the ingest API. Pick by where the order is confirmed.
Call Sumidata.purchase() in the browser when the money moves in the user's tab and you want to record the order from the thank-you page - the SDK attaches the visitor's deviceId and sessionId itself, so attribution links. The browser push('event', …) helper cannot carry an order (section 02). Call the ingest API from your backend when the source of truth lives on your server (webhooks, subscriptions, renewals, invoicing).
| Scenario | Send from |
|---|---|
| Synchronous checkout, user sees thank-you page | Browser Sumidata.purchase() |
| Stripe / PayPal / provider webhook | Server-side ingest |
| Subscription renewal, dunning recovery | Server-side ingest |
| Wire transfer / invoice paid out-of-band | Server-side ingest |
| Offline / in-person sale entered by staff | Server-side ingest |
| Replaying historical orders (first integration) | Backfill |
purchase() attaches the session and device itself. For an order your server sends, the UTM is looked up by the externalUserId or - when that user has no campaign visits - by the deviceId, so pass the browser's deviceId (or the UTM fields themselves) with the event, plus the sessionId to link the order to its session: grab both in the browser via window.Sumidata.getSessionId() and localStorage.getItem('sumidata_device_id') and hand them to your backend - read the session ID at the moment the order is placed, it changes whenever a new session starts. See Linking browser session to server.- Server-side ingest → full API reference, attribution patterns, experiments, errors, config.
- Historical backfill → replay past orders with correct first-time-buyer flags.
