User attributes

What you can know about a person, what to send, and exactly what we store. Send M or Male or MALE — they are one audience.

Two kinds of attribute

ReservedCustom
Examplesfirst_name, email, phone, gender, countryplan, lifetime_value, wishlist_item — anything you like
Where it livesIts own column on the personA JSON blob, auto-registered in the property catalog on first write
How you filter it{"attr":"country","op":"eq","value":"NP"}{"attr":"properties.plan","op":"eq","value":"pro"} — note the prefix
Normalised?Yes, see belowNo. Stored exactly as sent, and compared exactly

Reserved attributes are not merely well-known names: they are what {{first_name}} renders from, what email and SMS use to reach somebody, and what recipient-local delivery reads to know which 9 AM. Send them under these exact names and they land in the right place, whichever door you came through.

What we store

Every one of these is normalised on the way in, in one place, so that a value written from your backend and the same value written from a phone end up identical. Otherwise a segment for male would find the people your API wrote and miss the people your app wrote, and it would look like our targeting was broken.

AttributeWhat we storeExamples
first_nameAs written, whitespace tidied. Case is never changed. Aasha Aasha
van der Bergvan der Berg
last_nameAs written, whitespace tidied. Case is never changed.ShresthaShrestha
emailLower-cased. It is an address, not a property — it is how the email channel reaches this person.[email protected][email protected]
not an addressnot usable
phoneE.164, spaces and punctuation removed. A number with no country code is refused rather than guessed at.+977 98-1234 5678+9779812345678
9812345678not usable
genderLower-cased and slugified, with the obvious synonyms collapsed: M, male, Man and MALE all store as male. Anything we do not recognise is kept, slugified — this is not a field with a fixed set of right answers.Mmale
Femalefemale
non-binarynonbinary
Prefer not to sayunknown
Two-Spirittwo_spirit
- -not usable
countryISO 3166-1 alpha-2, upper-cased. NP, not Nepal or np.npNP
NPNP
Nepalnot usable
languageISO 639-1, lower-cased. A browser locale is accepted and its region dropped: en-GB stores as en.ENen
en-GBen
ne_NPne
englishnot usable
regionFree text, whitespace tidied. Segmentable, but compared exactly — pick one spelling and keep to it.BagmatiBagmati
cityFree text, whitespace tidied. Compared exactly, like region.KathmanduKathmandu
timezoneAn IANA name, in its canonical spelling. An offset such as +05:45 is refused — it cannot survive a daylight-saving change.Asia/KathmanduAsia/Katmandu
+05:45not usable
Europe/BerlinEurope/Berlin

GET /v1/attributes returns this table as JSON. It is generated from the code that does the normalising, so it cannot describe behaviour we no longer have.

Gender, specifically

The obvious synonyms collapse: M, m, male, Male, MALE, Man and boy all store as male. So do the equivalents for female, nonbinary, other and unknownPrefer not to say stores as unknown.

Anything we do not recognise is kept, lower-cased and slugified: Two-Spirit stores as two_spirit. We do not force this field into a three-value list, because gender is not a field with a fixed set of right answers and it is not ours to decide on your users’ behalf. What targeting needs is only that two people writing the same thing get the same value, and that is all slugifying does.

Filter on the stored value: {"attr":"gender","op":"eq","value":"male"}. If you are unsure what a value became, the Audience screen shows it.

Sending them

From your backend

await nb.users.upsert({
  external_id: "user-42",          // your own id for this person
  first_name:  "Aasha",
  last_name:   "Shrestha",
  email:       "[email protected]",
  phone:       "+9779812345678",   // E.164
  gender:      "F",                // stored as "female"
  country:     "NP",               // ISO 3166-1 alpha-2
  language:    "ne",               // ISO 639-1
  city:        "Kathmandu",
  region:      "Bagmati",
  timezone:    "Asia/Kathmandu",   // IANA name
  properties:  { plan: "pro", lifetime_value: 420.5 },
});

Custom attributes go in properties. Reserved ones go at the top level — putting first_name inside properties makes a custom attribute called first_name that no template and no channel will ever read.

From an SDK, inside your app

// Web
await nb.identify("user-42", {
  signature,                        // see Security & keys
  attributes: {
    first_name: "Aasha",
    email: "[email protected]",
    phone: "+9779812345678",
    gender: "F",
    country: "NP",
    plan: "pro",                    // custom, no prefix needed when writing
  },
});
// Android (Kotlin)
Notibase.identify("user-42", mapOf(
  "first_name" to "Aasha",
  "email" to "[email protected]",
  "gender" to "F",
  "country" to "NP",
))

// iOS (Swift)
Notibase.identify("user-42", attributes: [
  "first_name": "Aasha", "email": "[email protected]",
  "gender": "F", "country": "NP",
])

// Flutter (Dart)
await Notibase.identify("user-42", attributes: {
  "first_name": "Aasha", "email": "[email protected]",
  "gender": "F", "country": "NP",
});

Reserved and custom attributes travel in the same map. We lift the reserved ones into their columns and leave the rest as custom properties, so you do not have to keep two dictionaries.

Turn on identity verification if your app sets an email address or a phone number. Those are addresses: without a signature, somebody with your public client key can write their number onto your user and receive that person’s codes and receipts.

From a CSV

Import maps a column per attribute and normalises the same way, which is what lets a migrated audience be segmented alongside one built through the SDK. Export columns are usually the worst shapes there are — a gender column that was a CHAR(1), a country column that was free text — and that is the point.

When a value cannot be used

Two behaviours, chosen by who is calling rather than by which field it is.

CallerWhat happensWhy
POST /v1/users, server key400 with the reason, for the attributes where we cannot derive a correct value — country, language, email, phone, timezone. Nothing is written.A deliberate call from your backend, which still holds the right value. Telling you now is cheaper than an empty segment in three weeks.
identify(), from an SDKNever an error. The value is dropped from its column, stays in attributes where you can see it, and the response carries rejected_attributes saying which and why.This runs inside your app while somebody is using it. Breaking a screen over a country string costs more than the country string is worth.
CSV importThe row still imports without that value — except a bad country, which fails the row.A country column holding Nepal means every row has it, so it is a mapping mistake worth stopping for. A stray gender is not.
// identify() response when something did not fit
{ "id": "…",
  "rejected_attributes": [
    { "key": "country", "given": "Nepal",
      "reason": "\"Nepal\" is not a usable country. ISO 3166-1 alpha-2, upper-cased. NP, not Nepal or np." }
  ] }

Two mistakes worth naming

Putting a reserved name in properties. It becomes a custom attribute that happens to share a name with a real one. {{first_name}} renders as nothing, the email channel cannot find an address, and both values are visible in your dashboard the whole time — which is what makes it hard to spot.

Assuming region and city are normalised. They are free text and compared exactly, because there is no standard list to normalise them against. Kathmandu and kathmandu are two cities. Pick a spelling where the data is produced and keep to it.

← Audience filtersScheduling & time zones →