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
| Reserved | Custom | |
|---|---|---|
| Examples | first_name, email, phone, gender, country… | plan, lifetime_value, wishlist_item — anything you like |
| Where it lives | Its own column on the person | A 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 below | No. 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.
| Attribute | What we store | Examples |
|---|---|---|
first_name | As written, whitespace tidied. Case is never changed. | Aasha → Aashavan der Berg → van der Berg |
last_name | As written, whitespace tidied. Case is never changed. | Shrestha → Shrestha |
email | Lower-cased. It is an address, not a property — it is how the email channel reaches this person. | [email protected] → [email protected]not an address → not usable |
phone | E.164, spaces and punctuation removed. A number with no country code is refused rather than guessed at. | +977 98-1234 5678 → +97798123456789812345678 → not usable |
gender | Lower-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. | M → maleFemale → femalenon-binary → nonbinaryPrefer not to say → unknownTwo-Spirit → two_spirit- - → not usable |
country | ISO 3166-1 alpha-2, upper-cased. NP, not Nepal or np. | np → NPNP → NPNepal → not usable |
language | ISO 639-1, lower-cased. A browser locale is accepted and its region dropped: en-GB stores as en. | EN → enen-GB → enne_NP → neenglish → not usable |
region | Free text, whitespace tidied. Segmentable, but compared exactly — pick one spelling and keep to it. | Bagmati → Bagmati |
city | Free text, whitespace tidied. Compared exactly, like region. | Kathmandu → Kathmandu |
timezone | An IANA name, in its canonical spelling. An offset such as +05:45 is refused — it cannot survive a daylight-saving change. | Asia/Kathmandu → Asia/Katmandu+05:45 → not usableEurope/Berlin → Europe/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 unknown — Prefer 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.
{"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.
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.
| Caller | What happens | Why |
|---|---|---|
POST /v1/users, server key | 400 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 SDK | Never 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 import | The 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.