Skip to content

Nitx Publisher

Protocol reference

The wire contract every Nitx Publisher SDK implements: identifiers, endpoints, payloads, personalization and the shared mobile API.

Updated Oct 29, 2026

1. Identifiers and existing pieces

  • Website source code: PUB-AM-XXXXXX (ad_websites.code). ad_websites.url is the registered site.
  • Mobile source code: SDK-AM-XXXXX (out of scope for the browser SDK).
  • Slot code: SLOT-XXXXXX (ad_slots.code). ad_slots.format_type is a free string, normally WIDTHxHEIGHT.
  • Ad: an advertiser creative booked on one slot. Fields the SDK sees: uuid, name, format (free string, normally WIDTHxHEIGHT), path (public file URL or null), start, end (dates), status. Only status = active ads inside their date window are "active". There is no click URL on an ad yet (section 6).
  • Backend JSON envelope: success { "success": true, "message": "...", "data": ... }, errors { "success": false, "message": "..." }. 404s use that shape.
  • CORS: every origin is allowed with credentials; the SDK always uses credentials: "omit" and plain GETs, so no preflight happens.

2. Publisher page markup and tag API

Unchanged from v1. Head tag printed by the dashboard:

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>

Slot markup: <div class="nitx-ad" data-slot="SLOT-XXXXXX" data-size="300x250"></div>.

Commands: init(code, { api?, debug? }), refresh(), config({ api?, debug? }), enable('auto-ads') (ignored). Default API base https://api.nitx.app; non-production dashboards print { api: '<NEXT_PUBLIC_API_URL>' }.

data-nitx-state values: loading, rendered, empty, error. Rendering stays inside a sandboxed <iframe srcdoc> (sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"). Sizing precedence: data-size, then the slot's format_type from config, then the ad's format, then 100% x 250. none/no ad collapses the element unless data-size was given.

3. Backend endpoints the SDK calls (all live on dev)

All public, no auth, no throttle group. Plain GET, Accept: application/json.

3.1 GET /api/ad-websites/show/{code} (config)

Returns the website with its source and slots.

JSON
{ "success": true, "message": "...", "data": {
  "id": 1, "uuid": "...", "url": "https://myblog.com", "code": "PUB-AM-ABC123",
  "ad_source": { "id": 1, "uuid": "...", "name": "My Blog", "status": "pending_verification|pending_approval|active|rejected", "ad_category_id": 1,
    "source_type": "ad_website", "source_id": 1,
    "slots": [ { "id": 7, "uuid": "...", "code": "SLOT-ABC123", "name": "Sidebar", "format_type": "300x250",
                 "categories": ["display"], "pricing_mode": "automatic", "price": null,
                 "filler_content": "rotation", "rails": "banner", "created_at": "...", "updated_at": "..." } ],
    "created_at": "...", "updated_at": "..." },
  "created_at": "...", "updated_at": "..." } }

404 Ad website not found for an unknown code. The SDK parses width/height from each slot's format_type itself (^\d+x\d+$).

3.2 GET /api/ad-websites/init/{code} (activation)

Existing route, throttled 30/min per IP. Source lifecycle on dev is pending_verification (tag not seen yet) -> pending_approval (tag seen, Nitx admin still has to approve) -> active, or rejected. This route moves pending_verification to pending_approval and broadcasts ad.website.verified on channel ad-website.{code} so the dashboard's Echo listener fires; admin approval is a separate step. The SDK calls it once per page load, only when 3.1 reported ad_source.status === "pending_verification"; for every other status it never calls init. Responses are 200 either way (Ad source verified successfully or Ad source already active); 404 when unknown. Failures are ignored. Slot creatives (3.3) are served regardless of the source status; the backend does not gate on it today.

3.3 GET /api/ad-slots/show/{code} (creatives for one slot)

Returns the slot with its active ads (status = active, start <= today <= end).

JSON
{ "success": true, "message": "...", "data": {
  "id": 7, "uuid": "...", "ad_source_id": 2, "screen_id": null, "name": "Sidebar", "categories": ["display"],
  "format_type": "300x250", "pricing_mode": "automatic", "price": null, "filler_content": "rotation",
  "rails": "banner", "code": "SLOT-ABC123",
  "ads": [ { "id": 3, "uuid": "...", "advertiser_id": 9, "ad_slot_id": 7, "name": "Summer sale",
             "format": "300x250", "path": "https://cdn-new.nitx.app/ads/....png", "start": "2026-10-01T00:00:00.000000Z",
             "end": "2026-10-31T00:00:00.000000Z", "status": "active", "reject_reason": null, "rejected_by": null,
             "created_at": "...", "updated_at": "..." } ],
  "created_at": "...", "updated_at": "..." } }

404 Ad slot not found for an unknown code. One call per placement; the SDK issues them in parallel after config (or immediately if config failed).

Selection: the SDK ignores ads whose path is null. With several usable ads it picks one at random (filler_content = static picks the first by created_at ascending; the response order is used as given). The chosen ad renders as:

  • image when path ends in png, jpg, jpeg, gif, webp, svg (case-insensitive, query string ignored);
  • video when it ends in mp4, webm (muted, autoplay, loop, playsinline);
  • otherwise image (best effort). No ad with a usable path means empty.

Clicks: ads have no destination yet. The SDK renders the creative without a link. When the backend adds click_url to the ad resource (section 6), the SDK wraps the creative in <a href="{click_url}" target="_top" rel="noopener">; it must read that field if present and ignore its absence.

4. Tracking (not available on dev yet)

There is no impression, viewable or click tracking on dev. The SDK keeps its event machinery (IntersectionObserver viewability, batching) but sends nothing: with no impression_id from the backend there is nothing to report. When section 6 lands, the SDK registers an impression per rendered ad and resumes the v1 event and click behaviour. Until then:

  • window.__nitxPublisher.state() still counts rendered, empty, errors, requests; events.sent stays 0.
  • No request is ever made to /api/sdk/publisher/*.

7. Personalization (SDK v1.2, collect-only until the backend ships section 8)

7.1 Publisher commands

CommandSignatureBehaviour
consentnitx('consent', true | false | { 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 position once per page load. false deletes the profile, the consent record and the geo fix; the visitor id stays. Idempotent.
identifynitx('identify', { email?, phone?, gender?, birth_year?, age?, city?, country?, traits? })Normalises and hashes email and phone in the browser immediately; raw values are discarded. Merged into the profile; persisted only once consent is true. Works before init and before consent. null deletes a field. Calls implying age under 16 are discarded.
resetnitx('reset')Forgets the visitor: cookie, storage, profile, consent, geo. Best-effort POST forget when advertised (7.5). Ads keep serving context-only.

init/config options added: personalization (default true; false means no storage, no visitor block, the three commands are no-ops), geolocation ('none' default or 'prompt'), ads ('auto' default, 'get' or 'post' as a developer override).

The SDK never reads the publisher's forms, cookies or DOM for personal data. Identity enters only through identify. The SDK shows no consent UI; the publisher's own banner calls consent.

7.2 Browser storage

Written only when personalization is on:

KeyWhereContentLifetime
_nitx_vidcookie (Path=/; SameSite=Lax; Secure on https, host-only) and localStorage mirrorUUID v4 visitor id, created at init395 days, refreshed on each visit
_nitx_consentlocalStorage{ "v": 1, "personalization": true, "at": "<ISO>", "source": "publisher" }until revoked or reset
_nitx_profilelocalStorage{ "v": 1, "at": "<ISO>", "email_sha256"?, "phone_sha256"?, "gender"?: "m"|"f"|"other", "birth_year"?, "city"?, "country"?, "traits"? }until revoked or reset; only stored with a consent record

Normalisation before hashing: email trimmed and lowercased, must look like an address; phone reduced to E.164 (+ and 8 to 15 digits; numbers without a country code are dropped); gender mapped to m, f, other; birth_year 1900..current year (or derived from age); city and country trimmed, lowercased, max 64 chars (2-letter country kept as ISO alpha-2); traits max 20 keys of [a-z0-9_]{1,32} with string, number or boolean values (strings cut at 64). Hashes are SHA-256 hex via crypto.subtle; on insecure http origins hashing is unavailable, the field is omitted and state().personalization.hashing is false. Raw values are never stored or sent.

7.3 Mode flag in the website response (3.1)

The backend adds an optional block to GET /api/ad-websites/show/{code}:

JSON
"sdk": { "ads_endpoint": "/api/sdk/publisher/ads", "events_endpoint": "/api/sdk/publisher/events", "forget_endpoint": "/api/sdk/publisher/forget" }

Absent block or null keys mean "not available" and the SDK stays on the per-slot GETs of 3.3, sending nothing from this section on the wire. Paths must be relative to the API base; anything else is ignored. The backend can therefore roll personalization out per source. The SDK also falls back to GET for the rest of the page load when the POST answers 404, 405 or 501.

7.4 POST {ads_endpoint} (replaces 3.3 when the flag is present)

Content-Type: text/plain, body is JSON, credentials: omit, one call per batch of placements. This supersedes the page/viewport body in the v1 PDF the backend already has.

JSON
{ "v": 1, "source": "PUB-AM-ABC123", "slots": ["SLOT-ABC123", "SLOT-DEF456"],
  "visitor": {
    "id": "0f6c…-uuid", "consent": { "personalization": true, "at": "2026-10-22T10:00:00.000Z", "source": "publisher" },
    "email_sha256": "<64 hex>", "phone_sha256": "<64 hex>", "gender": "f", "birth_year": 1990,
    "city": "riyadh", "country": "sa", "traits": { "plan": "pro" },
    "geo": { "lat": 24.713, "lng": 46.675, "accuracy": 35 } },
  "context": {
    "page": { "url": "https://site.com/post/1", "title": "Post 1", "referrer": "https://www.google.com/" },
    "utm": { "source": "x", "medium": "y", "campaign": "z", "term": null, "content": null },
    "lang": "ar-SA", "tz": "Asia/Riyadh",
    "screen": { "w": 1440, "h": 900, "dpr": 2 }, "viewport": { "w": 1280, "h": 720 },
    "device": "desktop", "sdk": "1.2.0" } }

Rules: without consent visitor carries only id and consent: null; identity fields and geo appear only with consent; with personalization: false the block is omitted. page.url and page.referrer are stripped to origin and path (no query string); only the five utm_* keys are forwarded. device is mobile, tablet or desktop derived from viewport width and touch support. The SDK never sends IP, user agent or IP-derived location; the backend reads those from the request.

Validation the backend should apply: slots 1 to 50 unique codes belonging to the source; visitor.id UUID; hashes ^[0-9a-f]{64}$; page.url max 2048; unknown fields ignored.

Response data (the v1 shape, so events and clicks resume unchanged):

JSON
{ "ads": [ { "slot": "SLOT-ABC123", "impression_id": "<uuid>", "type": "image|html|video|none",
             "url": "https://cdn-new.nitx.app/…", "html": null, "width": 300, "height": 250,
             "click_url": "https://api.nitx.app/api/sdk/publisher/click/<impression_id>", "alt": "Summer sale" } ] }

A slot missing from ads[] renders empty. click_url is the backend click redirect, never the advertiser URL.

7.5 Events and forget

POST {events_endpoint}, text/plain JSON { "events": [ { "impression_id": "<uuid>", "type": "impression" | "viewable", "at": "<ISO>" } ] }, 1 to 100 per request, flushed every 2 s and on page hide with keepalive. Responds 204. Sets rendered_at / viewable_at once.

GET /api/sdk/publisher/click/{impression_id} records the click once and 302s to the ad's destination.

POST {forget_endpoint}, text/plain JSON { "visitor": "<uuid>" }, 204. Deletes the profile and consent record for that id and unlinks it from stored impressions.

10. Mobile SDKs (Android, iOS, React Native, Flutter)

Four packages, one contract. Kotlin (Android) and Swift (iOS) are the implementations; React Native and Flutter are thin bridges over them and expose the same API. Everything in sections 7 and 8 (visitor id, consent, identify, hashing, context, POST ads/events/forget) applies unchanged; this section adds what is specific to apps.

10.1 Source, activation, config

  • A mobile source has code SDK-AM-XXXXX (ad_mobiles.code), a platform (android or ios) and a bundle_id. One source per app per platform.
  • Config: GET /api/ad-mobiles/show/{code} returns data.code, data.platform, data.bundle_id and data.ad_source with status and slots[] exactly like 3.1. The SDK checks that bundle_id equals the running app's package or bundle identifier and logs a warning on mismatch (never blocks in v1).
  • Activation: GET /api/ad-mobiles/init/{code} once per process start, only while ad_source.status === "pending_verification" (moves it to pending_approval, broadcasts ad.mobile.verified). Same rule as 3.2.
  • Creatives today: GET /api/ad-slots/show/{code} per slot (3.3). Personalized serving tomorrow: the sdk block in the config response switches the SDK to POST ads (7.3, 7.4). Mobile uses the same request body with the app context block of 10.4.

10.2 Ad formats and slot kinds

A slot's format_type names its kind. Web keeps using WIDTHxHEIGHT; mobile uses these values (the dashboard's slot form for mobile sources must offer exactly this list):

format_typeKindHow the app shows itWhat the SDK renders
banner_320x50BannerAn inline view the app places in its layoutImage or video in a fixed-size view
banner_300x250Medium rectangleSameSame
banner_adaptiveAdaptive bannerInline view, full width, height chosen by the SDK (50 to 90 dp by width)Same
interstitialInterstitialFull-screen, shown by the app at a natural break (between sections, after an action)Image or video with a close control, portrait or landscape
app_openApp openFull-screen, shown when the app launches or returns to the foregroundSame as interstitial, with a frequency rule
nativeNativeThe app renders it with its own UI (a feed post, a card, a list row); the SDK only supplies assets and tracks the viewNothing; see 10.3
rewardedRewarded videoFull-screen video the user opts into for a reward (v2; defined now so the taxonomy is stable)Video with a reward callback

Both path-only ads (today's backend) and structured ads (10.3) work in every kind. For native, a path-only ad becomes { headline: ad.name, image: path } so apps render something sensible before the backend adds assets.

10.3 Ad payload for apps

The ad object in both 3.3 (ads[]) and 7.4 (data.ads[]) gains an optional assets block for native and an explicit creative type. Backend addition, additive and optional:

JSON
{ "uuid": "…", "name": "Summer sale", "format": "native", "path": "https://…/image.jpg", "status": "active",
  "click_url": "https://api.nitx.app/api/sdk/publisher/click/<impression_id>",
  "creative": "image" | "video" | "html" | "native",
  "assets": { "headline": "Summer sale", "body": "Up to 50% off this week.", "cta": "Shop now",
              "advertiser": "Brand", "icon": "https://…/icon.png", "image": "https://…/hero.jpg",
              "video": null, "rating": 4.5, "price": "SAR 99" } }

SDK mapping: creative when present, else by path extension (as the web SDK does). assets only for native; missing fields are null and the app must tolerate any of them being absent. click_url as in 7.4; when absent the creative is not clickable.

10.4 App context block

Replaces page in the 7.4 context object when the request comes from an app:

JSON
"context": { "app": { "bundle_id": "com.example.app", "version": "3.2.1", "build": "412", "screen": "HomeFeed" },
             "os": { "name": "ios" | "android", "version": "18.1" }, "device": "phone" | "tablet",
             "lang": "ar-SA", "tz": "Asia/Riyadh", "screen": { "w": 390, "h": 844, "dpr": 3 },
             "ad_id": { "value": "<GAID or IDFA>", "limited": false } | null, "sdk": "android-1.0.0" | "ios-1.0.0" }

app.screen is the route or screen name the app passes when it loads an ad (optional). ad_id is the Google advertising id on Android, or the IDFA on iOS only after the App Tracking Transparency prompt was accepted; limited mirrors the platform's limit-ad-tracking flag; the block is null when unavailable or denied. The backend treats ad_id.value as a second matching key next to the hashed email and phone; never as a requirement.

10.5 Public API (identical across the four packages)

CallBehaviour
Nitx.start(code, options)Loads config, activates once if needed, starts the session. Options: api (base URL override), debug, personalization (default true), testMode (forces the demo creatives from the config's test slots; v2).
Nitx.consent(bool)As 7.1.
Nitx.identify(traits)As 7.1, same fields and normalisation; hashing with the platform SHA-256 (MessageDigest / CryptoKit).
Nitx.reset()As 7.1, plus clears the cached ad id.
Nitx.setScreen(name)Sets context.app.screen for subsequent requests.
NitxBannerView(slot)A native view (android.view.View subclass / UIView subclass). Loads on attach, reloads on load(), exposes listener for loaded, failed, clicked, impression. Size from the slot kind.
NitxInterstitial.load(slot) { result } then .show(from)Preloads, then presents full-screen. Callbacks: loaded, failed, shown, clicked, dismissed. isReady.
NitxAppOpen.load(slot) and .showIfAvailable()Same as interstitial; the SDK also offers NitxAppOpen.enableAutoShow(slot, minIntervalSec) which shows on foreground returns at most once per interval.
NitxNativeAd.load(slot) { ad }Returns a NitxNativeAd with the 10.3 assets plus registerView(container, clickable: [views]): the SDK observes the container for viewability and attaches click handling to the given views; the app owns all layout. destroy() unregisters.
NitxRewarded.load / showv2; same shape as interstitial plus onReward.

Rendering rules: image and video creatives use native image and video players (no WebView); html creatives use a WebView only in banner and interstitial kinds. Clicks open click_url in Chrome Custom Tabs or SFSafariViewController. Impression = creative rendered on screen; viewable = at least 50 percent visible for 1 second (banners and native), or shown (interstitial, app open). Full-screen formats show a close control after at most 5 seconds and always on video end.

10.6 Storage and privacy on device

SharedPreferences / UserDefaults under the nitx. prefix: visitor_id, consent, profile (hashes only), app_open_last_shown. Same lifetimes as 7.2. The ad id is read at start and after consent changes, never persisted beyond the process. Both SDKs ship the store declarations: an Apple privacy manifest (PrivacyInfo.xcprivacy) and the Play data safety entries, listing exactly: hashed contact info (optional, with consent), coarse location (from IP, server side), device id (optional), app interactions (ad impressions and clicks).

10.7 React Native and Flutter

Bridges only: they call the native SDKs through the platform channel and expose the same calls and a NitxBanner / NitxNativeAd component. No ad logic, hashing or storage lives in JavaScript or Dart. Versioning: the bridge pins a native version; context.sdk reports both, e.g. rn-1.0.0/android-1.0.0.

Generated from CONTRACT.md in NitxTech/nitx-publisher-sdk at 7d267ca · Oct 29, 2026