SDK versions
This model needs web SDK 1.5.0 or later, and Android, KMP and Flutter 0.4.0 or later. Older SDKs keep working: their events get a category on the server, but they don't send lifecycle events, clicks or session ids on errors.Six categories
Each event belongs to exactly one category. The dashboard uses it for the icon and colour in the Events tab, the user journey and the funnel step picker, and you can filter any analytics view by it.
| Option | Type | Description |
|---|---|---|
| lifecycle | $app_opened, $session_started… | The app or tab opening, going to the background, and sessions starting and ending. Sent by the SDK only. |
| navigation | screen, $deep_link_opened | Screens and pages, and links that opened the app. |
| interaction | $click, filter_applied | Taps, clicks, submits and searches: things a user does on a screen. |
| identity | identify, login_failed | Sign-up, login, logout and identify, including the failures. |
| conversion | order_completed | The business steps you care about. The default for track. |
| technical | crash, api_error | Errors, crashes, ANRs and API calls. Set by the event type, never by track. |
When you don't pass a category
screenis navigation andidentifyis identity.- A
trackname starting withlogin_,signup_,sign_up_,logout,password_,mfa_orotp_is identity. - Any other
trackis conversion. - The list is fixed. The SDKs drop a category that isn't one of the five you can send (all but technical), log a warning and still send the event, so the server works one out. Sent straight to the ingest API, an unknown category rejects that event, and the response lists it under
rejected.
Automatic events
The SDKs send these for you. Their names start with $, which your own events can't use, so they never clash with yours. The dashboard shows them with friendly labels, such as "App opened".
| Option | Type | Description |
|---|---|---|
| $app_opened | launch_type, load_ms | Mobile: cold on the first foreground of the process, with load_ms; warm on every return to the foreground, next to $app_foregrounded. Web: once per page load, cold for the first load in a tab and warm after. |
| $session_started | launch_type | A new session began. |
| $app_backgrounded | screen | The app or tab was hidden. screen is where the user was. |
| $app_foregrounded | background_s | The user came back, and how long they were away. |
| $session_ended | duration_s, screens, end_reason | The session finished. inactive: 30 minutes idle, sent at the next event or foreground and dated at the last activity. closed (web): the tab was closed or reloaded. A same-site link click or back and forward don't end it. |
| $app_updated | previous_version, version | First launch after an app update. |
| $deep_link_opened | url, source | Web: a page load from another site or with utm_ parameters, once per load. url keeps the path and utm_ parameters only; source is utm_source, else the referrer's host. |
| $click | element, label, element_id, screen | Web only: a button, link, role=button element or submit button was clicked. label is the visible text (or aria-label), cut to 64 characters. |
Screen views can also carry load_ms (how long the screen took to show), previous_screen and, on the web, referrer.
What counts toward your plan
- Lifecycle events are free and recorded on every plan, including Free and Starter, so crash journeys show when a session started and ended. They never count toward the analytics meter.
$clickand$deep_link_openedare analytics events: Growth and Scale, and they count toward the meter like your own events.
Clicks on the web
On Growth and Scale, the web SDK records clicks on buttons and links by default. It reads the element type, its id and up to 64 characters of visible text (or the aria-label). It never reads what users type into inputs.
Turn clicks off for a whole project in the dashboard under Settings → SDK (no release needed), or in code. The script tag also accepts data-autocapture-clicks="false".
import { init } from "@codeskop/tracker"; init({ apiKey: "cs_live_pk_…", autocapture: { clicks: false } }); // or, with the script tag:// <script src="…" data-codeskop-key="cs_live_pk_…" data-codeskop-autocapture-clicks="false"></script>To skip one element and everything inside it, add data-codeskop-ignore:
<div data-codeskop-ignore> <button>Not recorded</button></div>On Android, iOS and Flutter, track the taps that matter with the interaction category:
// Mobile has no automatic tap capture. Track the taps that matter:Codeskop.track("delivery_method_selected", mapOf("method" to "pickup"), category = "interaction")Codeskop.shared.track(name: "delivery_method_selected", properties: ["method": "pickup"], category: "interaction")await Codeskop.track('delivery_method_selected', properties: {'method': 'pickup'}, category: 'interaction');The shared envelope
Every event, from every platform, has the same shape. You don't build it yourself (the SDK does), but it helps to know what each event carries when you read exports or webhooks.
{ "event_id": "4b1e…", // unique per event, used to drop duplicates "type": "track", // track, screen, identify, or a technical type "occurred_at": "2026-10-16T09:41:07.120Z", "session_id": "s_8f2c…", // the session it happened in, on every event "seq": 12, // order within the session "user": { "id": "u_1042", // after identify() "anonymous_id": "a_93d1…" // before and after identify(); new after reset() }, "payload": { "name": "login_failed", "category": "identity", "properties": { "method": "password", "reason": "invalid_password", "attempt": 2 } }}Sessions
- One rule on every platform: a session ends after 30 minutes without activity.
- On mobile the session is saved on the device, so reopening the app within 30 minutes continues it.
- On the web a session continues across page loads in the same tab. Closing or reloading the tab ends it with
$session_ended(closed). Following a link within your site, or going back and forward, keeps it going. - Every event carries the
session_id, including errors, crashes and API calls. That is how a journey shows that a user dropped off right after a slow or failing request.
Anonymous users and login
Before identify, events carry an anonymous id kept on the device and no user id. When the user signs in, everything that anonymous id did before is linked to them: the sign-up screens, the failed logins, the first visit. Funnels, paths and the user's journey all show it as one person.
Call reset() when a user logs out. The device gets a new anonymous id, so the next person on a shared phone or computer isn't merged into the last one.
Naming your events
- snake_case, object first, then a verb in the past tense:
order_completed,video_played. - Name the outcome, not the button:
payment_failed, notpay_button_tapped_error. - Send failures as their own events with a
reason. A failure you track is a funnel step you can measure. - Put variable detail in properties, not the name:
plan_selectedwithplan: "growth".
Recommended events
These are suggestions, not built in. Using them makes funnels and journeys read the same across your apps.
| Option | Type | Description |
|---|---|---|
| signup_started / signup_completed | method | identity. method: phone, email, google. |
| login_succeeded | method | identity. Pair it with identify(). |
| login_failed | method, reason, attempt | identity. reason: invalid_password, otp_expired, account_locked, network. |
| otp_requested / otp_failed | channel, reason | identity. channel: sms, whatsapp, email. |
| search_performed | query_length, results | interaction. Send the length, not the search text. |
| delivery_method_selected | method | interaction. method: delivery, pickup. |
| checkout_started | value, currency, items | conversion. |
| payment_failed | payment_method, reason, value | conversion. reason: declined, insufficient_funds, timeout. |
| order_completed | order_id, value, currency, payment_method | conversion. Use value for revenue. |
import { track } from "@codeskop/tracker"; // Category is optional. Without it, Codeskop works one out (see the table above).track("login_failed", { method: "password", reason: "invalid_password" }, { category: "identity" });track("filter_applied", { filter: "price", value: "under_1000" }, { category: "interaction" });track("order_completed", { order_id: "o_77", value: 4200, currency: "KES" }); // conversionCodeskop.track( "login_failed", mapOf("method" to "password", "reason" to "invalid_password"), category = "identity",)Codeskop.track("order_completed", mapOf("order_id" to "o_77", "value" to 4200, "currency" to "KES"))Codeskop.shared.track( name: "login_failed", properties: ["method": "password", "reason": "invalid_password"], category: "identity")await Codeskop.track('login_failed', properties: {'method': 'password', 'reason': 'invalid_password'}, category: 'identity');await Codeskop.track('order_completed', properties: {'order_id': 'o_77', 'value': 4200});Never send
- Names, emails, phone numbers or addresses. Values that look like an email or phone number are replaced with
[REDACTED]. - Passwords, OTP codes, card numbers, tokens or anything typed into a form.
- Free text users wrote, such as search queries or messages. Send a length or a category instead.
- Use your own user ids in
identify, never an email address.