Skip to main content

Endpoint

POST /api/sdk/users/identify
Upsert an end user record and optionally link them to an existing session. Called automatically when you use identify() in the SDK, but you can also call it directly from your backend.
Backfilling or importing existing users? Every identify call bumps the user’s last_seen to now, so looping it over your user base marks everyone “active today” and corrupts activity data. Use Update / Backfill Users instead, which never touches activity timestamps. See Importing & Backfilling Data.

Authentication

Requires your publishable widget key in the Authorization header:
Authorization: Bearer ab_live_xxxxxxxxxxxxxxxx
The org_id is derived from the widget key. You do not need to pass it in the request body.

Request Body

user_id
string
required
Your system’s unique identifier for this user.
traits
object
User attributes. Recognized fields (name, email, signed_up_at, renewal_date, renewal_status, contract_term, payment_terms, on_contract, mrr, arr) are stored in dedicated columns. All other fields are stored in custom_fields. See User Traits for recommended fields.Do not send id, external_id, org_id, company_id, created_at, updated_at, first_seen, last_seen, or last_contacted_at. These are managed by Halo and the endpoint returns HTTP 400 if any of them appear in the body. Use signed_up_at to set the date the user signed up in your product. See System-managed fields.
context
object
Structured context entries for AI consumption. Stored in the context jsonb column. Merged with existing context (new keys overwrite). See Context Entries.
session_id
string
If provided, any orphaned transcripts or tickets from this session (created before the user was identified) are retroactively linked to the user record.
user_token
string
JWT signed with your Identity Secret (HS256). Required when identity verification is enabled for your workspace. The token’s user_id claim must match the user_id field. See Identity Verification.

Recognized Traits

These trait keys are stored in dedicated database columns for filtering and display:
KeyTypeDescription
namestringUser’s display name
emailstringUser’s email address
signed_up_atstringISO 8601 date the user signed up in your product. Populates the dedicated end_users.signed_up_at column.
mrrnumberMonthly recurring revenue in cents (only when MRR mode is “manual” or Stripe is not connected)
arrnumberAnnual recurring revenue in cents (same conditions as MRR)
renewal_datestringISO 8601 date for contract renewal
contract_termstringOne of: monthly, quarterly, annual, bi_annual
payment_termsstringOne of: monthly, quarterly, annual, bi_annual
on_contractbooleanWhether the user is on a contract
renewal_statusstringOne of: up_for_renewal, in_progress, likely_to_renew, expansion_opportunity, set_to_cancel, at_risk, renewed, lost
All other trait keys are stored as custom fields and are accessible to the AI agent.

Rejected Traits

These keys trigger HTTP 400 if present in the traits body. They are managed by Halo and customer-set values would silently shadow the real columns. See System-managed fields for context.
KeyWhy
idHalo’s internal primary key. Pass your identifier as user_id instead.
external_idHalo’s mirror of the user_id you sent. Use user_id at the top level.
org_idSet by Halo from the API key.
company_idSet by Halo when you call /api/sdk/companies/identify. Don’t override here.
created_atHalo sets this when the row is inserted. Pass signed_up_at if you want to record customer signup.
updated_atMaintained automatically by a database trigger.
first_seenMaintained by Halo (first time we observed any activity from the user).
last_seenBumped by every identify, event, and message.
last_contacted_atSet when your team sends a broadcast, series, reply, or chat.

Example

curl -X POST https://api.haloagents.ai/api/sdk/users/identify \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer ab_live_xxxxxxxxxxxxxxxx" \
  -d '{
    "user_id": "user_123",
    "traits": {
      "name": "Jane Doe",
      "email": "[email protected]",
      "role": "admin",
      "plan": "enterprise",
      "department": "Engineering"
    },
    "context": {
      "recent_activity": {
        "label": "Recent Activity",
        "type": "list",
        "value": [
          { "name": "Created API key", "timestamp": "2026-03-15T10:00:00Z" },
          { "name": "Enabled SSO", "timestamp": "2026-03-14T15:30:00Z" }
        ]
      }
    }
  }'
Response:
{
  "user": {
    "id": "uuid",
    "org_id": "your-org-id",
    "external_id": "user_123",
    "email": "[email protected]",
    "name": "Jane Doe",
    "custom_fields": {
      "name": "Jane Doe",
      "email": "[email protected]",
      "role": "admin",
      "plan": "enterprise",
      "department": "Engineering"
    },
    "context": {
      "recent_activity": {
        "label": "Recent Activity",
        "type": "list",
        "value": [
          { "name": "Created API key", "timestamp": "2026-03-15T10:00:00Z" },
          { "name": "Enabled SSO", "timestamp": "2026-03-14T15:30:00Z" }
        ]
      }
    },
    "first_seen": "2026-01-15T08:00:00.123456+00:00",
    "last_seen": "2026-03-15T10:30:00.789012+00:00",
    "signed_up_at": null,
    "last_contacted_at": "2026-03-20T14:00:00.456789+00:00",
    "created_at": "2026-01-15T08:00:00.123456+00:00",
    "updated_at": "2026-03-15T10:30:00.789012+00:00"
  }
}
All timestamps in the response are Postgres timestamptz values serialized as ISO 8601 with microsecond precision and a +00:00 offset.
  • created_at, updated_at, first_seen, last_seen, and last_contacted_at are read-only Halo-managed timestamps. Do not echo them back into the traits body on subsequent calls (the endpoint returns HTTP 400 if you do).
  • signed_up_at is customer-writable via traits.signed_up_at on a follow-up identify call. On update the column uses COALESCE semantics, so passing null (or omitting the key) preserves the existing value. Pass a new ISO 8601 string to overwrite.

Behavior

  • If a user with the same user_id exists within your organization, it is updated
  • If it doesn’t exist, it is created
  • Traits are merged: existing values are preserved, matching keys are overwritten
  • Context is merged: existing keys are preserved, new keys are added, matching keys are overwritten
  • The combined size of traits and context must not exceed 20,000 bytes
  • When session_id is provided, orphaned transcripts and tickets from that session are retroactively linked to the user