Nitx Publisher SDK
Web SDK
A script tag that renders Nitx ad slots on any website, with consent-first personalization.
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.
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:
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).
Options:
api: API base URL, defaulthttps://api.nitx.app. Trailing slashes are trimmed. Staging dashboards print this option automatically.debug:trueprints[nitx]messages to the console. The SDK is silent otherwise, including on failures.personalization: defaulttrue.falsemeans no cookie, no storage, novisitorblock in requests, andconsent,identifyandresetare 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
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 explicitdata-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
initwrites the visitor id, requestsGET /api/ad-websites/show/{code}(the website with itsad_sourceandslots; each slot'sformat_typeis parsed into a width and height) and starts the scanner. When the response says the source ispending_verification, the SDK callsGET /api/ad-websites/init/{code}once per page load so the dashboard sees the tag; forpending_approval,activeandrejectedit never calls init. The result of init is ignored and nothing waits for it.- 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). Theadsoption overrides this ('get'forces GET,'post'forces POST to the advertised paths or, when nothing is advertised, to/api/sdk/publisher/ads,/eventsand/forget). - The scanner finds
.nitx-ad[data-slot]elements, immediately and whenever nodes are added to the page, marks themloadingand 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 ownformat_type). A 404 slot rendersempty. From the slot'sads[]the SDK ignores entries without apath, then picks one at random; slots withfiller_content: "static"always show the first ad in the response. The ad'spathdecides the creative type:png,jpg,jpeg,gif,webp,svg(and anything unknown) render as an image,mp4andwebmas a muted, autoplaying, looping, playsinline video. The adnamebecomes the image alt text; aclick_urlis 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 thevisitorandcontextblocks (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 animpression_id,type(image,html,video,none),urlorhtml, size,click_url(the backend's click redirect) andalt; a slot missing from the response rendersempty. 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 batcherror(refreshretries).
- GET mode:
- Each creative is written into an iframe with
sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"andsrcdoc. - Tracking runs only in POST mode, where ads carry an
impression_id:impressionis queued when the creative iframe has loaded,viewableafter 50% of the element has been visible for 1 continuous second (IntersectionObserver). Events are flushed every 2 seconds and on page hide (withkeepalive), at most 100 perPOST {events_endpoint}; a failed send is dropped, never retried. In GET mode no impression id exists, so no event request is ever sent andstate().events.sentstays 0. - 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 useContent-Type: text/plainwith a JSON body, so they never trigger a CORS preflight.
Endpoints the SDK calls (Protocol §3 and 7):
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:
What happens with each call:
- Without any
consentcall 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 fiveutm_*parameters, language, time zone, screen and viewport size, a device class (mobile,tablet,desktopfrom viewport width and touch support) and the SDK version. No identity fields, no precise location. identifyis 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 tom,forother,birth_yearin 1900..current year (or derived fromage), city and country trimmed and lowercased (max 64 characters),traitsat most 20 keys of[a-z0-9_]{1,32}with string, number or boolean values. Email and phone are then SHA-256 hashed withcrypto.subtleand 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 thevisitorblock of POST requests together with the consent record.consent(false)wipes profile and record but keeps the visitor id. resetwipes everything, including the id, and asks the backend to forget the visitor.
Storage the SDK writes, only while personalization is on:
What goes on the wire per mode:
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 isidentify. - 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
httporiginscrypto.subtleis unavailable: the field is omitted, the rest of the profile works, andstate().personalization.hashingisfalse. - 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 toidentify; Nitx processes it for targeting. A data processing agreement should cover this. - Cookie notice.
_nitx_vidis 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, sendsPOST {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
visitorandcontextblocks of Protocol §7.4.credentials: 'omit'on every request, so browser cookies are never attached.
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
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: writesdist/v1.jsanddist/v<version>.js(minified IIFE, ES2018), warns above 9 KB gzipped and fails whendist/v1.jsexceeds 10 KB gzipped.npm test: vitest with jsdom.npm run typecheck:tsc --noEmit.
Publishing
-
Bump
versioninpackage.jsonandsrc/version.ts(the build refuses to run when they differ). -
Merge to
main. Do not tag by hand. -
The
Publish SDKworkflow (.github/workflows/publish.yml) runs typecheck, tests and the build on every push tomain. When nov<version>tag exists yet, it uploads through the S3-compatible API of Cloudflare R2 and then creates the tag:dist/v<version>.jstosdk/publisher/v<version>.jswithCache-Control: public, max-age=31536000, immutabledist/v1.jstosdk/publisher/v1.jswithCache-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>.jsthat is already in the bucket.
The workflow needs these repository secrets: R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET.