Endpoint
user_id. Use this to backfill or sync data you already have for an existing user base, for example setting a missing role trait across every user, or importing your historical users when you first set up Halo.
This endpoint never changes a user’s activity timestamps. Unlike
identify, which treats every call as “this user is here now” and bumps last_seen, update leaves first_seen and last_seen untouched on existing users. That means you can run it over your whole user base without marking everyone “active today” and corrupting the Users list, activity segments, or health scores.When to use this vs identify
identify | update (this endpoint) | |
|---|---|---|
| Intent | A real user is present right now | Sync / backfill data you already have |
last_seen | Bumped to now() | Never touched (existing users) |
| Automations & series | Enrolls (user_created, segment_match) | Does not trigger automations |
| Lead → user conversion | Yes (with verified JWT) | No |
| Identity (JWT) | Per-user token (optional unless enforced) | Required: org-signed token in user_token |
| Shape | One user per call | One user or up to 1000 per call |
identify() from your app when a user loads it. Call update from your backend to backfill or import data.
Authentication
This endpoint needs two credentials: 1. Your publishable widget key in theAuthorization header:
user_token body field, signed (HS256) with your workspace’s Identity Secret. A bulk overwrite of up to 1000 users is a backend-only operation, and the publishable key is visible in browsers, so you must prove the request comes from your server. You sign the token server-side; the Identity Secret itself is never sent to Halo.
- be signed HS256 with your Identity Secret,
- include
{ "scope": "users.update" }, - include an
expclaim that is unexpired and no more than 1 hour out.
user_id claim (this is an org-scoped batch). The dedicated scope matters: identify’s per-user tokens are sent from the browser, so requiring a separate scope here prevents a captured identify token from being replayed to overwrite your whole user base. See Identity Verification.
Request Body
Send a single user:Your system’s unique identifier for this user.
User attributes. Same split as identify: recognized fields (
name, email, signed_up_at, renewal_date, renewal_status, contract_term, payment_terms, on_contract) go to dedicated columns; everything else (e.g. role, plan) goes to custom_fields. See User Traits.mrr and arr are not writable here. Set them via identify or the Stripe integration.Do not send id, external_id, org_id, company_id, created_at, updated_at, first_seen, last_seen, or last_contacted_at. The endpoint returns HTTP 400 if any of them appear.Structured context entries for AI consumption. Merged into the
context jsonb column. See Context Entries.Short-lived JWT (HS256) signed with your Identity Secret. See Authentication. Sent at the top level for both the single and batch shapes.
When
true, only users that already exist in Halo are updated. Unknown user_ids are skipped instead of created and counted in the skipped field of the response. Use this to backfill traits for your existing Halo contacts without importing people who never opened the widget. Sent at the top level.users array (up to 1000 per request):
Array of
{ user_id, traits?, context? } objects. Each entry follows the same rules as the single-user shape above. Combined traits + context per user must not exceed 20 KB, and the whole request must not exceed 5 MB. user_token stays at the top level, not inside each entry.Behavior
- Matched strictly by
user_id(yourexternal_id). There is no email/lead merging; that stays the job ofidentify. - Existing user: traits are merged (matching custom-field keys overwrite, others are preserved). Dedicated columns use
COALESCEsemantics (omitting a key preserves the current value).first_seen,last_seen, andsourceare never modified. - New user: a
user(not a lead) is created unlessupdate_only: true(then it’s skipped and counted inskipped). On create,first_seenandlast_seenare set fromsigned_up_atwhen you provide it, otherwise to the time of the request, so importing historical users does not show everyone active today. Passsigned_up_aton imports for accurate activity history. Notecreated_at(“Added to Halo”) always reflects the import time and is distinct fromsigned_up_at. - Does not trigger automations, series enrollment, health recalculation, or lead conversion.
- Backfilled custom fields (e.g.
role) are registered in the attribute catalog so they appear in the segment and audience filter pickers ascustom:role.
Example
created— users that did not exist and were inserted (always0whenupdate_only: true).updated— existing users whose traits were merged (activity untouched).skipped— unknownuser_ids not created becauseupdate_only: true(always0otherwise).total— entries processed in the request (created + updated + skipped).