Overview
User traits are simple key-value pairs that describe who a user is. They’re set viaidentify() or in the userTraits config option, stored in the end_users.custom_fields column, and included in the AI system prompt as [User Profile].
Recommended fields
These fields are recognized by Halo and get first-class treatment in the AI prompt:| Field | Type | Description |
|---|---|---|
name | string | User’s display name |
email | string | User’s email address |
avatar | string | URL to the user’s avatar image |
role | string | User’s role in their organization (e.g., “owner”, “admin”, “viewer”). Required for audience filters on Role (custom:role). If you omit it on identify, those filters will not match. See Automation Troubleshooting. |
plan | string | User’s subscription plan |
signed_up_at | string | ISO 8601 date when the user signed up in your product. Optional, but strongly recommended for backfills and any flow where identify() runs later than the actual signup. Populates the dedicated end_users.signed_up_at column so the value shows up in the Contacts list, inbox sidebar, segments, automations, broadcasts, email merge fields ({{ signed_up_at }}), the AI prompt, and the customer_for derived value. Same field name and semantics on companyTraits. |
renewal_status | string | Renewal status. Allowed: up_for_renewal, in_progress, likely_to_renew, expansion_opportunity, set_to_cancel, set_to_cancel_trial, at_risk, renewed, lost |
renewal_date | string | Next renewal date (ISO 8601) |
contract_term | string | Agreement frequency: monthly, quarterly, annual, bi_annual |
payment_terms | string | Billing frequency: monthly, quarterly, annual, bi_annual |
on_contract | boolean | Whether the user has an active contract |
Custom fields
You can pass any key-value pair as a trait. There are no restrictions:[User Profile] as formatted key-value pairs.
TypeScript interface
[key: string]: unknown index signature lets you pass any custom field, but the reserved keys in the System-managed fields table below (id, created_at, updated_at, first_seen, last_seen, last_contacted_at) are not writable. Sending any of them triggers HTTP 400 on /api/sdk/users/identify.
Storage
User traits are stored in two places:- Dedicated columns (
name,email,signed_up_at,renewal_status,renewal_date,contract_term,payment_terms,on_contract) on theend_userstable for filtering and display. custom_fieldsjsonb column for everything else.
[User Profile] section.
Need to set a trait on users who already exist (e.g. backfilling
role)? Use the Update / Backfill API or CSV import, not an identify() loop. identify() bumps last_seen on every call, so backfilling that way marks your whole user base “active today”. See Importing & Backfilling Data.System-managed fields
Halo sets these automatically. Do not pass them inuserTraits or in the REST traits body. Sending any of them returns HTTP 400. Same rule applies to companyTraits; see Company Traits → System-managed fields.
| Field | Type | Description |
|---|---|---|
id | string (uuid) | Halo’s internal primary key. Use user_id (your external ID) on identify. |
created_at | string | ISO 8601 timestamp set the first time Halo inserts the row. Maintained by Halo. Distinct from signed_up_at (customer-set) and first_seen (activity). |
updated_at | string | ISO 8601 timestamp of the most recent write to the row. Maintained automatically by a database trigger. |
first_seen | string | ISO 8601 timestamp of the user’s first interaction with Halo (may predate identify when the widget captures anonymous activity). |
last_seen | string | ISO 8601 timestamp of the user’s most recent interaction. Bumped on every identify, event, and message. |
last_contacted_at | string | ISO 8601 timestamp of the last time your team contacted this user (broadcast, series, ticket reply, or chat). Updated automatically. |
customer_for | string | Human-readable duration (e.g., “45 days” or “less than a day”) derived at chat time. |
signed_up_at vs first_seen vs created_at is the most common point of confusion:signed_up_atis yours. Customer-set, optional. Pass it to record when the user onboarded in your product. Treat it as a historical timestamp: it’s the date the customer relationship started, independent of when Halo first saw them.first_seenis Halo’s. First time we observed any activity from this user (anonymous or identified).created_atis Halo’s. The moment the row was inserted into our database (typically equal tofirst_seenfor SDK-only flows, later thansigned_up_atfor backfilled imports, Stripe syncs, HubSpot syncs, Intercom migrations, and CSV imports).
Do I need to send signed_up_at?
If you call identify() synchronously at the moment of signup and never backfill, sending it is optional. created_at will be approximately equal to the real signup time, and the few audience filters and onboarding rules that read signed_up_at fall back to created_at when it isn’t set.
You should send it whenever any of these are true:
- You backfill historical users via the API or CSV import and want their original signup date preserved
- You only call
identify()after a user reaches some later step (first login after email verification, first paid session, etc.). In that case, also call server-side/api/sdk/users/identifyat signup so the contact exists before first login. See Identify Users. - You want “N days after signup” automations and segments to measure from real product signup, not from the moment Halo first saw the row
How automations and segments read signed_up_at
- The “User signs up” series trigger (
entry_trigger: "user_created") fires off Halo’s row creation, notsigned_up_at. It’s based on the first identify call. You do not need to sendsigned_up_atfor welcome series to work. - Audience filter on the
signed_up_atfield (e.g. “Signed up in 30 days” preset) reads the column directly. If a user has nosigned_up_atvalue, that filter does not match them. There is no fallback. Sendsigned_up_atif you want to target by signup date. days_since_signupin onboarding rules and Slack mode-determination preferssigned_up_atand falls back tocreated_atwhen missing. So that signal degrades gracefully.{{ signed_up_at }}email merge field renders blank when the column is null. Always pair the merge tag with a fallback ({{ signed_up_at|recently }}) if you mix backfilled and SDK-only users.
Trait updates
Traits are merged additively. Callingidentify() multiple times adds or overwrites fields without removing existing ones:
Where to go next
Company Traits
The same idea, applied to companies.
Context Entries
Beyond simple traits. Structured state data for the AI.
Automation Troubleshooting
Why a series shows 0 matched when
role or signed_up_at is missing on identify.