Skip to main content

Overview

User traits are simple key-value pairs that describe who a user is. They’re set via identify() or in the userTraits config option, stored in the end_users.custom_fields column, and included in the AI system prompt as [User Profile]. These fields are recognized by Halo and get first-class treatment in the AI prompt:
FieldTypeDescription
namestringUser’s display name
emailstringUser’s email address
avatarstringURL to the user’s avatar image
rolestringUser’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.
planstringUser’s subscription plan
signed_up_atstringISO 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_statusstringRenewal status. Allowed: up_for_renewal, in_progress, likely_to_renew, expansion_opportunity, set_to_cancel, set_to_cancel_trial, at_risk, renewed, lost
renewal_datestringNext renewal date (ISO 8601)
contract_termstringAgreement frequency: monthly, quarterly, annual, bi_annual
payment_termsstringBilling frequency: monthly, quarterly, annual, bi_annual
on_contractbooleanWhether the user has an active contract
Do not send created_at, updated_at, first_seen, last_seen, last_contacted_at, or id in userTraits. Halo manages these. The SDK rejects requests that include any of them with HTTP 400. If you want to set the date the user signed up in your product, use signed_up_at (above). See System-managed fields for the full list.

Custom fields

You can pass any key-value pair as a trait. There are no restrictions:
ha.identify("user_123", {
  name: "Jane Doe",
  email: "[email protected]",
  role: "admin",
  plan: "pro",

  department: "Engineering",
  onboarding_step: 3,
  last_login: "2025-01-15T10:30:00Z",
  preferred_language: "en",
  account_age_days: 90,
  feature_flags: "beta_dashboard,new_analytics",
  support_tier: "premium",
});
All custom fields appear in the AI prompt under [User Profile] as formatted key-value pairs.

TypeScript interface

interface UserTraits {
  name?: string;
  email?: string;
  avatar?: string;
  role?: string;
  plan?: string;
  signed_up_at?: string;
  renewal_status?: string;
  renewal_date?: string;
  contract_term?: string;
  payment_terms?: string;
  on_contract?: boolean;
  [key: string]: unknown;
}
The [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:
  1. Dedicated columns (name, email, signed_up_at, renewal_status, renewal_date, contract_term, payment_terms, on_contract) on the end_users table for filtering and display.
  2. custom_fields jsonb column for everything else.
When the AI engine builds the prompt, it merges both sources into a single [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 in userTraits or in the REST traits body. Sending any of them returns HTTP 400. Same rule applies to companyTraits; see Company Traits → System-managed fields.
FieldTypeDescription
idstring (uuid)Halo’s internal primary key. Use user_id (your external ID) on identify.
created_atstringISO 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_atstringISO 8601 timestamp of the most recent write to the row. Maintained automatically by a database trigger.
first_seenstringISO 8601 timestamp of the user’s first interaction with Halo (may predate identify when the widget captures anonymous activity).
last_seenstringISO 8601 timestamp of the user’s most recent interaction. Bumped on every identify, event, and message.
last_contacted_atstringISO 8601 timestamp of the last time your team contacted this user (broadcast, series, ticket reply, or chat). Updated automatically.
customer_forstringHuman-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_at is 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_seen is Halo’s. First time we observed any activity from this user (anonymous or identified).
  • created_at is Halo’s. The moment the row was inserted into our database (typically equal to first_seen for SDK-only flows, later than signed_up_at for 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/identify at 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, not signed_up_at. It’s based on the first identify call. You do not need to send signed_up_at for welcome series to work.
  • Audience filter on the signed_up_at field (e.g. “Signed up in 30 days” preset) reads the column directly. If a user has no signed_up_at value, that filter does not match them. There is no fallback. Send signed_up_at if you want to target by signup date.
  • days_since_signup in onboarding rules and Slack mode-determination prefers signed_up_at and falls back to created_at when 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. Calling identify() multiple times adds or overwrites fields without removing existing ones:
ha.identify("user_123", { name: "Jane", role: "viewer" });

ha.identify("user_123", { role: "admin", email: "[email protected]" });

// Stored: { name: "Jane", role: "admin", email: "[email protected]" }

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.