Product events
Track any user action with a single push call. Events carry arbitrary metadata, link automatically to the current session and replay, and - when you tag them with a category, feature, and surface - feed the AAARRR funnel reports out of the box.
01Basic event tracking
Any user-visible action is a candidate for a product event.
Simple event
Sumidata.push('event', ['button_click'])Event with properties
Sumidata.push('event', ['form_submitted', {
formId: 'newsletter-signup',
fields: 5
}])02Automatic events
Zero-code events: pageviews, clicks, form submits, and scroll depth - collected automatically.
With autocapture on (the default), the SDK sends these events without any tracking code - the dashboard has data the moment the snippet is installed:
| Event | Fires | Properties |
|---|---|---|
$pageview | On load and on every SPA navigation - History API pushState / replaceState / popstate with a changed path or query. | url, referrer |
$autocapture_click | Any click. A click on or inside an interactive element - <a>, <button>, [role="button"], input[type="submit"] - describes that element; any other click is recorded with interactive: false and no element text. Repeated clicks on the same element within the same 32 px area are recorded at most once per second. | url + element context, pageX, pageY, offsetXFrac, offsetYFrac, viewportWidth, viewportHeight, documentHeight, interactive |
$autocapture_submit | Form submission. | url + element context |
$autocapture_scroll | Scroll depth milestones - 25 / 50 / 75 / 100% of page height. Each milestone fires once per pageview and the set resets on SPA navigation. A page with nothing to scroll counts as 100. | url, milestone |
$scroll_depth | Once per pageview, when the visitor leaves it - SPA navigation, closing the page, or hiding the tab - with the deepest point reached, as a percent of page height. | url, maxDepthPercent, documentHeight, viewportWidth, viewportHeight |
$layout_signature | At most once per pageview, about 1.5 s after the page has loaded (skipped if the visitor navigates away first) - a hash of the page's element structure (tags only, no text) that heatmaps use to notice layout changes. | url, path, layoutHash, viewportWidth, viewportHeight |
$pageview, $autocapture_click and $autocapture_submit share a limit of 60 events per minute - past it, these events are dropped until the minute is up. Scroll and layout events are not limited.
The $autocapture_scroll milestones power the Content Effectiveness board - for every page it shows the share of pageviews that reached 25 / 50 / 75 / 100% of the page, so you can see where readers drop off. "Reached 50%" counts every pageview whose furthest milestone was 50 or deeper; a page that fits on one screen reports 100% across the board.
Element context is a human-readable signature of what was clicked or submitted: tag, id, classes, tracking data-* attributes, inner text trimmed to 80 characters, and a CSS selector path - e.g. div#checkout > form.payment > button.cta. Only attributes from a tracking allowlist are collected (data-testid, data-test…, data-track…, data-analytics…, data-event…, data-action…, data-gtm…); any other data-* attribute never leaves the page.
Turning it off
One attribute on the snippet - data-autocapture, default on. off disables all automatic collection, including scroll depth; manual push('event', …) calls are unaffected:
<script
src="https://sdk.sumidata.io/loader.js"
data-project-id="YOUR_PROJECT_ID"
data-src="https://sdk.sumidata.io/sumidata.js"
data-autocapture="off"
async></script>Excluding elements
To keep a single element or a whole subtree out of autocapture, add data-sumi-no-capture to it - clicks and form submits on that element or any of its descendants are never captured:
<div data-sumi-no-capture>
<button>Not tracked</button>
</div>Masking interaction
Autocapture respects the same data-masking attribute as session replay (see SDK configuration):
- Form field values (
input,textarea,select) are never collected - in any masking mode, includingdangerouslyNoMask. - With
fieldsAndLabels,<label>text is also excluded from the element context. - The
urlon every automatic event is sanitized before it leaves the browser - sensitive query parameters (token,access_token,code,email,password, and similar) are removed.
_category / _feature / _surface) - tag your key actions with manual events when you want them in the AAARRR reports.03Event payload
Nested objects, arrays, numbers, booleans - anything JSON-serializable is fine.
Sumidata.push('event', ['feature_used', {
featureId: 'export_csv',
rowCount: 1200,
format: 'xlsx',
filters: ['last_30d', 'segment:enterprise']
}])JSONExtractString, JSONExtract*) and shows you the SQL it ran. Keep payloads small (a few KB) so ClickHouse JSON parsing stays cheap at report time.04SDK commands
Five push commands cover the full lifecycle - event, purchase, identify, reset, identifyExperiment.
Every method is GTM-style: Sumidata.push('method', [args]). Calls made before the SDK finishes loading are queued and flushed on init - the stub <script> tag swallows the intent even if the main bundle hasn't arrived yet.
event
Fire a product event. A purchase (conversion) can't be sent this way - use purchase.
Sumidata.push('event', ['page_viewed', { path: '/pricing' }])purchase
Record an order - once, when it is placed (SDK 0.10.0 or later). The SDK sends it right away and attaches your project, the visitor's device, the session and its replay, and the buyer you passed to identify() (an externalUserId on the order overrides it). Fields and attribution: section 05 and Conversions & attribution.
Sumidata.push('purchase', [{
orderId: 'ord-1001',
totalAmount: 100,
currency: 'USD',
items: [
{ productId: 'shirt', productName: 'Shirt', quantity: 1, unitPrice: 40 },
{ productId: 'shoes', productName: 'Shoes', quantity: 1, unitPrice: 60 }
]
}])identify
Stamp the session with your stable user ID. If a different user was previously identified on this device, the SDK automatically rotates the session - the old session stays attached to the old user, a new session is created for the new user. This prevents history merge on shared devices.
// On login
Sumidata.push('identify', [user.id])reset
Clears the stored user ID, rotates the session, and starts a fresh replay. Always call this on logout - otherwise the next user on the same device inherits the prior identity.
// On logout
Sumidata.push('reset', [])identifyExperiment
Register A/B variant assignments for the current session. Sticky: the first assignment per (sessionId, experimentId) wins. See Experiments for the full guide.
Sumidata.push('identifyExperiment', [[
{ id: 'pricing-v3', variant: 'control' }
]])getSessionId(). purchase attaches the session itself - you need this only when your server sends the order: pass the value to it as sessionId to link the order to the browser session and its replay. With the npm package call Sumidata.getSessionId(); with the CDN snippet the method exists only after the bundle has loaded, so call window.Sumidata?.getSessionId?.(). It is not a push() command and is not queued. Returns null until init() has run. The ID changes whenever a new session starts (30 min without activity, or 4 h) - read it right before you send, don't cache it at page load.05Conversions
A conversion is an order with a non-empty orderId - recorded with push('purchase') in the browser, or posted to the ingest API from a server.
A conversion (purchase) is one record per order. In the browser, record it with Sumidata.push('purchase', [order]) or Sumidata.purchase(order). A server posts it to POST /sdk/ingest as an event with a non-empty orderId (string or number) as a top-level field - that alone makes it a purchase; the event name isn't checked, use purchase. Either way, orderId is the dedup key: another order with the same orderId in the project is skipped as a duplicate. totalAmount (a number) is the order total after discount - what the customer paid - and the only field that counts toward revenue; without it the order is recorded with 0. Products go in items on the same order.
Sumidata.push('event', ['purchase', { … }]) or Sumidata.event(). The SDK extracts only _category / _feature / _surface and moves every other property into payload - so orderId and totalAmount never reach the conversion columns, the event is stored as an ordinary event, and no error comes back. Use purchase instead.With the CDN snippet use the push('purchase', [order]) form from section 04 - it also waits if the SDK has not loaded yet. With the npm package you can call the method directly:
Sumidata.purchase({
orderId: 'ord-1001',
totalAmount: 100,
currency: 'USD',
items: [
{ productId: 'shirt', productName: 'Shirt', quantity: 1, unitPrice: 40 },
{ productId: 'shoes', productName: 'Shoes', quantity: 1, unitPrice: 60 }
]
})From a server, post the order to the ingest API:
curl "https://api.sumidata.io/sdk/ingest" -X POST \
-H "Content-Type: application/json" \
-d '{"projectId":"YOUR_PROJECT_ID","deviceId":"00000000-0000-0000-0000-000000000000","source":"realtime","events":[{"name":"purchase","orderId":"ord_01HW","totalAmount":79.99,"currency":"USD","items":[{"productId":"sku_tee_black","quantity":1,"unitPrice":79.99}],"_category":"revenue","_feature":"checkout","_surface":"checkout"}]}'purchase() attaches the device, session and buyer itself. In the server request, YOUR_PROJECT_ID is your project's UUID. Pass the server the visitor's device ID - read in the browser with localStorage.getItem('sumidata_device_id'), or the zero UUID above when it is null - and the current sessionId: the device ID gives the order the latest campaign (UTM) this device arrived from, the session ID links it to the session replay. A server that has neither sends externalUserId - the ID you pass to identify() - instead.
purchase() reads the response itself: a recorded order is silent, and any other outcome - a duplicate orderId, a rejected field, dropped products, a used-up quota, a failed request - is logged to the console with the [Sumidata] prefix. The SDK does not resend on its own; call purchase() again if the console says the order was not delivered.
A server reads the response body, not just the HTTP status. {"status":"ok"} means the request was accepted, but an event listed in skipped (for example a duplicate orderId) was not stored, and warnings lists products dropped from items. {"status":"quota_exceeded"} (also HTTP 200) means the workspace's monthly session quota is used up and nothing from the request was stored - send the order again later.
For the full field reference, multi-item orders (one event per order, products in items), and backend ingest, see Conversions & attribution and Server-side ingest.
06Funnel metadata (AAARRR)
Three reserved keys (_category, _feature, _surface) bucket the event into AAARRR funnels at ingest.
Sumidata does not auto-classify events by name. Category is something you declare - by passing three reserved keys alongside the event. The server validates them, strips them out of the stored payload, and writes them into a per-project dictionary (event_metadata_dict) so every event with the same name, earlier ones included, joins the same funnel.
| Field | Allowed values |
|---|---|
_category | awareness · acquisition · activation · revenue · retention · referral |
_surface | marketing_site · app · pdp · checkout · dashboard · … (98 total - full list below) |
_feature | Lowercase alphanumeric + underscores only, ^[a-z0-9_]+$ |
Allowed surfaces
_surface accepts one of these 98 values. Anything else is skipped (see the validation note below) - pick the entry that best describes where in the product the event fired.
| Area | Surfaces |
|---|---|
| Marketing & public site | marketing_site · landing · home · pricing · features · about · contact · blog · careers · faq · partners · resources · changelog |
| App shells & platforms | app · mobile_app · mobile_web · admin · admin_panel |
| Dashboard & analytics | dashboard · overview · analytics · reports · insights · metrics · activity |
| Navigation & chrome | navbar · sidebar · header · footer · menu · search · search_results · notifications · user_menu |
| Auth & onboarding | login · signup · logout · forgot_password · reset_password · onboarding · email_verification · sso · invite |
| Commerce - browse | products · product · pdp · plp · catalog · collection · category |
| Commerce - buy | cart · checkout · payment · order_confirmation · order_history · wishlist · reviews · shipping · returns |
| Account & settings | account · profile · settings · billing · subscription · plan · usage · security · preferences · team · members · integrations · api_keys |
| Content & support | docs · documentation · help_center · support · knowledge_base · tutorials · guides · community · forum |
| Communication | feed · messages · chat · inbox · comments |
| UI surfaces | modal · dialog · banner · toast · widget · embed · popup |
| Channels | email · push_notification · sms |
| System | error_page · not_found |
// A signup - acquisition stage, marketing site, auth feature
Sumidata.push('event', ['signup', {
_category: 'acquisition',
_feature: 'auth',
_surface: 'marketing_site'
}])
// A checkout step - revenue stage, checkout feature, on the checkout page
// The order itself is recorded with push('purchase', [order]) - section 05
Sumidata.push('event', ['checkout_started', {
_category: 'revenue', _feature: 'checkout', _surface: 'checkout'
}])skipped array - { "index": i, "name": "…", "errors": { "_surface": ["Invalid surface. Allowed: …"] } } - while every valid event in the same batch is still stored and the response stays 200 with { "status": "ok", "skipped": [...] }. A { "status": "quota_exceeded" } response means nothing from the request was stored (see section 05). Keep your taxonomy stable - every event that carries these keys rewrites the dictionary entry for its name (the most recent one wins), and all events with that name, earlier ones included, resolve against that entry at query time.07Payload budget
Practical budgets - they aren't enforced server-side today, but staying under them keeps reports fast.
There is no hard 400 on payload size or key count at ingest today. Keep each event under these targets so ClickHouse JSON extraction at query time stays cheap and the UI renders payloads cleanly:
- ≤ 32 top-level keys per payload.
- ≤ 8 KB of JSON per payload.
- ≤ 64 characters per event name, snake_case by convention.
If you need to stash large blobs (full API responses, rendered documents), store them somewhere else and put the URL or hash on the event.
08Server-side ingest
For events that don't originate in a browser, post to the same SDK ingest endpoint with source: 'backend'.
Not every event fires from the browser. Stripe webhooks, background-job results, emailed receipts - all of these originate on your server. Post them to the same POST /sdk/ingest endpoint the SDK uses, with source: 'backend':
curl "https://api.sumidata.io/sdk/ingest" \
-X POST \
-H "Content-Type: application/json" \
-d '{"projectId":"YOUR_PROJECT_ID","deviceId":"00000000-0000-0000-0000-000000000000","externalUserId":"user_7e2a9c","source":"backend","events":[{"name":"subscription_renewed","_category":"retention","_feature":"billing","_surface":"app"}]}'An event without orderId is not a purchase. A renewal or any other payment you want in revenue is a purchase: send it as an order with orderId and totalAmount (see section 05).
See Server-side ingest for the full API reference - request body, error codes, attribution fallback, and AI-agent spec.
