Conversions & attribution
Track revenue with full UTM attribution, multi-touch journeys, and automatic deduplication. No extra configuration — UTM params from the landing URL flow through to every event fired in the session.
01UTM capture
UTM parameters on the landing URL are captured once per session.
The SDK reads the standard five UTM parameters from the initial landing URL and attaches them to every subsequent event in the session:
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
Send a conversion to the ingest API with orderId + totalAmount at the top level. All UTM params attach automatically.
A conversion is any event that carries orderId and totalAmount as top-level fields on the ingest API request. Everything else is optional — but the richer the payload, the deeper the reports.
Sumidata.push('event', ['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 and never register as a conversion. Send the event to POST /sdk/ingest with the fields at the top level, from your backend or a direct browser fetch.await fetch('https://api.sumidata.io/sdk/ingest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectId: 'proj_…',
deviceId: sumidataDeviceId, // SDK device id (or zero-UUID)
sessionId: sumidataSessionId, // optional — links UTM + experiments
externalUserId: user.id, // same id you pass to identify()
source: 'realtime',
events: [{
name: 'purchase',
orderId: 'ord_01HW…',
totalAmount: 79.99, // net, after discount
currency: 'USD',
productId: 'plan_pro_monthly',
quantity: 1,
isPrimarySale: true,
_category: 'revenue', _feature: 'checkout', _surface: 'app'
}],
}),
})totalAmount as the net after discount, and put the discount amount in discountAmount. Sumidata reports show gross / discount / net separately when both are present.Multi-line-item orders — send one event per line item in the same events array, all with the same orderId and a distinct productId:
events: cart.items.map(item => ({
name: 'purchase',
orderId: order.id, // shared across items
productId: item.productId, // unique per item — dedup key is (orderId, productId)
quantity: item.quantity,
unitPrice: item.unitPrice,
totalAmount: item.lineTotal,
currency: 'USD',
}))03Multi-touch attribution
For accounts with multiple sessions before they convert, Sumidata attributes across the journey.
When a user is identified (via identify()), all prior anonymous sessions for that device are stitched. The conversion event gets the full journey attached:
first_touch— UTM of the first session where we saw this userlast_touch— UTM of the session the conversion fired intouches— array of all sessions in between, ordered
04Deduplication
A conversion fired twice (e.g., the thank-you page loads on refresh) counts once.
The dedup key is (orderId, productId). Firing purchase twice with the same pair is skipped — the duplicate never reaches storage, so there is no double-counting if the thank-you page reloads, and it is reported in the skipped array of the response. For multi-line carts this means each line item is deduped independently, so a retry of a partially-sent cart drops only the already-ingested items and still stores the rest.
productId is omitted, the dedup key is (orderId, '') — which still works for single-product orders. Always send productId when you have one, so multi-item orders don't collide.05A/B experiments
Bind A/B variants to the session, then every conversion joins the correct group automatically.
Register the variants the user is seeing — once per session, as soon as your experiment framework hands you the assignments. All conversions fired afterwards (browser or server) are attributable to those variants for 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.
- Funnel drop-off by variant — which step hurts each group.
- Session replays filterable by variant — watch 5 "treatment" sessions vs 5 "control" side-by-side.
sessionId. Server-side conversions that link back to the same session (via sessionId in the ingest payload) inherit the variants automatically. See Linking browser session to server.06Event 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'
}])
// A purchase — revenue stage, checkout feature, in the app
Sumidata.push('event', ['purchase', {
orderId: order.id, totalAmount: order.total, currency: 'USD',
_category: 'revenue', _feature: 'checkout', _surface: 'app'
}])skipped array of the response, while every valid event in the same batch still stores. Keep your taxonomy consistent — the first event you send with a given name sets the metadata that powers future funnel queries.07Where to send conversions
Conversions always go through the ingest API. What changes is where you call it from.
Every conversion is an /sdk/ingest call with the fields at the top level — the browser push('event', …) helper cannot carry them (section 02). Call the ingest API from your backend when the source of truth lives on your server (webhooks, subscriptions, renewals, invoicing). Call it with a direct browser fetch when the money moves in the user's tab and you want to fire from the thank-you page — read the SDK's deviceId and sessionId first so attribution links.
| Scenario | Call ingest from |
|---|---|
| Synchronous checkout, user sees thank-you page | Browser fetch → ingest |
| 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 |
sessionId (or stored UTMs) when you send the event. From the browser, grab it via window.Sumidata.getSessionId() and localStorage.getItem('sumidata_device_id'). 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.
