Quick start
Install, pass the token you were issued, send an event.
npm install @fraios/inbox-client
import { FraiosAnalytics } from "@fraios/inbox-client";
const analytics = new FraiosAnalytics({
token: process.env.INBOX_TOKEN,
});
await analytics.track("Order Completed", {
order_id: "ORD-1183",
revenue: 149.5,
currency: "EUR",
});
That is the whole integration. source defaults to web, the
production host is the default, and the client handles credential caching and retries.
No build step is needed for the browser — see
Browser ESM.
Browser ESM — no build step
Load the module directly in a modern browser. The production Inbox host is the default,
and source defaults to web — set it when your tenant uses a
different registered transformer.
<script type="module">
import { FraiosAnalytics } from
"https://inbox.fraios.dev/p.module.js";
const analytics = new FraiosAnalytics({
token: "eyJhbGciOiJIUzI1NiJ9...", // browser-class Ingress Token
});
await analytics.page("Checkout");
await analytics.track("Order Completed", {
order_id: "ORD-123",
revenue: 149.5,
currency: "EUR",
}, { userId: "user-42" });
</script>
/p.module.js and /p.js serve the currently deployed client and
are the supported browser URLs. Pin a version by installing
@fraios/inbox-client from npm and bundling it — the
/clients/js/vX.Y.Z/ paths are not per-release snapshots and should not be
treated as pins.
Classic script tag
For sites that do not use modules, the classic bundle exposes the
window.FraiosAnalytics constructor.
<script src="https://inbox.fraios.dev/p.js"></script>
<script>
const analytics = new window.FraiosAnalytics({
token: "eyJhbGciOiJIUzI1NiJ9...", // browser-class Ingress Token
});
analytics.page("Home");
</script>
/p.js and the legacy /s/lib.js URL are aliases for this classic
build. They are not ES modules and cannot be used with named import syntax.
Bundled applications
The package name is @fraios/inbox-client. The npm release is cut by the
repository's signed clients/js/vX.Y.Z workflow with provenance. The hosted
browser files above are released from the same source and remain available independently.
npm install @fraios/inbox-client
import { FraiosAnalytics } from "@fraios/inbox-client";
const analytics = new FraiosAnalytics({
source: "node-worker",
token: process.env.INBOX_TOKEN,
});
await analytics.track("Daily Roll-up Completed", { row_count: 12_345 });
Common calls
Every send method takes the same three arguments: the subject, a payload, and optional envelope overrides.
analytics.track(event, properties, options)
// ↑ ↑ ↑
// 1 2 3
| # | Argument | What it is |
|---|---|---|
| 1 | event | The business fact — "Order Completed". |
| 2 | properties | Your data about this event. Recognised keys are promoted to typed columns; the rest stay in properties. |
| 3 | options | Not more data — overrides for fields the client would otherwise fill itself. |
options has exactly five fields, and no others:
| Option | Overrides |
|---|---|
userId | Who the event is about. Also set stickily by identify(). |
anonymousId | The persisted anonymous id. |
timestamp | When it happened. Defaults to now — set it for backfills. |
messageId | The dedup key. Defaults to a random UUID; supply a deterministic one and a retry stays one event. |
context | The context object — ip, locale, page. |
userId belongs in options, not properties. In
properties it is treated as ordinary event data and never fills the
identity column.
The five send methods
Same shape throughout — subject, payload, options. Note that
identify and group take traits (attributes of
the person or company) where track takes properties (facts
about the event).
identify(userId, traits?, options?)
track (event, properties?, options?)
page (name?, properties?, options?)
screen (name, properties?, options?)
group (groupId, traits?, options?)
await analytics.identify("user-42", { plan: "pro" });
await analytics.track("Order Completed", {
order_id: "ORD-1183",
revenue: 149.5,
currency: "EUR",
}, { userId: "user-42" }); // ← envelope override, not data
await analytics.page("Checkout");
await analytics.group("company-7", { name: "Acme" });
await analytics.reset(); // on logout
Event structure
The client sends a compact envelope. Ingression normalizes it into the canonical Semantic
Event: recognised groups become typed columns, the rest stays in properties,
and the server fills identity, geography and processing metadata. You never construct the
final record yourself.
Use the typed groups
Typed fields are queryable, joinable and stable across producers. properties
is the catch-all for everything else. Group them inside the payload argument and the
client lifts them to the root for you:
await analytics.track("ETL Job Completed", {
dimensions: {
job_name: "daily_user_aggregation",
source_system: "production_postgres_db",
destination_system: "analytics_data_warehouse",
status: "success",
},
metrics: {
duration_seconds: 1245.5,
rows_processed: 15782390,
rows_failed: 0,
cpu_utilization_percent: 78.5,
},
flags: {
is_full_refresh: true,
triggered_downstream: true,
},
pipeline_version: "4.2.1", // not a typed group — stays in properties
});
| Group | Holds |
|---|---|
dimensions | Low-cardinality strings you group by — status, region, job name. |
metrics | Numbers you aggregate — durations, counts, percentages. |
flags | Booleans you filter on. |
The client lifts 16 such groups. Anything else in the payload stays in
properties, which is fine — reach for a typed group when you intend to
query on the value.
What comes back
what your code sends
await analytics.track("Order Completed", {
order_id: "ORD-1183",
revenue: 149.5,
currency: "EUR",
});
what the pipeline stores
{
"type": "track",
"event": "Order Completed",
"event_gid": "847486b2-…", // server-assigned
"partition": "acme.com", // from your write key
"source": { "type": "web" }, // stamped by the client
"commerce": { "revenue": 149.5, "currency": "EUR" }, // lifted out of properties
"properties": { "order_id": "ORD-1183" }, // kept as sent
"location": [ { "country": "Iceland", "locality": "Reykjavík" } ],
"received_at": "2026-09-09T10:35:13.979Z",
"flags": { "page_missing": true } // what was expected and not found
}
Two things to read off that: revenue and currency moved into
commerce, and flags names what the pipeline looked for and did
not get. The activity stream shows this for your own events.
Where a field belongs
Every group has a full reference page:
| Group | What belongs there |
|---|---|
| Core properties | type, event, timestamp, message_id, event_gid — what happened and when. |
| Identity & session | user_id, anonymous_id, session_id, previous_id — who did it. |
| Context objects | context, device, os, app, network, page, screen, campaign — the Segment-compatible environment set. Mostly filled for you. |
| Traits | Attributes of the user or group on identify / group — name, plan, industry. |
| Commerce | Orders, revenue, tax, discounts, payment. revenue and currency are lifted here from properties automatically. |
| Products | The line items inside a commerce event — SKU, quantity, unit price, category. |
| Involves | The entity graph: which account owns the event, who acted, what it was about. The backbone of cross-event joins. |
| Dimensions & metrics | Typed slots for low-cardinality strings and numbers you want to group and aggregate on. |
| Content & properties | Free-form text and the catch-all map. Anything the schema does not recognise lands in properties. |
| Classification | Labels applied to the event — intent, topic, tier. |
| Sentiment | Sentiment scored against entities in the event. |
| Location | Resolved geography. Filled server-side from the request IP unless you supply it. |
| Governance | Retention, access and residency controls. |
| Processing | What the pipeline did — enrichment steps and their outcomes. |
Full reference: Semantic Event schema.
What is captured for you
In a browser the client fills these on every event. You do not pass them, and there is
nothing to switch on — the pipeline models them as typed columns and an event without
them arrives with flags.page_missing set.
| Captured | Becomes |
|---|---|
context.page | url, path, host, title, referrer, referring_domain, search |
context.campaign | the utm_* set, read from the query string |
context.screen | width, height, pixel density |
context.user_agent_raw | one string, which the service expands into the device, os and browser columns |
context.locale / timezone | from the browser |
Anything you pass in the call's context option overrides what was captured.
Outside a browser — Node, Workers, Deno, edge — nothing is captured rather than invented.
context.ip and location are resolved by the service from the
request, not by the client.
The privacy options cover what the visitor brings rather than what your site
owns: ipPolicy, userAgentPolicy and referrerPolicy
each take strip or mask.
Source and source.type
source is the transformer-registered source name. It becomes the
/inbox/{source} path segment and is stamped on every envelope built
by identify, track, page, screen and
group as source.type.
new FraiosAnalytics({ source: "web", token });
// POST /inbox/web "source": { "type": "web" }
new FraiosAnalytics({ token }); // source omitted
// POST /inbox/web "source": { "type": "Web" }
That field is not decoration. Ingression's source-claim guard reads
source.type whenever the write key carries a source claim —
without it, such tokens have every event dropped from the pipeline while the caller still
sees HTTP 200 with event_count: 0.
The URL default is lowercase web because the transformer lookup is an exact,
case-sensitive match: /inbox/Web returns 404 where /inbox/web
resolves. The claim comparison itself is case-insensitive, so a stamped
"Web" still satisfies a claim of web.
Envelopes handed to send() or sendBatch() are transmitted
verbatim and are not stamped; set source on those yourself.
Supplying the credential
Pass the platform-issued Ingress Token you were given. That is the normal case:
const analytics = new FraiosAnalytics({
token: process.env.INBOX_TOKEN,
});
Exactly one of token or refreshCredential is required; passing
both is a ConfigError.
Use token for server integrations, and for browser-class tokens:
those carry an aud claim pinning them to your origins, so they are safe to
ship in page source, much like a Segment write key. Never place a server-class token
(no aud) in a page.
When the credential rotates
If your backend mints a short-lived credential per session, supply a hook instead and the client calls it whenever it needs one:
const analytics = new FraiosAnalytics({
refreshCredential: async () => (await fetch("/api/inbox-credential")).json(),
});
The client caches the result, coalesces concurrent refreshes, and calls the hook again after a 401.
With a static token there is nothing to re-fetch: a 401 invalidates the
cache, the same token is returned, the retry fails, and AuthError is raised.
Reissue or revoke the token rather than relying on refresh.
Validating payloads
Wrong value types in the typed groups are not rejected — they are coerced, and the
coercion loses data. A number in dimensions is stringified; a string in
metrics becomes 0. The event is accepted either way.
dimensions: { rows: 15782390 } → stored as "15782390"
metrics: { status: "success" } → stored as 0
flags: { on: "yes" } → stored as true
Validate before sending
The schema is generated from the same Avro definitions the pipeline stores, so it cannot
drift from what the service expects. zod is an optional peer dependency,
pulled in only by importing this path.
import { PayloadSchema } from "@fraios/inbox-client/schema";
PayloadSchema.parse({
dimensions: { job_name: "daily_user_aggregation", status: "success" },
metrics: { duration_seconds: 1245.5 },
flags: { is_full_refresh: true },
});
It describes what you author. Fields the client fills — messageId,
timestamp, sentAt, anonymousId,
context — and fields the service owns —
partition, event_gid, received_at — are absent by
design, and setting one is an error naming where it belongs.
TrackPayloadSchema, PagePayloadSchema and
TraitsPayloadSchema validate a whole call including the event name and
options. Named types (Involved, Commerce,
ProductLine, Traits and others) are exported alongside, carrying
the field documentation into your editor.
Warnings while you build
With debug on, the client also reports the mistakes that the service accepts
and then discards — identity fields placed in the payload, a typed group that is not an
object, an event name the naming policy rejects. Warnings only: it never throws and never
blocks a send.
new FraiosAnalytics({ token, debug: true });
// [fraios/inbox-client] "Order Complete" — event: not Title Case past tense…
// [fraios/inbox-client] "Order Complete" — properties.userId: belongs in the options argument…
Confirming events landed
HTTP 200 does not mean an event was ingested. The service answers 200 once it has accepted and routed the request; events that fail validation are written to the dead-letter queue and the response reports how many actually made it through.
// single envelope — nothing ingested
{ "status": "accepted", "messageId": "unknown", "event_count": 0 }
// batch — nothing ingested
{ "accepted": 0, "rejected": "validation",
"errors": ["Event[0]: Missing required field 'type'"] }
// success
{ "status": "accepted", "messageId": "693c3a32-...", "event_count": 1 }
Read event_count (single) or accepted (batch). A
messageId of "unknown" is the same signal: no event was
resolved from the payload.
track(), page(), identify(), screen()
and group() resolve on transport success and do not surface the body, so a
resolved promise is not proof of ingestion. sendBatch() returns the parsed
response — call it once during integration and log the result to verify the pipeline
end to end before going live.
Seeing the processed event
event_count tells you an event was accepted. To see what the pipeline
actually did with it — which fields were promoted to typed columns, which stayed in
properties, what enrichment added — open the activity stream and send an
event while it is connected. It is a live feed of your own partition's events, after
processing.
GET https://inbox.fraios.dev/api/activity/stream
x-api-key <your write key>
Accept text/event-stream
The first frame confirms the credential resolved to the right tenant:
event:connection
data:{"status":"connected","partition":"acme.com","limit":50,"topicAvailable":true}
Leave that request open, POST an event from a second client, and it arrives as an
event:semantic-event frame carrying the complete normalized record. It carries the server-assigned event_gid and
partition, the promoted typed groups (see
Event structure), resolved location, and a
flags object naming what the pipeline expected and did not find:
"flags": { "page_missing": true, "library_missing": true }
That makes it a practical structural check during integration: fire one representative event of each kind you intend to send and read back where every field landed.
| Query parameter | Purpose |
|---|---|
match | Exact matches, comma-separated key:value pairs. Keys: partition, region, eventType, or any path into the event. |
search | Free-text across the event. |
limit | Accepted, but the consumer reads from LATEST — no backlog is replayed. |
pause | Connect without streaming. |
The stream is scoped to the partition your credential resolves to; you never see another tenant's traffic. It is not a dry run — events you send while inspecting are ingested and published like any other.
Credential endpoint
Needed only when you use refreshCredential. Integrations holding a
platform-issued Ingress Token pass it as token and need no endpoint at all.
Your authenticated backend should return a credential in this shape — keep it short-lived, since the client re-invokes the hook on expiry and after a 401:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{
"token": "eyJ...",
"expiresAtMs": 1787131200000
}
The JWT must be signed by the server-side Inbox trust path and contain at least
accountId, partition, iat, and exp. A
source claim can narrow it further. The client coalesces concurrent refreshes
and refreshes again after a 401.
Jitsu v2 lineage
This is a fraios-owned client, not a republished Jitsu script. It preserves the core
@jitsu/js call surface for identify, track,
page, screen, group,
setAnonymousId, and reset. The audited compatibility reference is
@jitsu/js@1.10.4 from the Jitsu v2 line.
fraios intentionally replaces Jitsu's static write-key and destination routing with
scoped, revocable credentials and /inbox/{source} — either a platform-issued
Ingress Token passed as token, or a per-session credential supplied through
refreshCredential. Jitsu destination
plugins and unrelated constructor options are not part of this package. Both hosted
files are generated from the fraios package source on every service-image build.
The jitsuAnalytics, inboxClient, and
window.fraiosInbox names remain compatibility aliases.
Deployment and troubleshooting
- Browser asset loading is public and cross-origin. Event ingestion remains protected by JWT validation and the configured CORS origin allow-list.
-
Add customer browser origins declaratively to
CORS_ALLOWED_ORIGINSin the ingression Kubernetes configuration before they send events directly from a browser. A blocked origin fails the preflight, so the request never leaves the browser and the client raises a transport error with no HTTP status — it reads like an outage rather than a configuration gap. Routing events through a first-party path on the customer's own origin avoids this entirely. -
A 401 means the credential is missing, invalid, expired, or lacks its server-side
partition claim. A 403 means the token's
sourceclaim does not match thesourcesegment of the URL, or its scope is not allowed. A 404 after a valid credential means no transformer is registered for the authenticated partition and requested source. - Browser privacy extensions can block analytics scripts or collector hostnames before a request reaches fraios. If delivery is business-critical, test with the extensions your audience uses and consider a first-party proxy or same-site hostname; server retries cannot recover a request blocked inside the browser.
-
The service accepts up to 1,000 events or 30 MiB per request. New integrations should
use
/inbox/...;legacySegmentModeexists only for migrations. Note that the transformer runs before validation, so batches only survive a transformer that explicitly iterates a top-level array — a naive JOLT identity spec ([{"operation":"shift","spec":{"*":"&"}}]) passes a single envelope through correctly but shreds an array, and every event then fails validation onMissing required field 'type'.