Skip to content
Docs
FA
Sign in
Start here
  • Overview
  • Quickstart
  • Concepts
Collect data
  • Designing events
  • Event dictionary
  • Placing events
  • Identity
  • Web SDK
  • Android SDK
  • Devices and push
  • Server to server
  • Product catalogue
  • Webhooks
Engage customers
  • Segments
  • Journeys
  • Transactional
  • Consent and caps
  • In-app and inbox
Analyze and export
  • Reports and exports
Developer reference
  • API reference
    • Ingest endpoints
    • Management API
  • Errors
  • Limits
  • OpenAPI
Developer tools
  • MCP server
  • Working with an agent
Privacy and changes
  • Personal data
  • Versioning

The web SDK

Installing on a site, every method, the options, the offline queue and browser push.

The web SDK collects events in the browser, holds them in localStorage until the network comes back, and sends every message with a stable identifier so that a resend over a bad connection does not count somebody's purchase twice. The same file also drives browser push and on-site messages.

The library identifies itself on every message with library.name = "segmentic-js" and library.version = "0.1.0".

#Installing

The bundle is served from the same origin the events are posted to. That is deliberate: it means one entry in the customer's Content-Security-Policy, not two.

https://in.segmentic.net/sdk/segmentic.js

#Script tag

This is what the Connect screen in the panel generates. The write key (wk_seg_...) is public and is meant to be visible in the page source.

HTML
<script src="https://in.segmentic.net/sdk/segmentic.js"></script>
<script>
  Segmentic.init({
    writeKey: "wk_seg_...",
    apiHost: "https://in.segmentic.net"
  });
  Segmentic.page();
</script>

In local development the bundle is served by the dashboard out of its own public directory, and the collector is somewhere else:

HTML
<script src="http://localhost:3000/sdk/segmentic.js"></script>
<script>
  Segmentic.init({
    writeKey: "wk_seg_...",
    apiHost: "http://localhost:8080"
  });
  Segmentic.page();
</script>

There is no versioned form of this URL. The path /sdk/segmentic.js always serves the current build, and no version-pinned address is published. No integrity hash is published either, so if your security policy requires Subresource Integrity you have to host the file yourself and compute the hash yourself.

#With a bundler

There is no npm package. The name @segmentic/web appears in the repository's package.json, but it is not published to any registry, no CI step builds or publishes it, and npm install @segmentic/web fails.

The npm tab on the panel's Connect screen still shows import segmentic from "@segmentic/web". That snippet does not run today. Until the package is published, the script tag is the only supported path.

If your project must import a module, take the ESM file out of the same bundle and vendor it beside your own code. The type definitions (index.d.ts) are not served from any public address, so TypeScript gets no types through that route.

#init and every option

writeKey and apiHost are required, and their absence throws: segmentic: writeKey is required and segmentic: apiHost is required. That is the only place the SDK throws. Trailing slashes on apiHost are stripped.

OptionTypeDefaultWhat it does
writeKeystringnone, requiredWrite key from the panel. Public by design
apiHoststringnone, requiredCollector base URL
batchSizenumber20Send immediately once this many messages are buffered
flushIntervalnumber ms10000Send at least this often
maxQueueSizenumber500How many messages may wait on disk while offline
maxRetriesnumber10Caps how far the retry interval grows. Not a cap on the buffer
autoContextbooleantrueCollect page, locale, screen and timezone automatically
autoPageViewbooleantrueSend one page message during init()
respectDoNotTrackbooleantrueHonour the browser's Do Not Track setting
onsitebooleantrueFetch and draw the tenant's on-site campaigns
debugbooleanfalseLog SDK activity to the console with the prefix [segmentic]
sessionTimeoutnumber ms1800000Idle gap after which the session identifier rotates
now() => number() => Date.now()Time source. For tests
fetchImpltypeof fetchglobalThis.fetchFetch implementation. For tests

onsite defaults on deliberately: an on-site campaign is published by hand, and a customer who publishes one and sees nothing on their site has no way to tell a broken install from an empty campaign list.

init() does this, in order: create the store, probing localStorage with a real write; load the queue from disk; load or start the session; load the opt-out state and check Do Not Track; load or mint the anonymous id; install the lifecycle hooks; schedule the periodic send; record the campaign click; then page() if autoPageView is on; then an immediate flush() to drain anything buffered offline; and finally start on-site.

The campaign click is recorded before the page view, so the click is the first thing recorded and the page view that follows already carries the attribution.

Calling init() again closes the previous client with close() first.

autoPageView sends a page view once, inside init(), and never again. The SDK installs no hook on pushState, replaceState or popstate. The doc comment on the Options type says "on init and on history navigation" and the second half of that does not exist in the code. In a single-page app, call Segmentic.page() yourself after each route change.

#Methods

TypeScript
init(options: Options): SegmenticClient
track(event: string, properties?: Properties, context?: Context): void
identify(userId: string, traits?: Traits, context?: Context): void
page(name?: string, properties?: Properties, context?: Context): void
screen(name: string, properties?: Properties, context?: Context): void
alias(previousId: string, context?: Context): void
reset(): void
flush(): Promise<void>
optOut(): void
optIn(): void
isOptedOut(): boolean
getAnonymousId(): string | null
getUserId(): string | null
stats(): Stats | null
subscribeToPush(options: PushOptions): Promise<PushResult>
unsubscribeFromPush(): Promise<boolean>
pushPermission(): NotificationPermission | "unsupported"

track with an empty event name does nothing and logs to the console. So does identify with an empty user id. Neither throws, because an analytics call must never be the thing that breaks a customer's checkout page. Eleven of these methods called before init() write [segmentic] <method>() called before init(); ignoring to the console and return: track, identify, page, screen, alias, reset, flush, optOut, optIn, subscribeToPush and unsubscribeFromPush. The five that only read a value say nothing at all before init(): isOptedOut() returns false, getAnonymousId(), getUserId() and stats() return null, and pushPermission() returns "unsupported". The false is the one to watch, because it reads as "not opted out" and no warning tells you the SDK was never started.

identify does three further things. First, if this is the first identify after anonymous browsing and the id differs from the previous one, it enqueues an alias message before the identify; without it the entire pre-login history is orphaned and every funnel crossing the login boundary reports the wrong number. Second, it flattens the scalar traits into localStorage so on-site targeting can match on them; anything of type object and anything null or undefined is skipped, and the rest is coerced with String(). Third, it calls refreshOnsite(), because a signed-in visitor may now match a campaign an anonymous one did not.

page(name) sends the name both as event and copied into properties.name.

screen(name) sends a message of type screen. On the web, page() is the right one; screen exists so the surface matches the mobile SDKs.

reset() clears the user id, mints a new anonymous id, drops the session, and drops the stored campaign attribution as well (on a shared computer, the next person's purchase must not be credited to the message the last one received). It deliberately does not clear the returning-visitor flag: signing out does not make somebody a new visitor.

flush() resolves when the attempt finishes, not necessarily when anything was sent: inside the backoff window it returns without issuing a request. Concurrent calls are chained rather than coalesced, because an event enqueued after the running pass emptied the queue but before it settled would otherwise be reported as delivered while still sitting on disk.

stats() returns this shape:

TypeScript
{
  queued: number;          // messages in the queue right now
  sent: number;            // messages accepted since init
  dropped: number;         // messages discarded
  failures: number;        // current run of consecutive failures
  optedOut: boolean;
  durableStorage: boolean; // false means memory only, the queue dies on reload
  anonymousId: string;
  userId: string | null;
}

#The global and the instance

Two surfaces exist and they are not the same.

window.Segmentic is the module namespace. It carries the seventeen methods above, plus SegmenticClient, pushSupported, decodeVapidKey, the on-site targeting evaluator (eligible, matches, maySee, isLive, deviceOf, readSeen, writeSeen, recordSeen, recordAction) and a default key.

The instance returned by init() has three methods that are not on the global:

TypeScript
close(): void                          // stops timers and listeners
onsiteCampaigns(): OnsiteCampaign[]     // the campaign list as last fetched
refreshOnsite(): void                   // decide again which campaign to show

To reach those three you have to keep the return value of init():

JavaScript
const segmentic = Segmentic.init({
  writeKey: "wk_seg_...",
  apiHost: "https://in.segmentic.net"
});

// after each route change in a single-page app
router.afterEach(() => {
  segmentic.page();
  segmentic.refreshOnsite();
});

#What goes on the wire

Every message has this shape. Empty fields are omitted; nothing is sent as null.

TypeScript
{
  type: "track" | "identify" | "page" | "screen" | "alias";
  message_id: string;      // always present
  timestamp: string;       // ISO 8601, always present
  sent_at?: string;        // stamped at send time
  event?: string;
  user_id?: string;
  anonymous_id?: string;
  previous_id?: string;
  properties?: Properties;
  traits?: Traits;
  context?: Context;
}

The body posted to POST {apiHost}/v1/batch wraps the messages in batch and writes sent_at both on the envelope and on every message inside it:

Shell
curl -X POST https://in.segmentic.net/v1/batch \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "sent_at": "2026-07-30T12:00:00.000Z",
    "batch": [
      {
        "type": "track",
        "message_id": "0f9c6f1e-3d4a-4a1e-9c5b-2b7a1f6d8e30",
        "timestamp": "2026-07-30T12:00:00.000Z",
        "sent_at": "2026-07-30T12:00:00.000Z",
        "anonymous_id": "6b7d2c11-8a45-4f0e-9d33-1c2e5b8a7f44",
        "event": "order_completed",
        "properties": { "revenue": 2500000, "currency": "IRR", "city": "تهران" },
        "context": {
          "library": { "name": "segmentic-js", "version": "0.1.0" },
          "session_id": "2a3c1d55-77b0-4c8e-9a1f-0e6d4b3a2c19",
          "locale": "fa-IR",
          "timezone": "Asia/Tehran",
          "page": {
            "url": "https://shop.example.com/checkout/done",
            "path": "/checkout/done",
            "search": "",
            "title": "سفارش ثبت شد",
            "referrer": "https://shop.example.com/cart"
          }
        }
      }
    ]
  }'

The response to a fully accepted batch:

JSON
{ "status": "ok", "accepted": 1 }

The response when one item of three is bad. The rest are accepted, and the collector says exactly which index was refused:

JSON
{
  "status": "ok",
  "accepted": 2,
  "rejected": 1,
  "errors": [{ "index": 1, "reason": "missing_identity" }]
}

Headers: Content-Type: application/json and Authorization: Bearer {writeKey}. The keepalive flag is set only when the body length is under 60000 bytes.

Server-side ceilings: at most 500 items per batch and at most five megabytes of body. With the default batchSize of 20 you approach neither.

#Automatic context

With autoContext: true, these travel on every message:

  • library with the library name and version. Always, and not overridable: a context you pass yourself is merged over the rest, but library is forced back to ours
  • session_id, always
  • locale from navigator.language
  • timezone from Intl.DateTimeFormat().resolvedOptions().timeZone, inside a try and catch, because Intl is missing on some embedded browsers
  • screen with width, height and density
  • page with url, path, search, title and referrer
  • network with cellular and wifi, only when navigator.connection.type exists

device and os are deliberately not sent. The collector derives them from the User-Agent header, which a client cannot forge; anything sent from here would be a hint rather than a fact. The IP likewise comes from the connection, never from the body.

With autoContext: false only library and session_id travel. Campaign attribution is attached even then, because it is an answer the customer asked for rather than something we collected about the visitor.

A per-call context is merged over the collected one, with the app, page and campaign sub-objects shallow-merged:

JavaScript
Segmentic.track("video_played", { id: 42 }, {
  app: { name: "shop-web", version: "5.2.1" }
});

#Campaign attribution

These URL parameters are read:

URL parametergoes to context.campaign.
utm_sourcesource
utm_mediummedium
utm_campaignname
utm_termterm
utm_contentcontent
sg_midmessage_id
sg_ttoken
sg_cidcampaign_id, only if finite and greater than zero

An attribution starts only with sg_mid. A URL carrying only UTM parameters records no click.

When a new sg_mid is seen, the SDK enqueues a track message with the event name message_clicked, once, until a different message id replaces it. A refresh, a back button, or a link passed around among colleagues does not count as a second click. The comparison is against the one id in storage, not against a history of them, so a visitor who lands on one message, then a second, then the first one again reports the first click twice.

The captured campaign is stored in localStorage with a timestamp and replayed onto every later event for seven days. Seven days matches the server's window exactly; two different windows would mean the SDK sending conversions the server silently discards.

Because it is replayed from storage rather than re-read from the address bar, tidying the URL with replaceState does not lose the attribution. Reading the query string on every event was the alternative, and it is wrong twice: on a single-page app the parameters linger and the whole visit is credited to the message, while a site that tidies its URL loses the credit one click later. Neither is visible in testing.

A newer sg_mid replaces the older one and reports a second click. reset() drops the attribution. A visitor who has opted out records nothing at all, not even the campaign click.

The SDK never looks at sg_t. Message ids are derived and guessable, and that token is what separates a real click from one anybody could have typed into a URL bar; the SDK's job is only to carry it back.

#Sessions

One session_id per visit. The identifier rotates after sessionTimeout of idleness rather than on page load, so somebody who reads an article for ten minutes and then clicks stays in the same session. An expired session is not resurrected on the next page load.

#The offline queue

Every message is written to localStorage, under the key segmentic_queue, before any network attempt. Closing the tab mid-request, losing signal, or an outage at our end costs nothing.

localStorage is probed with a real write (the key __segmentic_probe__) rather than by trusting that the object exists. In Safari private mode, in hardened enterprise browsers, and when the origin's quota is exhausted, that write throws and the SDK falls back to an in-memory store that at least keeps the current page working. stats().durableStorage reports which one is in use.

A send happens in two cases: when the queue length reaches batchSize, and every flushInterval. The browser's online event also triggers one. Draining loops, taking batchSize items at a time until the queue is empty; with batchSize: 2 and five events the batch sizes are exactly 2, 2 and 1.

A corrupt queue costs nothing. JSON that will not parse causes the key to be removed and an empty queue returned, and entries that do not look like a message (no string message_id, no string type) are filtered out individually rather than the whole buffer being discarded.

#What is dropped and how it is reported

There are three drop reasons. All of them accumulate in stats().dropped and, with debug: true, are written to the console as [segmentic] dropped <n> messages: <reason>.

ReasonWhen
queue_fullthe queue length exceeded maxQueueSize
storage_quotathe write to disk failed, half the buffer was shed and the write retried
storage_unavailablethe second write failed too. Work continues in memory and what is held dies with the tab

Overflow drops from the front, that is, the oldest first. After a long outage the freshest events are the ones still worth having. With maxQueueSize: 10 and twenty-five events, the last ten survive and fifteen are counted in dropped.

There is a fourth way to lose a message that also lands in dropped and does not come from the queue: a 4xx from the server, described below.

#Retries and status codes

ResponseBehaviour
2xxacked, removed from the queue, the failure counter resets
4xx except 429dropped permanently. Removed from the queue, counted in dropped, and with debug this line is logged: server rejected batch permanently: <status>
429stays queued and is retried
5xxstays queued, backoff applied
network, DNS or CORS errorassumed transient, stays queued

The reasoning behind 4xx: it means the payload is wrong and will never be accepted. Retrying forever would block every later event behind it. So it is dropped, but loudly.

The same logic is honoured on the server. When the write key lookup itself fails, the collector answers 503 with Retry-After: 5 rather than 401. An SDK reads 401 as "this key will never work" and discards the events; it reads 503 as "try again later" and keeps them.

Backoff is exponential with full jitter: the delay is a uniform random number between zero and min(300000, 1000 * 2^n) milliseconds, where n is the run of consecutive failures. So the ceiling is five minutes. The jitter matters more than the curve: when a backend recovers, thousands of devices that failed at the same moment must not all retry at the same moment and knock it over again.

maxRetries caps only how far that interval grows, not how long data is kept. When the failure count reaches it, this line is logged: max retries reached; messages stay queued for the next session. The messages stay. The user may simply be on a train.

#Deduplication by message_id

message_id is a UUID generated once, at enqueue time, and reused on every retry. That is the entire basis of deduplication: on a flaky mobile network the SDK will resend, and without a stable id the customer's purchase count silently doubles.

The id comes from crypto.randomUUID(), otherwise from crypto.getRandomValues() with the RFC 4122 version four bits set by hand, and finally from Math.random() for very old browsers. That last path is weaker, but a colliding id costs one deduplicated event, whereas throwing there would cost all of them.

On the server the collector remembers the ids it has seen. How long is set by DEDUPE_TTL, which defaults to 48 hours. Duplicates count as accepted to the sender, because the SDK already delivered them once and must stop retrying.

#Clock-skew correction

Every batch carries sent_at on the envelope and on each message. The collector measures the offset between its own receive time and that sent_at, and applies the same offset to the event's timestamp. A clock that is wrong but consistent is recovered that way.

The details that matter:

  • an offset under one minute is ignored
  • the correction is applied only if the corrected time stays inside the permitted window; otherwise the original timestamp is kept
  • a timestamp more than one hour ahead of the server is clamped to the receive time and a timestamp_in_future warning is returned
  • a timestamp older than the past window is clamped to that window's edge and a timestamp_too_old warning is returned. The window is the account's own events retention: an account that keeps events for ever gets 3650 days, an account that has set 90 days gets 90, and an account on the 30 day floor still gets the full 30 day default

#Leaving the page

The SDK listens for visibilitychange and pagehide, and on both sends one batch with navigator.sendBeacon, as a Blob of type application/json, to:

{apiHost}/v1/batch?write_key={writeKey}

The write key travels in the query string here because sendBeacon cannot set headers and the collector accepts it there for exactly this case.

Beaconed messages are not removed from the queue. sendBeacon reports only that the request was handed to the browser, never that it arrived. Leaving them means the next page load may resend, which is safe, because every message carries a stable message_id.

#Storage keys

KeyContents
segmentic_anonymous_idthis browser's anonymous id
segmentic_user_idthe last id given to identify()
segmentic_queuethe outbound queue
segmentic_sessionsession id, start time and last-seen time
segmentic_opt_outthe value 1 means opted out
segmentic_campaignthe campaign this visit is credited to, with its timestamp
segmentic_traitsthe scalar traits from the last identify(), for on-site targeting
segmentic_seenthis browser has been here before. Not cleared by reset()
sg_onsitehow often each on-site campaign was seen, dismissed or converted

Plus one Cache API entry named segmentic-config at the key /__segmentic_push_config, used only by browser push.

No cookie is ever written.

#Consent, opt-out and Do Not Track

optOut() stops collection and clears the queue. That is deliberate: honouring an opt-out only for future events, while quietly delivering what was already captured, is not an opt-out.

After optOut():

  • the enqueue path returns immediately, so track, identify, page, screen and alias produce nothing
  • flush() resolves without any request
  • the unload beacon does nothing
  • the campaign click is not recorded, so segmentic_campaign is never written
  • subscribeToPush() returns { state: "failed", reason: "..." }
  • no on-site message is drawn

The opt-out flag lives in localStorage, so it survives a reload. optIn() clears the flag and the key and restarts the send timer.

One honest caveat: optOut() stops the send timer only. The sixty-second on-site poll timer is not stopped, so if the SDK had already started polling before the opt-out, it keeps issuing a GET /v1/onsite every sixty seconds. That request carries no identity, and nothing is drawn or reported, but the request continues. Only close() on the instance stops it.

Do Not Track is read when respectDoNotTrack is true, which is the default: navigator.doNotTrack, then globalThis.doNotTrack, then navigator.msDoNotTrack, with "1" or "yes" meaning opted out. It is read once, in the constructor. Changing the browser setting mid-session has no effect until the next init(). That is also why optIn() successfully re-enables collection within the same session even under Do Not Track.

#Browser push

There are two prerequisites and neither is optional.

One: identify() must have run. A subscription is stored against a person, and one saved for an anonymous visitor could never be targeted by a segment. Without it you get { state: "failed", reason: "اول باید identify صدا زده شود تا اشتراک به کاربر وصل شود" }.

Two: the VAPID public key. Without it you get { state: "failed", reason: "کلید VAPID تنظیم نشده است" }.

The VAPID public key is shown on no screen in the panel today, and no endpoint returns it. It is one key for the whole installation, generated with adminctl vapid and set as VAPID_PUBLIC_KEY in the collector's and the worker's environment. Ask your Segmentic contact for it. Changing that key invalidates the subscription of every browser already subscribed.

Subscribing is not the same as being reachable, and today that gap is real. Before the delivery path ever reaches the browser-push sender, it asks the device registry how many installs this user_id has, and it treats the webpush channel exactly like mobile push: as an address belonging to an install. If that user has no row in the device registry, the message is set aside as not_reachable before anything is attempted. This SDK calls POST /v1/webpush/subscribe and never POST /v1/devices, so a visitor who only has a browser and has installed no app gets their subscription stored and no campaign at all. It does not show up as a failure in the report; it shows up as unreachable. Check with your Segmentic contact before building browser push into a site with no app behind it.

#The service worker

You have to serve segmentic-sw.js from your own origin root. A worker's scope cannot be broader than the path it is served from, so a worker served from /static/ can only receive pushes for pages under /static/. Our origin is no use either: a service worker has to be same-origin with the page.

The current file is downloadable from the panel:

Shell
curl -o segmentic-sw.js https://app.segmentic.net/segmentic-sw.js

Put it beside your own index.html so that it is served at https://your-site.example/segmentic-sw.js. If you put it somewhere else, pass serviceWorkerPath and scope to match.

What the worker does: install calls skipWaiting and activate calls clients.claim, so a new version takes over without every tab being closed. Notifications are drawn with dir: "rtl" and lang: "fa", and badge falls back to icon. A push that is not our JSON still shows a notification titled پیام جدید, because no browser permits a silent push and Chrome substitutes its own "this site has been updated in the background" notice, which is worse.

A click on a notification focuses an existing tab on the same URL if there is one, and opens a window otherwise. The identifiers (sg_mid and a signed token) ride on the URL itself, so the click is reported by your own site on page load and no redirect service of ours sits in the middle. That is what keeps the link working when our analytics is not.

The worker also listens for pushsubscriptionchange and re-posts the replacement subscription. To do that it needs apiHost, the write key, the public key and the user id, which are stashed in the Cache API at subscribe time, because that event can fire with no tab open and a worker has no access to the page's variables. Without it, a push service can rotate a subscription on its own and the person silently falls out of every campaign; the only symptom is a delivery rate that drifts down over months.

#The push methods

TypeScript
subscribeToPush(options: {
  publicKey: string;             // required
  serviceWorkerPath?: string;    // defaults to "/segmentic-sw.js"
  scope?: string;                // defaults to "/"
}): Promise<{ state: PushState; reason?: string }>

unsubscribeFromPush(): Promise<boolean>
pushPermission(): NotificationPermission | "unsupported"

PushState is one of subscribed, denied, dismissed, unsupported or failed. reason is a Persian string, safe to show a visitor or to log.

Call subscribeToPush from a click. A browser lets a site ask for notification permission once, and Chrome blocks a site's prompts outright if enough people dismiss them. An unprompted request on arrival is therefore the most reliable way to lose the channel permanently. The SDK does not enforce this and will not stop you; the consequence is simply irreversible.

HTML
<button id="notify">Turn on offer alerts</button>

<script src="https://in.segmentic.net/sdk/segmentic.js"></script>
<script>
  Segmentic.init({
    writeKey: "wk_seg_...",
    apiHost: "https://in.segmentic.net"
  });
  Segmentic.identify("u_123", { city: "تهران" });

  document.getElementById("notify").addEventListener("click", async () => {
    const result = await Segmentic.subscribeToPush({
      publicKey: "BEl62iUYgUivxIkv69yViEuiBIa40HI0DLLuxazjqAKeFXlyeeVpMS0"
    });
    if (result.state !== "subscribed") {
      console.log(result.state, result.reason);
    }
  });
</script>

The order the checks run in, and what each one answers:

Casestatereason
the user opted outfailedکاربر از ردیابی انصراف داده است
identify() has not runfailedاول باید identify صدا زده شود تا اشتراک به کاربر وصل شود
the browser lacks one of the three APIsunsupportedاین مرورگر از اعلان وب پشتیبانی نمی‌کند
publicKey is emptyfailedکلید VAPID تنظیم نشده است
permission was already refuseddeniedکاربر قبلاً اجازهٔ اعلان را رد کرده است
the prompt was answered with nodeniedاجازهٔ اعلان داده نشد
the prompt was closed unanswereddismissedپنجرهٔ اجازه بسته شد
the server registration failedfailedثبت اشتراک روی سرور انجام نشد
successsubscribednone

Three things in that table.

If Notification.permission is already denied, the SDK does not call requestPermission at all. Asking again somebody who already said no is both useless and, in Chrome, a step towards having the site's prompts blocked permanently.

dismissed is distinct from denied because it means something different. Somebody who closed the prompt has not refused; the site may ask again later, which a hard denied would wrongly rule out.

If a subscription already exists on this registration it is reused rather than replaced. Unsubscribing and resubscribing mints a new endpoint, which leaves the old row in the table pointing at a subscription the browser has forgotten, and every campaign then reports one failure per stale endpoint.

Three more checks the SDK makes that are not in the table: pushSupported() requires all three of serviceWorker in navigator, PushManager in window and Notification in window; after registering the worker it awaits navigator.serviceWorker.ready, because calling pushManager on a registration that is still installing throws on Safari; and it always subscribes with userVisibleOnly: true, which every browser implementing the Push API requires.

On success this request is sent directly rather than through the queue:

Shell
curl -X POST https://in.segmentic.net/v1/webpush/subscribe \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "u_123",
    "subscription": {
      "endpoint": "https://fcm.googleapis.com/fcm/send/abc123",
      "p256dh": "BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM",
      "auth": "tBHItJI5svbpez7KI4CCXg"
    }
  }'
JSON
{ "status": "ok" }

It does not go through the queue because no buffer can replay it: the permission has already been granted and the browser will not ask again, so a subscription that never reaches us is a person who agreed to be notified and can never be reached. That is why a non-2xx response is returned to the caller as { state: "failed" }, so the host page can retry.

unsubscribeFromPush() reads the browser's subscription, calls unsubscribe(), then posts POST {apiHost}/v1/webpush/unsubscribe with the body { "endpoint": "..." }. If there was no subscription it returns false without any request. The server requires no user id and checks none: the endpoint is the subscription's own secret, and possession of it is already sufficient to send to that browser, so demanding more before allowing somebody to stop receiving would be protecting the wrong direction.

pushPermission() returns Notification.permission without prompting, or "unsupported" when the three APIs are not all present.

#On-site messages

With onsite: true, the default, the SDK fetches the list of live campaigns and decides in the browser which one to show.

GET {apiHost}/v1/onsite?write_key={writeKey}

Once during init() and then every sixty seconds. Sixty matches the response's Cache-Control exactly; fetching faster misses the cache and puts a request on the customer's page load for an answer that cannot have changed. The response carries no identity, so one copy serves every visitor and is cacheable.

A failure of that request is completely silent. It runs inside somebody else's page load; a failure of ours degrades to "no banner today", never to a console error on their site. The same holds on the server: a database error returns an empty list rather than a 5xx.

The targeting is local because the alternative is one request per page view, a million a day for a mid-sized Iranian shop, on the critical rendering path of their site, with our latency in front of their content and our availability in front of their business. The cost of that decision is that the rules are public: anybody can read them in the network tab, which is why the rule vocabulary deliberately contains nothing a customer would mind a competitor seeing.

The rules evaluated in the browser: url_contains, url_not_contains, devices, new_visitors_only, returning_only, logged_in and traits.

URL matching is substring, never regular expression. A pattern written by a marketer is one that can be catastrophically slow, and this runs on every page of somebody else's site.

The device class comes from the viewport width, not the user agent: under 768 is mobile, under 1024 is tablet, anything else is desktop. User-agent sniffing is wrong on every device that lies about itself, which by now is most of them, and what a campaign targeting "mobile" actually means is "a narrow screen".

traits is plain equality against the traits the last identify() left in this browser, not against the warehouse. The browser has only what it was given, and pretending otherwise would make every trait rule silently false.

The frequency cap is applied in this exact order:

  1. the campaign is outside its starts_at and ends_at window: no. ends_at is exclusive, so the end instant is already not live
  2. never seen: yes
  3. converted is recorded: no, for ever. Somebody who did the thing should never be asked again, and that outranks every other rule including a campaign that is still running
  4. dismissed is recorded and the campaign is dismissible: no. A non-dismissible banner keeps showing
  5. max_impressions is greater than zero and the seen count has reached it: no. Zero means no ceiling
  6. cooldown_hours is greater than zero and less than that has passed since the last impression: no

Both the browser and the server apply this cap. Local storage alone means clearing it gives an uncapped modal; the server alone means a request per page view, which is what this whole design exists to avoid.

A click counts as a conversion for capping purposes: somebody who followed the link has done the thing, and showing it again asks them to do it twice.

#What is drawn

All four kinds are drawn: banner (a strip at the top or bottom), modal (centred, with a backdrop), slidein (a corner) and survey (a corner, with either an NPS zero to ten scale or a list of choices).

At most one campaign at a time. Two modals at once is not a design anybody chose, and the second would cover the first's close button. The first eligible campaign, in the order the server sent them, is the one shown.

Three rules shape the whole rendering code, and all three are about being a guest on somebody else's page:

  • Never throw. An analytics widget must not be the thing that breaks a checkout. Every entry point is wrapped
  • Never inherit. The host page's CSS reset, font stack and * { } rules would otherwise reshape the widget in ways nobody previewed, so every property that matters is set explicitly on the element
  • Never inject markup. Content is set with textContent, never innerHTML. A headline comes from a panel field, and a customer who pastes markup into it must not get a script running on their own site

A button URL becomes an href only if it starts with http:// or https:// or with /. A javascript: URL gets no href at all. The button text stays and the click is still reported.

Layout details: the container is one position: fixed div with the id segmentic-onsite, z-index of 2147483000 (below the maximum, so a customer's own overlay can still win), pointer-events: none on the container with each widget re-enabling clicks for itself (otherwise an invisible full-page div would swallow every click on the customer's site), and direction: rtl on the container, because a widget authored in Persian must read right to left even on a page that does not. The close button sits on the left and its aria-label is بستن. The default colours are background #1f2937, text #ffffff, accent #2563eb.

Triggers: scroll, exit intent and delay. The delay is a trigger of its own only when neither scroll nor exit intent is set; alongside either of those it would race them and show the message on a timer the marketer meant as a minimum. Exit intent is desktop only: a touch device has no pointer to leave for the tab bar, and libraries that fake this fire on every upward scroll.

The survey shows the question, then either an NPS row of zero to ten drawn direction: ltr inside the right-to-left card (zero on the left through ten on the right, as the scale is universally drawn) or the list of choices. If follow_up is set, a textarea appears after the answer and its text is sent as a second response. That second post carries the whole answer, the score or the choice as well as the new text, because the save behind it is idempotent on (campaign, person) and replaces the row rather than adding to it. The default thank-you is ممنون از وقتی که گذاشتید. and it stays up for two seconds before closing: a widget that vanishes the moment somebody answers reads as a page glitch, not as an acknowledgement.

The impression is recorded locally before the report is sent, so the cap holds even if the request never lands.

The reports go directly rather than through the queue, with keepalive: true, and their errors are swallowed. An impression that arrives ten seconds late is fine, but it must not be held behind a batch waiting for nineteen more messages on a page the visitor is about to leave.

Shell
curl -X POST https://in.segmentic.net/v1/onsite/event \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "campaign_id": 7,
    "action": "impression",
    "anonymous_id": "6b7d2c11-8a45-4f0e-9d33-1c2e5b8a7f44",
    "user_id": "u_123"
  }'

action is one of impression, click, dismiss or convert. user_id is omitted when the visitor is anonymous.

A survey answer goes to its own endpoint. The server reads two answer fields and no others: score, zero to ten on an NPS survey, and answers, a map of string to string, which is where a multiple choice or a free text answer belongs. Any other key is discarded when the body is decoded and the answer still comes back 200, so if you draw the survey yourself, post answers and nothing else. Our renderer uses the keys choice and text inside that map; the panel shows those two under a readable label and any key of your own exactly as you sent it.

Omitting score on an NPS survey is refused with 400 rather than read as zero. That is deliberate and it is the second half of a bug this SDK had: a missing number is not the worst number on the scale.

Both of these were wrong until recently, and the answers lost in between are gone. On-site messages has what to check.

Shell
curl -X POST https://in.segmentic.net/v1/onsite/response \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "campaign_id": 7,
    "anonymous_id": "6b7d2c11-8a45-4f0e-9d33-1c2e5b8a7f44",
    "user_id": "u_123",
    "score": 9,
    "answers": { "reason": "ارسال سریع بود" }
  }'

#Rendering them yourself

If your shop has its own design system, turn our rendering off and keep the eligibility and frequency-cap logic. The targeting evaluator is exported rather than hidden precisely because it is the part a customer may want to run themselves.

HTML
<script src="https://in.segmentic.net/sdk/segmentic.js"></script>
<script>
  const segmentic = Segmentic.init({
    writeKey: "wk_seg_...",
    apiHost: "https://in.segmentic.net",
    onsite: false
  });

  // With onsite: false nothing is fetched, so fetch it yourself.
  fetch("https://in.segmentic.net/v1/onsite?write_key=wk_seg_...")
    .then((res) => res.json())
    .then((body) => {
      const seen = Segmentic.readSeen(localStorage);
      const visitor = {
        url: location.href,
        device: Segmentic.deviceOf(window.innerWidth),
        loggedIn: segmentic.getUserId() !== null,
        returning: localStorage.getItem("segmentic_seen") === "1",
        traits: JSON.parse(localStorage.getItem("segmentic_traits") || "{}"),
        now: Date.now()
      };
      const list = Segmentic.eligible(body.campaigns || [], visitor, seen);
      if (list[0]) {
        myOwnBanner(list[0]);
        Segmentic.writeSeen(
          localStorage,
          Segmentic.recordSeen(seen, list[0].id, visitor.now)
        );
      }
    });
</script>

With onsite: false nothing is fetched, so onsiteCampaigns() returns an empty array and refreshOnsite() returns immediately. If you leave onsite on but want your own rendering, there is no way to disable only the rendering.

#CORS and CSP

Every write-key endpoint on the collector writes these headers before it does anything else, including before authenticating, so a browser sees the real status code rather than a CORS error. That covers everything this SDK calls: /v1/batch, /v1/webpush/subscribe and /v1/webpush/unsubscribe, and the three on-site endpoints.

HTTP
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Segmentic-Key
Access-Control-Max-Age: 86400

Access-Control-Allow-Credentials is deliberately not set. A wildcard origin is only safe because of that.

GET /v1/status carries none of these headers, so a browser cannot read it from your page. It is there for a monitor or a curl, not for a health check you run in the front end. The email endpoints under /e/ carry none either.

The methods list has no GET, and does not need one: the only GET the SDK issues is the on-site campaign fetch, which sets no custom header, so it is a simple request and is never preflighted.

For Content-Security-Policy one entry is enough, because the bundle and the events share an origin:

script-src https://in.segmentic.net;
connect-src https://in.segmentic.net;

If you publish an on-site message with an image_url, img-src also needs that image's origin. The widgets set their styles inline on the element rather than through a style tag, so style-src is untouched.

#Measured size

Measured on the built files that are being served right now:

FileRawGzipped
script-tag bundle (IIFE)26091 bytes8832 bytes, as served
ESM25579 bytes8580 bytes under a local gzip -9
CJS26380 bytes8869 bytes under a local gzip -9

There is no single gzipped number and it would be dishonest to print one: it depends on the compressor and the level. 8832 is what in.segmentic.net actually returns on the wire and therefore what a visitor downloads; the same bytes under gzip -9 on a laptop come to 8793. We host neither the ESM nor the CJS file, so for those only the local number exists.

So about eight and a half kilobytes for the script-tag form. There are no runtime dependencies.

The README inside the repository used to say "4.2 KB gzip". That number is stale and roughly half the real one; the growth came from the on-site renderer and browser push being added afterwards. The README now lists it among the claims it got wrong and prints this same 8832. If you meet the old figure anywhere else, trust this page.

#Debugging

Pass debug: true and every action is written to the console with the prefix [segmentic]: each message being queued, beacons being sent, drops with their reason, campaign attribution, send failures with the estimated retry delay, and reaching the retry ceiling.

JavaScript
const segmentic = Segmentic.init({
  writeKey: "wk_seg_...",
  apiHost: "https://in.segmentic.net",
  debug: true
});

setInterval(() => console.table(segmentic.stats()), 5000);

Three things to look for in stats():

  • durableStorage: false means localStorage was unavailable and the queue dies with the tab. Safari private mode, or an exhausted quota
  • dropped climbing while failures stays at zero means the server is answering 4xx. Turn on debug to see the status code
  • queued climbing while sent does not move means no send has succeeded. Check CORS first, then that apiHost is right

One thing you will not see in the panel: the live event debugger does not show an install of this SDK. Debug recording is called only on the single-event path, and this SDK always posts /v1/batch, so that screen stays empty for an SDK install no matter how much traffic is arriving. To confirm events landed, leave the panel's connect screen open instead; it asks about the app's activity over the last twenty-four hours and does see the batches.

For a quick check without a browser:

Shell
curl -X POST https://in.segmentic.net/v1/track \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"anonymous_id":"test-1","event":"install_check"}'
JSON
{ "status": "ok", "accepted": 1 }

#What it deliberately never does

  • It writes no cookie. Only localStorage, and one Cache API entry for push
  • It captures nothing automatically. No clicks, no form submissions, no JavaScript errors, no session replay. Only what you call goes out
  • It does not hook browser history. In a single-page app, call page() yourself
  • It does not send device or os. The collector derives them from the User-Agent header, which a client cannot forge
  • It does not fingerprint. No canvas, no font enumeration, no identifier derived from hardware
  • It does not inspect the click token. sg_t is only carried and handed back
  • It runs no regular expression in targeting. Substrings only
  • It writes no innerHTML. Anywhere
  • It does not remove beaconed messages from the queue. Server-side deduplication is what makes that safe
  • It does not keep the queue on opt-out. It clears it
  • It does not throw, except from init() without a writeKey or without an apiHost
  • It does not register the push subscription in the device registry. Only POST /v1/webpush/subscribe. Browser push says why that matters
  • It has no inbox client. The endpoints POST /v1/inbox and POST /v1/inbox/ack exist on the collector and are tested, but no SDK has code for them. If you need them, call them yourself
  • It is published to no npm registry. See with a bundler

Related: Quickstart for the first event, Identity for how anonymous history joins an account, Consent for the platform-wide opt-out policy, On-site messages for building the campaigns in the panel, Devices and push for the other notification channels, and Errors and Limits for collector behaviour.

PreviousIdentityNextAndroid SDK

On this page

  • Installing
  • init and every option
  • Methods
  • What goes on the wire
  • The offline queue
  • Consent, opt-out and Do Not Track
  • Browser push
  • On-site messages
  • CORS and CSP
  • Measured size
  • Debugging
  • What it deliberately never does

Segmentic

This page is written from the code