User data
Events say what a visitor did. User data says who the visitor is. Two calls carry it:
identifystores traits on the visitor’s profile: an email, a name, a plan, a loyalty tier, anything you want your apps to know about the person.aliasattaches an identifier you own, such as an email, a phone number or a customer id, to the visitor, so that every browser, device and backend call that presents the same identifier reaches one person.
The difference matters. An email sent as a trait is stored and nothing more, because a form field is not proof that
the browser belongs to that person. Only alias links the browser to a person profile. If you remember one thing from
this page: send the email as a trait so apps can use it, and alias it so the graph can link on it. The
ID graph page explains why, and what a person profile holds. This page covers the calls.
Every call below works from four places. Pick one tab and the whole page follows.
The snippet from your site’s Snippet page binds window.qinto. A call made before the tag has loaded is queued and
sent once the connection is open. Every call returns a promise, and two calls can reach the edge in the other order
unless you await the first. See Browser.
@blotout/qinto-sdk-browser is the same client as the
tag, as an npm package for a bundled site. Call init once, then import identify and alias anywhere. Every call
returns a promise that settles when the edge accepted it. See Headless.
@blotout/qinto-sdk-server sends the same calls from a
backend, a CRM sync or a webhook. The first argument names the person: an anon_… id or any identifier of your own.
It needs an edge token from your site’s Developer tools → Edge tokens page with the Identify users permission
for identify and the Link identifiers permission for alias.
Any language can call POST /v1/identify and POST /v1/alias on your site’s Qinto host, with the edge token as a
bearer token and a JSON body. This is what the server SDK sends. See HTTP for the
other endpoints.
Identify
Section titled “Identify”Call identify whenever you learn something about the visitor: at sign-up, at login, when a profile is edited, when a
plan changes. Traits merge, so send only what changed. A trait sent as null clears the stored value.
<script> qinto.identify({ email: 'ada@example.com', first_name: 'Ada', plan: 'pro' })</script>A second argument links an identifier in the same call, so identify(traits, id) is identify(traits) followed by
alias(id):
qinto.identify({ email: 'ada@example.com', plan: 'pro' }, 'crm-user-8412')import { identify } from '@blotout/qinto-sdk-browser'
await identify({ email: 'ada@example.com', first_name: 'Ada', plan: 'pro' })A second argument links an identifier in the same call, so identify(traits, id) is identify(traits) followed by
alias(id):
await identify({ email: 'ada@example.com', plan: 'pro' }, 'crm-user-8412')import { createClient } from '@blotout/qinto-sdk-server'
const qinto = createClient({ destination: 'https://q.example.com', token: process.env.QINTO_EDGE_TOKEN })
await qinto.identify('crm-user-8412', { email: 'ada@example.com', first_name: 'Ada', plan: 'pro' })The first argument decides who receives the traits. An anon_… id addresses one browser. Any other value is an
identifier: if a person already owns it, the traits land on that person; if nobody does, Qinto creates a person
profile and claims the identifier for it. So a server identify addressed by an email both stores the traits and
links the email, and a later track can name the person by it.
curl -X POST https://q.example.com/v1/identify \ -H 'Authorization: Bearer qwat_…' \ -H 'Content-Type: application/json' \ -d '{ "identifier": "crm-user-8412", "traits": { "email": "ada@example.com", "first_name": "Ada", "plan": "pro" } }'| Field | Required | What it is |
|---|---|---|
identifier |
Yes | The person: an anon_… id or an identifier of your own. Internal gid_… ids are rejected. |
traits |
No | The traits to merge. A key set to null clears that trait. |
observed |
No | occurredAt, page, network, session, offline and replay for a call that did not happen just now, or that Qinto could not observe. |
An anon_… identifier addresses one browser. Any other value is an identifier: if a person already owns it, the
traits land on that person; if nobody does, Qinto creates a person profile and claims the identifier for it.
The response is { "ok": true, "id": "act_…" }, where id is the id of the accepted action. An invalid body returns
400 with invalid_body and the failing fields. A token without the Identify users permission returns 403 with
forbidden.
Standard traits
Section titled “Standard traits”Eight trait keys are the person’s identity fields. Qinto validates and normalises them, stores them under these names, and apps read them by these names, for example to match a purchase to an ad click. Send them whenever you have them. The more of them a profile holds, the better every destination can match the person.
| Key | Meaning | How Qinto stores it |
|---|---|---|
email |
Email address | Trimmed and lowercased. Must look like name@host.tld, or the trait is dropped. |
phone |
Phone number | Trimmed; spaces, (), . and - removed; a leading + kept. Needs at least 7 digits and no other characters, or the trait is dropped. |
first_name |
Given name | As sent |
last_name |
Family name | As sent |
country |
Country | As sent |
state |
State, province or region | As sent |
city |
City | As sent |
postal_code |
Postal or ZIP code | As sent |
qinto.identify({ first_name: 'Ada', email: ' Ada@Example.com ', phone: '+1 (415) 555-0142' })// stored: first_name 'Ada', email 'ada@example.com', phone '+14155550142'- Write the keys in snake_case. That is the name Qinto stores and the name a profile read returns. A camelCase
key such as
firstNameis folded tofirst_nameon the way in. - A dropped value changes nothing. An invalid
emailorphoneleaves the stored one in place, and the other traits in the same call still apply. - Send phone numbers in international format.
+14155550142is unambiguous. A number without a country code is stored as you sent it, and a destination that expects E.164 may not match it. - Values are not hashed. Send the plain value over HTTPS. The app that delivers to a destination hashes it where that destination requires a hash.
- Do not send a standard key for something else.
state: 'active'on a subscription overwrites the person’s region. Give the subscription its own key, such assubscription_status.
Custom traits
Section titled “Custom traits”Any other key is a custom trait. It converges on the person profile like a standard trait, so a plan set on the
phone is visible on the laptop. Use custom traits for whatever your apps or your own reads need: a plan, a loyalty
tier, a signup date, a segment.
- Keys are stored in snake_case.
loyaltyTieris stored asloyalty_tier, andsignupDateassignup_date. Read a trait back by its snake_case name. - A string is stored as is. Any other value, such as a number, a boolean or an object, is stored as its JSON text.
- A key set to
nullclears the trait. - An app that does not know a trait ignores it.
Call alias with an identifier you own at the moment you learn that this browser belongs to that person: after
login, after sign-up, after an order is placed with an email. The graph looks the identifier up. If nobody owns it, a
person profile is created and this browser’s history, including purchases made before login, moves into it. If a
person already owns it, this browser joins that person and receives the traits and consent the other devices
contributed.
<script> qinto.alias('ada@example.com')</script>import { alias } from '@blotout/qinto-sdk-browser'
await alias('ada@example.com')await qinto.alias('anon_7b8d1a52-6c1e-4b6f-9a30-2f8a54d1c3e9', 'crm-user-8412')The first argument names the person, by an anon_… id or an identifier the person already owns. The second is the
identifier to attach. Use it when your backend knows two names for one person, for example to attach a customer id to
the email a webhook delivered.
A server alias cannot find a browser by itself. If the goal is to link the visitor’s browser, read its anon_… id
in the browser with getAnonId() and send it to your backend, or call alias from the browser.
curl -X POST https://q.example.com/v1/alias \ -H 'Authorization: Bearer qwat_…' \ -H 'Content-Type: application/json' \ -d '{ "identifier": "anon_7b8d1a52-6c1e-4b6f-9a30-2f8a54d1c3e9", "alias": "crm-user-8412" }'| Field | Required | What it is |
|---|---|---|
identifier |
Yes | The person: an anon_… id or an identifier the person already owns. Internal gid_… ids are rejected. |
alias |
Yes | The identifier to attach. |
The response is { "ok": true, "id": "act_…" }. A token without the Link identifiers permission returns 403 with
forbidden.
What counts as an identifier
Section titled “What counts as an identifier”Qinto reads the value you pass and classifies it. You never say which kind it is.
| You send | Qinto treats it as | Normalised to |
|---|---|---|
Ada@Example.com |
An email | ada@example.com |
+1 (415) 555-0142 |
A phone number, because it starts with + and has at least 7 digits |
+14155550142 |
crm-user-8412, 4155550142, a UUID |
A custom id | Trimmed, otherwise as sent |
A phone number without a leading + is a custom id, not a phone. It still links, but only to calls that send the
exact same string, so pick one format and use it everywhere. The same goes for custom ids: 8412 and crm-user-8412
are two different identifiers.
Rules to design around
Section titled “Rules to design around”- An identifier has one owner, forever. The first person profile to claim it keeps it. Repeating
aliaswith the same value is safe and changes nothing. - An identifier that belongs to someone else does not move. If this browser already belongs to one person and
presents an identifier a different person owns, the identifier is set aside. The browser keeps its person, the owner
keeps the identifier, and two person profiles are never merged. The clash is not lost:
getPerson(identifier)on the server returns the owner, and lists the person that also presented it underrelated. Related persons are unverified, so treat them as a hint for your own review, not as the same person. - Shared devices are not special. If two people log in from one browser, the second person’s identifier joins the
first person unless it already has an owner, whether it is an email, a phone number or an account id. Nothing you
pass to
aliasisolates two accounts on one browser, and a wrong link is permanent. Callaliasonly after a successful login, never from a form field before it is verified. - Link before you track from the server. A server
trackaddressed by an identifier the site has never seen fails with 404.identifyoraliasthe identifier first, in the same request if you need to.
Putting it together
Section titled “Putting it together”Sign-up or login in the browser. Store what you know and link the account in one call. The account id is the best identifier, because it never changes; the email is the best trait, because destinations match on it.
qinto.identify({ email: user.email, first_name: user.firstName, last_name: user.lastName }, user.id)If you want backend calls to be able to name the person by email as well, link both. Await the identify first: if
the alias lands first and the email is new, it creates a person of its own, and the account id then arrives as a
conflict with the person your backend already created.
await qinto.identify({ email: user.email }, user.id)await qinto.alias(user.email)An order webhook on your server. The webhook carries the customer’s email. This runs in your backend with
@blotout/qinto-sdk-server, so the first argument names the person. Identify first so the person exists, then track.
import { createClient } from '@blotout/qinto-sdk-server'
const qinto = createClient({ destination: 'https://q.example.com', token: process.env.QINTO_EDGE_TOKEN })
await qinto.identify(order.email, { email: order.email, first_name: order.firstName, country: order.country })await qinto.track(order.email, 'Purchase', { orderId: order.id, currency: order.currency, value: order.total })If the browser aliased the same email at checkout, this lands on the person the browser created, and the orderId
deduplicates the purchase against the one the page sent.
A CRM sync. Address each record by the id your CRM uses, and send the traits that changed. Records that have never been seen get a person profile. Records already linked from a browser are updated in place, and the new traits reach every linked device.
await qinto.identify(`crm-user-${record.id}`, { plan: record.plan, city: record.city })Check that it worked
Section titled “Check that it worked”- In the browser,
await qinto.getVisitor()returns{ anonId, globalId, … }.globalIdisnulluntil the visitor is linked to a person, and set afterwards. It asks the edge each time, so it reflects analiasthat just completed. - From the server,
getPerson(identifier)returns the person an identifier belongs to, the visitor ids linked to it, and underrelatedany other person that presented the same identifier and was set aside. An emptyrelatedmeans the identifier has never clashed. It needs the Read visitor ids permission. - In the platform, User data looks up any visitor or person by an identifier and shows the traits, identifiers and purchases the graph holds.
Coming from EdgeTag
Section titled “Coming from EdgeTag”edgetag('data', { … })isqinto.identify({ … }). Same shape, same merge behaviour.edgetag('user', key, value)isqinto.identify({ [key]: value }). There is no one-key call.zipispostal_code. The other standard keys keep their names, written in snake_case.genderanddateOfBirthare custom traits. They are stored asgenderanddate_of_birthand converge like any other trait.- A trait does not link devices. Where your EdgeTag code sent the email to connect a visitor to a person, add
qinto.alias(email)next to theidentify.