Skip to content

HTTP

Your Qinto host answers HTTP on /v1/*. A backend, a webhook handler or a scheduled job sends the same track, identify, alias and consent calls the browser sends, against the same identity graph, and can read back what Qinto stored for a person. Every call takes an edge token as a bearer token and a JSON body, so any language can make it. For Node.js and the runtimes that share its globals, @blotout/qinto-sdk-server wraps the endpoints in typed calls.

The browser cannot see the events that matter most, and what it does see arrives from a device you do not control. Send from a server when:

  • The event happens on a server. A payment webhook confirms an order minutes after the customer closed the tab. A subscription renews on a schedule. A refund is issued in your admin tool.
  • You know the person by your own id. An email, a phone number or a customer id reaches the person the browser linked earlier, and you never store a Qinto id to make that work.
  • You need to read a profile. A support tool shows the consent a customer gave, or a sync job needs the stored traits.
  • You write in a language other than JavaScript. The endpoints are plain HTTP with JSON. Nothing in them needs the SDK.

Pick another surface when the event happens on a page. The Browser tag and the Headless package carry the visitor’s cookie, page and consent for you, and need no token. An edge token in browser code is readable by every visitor, so the server SDK refuses to run there.

Every /v1 call needs an edge token. Open your site in the Blotout platform, go to Developer tools → Edge tokens, and create one. Tick only the permissions the service needs. The token starts with qwat_ and is shown once, so store it as a secret.

Permission Endpoints What it allows
Track events POST /v1/track Record an event a person performed.
Identify users POST /v1/identify Store traits on a person’s profile.
Link identifiers POST /v1/alias Link an identifier of your own to a person.
Save consent POST /v1/consent Save a person’s consent choices.
Read consent GET /v1/consent Read the consent stored for a visitor.
Read properties GET /v1/properties Read every property stored for a visitor.
Read visitor ids GET /v1/visitor, GET /v1/person Resolve an identifier to its visitor and person ids.
Write captures POST /v1/capture Record click ids and vendor ids an installed app declares.

A token is per site. A checkout service that records purchases needs Track events and, to bind a new customer id, Link identifiers, and nothing else. Give a support tool that reads profiles the three read permissions and no write.

The package calls only global fetch, AbortSignal.timeout and crypto.randomUUID, so it runs on Node.js 24 or newer, Cloudflare Workers, Deno and Bun. It has no runtime dependencies.

Terminal window
npm i @blotout/qinto-sdk-server
import { createClient } from '@blotout/qinto-sdk-server'
const qinto = createClient({
destination: 'https://q.example.com',
token: process.env.QINTO_EDGE_TOKEN
})
Option Default What it does
destination Required Your site’s Qinto host, the same origin your pages send to.
token Required An edge token.
requestTimeoutMs 10000 How long one request waits before it rejects with QintoTimeoutError.

createClient holds the token and the host and opens no connection, so create one client per process and reuse it. In a browser it throws QintoEnvironmentError.

The first argument of every call is the person: an anon_… id, or any identifier of your own. The same identifier always reaches the same person. Internal gid_… ids are rejected.

Method Permission What it does
track(identifier, event, properties?, options?) Track events Records an event. Resolves to { ok, id, eventId }.
identify(identifier, traits?, options?) Identify users Merges traits into the person’s profile.
alias(identifier, alias) Link identifiers Links a second identifier to the person.
consent(identifier, input, options?) Save consent Saves consent choices.
getVisitor(identifier) Read visitor ids { anonId, globalId }. Both null for an identifier never seen.
getPerson(identifier, options?) Read visitor ids The person and every visitor linked to them, 100 per page.
getConsent(anonId) Read consent The stored consent state.
getProperties(anonId) Read properties Every stored property.
await qinto.identify('crm-user-8412', { email: 'ada@example.com', plan: 'pro' })
await qinto.track('crm-user-8412', 'Purchase', { currency: 'USD', value: 20.5, orderId: '190315' })
const { anonId } = await qinto.getVisitor('crm-user-8412')
if (anonId !== null) {
const consent = await qinto.getConsent(anonId)
}

getConsent and getProperties take an anon_… id, so resolve one with getVisitor first. A read never creates anything.

Each write takes options.observed for an event that did not happen just now, or that happened where Qinto could not observe it:

await qinto.track(userId, 'Purchase', properties, {
observed: {
occurredAt: completedAt.getTime(),
offline: true,
network: { ip: checkoutIp, userAgent: checkoutUserAgent }
}
})

Actions lists the observed fields and the installs and apps options that pick which apps receive an event. User data covers traits and linking, and Consent covers the consent input.

Every error extends QintoError. QintoApiError carries the response status and a code, and for a 403 its message names the permission to add. QintoTimeoutError means the request exceeded requestTimeoutMs. QintoEnvironmentError means createClient ran in a browser.

import { QintoApiError } from '@blotout/qinto-sdk-server'
try {
await qinto.track(userId, 'Purchase', properties)
} catch (error) {
if (error instanceof QintoApiError && error.status === 404) {
await qinto.identify(userId, { email })
await qinto.track(userId, 'Purchase', properties)
}
}

The codes are the ones the endpoints return. The 404 for an unknown person arrives with code: 'unknown', so recognise it by status.

The base URL is your Qinto host, such as https://q.example.com. Every request carries Authorization: Bearer qwat_…. A write is a POST with Content-Type: application/json, and a read is a GET with the identifier in the query string.

Endpoint Permission What it does
POST /v1/track Track events Records an event.
POST /v1/identify Identify users Merges traits into a person’s profile.
POST /v1/alias Link identifiers Links an identifier to a person.
POST /v1/consent Save consent Saves consent choices.
POST /v1/capture Write captures Records values an installed app declared, by app slug.
GET /v1/visitor?identifier= Read visitor ids { anonId, globalId } for an identifier.
GET /v1/person?identifier=&cursor= Read visitor ids The person and one page of its visitors.
GET /v1/consent?identifier=anon_… Read consent The stored consent state.
GET /v1/properties?identifier=anon_… Read properties Every stored property.

Your Qinto host answers three more paths that are not for your servers. GET /q.js is the tag, GET /open is the WebSocket the browser clients use, and GET /health returns { "status": "success" }.

Every write names the person in its identifier field, and every read in its identifier query parameter. It is an anon_… id, the visitor id the browser client reports in getAnonId() and the qinto:connect event, or an identifier of your own, such as an email, a phone number or a customer id. Qinto resolves it through the identity graph, so an identifier the page linked with alias reaches the same person from your server.

Two rules decide what happens to an identifier the site has never seen:

  • identify, alias and consent create a profile and bind the identifier to it. That is how a person your backend knows first, such as an imported customer, gets a profile before their first visit.
  • track and capture do not. They answer 404 with { "error": "unknown_user" }, because an event for an identifier nothing else knows would create a profile that never links to a browser. Bind the identifier first, or send the anon_… id.

The two read endpoints that take an anon_… id only, GET /v1/consent and GET /v1/properties, answer 400 with invalid_params for any other identifier. Resolve it with GET /v1/visitor first.

Records an event a person performed. Permission: Track events.

Request body

Field Type Required Description
identifier string Yes The person. An anon_… id or an identifier of your own. A gid_… id is rejected.
event string Yes The event name, such as Purchase. See Standard events.
eventId string Yes The id destinations deduplicate on. For a Purchase, use the order id.
properties object No The event payload, any JSON object. Default {}.
observed object No Optional timing and origin of the event. See Observed.
apps object No { "mode": "only" | "except", "slugs": [string] }. Picks the apps that receive the event.
installs object No { "mode": "only" | "except", "installIds": [string] }. Picks the app installs that receive the event.

The body rejects unknown fields.

Terminal window
curl -X POST https://q.example.com/v1/track \
-H 'Authorization: Bearer qwat_…' \
-H 'Content-Type: application/json' \
-d '{
"identifier": "crm-user-8412",
"event": "Purchase",
"eventId": "190315",
"properties": { "currency": "USD", "value": 20.5, "orderId": "190315" },
"observed": { "occurredAt": 1761300000000, "offline": true }
}'

Response 200

Field Type Description
ok true The event was accepted.
id string The id of the accepted action, act_…. Not the person and not the event id. Keep the eventId you sent.
{ "ok": true, "id": "act_…" }

Errors: 404 unknown_user for an identifier the site has never seen. Bind it with /v1/identify or /v1/alias first, or send the anon_… id. Actions covers apps and installs.

Merges traits into a person’s profile. Permission: Identify users.

Request body

Field Type Required Description
identifier string Yes The person. An anon_… id or an identifier of your own. An identifier the site has never seen creates a profile and binds the identifier to it.
traits object No The traits to merge, as { "email": "ada@example.com", "plan": "pro" }. A key set to null clears that trait. Default {}.
observed object No Optional timing and origin of the call. See Observed.
Terminal window
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", "plan": "pro" } }'

Response 200

{ "ok": true, "id": "act_…" }

ok is true and id is the accepted action. User data lists the trait keys Qinto normalises.

Links an identifier of your own to a person. Permission: Link identifiers.

Request body

Field Type Required Description
identifier string Yes The person. An anon_… id or an identifier the person already owns. An unseen identifier creates a profile.
alias string Yes The identifier to attach, such as an email, a phone number or a customer id. Trimmed.
Terminal window
curl -X POST https://q.example.com/v1/alias \
-H 'Authorization: Bearer qwat_…' \
-H 'Content-Type: application/json' \
-d '{ "identifier": "anon_01J…", "alias": "crm-user-8412" }'

Response 200

{ "ok": true, "id": "act_…" }

Saves a person’s consent choices. Permission: Save consent.

Request body

Field Type Required Description
identifier string Yes The person. An anon_… id or an identifier of your own. An unseen identifier creates a profile.
categories object No The categories to change. Keys: necessary, functional, analytics, marketing, salesOfData, each a boolean. necessary accepts only true. A key left out keeps its stored value.
apps object No The apps to change, by slug, as { "facebook": false }.
observed object No Optional timing and origin of the choice. See Observed.

The body rejects unknown fields and unknown categories.

Terminal window
curl -X POST https://q.example.com/v1/consent \
-H 'Authorization: Bearer qwat_…' \
-H 'Content-Type: application/json' \
-d '{
"identifier": "crm-user-8412",
"categories": { "analytics": true, "marketing": false },
"apps": { "facebook": false }
}'

Response 200

{ "ok": true, "id": "act_…" }

Consent explains what each category gates.

Records click ids and vendor ids an installed app declares, such as a gclid the browser could not see. Permission: Write captures.

Request body

Field Type Required Description
identifier string Yes The person. An anon_… id or an identifier of your own. A gid_… id is rejected.
apps object Yes One entry per app slug. Each value is an object of { "<key>": "<value>" }, where the key is a query parameter or cookie name the app’s manifest declares under browser.capture, and the value is a string of up to 512 characters. Up to 50 apps and 32 keys per app.

The body rejects unknown fields.

Terminal window
curl -X POST https://q.example.com/v1/capture \
-H 'Authorization: Bearer qwat_…' \
-H 'Content-Type: application/json' \
-d '{
"identifier": "anon_01J…",
"apps": { "google-ads": { "gclid": "Cj0KCQ…" } }
}'

Response 200

Field Type Description
ok true The request was processed.
accepted object The keys stored, by app slug, as { "google-ads": ["gclid"] }. A key is left out when the app is not installed, does not declare it, or consent blocks the app.
{ "ok": true, "accepted": { "google-ads": ["gclid"] } }

Errors: 404 unknown_user for an identifier the site has never seen.

Resolves an identifier to its visitor and person ids. Permission: Read visitor ids.

Query parameters

Parameter Type Required Description
identifier string Yes An anon_… id or an identifier of your own. A gid_… id is rejected.
Terminal window
curl 'https://q.example.com/v1/visitor?identifier=crm-user-8412' \
-H 'Authorization: Bearer qwat_…'

Response 200

Field Type Description
anonId string | null The visitor the identifier resolves to. null for an identifier never seen.
globalId string | null The person profile. null until something links the visitor to a person.
{ "anonId": "anon_01J…", "globalId": "gid_01J…" }

A read never creates a profile. Pass anonId to GET /v1/consent and GET /v1/properties.

Returns the person an identifier belongs to and one page of the visitors linked to them. Permission: Read visitor ids.

Query parameters

Parameter Type Required Description
identifier string Yes An anon_… id or an identifier of your own. A gid_… id is rejected.
cursor string No The nextCursor of the previous page. Digits only. Leave it off for the first page.
Terminal window
curl 'https://q.example.com/v1/person?identifier=crm-user-8412' \
-H 'Authorization: Bearer qwat_…'

Response 200

Field Type Description
globalId string | null The person. null when the identifier resolves to a visitor nothing has linked yet, or to nothing.
anonIds string[] Up to 100 visitors linked to the person. For an unlinked visitor, that one id on the first page.
nextCursor string | null Pass it back as cursor for the next page. null on the last page.
related object[] Other persons that also presented the identifier, each as { globalId, anonIds, nextCursor }. Unverified. Filled on the first page only.
{
"globalId": "gid_01J…",
"anonIds": ["anon_01J…", "anon_01K…"],
"nextCursor": null,
"related": []
}

Reads the consent stored for a visitor. Permission: Read consent.

Query parameters

Parameter Type Required Description
identifier string Yes An anon_… id only. Resolve an identifier of your own with GET /v1/visitor first.
Terminal window
curl 'https://q.example.com/v1/consent?identifier=anon_01J…' \
-H 'Authorization: Bearer qwat_…'

Response 200

Field Type Description
categories object Every category, each a boolean: necessary (always true), functional, analytics, marketing and salesOfData.
apps object A boolean per app slug the visitor decided on. Empty when none.
{
"categories": { "necessary": true, "functional": true, "analytics": true, "marketing": false, "salesOfData": false },
"apps": { "facebook": false }
}

Errors: 400 invalid_params for an identifier that is not an anon_… id.

Reads every property stored for a visitor. Permission: Read properties.

Query parameters

Parameter Type Required Description
identifier string Yes An anon_… id only. Resolve an identifier of your own with GET /v1/visitor first.
Terminal window
curl 'https://q.example.com/v1/properties?identifier=anon_01J…' \
-H 'Authorization: Bearer qwat_…'

Response 200

A flat object with one key per stored property: the normalised traits identify stored, such as email and first_name, and every custom trait. {} for a visitor with no stored profile.

{ "email": "ada@example.com", "first_name": "Ada", "plan": "pro" }

Errors: 400 invalid_params for an identifier that is not an anon_… id.

/v1/track, /v1/identify and /v1/consent take an optional observed field for a call that did not happen just now, or that happened where Qinto could not observe it. Every field is optional, and the object rejects unknown fields.

Field Type Description
occurredAt integer When it happened, in epoch milliseconds. Default: the time the request arrives.
offline boolean true for an event that never touched a page, such as a refund in your admin tool. Default false.
replay boolean true for a resend of an event you sent before, such as a backfill. Apps receive the flag. Default false.
page object The page the event happened on: { "url": string, "referrer"?: string }. url is required inside page, up to 4096 characters.
network object The person’s connection: { "ip"?: string, "userAgent"?: string, "locale"?: string }. Apps that match on them see the right visitor.
session object The visit the event belongs to: { "id": string, "isNew": boolean, "startedAt": integer }. All three are required inside session. Take them from the browser’s qinto:connect event.
{
"occurredAt": 1761300000000,
"offline": true,
"network": { "ip": "203.0.113.7", "userAgent": "Mozilla/5.0 …" }
}

An error response is JSON with an error code. A 400 adds details with the failing fields.

Status error Meaning Retry
400 invalid_body A field is missing, has the wrong type, or is unknown. /v1/track, /v1/consent and /v1/capture reject unknown fields. No. Fix the request.
400 invalid_params A read’s query is wrong, or a consent or properties read got an identifier that is not an anon_… id. No.
401 unauthenticated The bearer token is missing, malformed or revoked. No.
403 forbidden The token lacks the permission the endpoint needs. No. Add the permission, or use a token that has it.
404 unknown_user /v1/track or /v1/capture named an identifier the site has never seen. After you bind the identifier.
500 internal Nothing is guaranteed written. Reads, yes. A retried write may record parts of the action twice, except a Purchase, which deduplicates on its order id.
  • One token per service, with the fewest permissions. A leaked checkout token that can only track cannot read a profile. Rotate by creating the new token, deploying it, then deleting the old one.
  • Keep the token on the server. Never in a page, a mobile app bundle or a public repository. The server SDK refuses to run in a browser for that reason.
  • Bind the identifier before you track. Call identify or alias when you first learn a customer id, at sign-up or at the first order, and the later track calls never hit 404. Linking the same identifier again is safe.
  • Hand the visitor id to your backend when you can. Put anonId from getAnonId() or the qinto:connect event in the order, and track the confirmation against it. An event tracked against the anon_… id lands on the visitor even before any identifier is linked.
  • Use the order id as the event id for a Purchase. The page and the webhook then send one event, and a retry of either counts once.
  • Send observed.occurredAt for anything that did not happen now. A webhook arrives late and a batch job runs later still. Pass the real time in epoch milliseconds, and set offline: true for an event that never touched a page. Pass the customer’s IP and user agent in observed.network when you have them, so apps that match on them see the right visitor.
  • Retry by status, not blindly. A 400, 401 or 403 does not fix itself. A 404 needs a bind first. A 500 or a timeout is safe to retry for reads and for a Purchase, and may duplicate parts of another write.
  • Reuse one client, and raise requestTimeoutMs on a slow network, not in a request handler. A webhook handler that waits ten seconds on Qinto holds the provider’s retry window. Record the event in a queue and send it from a worker if your provider’s timeout is shorter.
  • Read with the anon_… id you got back. GET /v1/visitor resolves your identifier once; GET /v1/consent and GET /v1/properties take only the anon_… id. A read never creates a profile.
  • Check the consent you stored before you act on a profile. With consent mode on, the same consent gates a server-side event: a Purchase sent for a person who refused marketing reaches no marketing app. See Consent.