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:
- You call
track(eventName, properties)in your app. - 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.
- The batch is delivered to
POST /v1/track/batch, authenticated with your app's SDK key (formatjry_sdk_<platform>_<random>, one per app - see Apps Overview). - 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 Completedfrom 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 Completedsurvives a redesign;Green Button Clickeddoes not. - Use object + past-tense verb.
Subscription Upgraded,Video Played,Search Performed. - Keep variability in properties, not names. One
Product Viewedevent with acategoryproperty beats fiftyViewed <Category>events - segments and triggers match on the event name first, then filter on properties. - Avoid overly generic events.
Clickedwith 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:
| Type | Example | Notes |
|---|---|---|
| String | "currency": "USD" | Also use for dates, as ISO-8601 strings |
| Number | "total": 149.99 | Integers 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:
| Limit | Value |
|---|---|
| Event name length | 255 characters |
| Total properties size (serialized JSON) | 50 KB |
| Top-level property keys | 200 |
| Nesting depth | 5 levels |
| Events per batch request | 500 |
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 allowed | Why |
|---|---|
A . anywhere in the key | A 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 key | Nothing 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
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 withsetAttributesdescribe 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
identifyas 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), useaddAlias(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.
- Web (JS)
- iOS (Swift)
- Android (Kotlin)
- React Native
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',
});
Joryio.shared.track("Order Completed", properties: [
"orderId": "ORD-2024-001",
"total": 149.99,
"currency": "USD",
"item_count": 3,
"coupon": "SAVE10"
])
Joryio.track("Order Completed", mapOf(
"orderId" to "ORD-2024-001",
"total" to 149.99,
"currency" to "USD",
"item_count" to 3,
"coupon" to "SAVE10"
))
import Joryio from '@joryio/react-native-sdk';
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 Completedin 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 Startevents refresh the user's device record and set profile attributes such ascountry(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
identifyandsetAttributesare 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.
| Response | Meaning | What to do |
|---|---|---|
200 with success: true | Every event was stored | Delete the batch |
200 with success: false | Some 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 lists | Retry 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 |
400 | The body failed validation (for example more than 500 events, or a malformed event) | Do not resend it unchanged - split the batch |
413 | The body is over 1 MB | Split the batch |
401 | The 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 token | Refresh 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 account | Stop 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 |
403 | The 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 delivered | Stop sending for this session |
429 | Rate limited. The Retry-After header gives the wait in seconds (also retryAfter in the body) | Wait, then retry |
5xx | Server error | Retry 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.
| Response | Meaning | What to do |
|---|---|---|
200 with success: true | Done | Acknowledge |
400 | The 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, 429 | As in the batch table above | As above |
404 | Subscriptions, 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 does | Retry 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 allowed | Do 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 profile | Do 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 them | Do not retry |
503 with code: "SDK_TEMPORARILY_UNAVAILABLE" | A failure on our side | Keep 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
userIdis 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 theuserIdon 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 normalidentifyruns.reset()andwipeData()forget it. /v1/push/unregisterneeds noX-Joryio-Authtoken even when the body names auserId: 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
anonymousIdthat belongs to an identified user cannot withdraw that user's consent without their token (403,SDK_ANONYMOUS_ID_IDENTIFIED). A subscription write that names auserIdstill needs that user's token, as everywhere.
Verifying and debugging
Check that an event arrived
- Trigger the event in your app.
- 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).
- 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
enableDebugoption 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 with401andcode: "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
successand, on partial failure,failedIndices(not stored, will be retried) andrejectedIndices(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 wrongapiEndpointor 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.