Custom User Attributes
User attributes are properties that describe your users. Use them to personalize messages, create segments, and analyze your user base.
What are User Attributes?
Attributes are key-value pairs attached to user profiles:
{
"userId": "user_123",
"attributes": {
"firstName": "John",
"lastName": "Doe",
"plan": "premium",
"signupDate": "2024-01-15",
"totalPurchases": 5,
"lastLoginDate": "2024-01-20",
"preferences": {
"newsletter": true,
"notifications": "email"
}
}
}
Setting Attributes
Via SDK (Client-Side)
import JoryioSDK from '@joryio/web-sdk';
const joryio = new JoryioSDK({ sdkKey: 'jry_sdk_web_...' });
// Identify a user
joryio.identify('user_123');
// Set attributes separately
joryio.setAttributes({
firstName: 'John',
lastName: 'Doe',
plan: 'premium',
signupDate: '2024-01-15'
});
// Update attributes later
joryio.setAttributes({
plan: 'enterprise',
lastUpgrade: new Date().toISOString()
});
Via API (Server-Side)
// Create or update user with attributes
fetch('https://api-eu1.joryio.com/api/users/track', {
method: 'POST',
headers: {
'Authorization': 'Bearer jry_live_your_api_key',
'Content-Type': 'application/json'
},
body: JSON.stringify({
attributes: {
externalId: 'user_123',
attributes: {
firstName: 'John',
plan: 'premium',
totalOrders: 10,
lifetimeValue: 1250.00
}
}
})
});
Standard vs Custom Attributes
Standard Attributes
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
Reserved by Joryio (stored at root level):
userId- Unique user identifieremail- Email addressphone- Phone numberexternalId- External system IDcreatedAt- User creation timestampupdatedAt- Last update timestamp
Custom Attributes
Any other properties you define (stored in attributes object):
firstName,lastName,nameplan,subscription Tiercompany,industry,jobTitletotalPurchases,lifetimeValuepreferences,settings- And anything else you need!
Data Types
Attributes support multiple data types:
String
{
firstName: "John",
plan: "premium",
country: "US"
}
Number
{
age: 30,
totalPurchases: 15,
lifetimeValue: 1250.50
}
Boolean
{
emailVerified: true,
newsletter: false,
isPremium: true
}
Date/Timestamp
{
signupDate: "2024-01-15",
lastLoginDate: "2024-01-20T10:30:00Z",
trialEndsAt: "2024-02-15T23:59:59Z"
}
For dates, use ISO 8601 format: YYYY-MM-DDTHH:mm:ssZ
Array
{
tags: ["vip", "early-adopter"],
interests: ["technology", "sports", "music"],
purchasedProducts: ["product_1", "product_2"]
}
Object/Nested
{
preferences: {
theme: "dark",
language: "en",
notifications: {
email: true,
sms: false,
push: true
}
},
address: {
street: "123 Main St",
city: "San Francisco",
state: "CA",
zip: "94102"
}
}
How a type is decided
Joryio never guesses from a field's NAME. It resolves a type in this order:
- Declared - somebody set the type on the Attributes page (or, for event properties, on the Events page). This always wins.
- A standard commerce field -
price,total,revenue,quantity,tax,shipping,discount,subtotalandvalueare treated as numbers by default, whether you send12.34or"12.34". Identifiers such asorder_idandskuare deliberately NOT in that list: they often look numeric, and typing them as numbers is how an order like007stops matching. - Inferred from your data - the most common type across a sample, per
event.
Product Added.stepandOnboarding Step Tapped.stepare typed separately, so a name reused for two different things is not a conflict.
When values disagree
If more than about 20% of the values are a different type, the attribute is
shown as Mixed with the counts (for example string 994 - number 448).
Joryio does not pick a side. It compares the values as text, which never gives
a wrong answer - only a coarse one - and asks you to set the type.
Setting the type fixes the comparison for values already stored: a field
declared as a number compares "12.34" and 12.34 as the same number, so
price > 100 stops comparing text (where "90" > "100" is true).
Declaring a type changes how values are COMPARED. It does not rewrite what was stored - your historical data keeps exactly the values you sent.
What setting a type costs
Setting a type is a decision about how to treat a disagreement, not a way to end one. Values that cannot be read as the type you chose stop matching filters - and they drop out of both sides of a range.
Say step holds the numbers 1, 2, 3 on most events and the words
"invite" and "photo" on a few, and you set it to Number:
| filter | a contact whose step is "invite" |
|---|---|
step > 1 | does not match |
step <= 1 | does not match |
step = 1 | does not match |
step is not 1 | matches |
step exists | matches |
So two segments meant to divide your audience in half - step > 1 and
step <= 1 - leave those contacts in neither. A split test built that way
silently loses them.
Numbers written as text are fine: "12.34" reads as 12.34 and matches
normally. Only values with no reading as the chosen type are affected.
The attribute list shows how many values are affected next to the type, so the disagreement stays visible after you decide. The fix is at the source - send the value in one consistent form - because nothing here rewrites data you have already sent.
Standard fields are typed for you
Some fields have a meaning we define rather than one you choose, and their type is fixed:
- Platform attributes the system writes -
lastEventTime(a date, even though it is stored as text),total_purchases,ml_churn_score,is_purchase_overdue. - Standard commerce properties -
price,total,revenue,quantity,tax,shipping,discount,subtotal,value.
You do not need to set these, and you cannot change them. That is deliberate: if the same standard field could mean a number in one workspace and text in another, nothing built on top of it - reports, recommendations, revenue - could rely on it.
If one of these names means something different in your data, send it under a
different name. A price that is really a label should be price_label.
Everything outside these lists is yours to declare.
Common Attribute Examples
E-commerce
joryio.setAttributes({
// Account info
accountType: "premium",
memberSince: "2024-01-15",
// Purchase history
totalOrders: 15,
lastOrderDate: "2024-01-20",
lifetimeValue: 2500.00,
avgOrderValue: 166.67,
// Preferences
favoriteCategory: "electronics",
preferredShipping: "express",
// Engagement
cartAbandoned: false,
wishlistItems: 5,
reviewsWritten: 3
});
SaaS
joryio.setAttributes({
// Subscription
plan: "pro",
billingCycle: "monthly",
subscriptionStatus: "active",
trialEndsAt: "2024-02-15T23:59:59Z",
mrr: 99,
// Usage
loginCount: 45,
lastLoginDate: "2024-01-20",
featuresUsed: ["export", "api", "integrations"],
apiCallsThisMonth: 10500,
storageUsedGB: 15.5,
// Team
teamSize: 8,
role: "admin",
companyName: "Acme Corp"
});
Media/Content
joryio.setAttributes({
// Subscription
subscriptionTier: "premium",
contentAccessLevel: "unlimited",
// Engagement
articlesRead: 125,
videosWatched: 45,
podcastsListened: 30,
favoriteTopics: ["technology", "business"],
// Behavior
avgSessionDuration: 25.5,
lastVisit: "2024-01-20T14:30:00Z",
deviceType: "mobile"
});
Updating Attributes
Merge vs Replace
Merge (default) - Updates specified fields:
// Initial attributes
{
firstName: "John",
plan: "free",
country: "US"
}
// Update attributes (merges with existing)
joryio.setAttributes({
plan: "premium"
});
// Result (plan updated, others preserved)
{
firstName: "John",
plan: "premium", // Updated
country: "US" // Preserved
}
Calling setAttributes() merges new attributes with existing ones. Existing attributes not mentioned in the call are preserved.
Increment/Decrement
For counters, increment instead of getting current value:
// Bad: Race condition possible
const current = await getUserAttribute('loginCount');
joryio.setAttributes({ loginCount: current + 1 });
// Good: Atomic increment
joryio.incrementAttribute('loginCount', 1);
// Decrement
joryio.incrementAttribute('creditsRemaining', -10);
Array Attributes
You can store and manipulate arrays as attribute values:
// Set array attribute
joryio.setAttributes({
tags: ['vip', 'early-adopter'],
interests: ['technology', 'sports']
});
// Add to array (only adds if value doesn't already exist)
joryio.addToArray('tags', 'premium');
// Result: ['vip', 'early-adopter', 'premium']
// Add duplicate (no-op, prevents duplicates)
joryio.addToArray('tags', 'vip');
// Result: ['vip', 'early-adopter', 'premium'] (unchanged)
// Remove from array
joryio.removeFromArray('tags', 'early-adopter');
// Result: ['vip', 'premium']
Method Details:
| Method | Description |
|---|---|
addToArray(key, value) | Adds value to array if it doesn't already exist (prevents duplicates). Creates new array if attribute doesn't exist. |
removeFromArray(key, value) | Removes all instances of value from array. Warns if attribute isn't an array. |
Array attributes are perfect for:
- User tags:
['vip', 'trial', 'beta-tester'] - Interests:
['sports', 'technology', 'fashion'] - Purchased products:
['prod_123', 'prod_456'] - Feature flags:
['feature-a', 'feature-b'] - Roles:
['admin', 'editor']
Using Attributes for Segmentation
Create segments based on attributes:
Simple Attribute Filter
plan equals "premium"
Numeric Comparison
lifetimeValue >= 1000
totalPurchases > 5
age between 25 and 45
Date-Based Filters
signupDate is within last 30 days
trialEndsAt is within next 7 days
lastLoginDate is more than 14 days ago
String Matching
email contains "@company.com"
country equals "US"
firstName exists
plan is not "free"
Array Filters
tags contains "vip"
interests contains any of ["technology", "business"]
Personalizing Messages
Use attributes in campaign content:
Email Personalization
Hi {{firstName}},
Your {{plan}} plan includes these benefits:
...
{{#if trialEndsAt}}
Your trial ends on {{trialEndsAt}}. Upgrade now!
{{/if}}
Dynamic Content
{{#if plan == "free"}}
<p>Upgrade to Premium for more features!</p>
{{else}}
<p>Thanks for being a {{plan}} member!</p>
{{/if}}
Conditional Blocks
{{#if totalPurchases > 10}}
<div class="vip-offer">
As a valued customer, here's an exclusive offer...
</div>
{{/if}}
Attribute Best Practices
1. Use Descriptive Names
Good:
{
subscriptionTier: "premium",
lifetimeValueUSD: 1250.00,
emailVerified: true
}
Bad:
{
sub: "p",
ltv: 1250,
verified: 1
}
2. Be Consistent
Use the same naming convention:
Good (camelCase):
{
firstName: "John",
lastName: "Doe",
signupDate: "2024-01-15"
}
Bad (mixed):
{
first_name: "John",
LastName: "Doe",
"signup-date": "2024-01-15"
}
3. Use Appropriate Types
Good:
{
age: 30, // Number
isPremium: true, // Boolean
signupDate: "2024-01-15", // ISO Date String
tags: ["vip", "beta"] // Array
}
Bad:
{
age: "30", // String instead of number
isPremium: "true", // String instead of boolean
signupDate: 1705276800, // Timestamp instead of ISO
tags: "vip,beta" // String instead of array
}
4. Don't Store Sensitive Data
Never store:
- Passwords or password hashes
- Full credit card numbers
- Social security numbers
- Banking details
- Health information
Safe to store:
- Last 4 digits of card
- Payment method type ("visa", "mastercard")
- Encrypted/hashed tokens
- Public profile information
5. Keep Attribute Names Short
Good: plan, ltv, mrr
Bad: currentSubscriptionPlanTierLevel
Reserved Attribute Names
Avoid using these reserved names:
userId,user_id,idemailphonecreatedAt,created_atupdatedAt,updated_at$app_id,$platform(prefixed with $)
Bulk Updates
Update attributes for multiple users:
// API: Bulk update - each field of POST /users/track accepts an array (attributes: max 1000)
POST /users/track
{
"attributes": [
{
"externalId": "user_1",
"attributes": { "plan": "premium" }
},
{
"externalId": "user_2",
"attributes": { "plan": "enterprise" }
}
]
}
Attribute Limits
| Limit | Value |
|---|---|
| Max attributes per user | 200 |
| Max attribute name length | 100 characters |
| Max string value length | 10,000 characters |
| Max array length | 100 items |
| Max nesting depth | 5 levels |
Troubleshooting
Attributes Not Showing
- Check attribute name spelling
- Verify user is identified
- Enable debug mode to see API calls
- Check for API errors in response
Attribute Not Updating
- Ensure using merge (not replace)
- Check data type matches
- Verify attribute name is correct
- Check rate limits
Segmentation Not Working
- Verify attribute exists for users
- Check attribute data type
- Test filter logic with known users
- Ensure attribute values match filter exactly