Skip to main content

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",
"email": "[email protected]",
"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​

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

Reserved by Joryio (stored at root level):

  • userId - Unique user identifier
  • email - Email address
  • phone - Phone number
  • externalId - External system ID
  • createdAt - User creation timestamp
  • updatedAt - Last update timestamp

Custom Attributes​

Any other properties you define (stored in attributes object):

  • firstName, lastName, name
  • plan, subscription Tier
  • company, industry, jobTitle
  • totalPurchases, lifetimeValue
  • preferences, 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"
}
Use ISO 8601 Format

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:

  1. Declared - somebody set the type on the Attributes page (or, for event properties, on the Events page). This always wins.
  2. A standard commerce field - price, total, revenue, quantity, tax, shipping, discount, subtotal and value are treated as numbers by default, whether you send 12.34 or "12.34". Identifiers such as order_id and sku are deliberately NOT in that list: they often look numeric, and typing them as numbers is how an order like 007 stops matching.
  3. Inferred from your data - the most common type across a sample, per event. Product Added.step and Onboarding Step Tapped.step are 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).

note

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:

filtera contact whose step is "invite"
step > 1does not match
step <= 1does not match
step = 1does not match
step is not 1matches
step existsmatches

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
}
Attributes are Merged

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:

MethodDescription
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.
Use Cases

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, id
  • email
  • phone
  • createdAt, created_at
  • updatedAt, 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​

LimitValue
Max attributes per user200
Max attribute name length100 characters
Max string value length10,000 characters
Max array length100 items
Max nesting depth5 levels

Troubleshooting​

Attributes Not Showing​

  1. Check attribute name spelling
  2. Verify user is identified
  3. Enable debug mode to see API calls
  4. Check for API errors in response

Attribute Not Updating​

  1. Ensure using merge (not replace)
  2. Check data type matches
  3. Verify attribute name is correct
  4. Check rate limits

Segmentation Not Working​

  1. Verify attribute exists for users
  2. Check attribute data type
  3. Test filter logic with known users
  4. Ensure attribute values match filter exactly

Next Steps​