Skip to content

Nitx Publisher SDK

Web SDK

A script tag that renders Nitx ad slots on any website, with consent-first personalization.

JavaScript
@nitx/publisher-sdk
ES2018
~8 KB gzipped
Zero dependencies
Updated Oct 22, 2026

Browser SDK that renders Nitx ad slots on publisher websites. Zero runtime dependencies, about 8 KB gzipped, ES2018.

The wire contract shared with the backend and the publisher dashboard lives in Protocol reference. Version 1.2 serves ads from the public "show by code" endpoints the backend exposes today (collect-only mode) and adds the personalization layer of Protocol §7: a visitor id, publisher-driven consent, hashed identity and page context. The SDK switches to the context-aware POST ad request, with impression and viewability events, the moment the backend advertises those endpoints in the website response; no SDK re-release is needed for that.

Install tag

Paste this into the <head> of every page that shows ads. The dashboard prints it for you with your website code.

HTML
<!-- Nitx Publisher Tag -->
<script>
  (function(n,i,t,x){
    n['NitxPublisher']=t;
    n[t]=n[t]||function(){(n[t].q=n[t].q||[]).push(arguments)};
    var s=i.createElement('script');s.async=1;
    s.src='https://cdn-new.nitx.app/sdk/publisher/v1.js';
    i.head.appendChild(s);
  })(window,document,'nitx');
  nitx('init', 'PUB-AM-XXXXXX');
</script>

The snippet creates a nitx() queue function immediately, so every command is safe to call before the script has loaded. When v1.js arrives it drains the queue and replaces the stub.

Pinned builds are available at https://cdn-new.nitx.app/sdk/publisher/v1.<minor>.<patch>.js (for example v1.1.0.js) and are immutable. v1.js always points at the latest 1.x build.

Slot markup

One element per placement:

HTML
<div class="nitx-ad" data-slot="SLOT-XXXXXX" data-size="300x250"></div>

Each ad is rendered inside a sandboxed <iframe> appended to the element. Ads have no destination URL on the backend yet, so creatives render without a link; as soon as the backend returns click_url on an ad the SDK wraps the creative in <a target="_top" rel="noopener">.

Commands

All commands are called as nitx(command, ...args).

CommandArgumentsBehaviour
initcode, { api?, debug?, personalization?, geolocation?, ads? }Registers your website code, writes the _nitx_vid visitor id (unless personalization: false) and starts scanning for slots. Idempotent: a second init is ignored (a warning is logged when debug is on).
configsame options as initSets options. May be called before init.
refreshnoneRescans the DOM for unrendered .nitx-ad[data-slot] elements and elements that previously failed. Optional: the SDK already watches the DOM with a MutationObserver, so slots added by single-page apps are picked up automatically.
consenttrue, false or { personalization: boolean }true stores a consent record, persists any buffered profile, allows identity fields on the wire and (only with geolocation: 'prompt') asks the browser for a position once per page load. false deletes the profile, the consent record and the geo fix; the visitor id stays. Idempotent.
identify{ email?, phone?, gender?, birth_year?, age?, city?, country?, traits? }Normalises the values, hashes email and phone right away and merges the result into the profile. Persisted only once consent is true. Works before init and before consent. null deletes a field.
resetnoneForgets the visitor: cookie, storage, profile, consent, geo. Sends a best-effort forget request when the backend advertises that endpoint. Ads keep serving without a visitor for the rest of the page load; the next page load gets a new id.
enable'auto-ads'Accepted and ignored, kept for older tags.

Options:

  • api: API base URL, default https://api.nitx.app. Trailing slashes are trimmed. Staging dashboards print this option automatically.
  • debug: true prints [nitx] messages to the console. The SDK is silent otherwise, including on failures.
  • personalization: default true. false means no cookie, no storage, no visitor block in requests, and consent, identify and reset are no-ops.
  • geolocation: 'none' (default) or 'prompt'. With 'prompt' the SDK asks the browser for a low-accuracy position once per page load, only after consent, and keeps the fix (rounded to 3 decimals) in memory. Ads never wait for it.
  • ads: 'auto' (default), 'get' or 'post'. Developer override of the ad request mode, see "How it works".

Data attributes

AttributeWritten byMeaning
data-slotyouSlot code (required).
data-sizeyouWIDTHxHEIGHT, for example 728x90 (optional). When absent, the SDK uses the slot's format_type from the website config, then the chosen ad's format; when none of those parses as WIDTHxHEIGHT, the ad fills the container width with a 250px height.
data-nitx-stateSDKCurrent state of the element, see below.

States

data-nitx-state takes one of these values, so you can style each case with CSS:

  • loading: the element was found and a slot request is in flight.
  • rendered: a creative is displayed in the iframe.
  • empty: the slot has no active ad with a file, or the slot code is unknown (404). The element collapses to height 0 unless it has an explicit data-size, in which case it keeps its box and stays blank.
  • error: the request failed (network, timeout, 5xx, malformed response). nitx('refresh') retries these elements.

How it works

  1. init writes the visitor id, requests GET /api/ad-websites/show/{code} (the website with its ad_source and slots; each slot's format_type is parsed into a width and height) and starts the scanner. When the response says the source is pending_verification, the SDK calls GET /api/ad-websites/init/{code} once per page load so the dashboard sees the tag; for pending_approval, active and rejected it never calls init. The result of init is ignored and nothing waits for it.
  2. The same response decides the ad request mode. When it carries data.sdk = { ads_endpoint, events_endpoint, forget_endpoint } with paths relative to the API base, the SDK runs in POST mode; when the block is absent, null, or points anywhere else, it stays in GET mode (collect-only). The ads option overrides this ('get' forces GET, 'post' forces POST to the advertised paths or, when nothing is advertised, to /api/sdk/publisher/ads, /events and /forget).
  3. The scanner finds .nitx-ad[data-slot] elements, immediately and whenever nodes are added to the page, marks them loading and collects them for 50 ms. After the config request has settled, each batch is requested:
    • GET mode: GET /api/ad-slots/show/{slot} once per unique slot code, in parallel (a failed config request does not block slots: the slot response carries its own format_type). A 404 slot renders empty. From the slot's ads[] the SDK ignores entries without a path, then picks one at random; slots with filler_content: "static" always show the first ad in the response. The ad's path decides the creative type: png, jpg, jpeg, gif, webp, svg (and anything unknown) render as an image, mp4 and webm as a muted, autoplaying, looping, playsinline video. The ad name becomes the image alt text; a click_url is honoured only when present and an http(s) URL. Nothing personal travels: no visitor id, no cookies, no query parameters, no POSTs.
    • POST mode: one POST {ads_endpoint} for all slot codes of the batch with the visitor and context blocks (section "Personalization"). The SDK waits at most 200 ms for email and phone hashes still being computed. The response lists one ad per slot with an impression_id, type (image, html, video, none), url or html, size, click_url (the backend's click redirect) and alt; a slot missing from the response renders empty. When the endpoint answers 404, 405 or 501 the SDK switches to GET mode for the rest of the page load and retries the batch once over GET; any other failure marks the batch error (refresh retries).
  4. Each creative is written into an iframe with sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation" and srcdoc.
  5. Tracking runs only in POST mode, where ads carry an impression_id: impression is queued when the creative iframe has loaded, viewable after 50% of the element has been visible for 1 continuous second (IntersectionObserver). Events are flushed every 2 seconds and on page hide (with keepalive), at most 100 per POST {events_endpoint}; a failed send is dropped, never retried. In GET mode no impression id exists, so no event request is ever sent and state().events.sent stays 0.
  6. Every request carries Accept: application/json, never sends credentials, times out after 8 seconds, and every entry point is wrapped: the SDK never throws into the host page. POST requests use Content-Type: text/plain with a JSON body, so they never trigger a CORS preflight.

Endpoints the SDK calls (Protocol §3 and 7):

EndpointModeWhen
GET {api}/api/ad-websites/show/{code}bothonce, on init
GET {api}/api/ad-websites/init/{code}bothonce per page load, only when the source is pending_verification
GET {api}/api/ad-slots/show/{code}GETonce per unique slot code per batch
POST {api}{ads_endpoint}POSTonce per batch, body { v, source, slots, visitor?, context }
POST {api}{events_endpoint}POSTevery 2 s and on page hide while events are queued, body { events: [{ impression_id, type, at }] }
POST {api}{forget_endpoint}POSTon nitx('reset'), body { visitor }

window.__nitxPublisher exposes { version, state() } for debugging; state() returns the current source code, API base, parsed config, sourceStatus, whether init was requested, request and render counters, the event counters and personalization: { enabled, consent, visitor_id, profile, hashing, geo, mode } where profile holds the hashed and normalised fields only and mode is get or post.

Personalization

The personalization layer (Protocol §7) is on by default and collects locally until the backend turns the POST endpoints on for your source. The publisher owns the consent UI: the SDK shows no banner and never reads your forms, inputs or cookies. Identity enters only through nitx('identify', ...).

A typical integration:

HTML
<script>
  nitx('init', 'PUB-AM-XXXXXX');
  // From your own consent banner:
  nitx('consent', true);
  // After a login or sign-up, only with the user's consent:
  nitx('identify', { email: user.email, phone: user.phone, gender: user.gender, birth_year: user.birthYear, city: user.city, country: user.country, traits: { plan: user.plan } });
  // On logout or when the user asks to be forgotten:
  nitx('reset');
</script>

What happens with each call:

  • Without any consent call the SDK keeps a visitor id and sends page context (POST mode only): URL stripped to origin and path, title, referrer stripped the same way, the five utm_* parameters, language, time zone, screen and viewport size, a device class (mobile, tablet, desktop from viewport width and touch support) and the SDK version. No identity fields, no precise location.
  • identify is normalised at once: email trimmed and lowercased, phone reduced to E.164 (+ and 8 to 15 digits; numbers without a country code are dropped), gender mapped to m, f or other, birth_year in 1900..current year (or derived from age), city and country trimmed and lowercased (max 64 characters), traits at most 20 keys of [a-z0-9_]{1,32} with string, number or boolean values. Email and phone are then SHA-256 hashed with crypto.subtle and the raw values are discarded. A call implying an age under 16 is discarded entirely.
  • Until consent is true the profile lives in memory only. consent(true) persists it (hashes only) and, from then on, sends it in the visitor block of POST requests together with the consent record. consent(false) wipes profile and record but keeps the visitor id.
  • reset wipes everything, including the id, and asks the backend to forget the visitor.

Storage the SDK writes, only while personalization is on:

KeyWhereContentLifetime
_nitx_vidcookie (Path=/; SameSite=Lax, Secure on https, host-only) with a localStorage mirrorUUID v4 visitor id395 days, refreshed on every visit
_nitx_consentlocalStorage{ v: 1, personalization: true, at, source: "publisher" }until revoked or reset
_nitx_profilelocalStorage{ v: 1, at, email_sha256?, phone_sha256?, gender?, birth_year?, city?, country?, traits? }until revoked or reset; only ever written together with a consent record

What goes on the wire per mode:

GET mode (today's backend)POST mode (backend advertises sdk)
Visitor idneveralways (unless personalization: false)
Consent record, hashed identity, geo fixneveronly with consent
Page contextneveralways
IP address, user agentread by the backend from the request itself in both modes; the SDK never sends them

Privacy

  • No scraping. The SDK never reads the publisher's forms, inputs, cookies (other than its own _nitx_vid) or DOM for personal data. The only identity input is identify.
  • Hashes only. Email and phone are normalised and SHA-256 hashed in the browser before anything is stored or sent; raw values are never written to storage, logged or transmitted. On plain http origins crypto.subtle is unavailable: the field is omitted, the rest of the profile works, and state().personalization.hashing is false.
  • Consent first. Identity fields, the consent record and the geo fix leave the page only after nitx('consent', true), and only in POST mode. The publisher decides how consent is collected and is the controller for the data it passes to identify; Nitx processes it for targeting. A data processing agreement should cover this.
  • Cookie notice. _nitx_vid is a non-essential cookie with a 13-month lifetime (395 days) and belongs in the publisher's cookie notice. Safari caps script-set cookies and localStorage at 7 days; the localStorage mirror only helps against cookie loss, not against that cap.
  • Minors. Calls that imply an age under 16 are discarded; publishers must not identify minors.
  • Erasure. nitx('reset') removes everything locally and, in POST mode, sends POST {forget_endpoint} so the backend deletes the profile and the consent record and unlinks stored impressions. A request already in flight when consent is revoked cannot be recalled; the forget endpoint covers it.
  • Nothing extra on the wire. POST bodies contain exactly the visitor and context blocks of Protocol §7.4. credentials: 'omit' on every request, so browser cookies are never attached.
Contributing

The sections below are for working on the SDK itself: running it locally, testing and releasing. You don't need them to integrate.

Local development

Shell
npm install
npm run demo

npm run demo builds dist/, starts a mock implementation of the three endpoints on http://localhost:4301 (see examples/mock-server.mjs: an in-memory website PUB-AM-DEMO01 that starts as pending_verification, slots SLOT-DEMO300, SLOT-DEMO728 and SLOT-DEMOFLEX with active ads, and a log line per request) and a static server on http://localhost:4300. Open http://localhost:4300/examples/index.html: the page contains a rotating 300x250 slot, a static 728x90 slot, a fluid slot without data-size, an unknown slot (404, renders empty), a below-the-fold slot, a button that appends a new slot (MutationObserver path), a Refresh button and a personalization panel (consent checkbox, identify form, Reset button and a mode indicator fed by state()).

PERSONALIZATION=1 npm run demo makes the mock advertise the sdk block and serve POST /api/sdk/publisher/ads, /events and /forget (bodies are logged with hashes cut to 8 characters; served ads get impression ids and a click_url that 302s through the mock), so the POST mode can be exercised end to end.

To point the demo page at a real backend, open http://localhost:4300/examples/index.html?api=http://localhost:8001.

Other scripts:

  • npm run dev: esbuild in watch mode plus the static server on port 4300 (no mock API).
  • npm run build: writes dist/v1.js and dist/v<version>.js (minified IIFE, ES2018), warns above 9 KB gzipped and fails when dist/v1.js exceeds 10 KB gzipped.
  • npm test: vitest with jsdom.
  • npm run typecheck: tsc --noEmit.

Publishing

  1. Bump version in package.json and src/version.ts (the build refuses to run when they differ).

  2. Merge to main. Do not tag by hand.

  3. The Publish SDK workflow (.github/workflows/publish.yml) runs typecheck, tests and the build on every push to main. When no v<version> tag exists yet, it uploads through the S3-compatible API of Cloudflare R2 and then creates the tag:

    • dist/v<version>.js to sdk/publisher/v<version>.js with Cache-Control: public, max-age=31536000, immutable
    • dist/v1.js to sdk/publisher/v1.js with Cache-Control: public, max-age=300

    Pushes that do not change the version only run the checks. The upload refuses to overwrite a pinned v<version>.js that is already in the bucket.

The workflow needs these repository secrets: R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET.

Repository layout

Text
src/
  index.ts      bootstrap: reads window.NitxPublisher, drains the queue, installs the dispatcher
  commands.ts   init, config, refresh, enable, consent, identify, reset, state; mode selection and the GET and POST serving flows
  scanner.ts    querySelectorAll + MutationObserver, batching, dedupe
  api.ts        GET wrappers for the website, init and slot endpoints; POST ads, events and forget behind the sdk paths
  select.ts     maps backend payloads (GET slot ads and POST served ads) to render input and picks the ad for a slot
  render.ts     sandboxed iframe rendering per creative type
  events.ts     impression and viewable tracking; sends only while an events endpoint is enabled
  storage.ts    cookie and localStorage helpers, all wrapped
  visitor.ts    the _nitx_vid visitor id (uuid, cookie plus localStorage mirror)
  identity.ts   normalisers, SHA-256 hashing, the profile and consent records
  context.ts    page context and the optional geolocation fix
  log.ts        debug-gated console output, the safe() wrapper and the shared str() helper
  types.ts      backend response shapes, request bodies and the mapped Ad type
tests/          vitest + jsdom
examples/       demo page and mock API server (PERSONALIZATION=1 for the POST mode)
scripts/        build, dev and demo runners

Generated from README.md in NitxTech/nitx-publisher-sdk at 5ba6c1e · Oct 22, 2026