Skip to main content

Tracking Events

Events are the raw material for everything in Joryio: segments, journey triggers, campaign filters, and analytics all run on the events your apps send. This guide covers the conventions and mechanics that are shared across all SDKs - Web, iOS, Android, and React Native. For installation and platform-specific setup, see the individual SDK pages.

How tracking works​

Every SDK follows the same pipeline:

  1. You call track(eventName, properties) in your app.
  2. The SDK queues the event locally (with offline persistence) and sends it in batches - by default every 5 seconds or every 50 events, whichever comes first.
  3. The batch is delivered to POST /v1/track/batch, authenticated with your app's SDK key (format jry_sdk_<platform>_<random>, one per app - see Apps Overview).
  4. The server resolves the user (anonymous or identified), stores the events, and fans them out to segments, journey triggers, and analytics.

A batch may contain at most 500 events. Timestamps are set by the SDK at call time, in UTC with millisecond precision on every SDK (ISO-8601 such as 2026-10-07T09:15:02.123Z, or epoch milliseconds), so two calls made within the same second keep their order; the server accepts epoch milliseconds or ISO-8601 strings, and falls back to server time if the timestamp is missing or invalid (so a bad device clock never causes a rejected event).

Sessions​

A session starts only through the SDK's normal lifecycle - the app coming to the foreground, or a page load on the web - and that always sends a Session Start event. Reading the session (its id or details) and in-app message syncs have no side effects: they never start a session or extend one, so checking the session from your code cannot create sessions without a Session Start.

Event naming conventions​

An event name is a string of up to 255 characters. Beyond that, Joryio does not enforce a format - but consistency matters, because event names are how you find events later in segment builders, journey triggers, and the Event Explorer.

Recommendations:

  • Use Title Case with spaces. Joryio's built-in events follow the industry-standard e-commerce event taxonomy (Object + past-tense Action, Title Case): Product Viewed, Product Added, Checkout Started, Order Completed from the store integrations, so Title Case custom events (Trial Started, Signup Completed) keep the catalog uniform. Snake case also works - but pick one convention and stick to it; mixing creates duplicate-looking events.
  • Name the action, not the UI. Order Completed survives a redesign; Green Button Clicked does not.
  • Use object + past-tense verb. Subscription Upgraded, Video Played, Search Performed.
  • Keep variability in properties, not names. One Product Viewed event with a category property beats fifty Viewed <Category> events - segments and triggers match on the event name first, then filter on properties.
  • Avoid overly generic events. Clicked with no properties tells you nothing you can act on.

Event names are case-sensitive: order_placed and Order_Placed are two different events.

Event names starting with canvas., message. or in_app. (in any case) are reserved for Joryio's own journey and messaging events, and an event you send with one is refused - see Reserved event names.

Properties and data types​

Properties are a JSON object attached to each event. Any JSON value is accepted:

TypeExampleNotes
String"currency": "USD"Also use for dates, as ISO-8601 strings
Number"total": 149.99Integers and floats
Boolean"first_order": true
Array"item_ids": ["SKU-1", "SKU-2"]
Object"shipping": { "method": "express" }Nested objects allowed

Server-side limits per event:

LimitValue
Event name length255 characters
Total properties size (serialized JSON)50 KB
Top-level property keys200
Nesting depth5 levels
Events per batch request500

Size is the length of the properties serialized as JSON, in characters (51,200), and nesting depth counts the properties object itself as level 1. The limits apply to the properties your app sends - the $ properties the server adds do not count against them. An event over a limit, or with an empty or over-long name, is refused for good: the batch response lists it in rejectedIndices (see Batch responses) and the rest of the batch is stored. The SDKs check the same limits when you call track() and drop such an event there, with a log line.

NaN, Infinity and -Infinity are not JSON numbers. Every SDK drops a property or attribute whose value is one of them when you make the call, logs a warning, and sends the rest of the call without that key - nothing throws into your code, nothing is stored on the device, and one bad value never holds back other writes. The same applies to the custom properties of the e-commerce trackers. On the web, an attribute value that cannot be serialized (a circular object, a BigInt, a function) is dropped the same way.

Events exceeding these limits are rejected. Property names follow the same advice as event names: pick a convention (product_id, not sometimes productId) and keep types stable - an order_id that is a string in one event and a number in another makes filtering unreliable.

Properties prefixed with $ (such as $platform, $session_id, $app_id) are added automatically - some by the SDKs ($device_id, device data on Session Start) and some by Joryio's ingestion pipeline when the event is received ($app_id, $session_id, $is_identified). Treat that prefix as reserved and do not use it for your own properties.

User attribute key rules​

Attribute keys (the object you pass to setAttributes / setAttribute) have two constraints beyond the property advice above, and a key that breaks either one is dropped - the rest of the call is stored normally:

Not allowedWhy
A . anywhere in the keyA dot is read as a PATH separator, not a character. "profile.email" would store nested profile: { email }, so a segment on profile.email would match nothing and you would have no way to see why.
A leading $Reserved, exactly as for event properties above.
An empty keyNothing to store.

Keys are dropped rather than renamed on purpose: renaming profile.email to profile_email would report success while putting your data somewhere you never query. The dropped keys are named in the server log, and the SDKs warn about them in your console during integration.

identify vs track​

Send phone numbers with + and the country code

Always send phone numbers in full international format (E.164): +, the country code, then the number - for example +972501234567. A number without + is read using the workspace's default country for phone numbers; if none is set, or the digits can be read two ways, it is saved as unverified and not used for messages. How phone numbers are handled

The two core calls do different jobs:

  • identify(userId) says who the user is. It binds the current device/session to your stable user ID and merges any anonymous history into that profile. Attributes set with setAttributes describe the user (email, plan, name) and live on the profile.
  • track(eventName, properties) says what happened. Properties describe the event, not the user, and are immutable once recorded.

Rules of thumb:

  • Call identify as early as you know the user - at login, and on app start if a session is restored. Use the same user ID on every platform so web and mobile activity land on one profile (see Multi-Platform Tracking).
  • Before identify, events are tracked under an anonymous ID. When you later identify, the server merges the anonymous history into the identified profile, so pre-signup events (first visit, attribution) are not lost.
  • There is no separate alias(userId) call: identify(userId) both links the anonymous history and names the person. To record the person's IDs in other systems (a CRM ID, a loyalty number), use addAlias(label, name) - it works before login too, and the aliases move to the identified profile when the visitor identifies.
  • Put per-occurrence data in event properties (total, coupon), and durable facts about the person in attributes (plan, lifetime_value).
  • Call reset() on logout so the next user on the device does not inherit the profile.

The same event in every SDK​

The track call is intentionally identical in shape across SDKs. Here is the same Order Completed event on all four platforms.

import JoryioSDK from '@joryio/web-sdk';

const joryio = new JoryioSDK({ sdkKey: 'jry_sdk_web_...' });

joryio.track('Order Completed', {
orderId: 'ORD-2024-001',
total: 149.99,
currency: 'USD',
item_count: 3,
coupon: 'SAVE10',
});

Because the name and properties are identical, one segment condition or journey trigger matches the event no matter which platform it came from.

For standard e-commerce activity (product views, carts, checkout, orders), prefer the SDKs' built-in e-commerce trackers - they emit the standardized event names Joryio's e-commerce features expect. See the cross-SDK E-Commerce Tracking guide.

What happens server-side​

Once a batch is accepted, each event:

  • Is stored in the analytics store, attached to the resolved user profile (identified or anonymous).
  • Is evaluated against journey triggers. A journey whose entry trigger matches the event name (and property filters) enrolls the user immediately - this is how "abandoned cart" or "welcome" journeys start.
  • Feeds segments. Event-based segment conditions ("performed Order Completed in the last 30 days") update from the event stream, and campaigns targeting those segments pick up the change.
  • Appears in analytics - the Event Explorer, funnels, and per-app analytics.
  • Can update the profile. Some events have server-managed side effects: for example, Session Start events refresh the user's device record and set profile attributes such as country (derived from the request IP).

Two server behaviors worth knowing:

  • Bot flagging. Requests from known bot user agents are flagged (events are tagged, and analytics exclude them); profile-mutating calls such as identify and setAttributes are skipped for bots. Traffic from headless browsers in your own test automation may therefore not create profiles.
  • Lenient validation. Unknown extra fields in the payload are stripped rather than rejected, so an SDK version mismatch does not drop your events.

Server contract for SDK requests​

The SDKs' queueing and retry logic is built on the server behaviour below. If you call these endpoints yourself (a custom integration or a proxy), handle the responses the same way.

Batch responses​

POST /v1/track/batch accepts at most 500 events and a request body of at most 1 MB.

ResponseMeaningWhat to do
200 with success: trueEvery event was storedDelete the batch
200 with success: falseSome or all events were not stored. Two lists give their 0-based positions in the batch you sent: failedIndices (a failure on our side) and rejectedIndices (refused for good - over the per-event limits or an invalid name). partial: true means the others were stored; failed counts both listsRetry only the events in failedIndices - retrying a stored event is safe, clientEventId deduplicates it. Drop the events in rejectedIndices: sending them again can never succeed
400The body failed validation (for example more than 500 events, or a malformed event)Do not resend it unchanged - split the batch
413The body is over 1 MBSplit the batch
401The SDK key is invalid or inactive, or (with SDK Authentication on) a request that names a userId has a missing, expired or mismatched X-Joryio-Auth token - body error: "sdk_authentication_error" and a reason: missing, expired, invalid or sub_mismatch (the token belongs to another user). Anonymous requests need no tokenRefresh the token and retry. Events queued for a user who is no longer the signed-in one cannot get a token again: on any reason (missing, expired, invalid or sub_mismatch) drop them. Only the signed-in user's failures reach your SDK Authentication error handler, so a previous user's leftovers never sign out the person using the app now
402 with code: "SDK_INGESTION_SUSPENDED"Event ingestion is paused for the accountStop sending for this session, keep the writes, and do not count the 402 toward their retry limit; withdrawals still go, even when they are queued behind a refused write
403The app's identity policy refused the request: Strict SDK identity refuses anonymous writes (code: "SDK_STRICT_IDENTITY_REQUIRED"), and SDK Authentication refuses an anonymousId that belongs to an identified user (code: "SDK_ANONYMOUS_ID_IDENTIFIED"). Withdrawals are the exception to Strict SDK identity - see Withdrawals are always deliveredStop sending for this session
429Rate limited. The Retry-After header gives the wait in seconds (also retryAfter in the body)Wait, then retry
5xxServer errorRetry with backoff

A partial failure looks like this:

{ "success": false, "partial": true, "processed": 47, "failed": 3, "failedIndices": [3, 17], "rejectedIndices": [21], "error": "failed to track: 2 (retry), rejected: 1 (do not retry)" }

Other write responses​

/v1/identify, /v1/attributes, /v1/alias/add, /v1/subscriptions/channel, /v1/subscriptions/group, /v1/push/register and /v1/push/unregister have no partial result, so the status code alone says what happened. Only the batch route above answers a failure with 200.

ResponseMeaningWhat to do
200 with success: trueDoneAcknowledge
400The request is wrong as sent - for example no userId or anonymousId, no valid attributes, web push without a subscription, an endpoint that is not allowed, or an iOS push registration whose token is not an APNs device token (an FCM token, for example - register FCM tokens on Android only)Do not retry it - drop it and log
401, 402, 403, 429As in the batch table aboveAs above
404Subscriptions, older servers only: no profile for that userId or anonymousId yet. The current server creates the missing profile instead, the same way /v1/alias/add doesRetry after your queued events are sent, a few times at most
403 with code: "verified_identity_required"SDK endpoints only (/v1/subscriptions/channel, /v1/subscriptions/group): re-subscribing a person who explicitly opted out (own unsubscribe or list opt-out, a STOP, an opt-out on their email or phone - another contact's included) from a subject that is not verified - a userId without SDK Authentication, or an anonymousId that belongs to an identified person. A first sign-up is accepted; a list that requires double opt-in answers pending: true and sends the confirmation email. Unsubscribing is always allowedDo not retry - enable SDK Authentication, or re-subscribe from your server with the API
409 with code: "ALIAS_TAKEN"/v1/alias/add only: the alias belongs to another profileDo not retry
400 with code: "ALIAS_LABEL_RESERVED"/v1/alias/add and the aliases API: the labels shopify_customer, woocommerce_customer and magento_customer are reserved - your store integration writes themDo not retry
503 with code: "SDK_TEMPORARILY_UNAVAILABLE"A failure on our sideKeep the write and retry with backoff

How the SDKs count retries​

Every SDK counts a write's failed attempts the same way. Being offline, a timeout, 402, 429 and 5xx never count: the write waits and is sent once the cause is gone. A 404 (older servers, no profile yet) counts toward a limit of 5, and any other retryable answer - 401 for the signed-in user, or success: false - toward a limit of 10, after which the write is dropped. 400, 403, 413 and 422 drop it at once.

Aliases for anonymous visitors​

POST /v1/alias/add takes { "anonymousId", "userId", "aliasLabel", "aliasName" }, where userId is optional. Without userId the alias is attached to the anonymous profile; when the visitor identifies, the alias moves to the identified profile together with the rest of the anonymous history.

An alias (aliasLabel + aliasName) belongs to one profile per workspace. If it already belongs to a different profile it is not moved: the response is 409 with code: "ALIAS_TAKEN", and retrying will not help. SDK Authentication works the same way here as everywhere: an anonymous visitor has no token to show, so an anonymous addAlias is accepted; once a userId is sent it must be bound to the token, as on identify. Only Strict SDK identity refuses anonymous aliases. The labels shopify_customer, woocommerce_customer and magento_customer are reserved: they hold the customer ids of a connected Shopify, WooCommerce or Magento store and only the store integration writes them, so addAlias with one of them is refused with 400 and code: "ALIAS_LABEL_RESERVED".

In-app identity and click tracking​

/v1/in-app/sync and /v1/in-app/track take userId (once the visitor is identified) and anonymousId as separate fields. The server evaluates the identified profile and, while an identify is still on its way, the anonymous profile. Never put an email address in externalId.

action on /v1/in-app/track comes from a fixed vocabulary (displayed, clicked, dismissed, converted and a few synonyms); an unknown action is not recorded and the response says reason: "unknown_action". To record which button or link was clicked, send its name in elementId (up to 128 characters) next to action: "clicked". It is stored on the in_app.clicked event, and the campaign's click report groups by it.

Push tracking routes​

Push delivery receipts (POST /track/push/delivered/<trackingId>) and clicks (GET /track/push/click/<trackingId>) are served at the root of the API host, not under /api. The SDKs build them from the scheme and host of the configured endpoint. The same routes also answer under /api/track/push/..., so a build that kept the /api path still reports.

The opt-out record​

When a person opts out of tracking (optOut() in every SDK), the SDK writes the profile attribute tracking_opted_out: true, and optIn() sets it back to false. It is a boolean attribute like any other: filter on it in a segment ("tracking_opted_out is true") to keep opted-out people out of an audience, or to count them. It is a record of the decision, not the enforcement - the SDK stops sending on its own - and SDK versions before 1.3.0 sent it as $tracking_opted_out, which the server now stores under the same tracking_opted_out key.

Withdrawals are always delivered​

Four writes only ever reduce what is collected or sent: a channel subscription set to unsubscribed, a group unsubscribe, the opt-out record tracking_opted_out: true, and /v1/push/unregister. The SDKs keep them queued and retry them when the person has opted out, when tracking consent is denied or pending, and after wipeData(). When the on-device queue is full, the oldest write that is not a withdrawal is dropped first. The server accepts them in the same situations:

  • No consent or opt-out check on the server refuses them - a person who opted out can still unsubscribe.
  • Strict SDK identity does not refuse them from an anonymous visitor. Only the opposite direction - subscribing, tracking_opted_out: false, or the record sent together with any other attribute - still needs an identified, token-verified user.
  • A paused account does not refuse them: while event ingestion is paused (402, SDK_INGESTION_SUSPENDED) the server still accepts them and the SDKs keep sending them, so a person's "no" is recorded whatever the account's billing state.
  • An expired token still works for them. With SDK Authentication on, a withdrawal that names a userId is accepted with that user's token for up to 30 days after the token expired. The signature and the subject (sub) are checked as always; only the expiry is relaxed, and only for these four writes - everything else needs an unexpired token. This is what lets a sign-out lose nothing: when a user signs out (reset()), the SDK keeps that user's token - encrypted where the platform has secure storage (the iOS Keychain, Android encrypted preferences; on the web, local storage next to the queue) - together with their queued writes, and deletes it once the last of them is delivered or dropped, so an unsubscribe tapped just before signing out still arrives, even after the app restarts.
  • Signed in while tracking is refused: identify(userId) while consent is denied or the person has opted out sends nothing, tracks nothing and writes no profile, but the SDK keeps the userId on the device for one purpose - a withdrawal made then carries it, so the person's own profile is unsubscribed rather than a new anonymous one. When consent is granted or the person opts back in, the normal identify runs. reset() and wipeData() forget it.
  • /v1/push/unregister needs no X-Joryio-Auth token even when the body names a userId: what is removed is the push token or web push endpoint, so the device of a user who signed out stops receiving pushes even though their token is gone.
  • The one rule that still applies: with SDK Authentication on, an anonymousId that belongs to an identified user cannot withdraw that user's consent without their token (403, SDK_ANONYMOUS_ID_IDENTIFIED). A subscription write that names a userId still needs that user's token, as everywhere.

Verifying and debugging​

Check that an event arrived​

  1. Trigger the event in your app.
  2. In the Joryio dashboard, open Analytics → Event Explorer. Filter by event name and you should see the event within seconds of the SDK flushing its batch (default flush interval: 5 seconds).
  3. For a per-app view, open Settings → Apps and click View Analytics on the app - it shows total events, the last event timestamp, and top event names, which quickly confirms whether anything from that SDK key is arriving.

If events do not show up​

  • Force a flush. Events are batched; call flush() (available in every SDK) to send immediately instead of waiting for the timer.
  • Enable debug logging. Every SDK has an enableDebug option that logs each queued event and each network request with its response.
  • Check the SDK key. It must match the app's platform - jry_sdk_web_... for the Web SDK, jry_sdk_ios_... for iOS, jry_sdk_android_... for Android. The Android SDK (including React Native and Unity on Android) tells the server its platform, and a key of another platform's app is refused with 401 and code: "SDK_KEY_PLATFORM_MISMATCH". A regenerated key invalidates the old one immediately.
  • Check the app is active under Settings → Apps - events sent with the key of a deactivated app are rejected.
  • Watch the network response. A batch response includes success and, on partial failure, failedIndices (not stored, will be retried) and rejectedIndices (refused for good). Oversized properties (over 50 KB, over 200 keys, or nesting deeper than 5 levels) are the usual cause of rejected events.
  • Web only: the SDK tracking endpoints allow any origin (Access-Control-Allow-Origin: *), so CORS errors usually mean a wrong apiEndpoint or a blocking browser extension - check the console. Also confirm the site runs over HTTPS so localStorage-based queue persistence works.
  • Testing from automation? Remember bot flagging above - verify with a real browser or device.