@blotout/qinto-sdk-server
v0.1.9
Published
Qinto server-side SDK: a token-authenticated client for the /v1 identity graph API. Zero runtime dependencies.
Readme
@blotout/qinto-sdk-server
The Qinto server-side client. It sends events, identity, and consent to your site's Qinto edge API from a backend, with a scoped access token. Zero runtime dependencies.
Why we built it
The browser is the worst place to record the things that matter most. A refund happens in your admin tool, a subscription renews on a schedule, an order is confirmed by a payment webhook minutes after the customer closed the tab — and none of that reaches a page script. What does reach it arrives from a device you do not control, on a connection that may drop.
So Qinto accepts the same actions from your servers, against the same identity graph:
- Address a person by the id you already have. An email, a phone number, an internal customer id — the identifier the browser linked earlier reaches the same person, and you never store a Qinto id to make that work.
- Events your page cannot see. Offline conversions, webhook confirmations, back-office corrections, CRM syncs. Pass the time it really happened through the event context.
- One integration for every destination. As in the browser, an action is sent once and the site's installed apps each receive it. Adding a destination is a change in the Qinto platform, not in your backend.
- Scoped credentials. An edge token grants only the endpoints it names, so the checkout service that records purchases cannot read a visitor's stored profile.
Install
npm i @blotout/qinto-sdk-serverNeeds Node.js 24 or newer. It calls only global fetch, AbortSignal.timeout, and crypto.randomUUID, so it also
runs on Cloudflare Workers, Deno, and Bun.
Create a client
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 page's tag talks to. |
| token | — | Required. An edge token (qwat_…). |
| requestTimeoutMs | 10000 | Per-request timeout; on expiry the call rejects with QintoTimeoutError. |
Get the token from the Qinto platform, under your site's Settings → Edge tokens. Tick only the permissions the service needs; the table in Send names the permission each method uses. The token is shown once, so store it as a secret.
createClient refuses to run in a browser and throws QintoEnvironmentError there — the token would be readable by
every visitor. Use @blotout/qinto-sdk-browser in a page.
Send
await qinto.track('crm-user-8412', 'Purchase', {
currency: 'USD',
value: 20.5,
orderId: '190315'
})
await qinto.identify('crm-user-8412', { email: '[email protected]', plan: 'pro' })
await qinto.alias('anon_7b8d1a52-6c1e-4b6f-9a30-2f8a54d1c3e9', 'crm-user-8412')
await qinto.consent('crm-user-8412', { categories: { marketing: false } })| Method | Permission | What it does |
| --- | --- | --- |
| track(identifier, event, properties?, options?) | Track events | Records something the user did. Resolves to { ok, id, eventId }. |
| identify(identifier, traits?, options?) | Identify users | Merges identity traits into the user's profile. |
| alias(identifier, alias) | Link identifiers | Links an identifier of your own to that user. |
| consent(identifier, input, options?) | Save consent | Records the user's consent choices. |
The first argument is the user, named by a canonical Qinto id (anon_…) or any identifier of your own — an email,
a phone number, a custom id. The same identifier always reaches the same person. Internal gid_… ids are rejected.
- Tracking never creates a user. An identifier this site has never seen fails with HTTP 404. Bind it first with
identifyoralias, or send the canonicalanon_…id. trackreturns the event id. A payload with a non-emptyorderIduses that id, so the same purchase sent from the browser and from your backend deduplicates at the destination. Otherwise one is minted; pass your own throughoptions.eventId.- Traits merge, and a trait sent as
nullclears the stored one. Anemailorphonetrait is validated before it is stored, and it stays a trait: linking an identifier takesalias, or addressing the call by that identifier. - Consent choices merge, so you can send one toggle.
necessaryis always granted and accepts onlytrue.
Event context
An event that did not happen just now, or that happened somewhere Qinto cannot observe, carries its own context:
await qinto.track(userId, 'Purchase', properties, {
context: {
occurredAt: completedAt.getTime(),
offline: true,
network: { ip: checkoutIp, userAgent: checkoutUserAgent }
}
})Apps that requested the event-context capability receive it. occurredAt is when it happened; when Qinto accepted it is
recorded separately and cannot be overridden, and neither can the fact that the action came from a server.
Read
const { anonId, globalId } = await qinto.getSession('[email protected]')| Method | Permission | What it returns |
| --- | --- | --- |
| getPerson(identifier, options?) | Read session ids | { globalId, anonIds, nextCursor } — the person an identifier belongs to and every session linked to them, 100 per page. Pass nextCursor back as options.cursor until it is null. With no person yet, anonIds holds the one session the identifier resolves to; it is empty for an identifier never seen. |
| getSession(identifier) | Read session ids | { anonId, globalId } for an identifier of your own or an anon_… id. Both are null for an identifier never seen; globalId alone is null until something links the visitor to a person. |
| getConsent(anonId) | Read consent | The consent state stored for that user. |
| getProperties(anonId) | Read properties | Every property stored for that user. |
getConsent and getProperties take a canonical anon_… id — resolve one with getSession first. A read never
creates anything.
Errors
Every error extends QintoError.
| Class | When |
| --- | --- |
| QintoApiError | The edge answered with a failure. Carries status and code. |
| QintoTimeoutError | The request exceeded requestTimeoutMs. |
| QintoEnvironmentError | createClient was called in a browser. |
code is one of invalid_body, invalid_params, unauthenticated, forbidden, internal, or unknown for
anything else — including the 404 an unseen identifier produces, which is why that case is best recognized by status.
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)
}
}What each failure means for a retry:
- 401
unauthenticated— the token is missing, malformed, or no longer valid. Retrying it unchanged will not help. - 403
forbidden— the token is valid but lacks the permission for this call. The message names the permission to add. - 400 — the request itself is wrong; fix it rather than retry it.
- 500
internal— nothing is guaranteed to have been written. Reads are safe to retry. A retried write may record parts of the action a second time, except a conversion, which deduplicates on its order id.
Types
QintoServerClient, QintoServerOptions, AcceptedResult, TrackResult, ActionOptions, ServerTrackOptions,
ServerActionContextInput, ConsentCategory, ConsentInput, ConsentState, Properties, SessionInfo,
TrackOptions, Traits, and QintoApiErrorCode are all exported.
Documentation
The standard events, the identity model, and the edge API reference are at docs.qinto.io.
Every deployed site also serves its own OpenAPI document at https://<your-qinto-host>/openapi.json.
