[data-reveal]{opacity:1!important;transform:none!important}
Interface reference
Interface reference

Marketing interface

Read a tenant-scoped click heatmap, declare websites and funnels, and import historical analytics from another backend module.

@antelopejs/interface-dms-marketing provides a backend API: one read (GetClickHeatmap) and the content calls a project uses to ship marketing content as code (EnsureWebsite, EnsureFunnel, EnsurePageSnapshot, ImportAnalytics, see Content from code). The tracker JavaScript and HTTP APIs belong to the runtime extending guide, not this package.

Use interface 0.5.x with module @antelopejs/dms-marketing 0.6.0, whose implemented interface range is >=0.5.0 <0.6.0. Module 0.5.x belongs with interface 0.4.x and does not implement the new content calls. Upgrade the module and its consumers together; do not widen an older module's range to admit the new interface.

Read clicks on one pricing page

Load the Marketing runtime before calling the interface. Authorize the tenant in your calling module, then select the website and tracked path you want to read.

import { GetClickHeatmap } from "@antelopejs/interface-dms-marketing";

export async function pricingHeatmap(authorizedTenantId: string, websiteId: string) {
  try {
    return await GetClickHeatmap({
      tenantId: authorizedTenantId,
      websiteId,
      path: "/pricing",
    });
  } catch {
    return null; // Analytics are optional for this page.
  }
}

With no date bounds, this reads the last seven days. An empty successful result has points: [] and totalClicks: 0; null in this wrapper means the read failed, not that nobody clicked. If a cell has weight: 8, it represents eight clicks, not eight distinct visitors. Check truncated before presenting the result as the whole period.

GetClickHeatmap

Contract
interface MarketingHeatmapQuery {
  tenantId: string;
  path: string;
  from?: string;
  to?: string;
  websiteId?: string;
}

interface MarketingHeatmapAnchor {
  selector: string;
  ox: number;
  oy: number;
  nth: number;
}

interface MarketingHeatmapPoint {
  x: number;
  y: number;
  weight: number;
  anchor?: MarketingHeatmapAnchor;
}

interface MarketingHeatmapPayload {
  points: MarketingHeatmapPoint[];
  maxWeight: number;
  totalClicks: number;
  truncated: boolean;
}

declare function GetClickHeatmap(
  query: MarketingHeatmapQuery,
): Promise<MarketingHeatmapPayload>;

All four interfaces are exported from the package root. path must match the tracked path exactly, for example /pricing. Without websiteId, the query includes all websites owned by the tenant. An unknown website or one belonging to another tenant returns an empty heatmap: points: [], zero weights and clicks, and truncated: false.

from and to are ISO 8601 instants identifying the first and last days, not arbitrary sub-day bounds. Supply the beginning of the first day and end of the last day, as the dashboard period picker does. Missing or invalid bounds fall back to the last seven days. The current implementation caps the range at 90 days, dropping the oldest days, and reads at most 50,000 click events. truncated indicates that the event cap was reached; it is not a guarantee that exactly that many usable points exist.

points contains aggregated cells, not raw visitor events. weight counts clicks in a cell, maxWeight is the highest cell weight, and totalClicks counts usable clicks in the result. x and y are normalized document coordinates in the range 0–1. When present, anchor gives the clicked element's CSS selector, its zero-based match index nth, and normalized offsets ox/oy inside its box. Project from the anchor when the target page can resolve it; otherwise fall back to document coordinates. Old clicks and clicks targeting the document itself can lack anchors.

Tenant and availability boundaries

The call has no HTTP authentication context. The caller must authenticate and authorize tenantId before passing it; do not forward an arbitrary tenant ID from a request. Website scoping prevents a foreign websiteId from expanding that authorized tenant scope. No permission check grants access to another tenant's data.

Declare the interface as an optional dependency when analytics are optional. A call made before the implementing module is attached waits for it; storage failures reject. Catch failures if your page can work without a heatmap, and distinguish unavailable analytics from a successful empty result. This read does not ingest events, create websites or change snapshots.

Content from code

A demo instance, a template or a migration ships its marketing content from its own backend code, without the dashboard's HTTP routes and without writing the module's tables: websites, funnels with their A/B splits, page snapshots for the heatmaps, and, separately, historical analytics.

  • Idempotent, keyed by the caller. Every call carries an id the caller chooses (for a snapshot, the page and its layout). Call it on every boot or hot reload: the content is created once, never duplicated.
  • Never over a user's work. See The declaration rule.
  • Explicit tenant, not tenant provisioning. All four content calls require tenantId in TypeScript and at runtime. Omission, non-string values, empty or whitespace-only strings, and surrounding whitespace reject before model access; nothing falls back to "default". An explicitly chosen "default" is valid. These trusted backend calls neither authorize the tenant nor prove provisioning is complete. The caller must authorize the tenant and ensure its storage is ready. EnsureWebsite writes a global row with that owner; EnsureFunnel, EnsurePageSnapshot and ImportAnalytics check the website's owner before accessing tenant-scoped storage. This requirement does not change older module APIs.
  • Validated like the dashboard. The same field rules as the website and funnel forms apply; a refusal rejects with a MarketingDeclarationError naming the call, the id and the field, for example [dms-marketing] EnsureWebsite("shop-site"): domain: not a hostname, an IP address or localhost.
  • Start order. A call made before the module is ready waits: for its implementation to attach, then for the DMS database (Hook.DATABASE_INITIALIZED, which the DMS settles from its own start(), after every module's construct()). Await the calls from your module's start() or later. In construct() or inside a DATABASE_INITIALIZED handler, do not await them — start them and catch the rejection: awaiting there waits for a step that only comes after.

The following start() example explicitly selects the default tenant for a global demo. It runs per process, not per tenant creation. SaaS applications should instead initialize new tenants through the durable created lifecycle. Website ids must be globally unique (within 64 characters). Global database readiness does not guarantee an arbitrary tenant's storage exists.

import {
  EnsureFunnel,
  EnsureWebsite,
  ImportAnalytics,
  type MarketingImportSession,
} from "@antelopejs/interface-dms-marketing";

export async function start(): Promise<void> {
  const tenantId = "default"; // Deliberate global demo, not a SaaS default.
  await EnsureWebsite({
    tenantId,
    id: "shop-site",
    name: "Shop",
    domain: "shop.example.com",
    extraDomains: ["localhost"],
  });
  await EnsureFunnel({
    tenantId,
    id: "shop-quote-request",
    websiteId: "shop-site",
    name: "Quote request",
    steps: [
      { kind: "url", value: "/pricing" },
      { kind: "custom", value: "quote_submitted" },
    ],
    conversionWindowHours: 72,
    experiment: {
      key: "hero-cta",
      status: "running",
      startedAt: "2026-07-01T00:00:00.000Z",
      variations: [
        { key: "control", weight: 1 },
        { key: "bold", weight: 1 },
      ],
    },
  });
  await ImportAnalytics({
    tenantId,
    importId: "shop-history",
    websiteId: "shop-site",
    sessions: historySessions(),
  });
}

declare function historySessions(): MarketingImportSession[];

SaaS lifecycle integration

For a new SaaS tenant, register an application-owned consumer of the durable created workspace transition. It runs after preparation and payment, with at-least-once delivery, including concurrent duplicates. Register it during construct(), before startup reconciliation can dispatch pending operations: registration is synchronous and can precede SaaS construction. Do not await marketing calls during construct(); only register the callback there. start() remains suitable for explicitly selected global resources, or registration only if the application guarantees that reconciliation has not started; it is not a per-tenant creation event.

This registration adapter is not a complete provisioning implementation. The application must supply completeCreation, backed by its own durable operation/resource records. No persistence helper is provided or assumed to exist:

import { RegisterWorkspaceLifecycleConsumer } from "@antelopejs/interface-dms-saas/workspace-lifecycle";

export function registerMarketingLifecycle(
  completeCreation: (input: {
    tenantId: string;
    operationId: string;
  }) => Promise<{ receiptId: string }>,
) {
  return RegisterWorkspaceLifecycleConsumer({
    name: "application-marketing-v1",
    transitions: ["created"],
    async consume({ tenantId, operationId }) {
      return completeCreation({ tenantId, operationId });
    },
  });
}

The supplied callback must deduplicate by operationId (within this consumer), retain the tenant association, choose stable globally unique website ids, and call EnsureWebsite/EnsureFunnel with the event's explicit tenantId. It must durably bind the resulting resource ids to the operation before returning the persisted receiptId. On replay, recover those bindings and the same receipt rather than create unrelated resources. Propagate errors: never acknowledge incomplete work. Plan recovery for a crash between marketing writes and the application's durable binding; these are not one transaction. Declaration idempotency does not make every side effect exactly-once, and an interrupted ImportAnalytics still requires its documented reconciliation rather than a blind retry.

TENANT_BEING_PROVISIONED is a different, prepayment hook: it is not durable created, and its consumer effects have no automatic compensation or replay. Marketing neither imports SaaS at runtime nor registers this consumer; the application owns this integration and its ordering.

For deployment updates or backfills of existing tenants, a created consumer is not enough. Use the application's tenant inventory and check IsWorkspaceProvisioningCommitted(tenantId) from @antelopejs/interface-dms-saas/workspace-lifecycle before each tenant's work. Skip uncommitted tenants; on lookup or inventory errors, fail closed rather than assuming commitment or falling back to default. Keep durable progress and retry/reconciliation records for the rollout, pass explicit tenant ids, and preserve the declaration ownership rule. A nonempty tenant id alone proves neither authorization nor committed provisioning.

EnsureWebsite

Contract
interface MarketingWebsiteDeclaration {
  id: string;
  tenantId: string;
  name: string;
  domain: string;
  extraDomains?: string[];
  trackingEnabled?: boolean;
  snapshotsEnabled?: boolean;
  snapshotMaskText?: boolean;
}

declare function EnsureWebsite(
  declaration: MarketingWebsiteDeclaration,
): Promise<MarketingDeclarationResult>;

id is the website id itself — the data-website-id of the tag, so a site's tag can name it before the site exists. It is 1-64 letters, digits, - or _, starting with a letter or a digit, and global across tenants: a multi-tenant project puts the tenant in it. The fields follow the dashboard's rules: name 1-100 characters, domain and the at most 20 extraDomains reduced to bare hostnames (https://Shop.Example.com/x → shop.example.com), tracking on, snapshots and text masking off by default. A host another website of the tenant already declares is refused, the dashboard's own website excepted; so is an id that names another tenant's website. A refresh that changes a snapshot option discards the site's snapshots, as the dashboard does.

EnsureFunnel

Contract
interface MarketingFunnelStep {
  kind: "url" | "custom";
  value: string;
}

interface MarketingExperimentDeclaration {
  key: string;
  variations: { key: string; weight: number }[];
  status?: "draft" | "running" | "stopped";
  startedAt?: number | string;
  stoppedAt?: number | string;
}

interface MarketingFunnelDeclaration {
  id: string;
  tenantId: string;
  websiteId: string;
  name: string;
  steps: MarketingFunnelStep[];
  conversionWindowHours?: number;
  experiment?: MarketingExperimentDeclaration | null;
}

declare function EnsureFunnel(
  declaration: MarketingFunnelDeclaration,
): Promise<MarketingDeclarationResult>;

id is the funnel id within its tenant (same shape as a website id), websiteId a website of the same tenant. 1-10 steps; a page step is stored as a path, its query and trailing slash dropped. conversionWindowHours is 1-720, 24 by default. The split's key is unique per website, with 2-8 arms of distinct keys and at least one weight above 0; the first arm is the control.

status (default "draft") moves the split as the dashboard does: draft → running ⇄ stopped, never back to draft, and its key, arms and website are frozen once it has left draft. A declaration may also create a split already stopped, with one closed run. startedAt and stoppedAt (epoch ms or ISO 8601, never in the future; now by default) date the run a move starts or stops — the results are counted over the runs, so a split shipped with an imported history starts when that history does. They are only read by the move itself: a later declaration with another startedAt changes nothing.

The declaration rule

EnsureWebsite and EnsureFunnel answer { id, status }; EnsurePageSnapshot answers the same statuses with its page and layout:

StatusWhenWhat happens
createdNo record has this id.Created from the declaration.
unchangedThe record is still what this call declares.Nothing is written.
updatedThe record is still exactly what was last declared, and the declaration changed.Refreshed to the new declaration.
keptOwner content differs, the record was never declared, or a concurrent change prevented the write.This call leaves content untouched. Observed owner divergence permanently relinquishes ownership only if its conditional hash reset succeeds.

The module stores, on the record, a hash of the content it last wrote from a declaration: for a website its name, domain, extra domains and the three switches; for a funnel its website, name, steps, window and the split's key, arms and status (not its runs, which only the moves write). When a declaration observes different content, it attempts to clear the hash without changing the owner's fields. After a successful reset, later calls keep the row even if its content returns to the original values. To take it back, delete it from the dashboard: the next declaration creates it again.

Both refresh and hash reset compare the observed content and declaration hash in the model's write predicate. The funnel comparison includes experiment runs, although runs are not part of the declaration hash. If another writer changes this state first, the call returns kept without overwriting it or clearing its new hash; no automatic retry follows. A contention-only kept does not permanently transfer ownership. Database errors reject rather than becoming kept.

This conditional-write guarantee is verified with MongoDB 1.4.3, which retains the predicate in its native update. Generic filter().update() is not an atomicity guarantee of the database interface: another adapter must preserve the predicate at write time before relying on this behavior. This is state comparison, not an edit log or revision protocol: changes restored before the write (ABA) and changes outside the compared fields are not detected. Snapshot deletion after a successful refresh is a separate operation, not part of a transaction.

EnsurePageSnapshot

A heatmap is drawn over a snapshot of its page, which the tracker captures from a sampled visit. A page that is not online, or history brought by ImportAnalytics, has clicks but no snapshot: the dashboard then shows the counts without a backdrop. EnsurePageSnapshot declares that backdrop from code.

type MarketingSnapshotLayout = "desktop" | "tablet" | "phone";

interface MarketingPageSnapshotDeclaration {
  tenantId: string;
  websiteId: string; // a website of the tenant, with snapshotsEnabled
  url: string; // absolute, on a host the website accepts
  html: string; // the serialized document, at most 1 MiB
  viewport: { width: number; height: number }; // 1-10,000 px
  document: { width: number; height: number }; // 1-200,000 px
  colorScheme?: "light" | "dark";
}

interface MarketingPageSnapshotResult {
  url: string; // the path it is stored under
  layout: MarketingSnapshotLayout;
  status: MarketingDeclarationStatus;
}

declare function EnsurePageSnapshot(
  declaration: MarketingPageSnapshotDeclaration,
): Promise<MarketingPageSnapshotResult>;
import {
  EnsurePageSnapshot,
  EnsureWebsite,
} from "@antelopejs/interface-dms-marketing";

export async function start(): Promise<void> {
  const tenantId = "default";
  await EnsureWebsite({
    tenantId,
    id: "shop-site",
    name: "Shop",
    domain: "shop.example.com",
    snapshotsEnabled: true,
  });
  await EnsurePageSnapshot({
    tenantId,
    websiteId: "shop-site",
    url: "https://shop.example.com/pricing",
    html: pricingPageHtml(),
    viewport: { width: 1280, height: 800 },
    document: { width: 1280, height: 2600 },
  });
}

declare function pricingPageHtml(): string;
  • Keyed by the page. A snapshot is stored per website, page path and layout, as a capture is: the URL keeps its path (query and trailing slash dropped, like a tracked page), and the viewport width picks the layout (desktop from 1024 px, tablet from 600 px, phone below). Declare one per layout the heatmap should show; the dashboard reflows the nearest one for the others.
  • The same document as a capture. The HTML is what snapshot.js would serialize: styles inline (or absolute stylesheet URLs) and images by absolute URL, reachable from the dashboard. It is sanitized like an upload — scripts, frames, plugins, event handlers, script URLs and form values are stripped, and a content security policy goes in front. The website's snapshotMaskText is applied by the capture script, not here: declare an already masked document for such a site. The website must have snapshotsEnabled, the opt-in for storing page content; turning it off, or changing text masking, discards its snapshots, declared ones included, until the next declaration.
  • The declaration rule, with captures. The result follows the declaration rule, the hash covering the origin, both sizes, the color scheme and the sanitized document. A capture of the live page replaces the whole row, without a hash: from then on, declarations of that page and layout are kept. The tracker recaptures a page once its snapshot is older than the refresh age (a day), so a declared backdrop gives way to the real page once it is online and sampled.
  • Retention and limits. A declared snapshot is pruned by the snapshot retention like a capture, counted from its last write (an unchanged declaration writes nothing); the next declaration creates it again. A website holds at most 1,000 snapshots: a new page past the cap rejects.

ImportAnalytics

Separate from the declarations on purpose: it adds data, once, and nothing undoes it but deleting the website.

Contract
interface MarketingImportEvent {
  kind: "pageview" | "custom" | "exposure" | "click" | "scroll";
  at: number | string;
  url: string;
  referrer?: string;
  title?: string;
  name?: string;
  data?: Record<string, unknown>;
}

interface MarketingImportSession {
  visitor: string;
  userAgent?: string;
  country?: string;
  screen?: string;
  language?: string;
  events: MarketingImportEvent[];
}

interface MarketingAnalyticsImport {
  importId: string;
  tenantId: string;
  websiteId: string;
  sessions: MarketingImportSession[];
}

type MarketingImportStatus = "imported" | "in_progress" | "skipped" | "failed";

interface MarketingImportResult {
  importId: string;
  status: MarketingImportStatus;
  sessions: number;
  events: number;
  dropped: number;
}

declare function ImportAnalytics(
  batch: MarketingAnalyticsImport,
): Promise<MarketingImportResult>;

What it does. The history goes through the live collection pipeline with its own timestamps: each visitor's events are cut into the batches the tracker would have sent (a page load starts one, a batch spans at most the tracker's 5-second flush interval and 25 events, on one UTC day) and ingested on the clock of their last event. Sessions are re-derived exactly as live — the 30-minute idle timeout, a campaign landing opening its own visit, a visitor's first session making them new — and the raw events, the sessions and the daily marketing_website_statistics rows come out as if the tracker had sent them then. A caller's session is a grouping of events, not a forced visit.

Visitors. visitor is an opaque key from the source; sessions sharing it, in this import or another, are one returning visitor. It is hashed with the same monthly salt as live visitors, never stored, and never merges with a live visitor. userAgent is parsed into browser, OS and device type and discarded, country (ISO 3166-1 alpha-2) stands for what live collection derives from the IP; no IP is ever involved.

Once per import id. importId (same shape as a website id) is claimed for the website before the first event is written: every later call with it — the next boot, another instance starting at the same time — writes nothing, whatever its payload, and answers how the first call stands:

statusMeaningCounts
"imported"This call replayed the history.What it wrote.
"in_progress"Another call claimed the id and is still replaying it. Call again later for the final counts. A call that never moves past it was interrupted: see failure and rollout.Zero: not final.
"skipped"An earlier call applied it.That call's.
"failed"An earlier call failed part-way.Not authoritative.

History cannot be replaced: a corrected history takes a new import id, after deleting the website if the old one must go. An import that fails part-way stays claimed and is reported as failed, since replaying it would count twice what got in.

Chronology and concurrency. Split history into chronological windows for the entire website, not separate visitor-by-visitor files. Every eligible timestamp in the next call must be at least the latest eligible timestamp of previous completed imports; equal instants are allowed. This includes interactions on expired sessions, whose timestamp does not advance the live session's idle clock. An empty or entirely filtered batch preserves the boundary. A backward window rejects before writing analytics or consuming its import id. This restriction avoids attaching old events to a newer visit; it does not reconstruct historical sessions.

A database claim allows only one import to write a website at a time across instances. A different import id rejects while the website is busy; retry it after completion. A concurrent call with the same id returns in_progress while the first is replaying, never an unfinished skipped. Imported batches read durable session state rather than a previous instance's cached counters. Other websites can import concurrently.

Failure and rollout. This is not a transaction or a resumable job. After replay starts, a failure or uncertain write retains the website claim and prevents new imports, even under another id. A later call with the failed id answers failed; its counts are not authoritative partial-progress totals. If the failure left no trace of the import row (an uncertain first write), the retained claim answers in_progress for that id until reconciled. Do not automatically remove or expire claims: stop all import writers and reconcile stored data first. For a disposable history, deleting and recreating the website clears its analytics and claims, but also its funnels and snapshots. Completed imports from an earlier implementation without a chronology boundary also require reconciliation before another import. Stop old importer instances during rollout; they do not participate in the new claim protocol.

Dropped events are counted, never fatal: an event the collect endpoint would refuse (unknown kind, a field past its limit, a URL that is not http(s) or a path), an unusable at, one beyond the permitted client clock skew or older than the statistics retention, every event of a session whose userAgent is a bot, an interaction without a preceding session, and an exposure of a split that never left draft (an exposure of a running or stopped split is kept: the results only count those inside its runs). The collection switches (the module's and the website's) do not apply: an import is not collection.

Limits. At most 10,000 sessions per call and 1,000 events per session: split a larger history into several calls with distinct import ids. Raw events older than the raw-event retention (90 days by default) are removed by the next hourly prune, as live ones would be; the daily rows stay for the statistics retention (395 days). Funnels and A/B results read raw events, the dashboards the daily rows.