Skip to main content

Mask State API

Masking keeps sensitive values out of Autopilot recordings — see Masking Sensitive Inputs. But a host application often needs the same information for its own purposes: a native webview shell that captures screenshots, for instance, has to know where the masked elements are on screen so it can obscure those regions in the image it captures.

window.autopilot.getMaskState() exposes exactly that: the live rectangle of every element Autopilot masks, plus whether the page is moving underneath them.

const state = window.autopilot.getMaskState();

// {
// scrolling: false,
// sinceLastScrollMs: 1840,
// rects: [
// { id: 42, x: 16, y: 220, pageX: 16, pageY: 220, width: 280, height: 40 }
// ]
// }

Availability

The function is assigned as the first thing Autopilot does on startup — before the Web Worker is loaded and, in iframe mode, before the frame-id handshake with the parent frame. It does not depend on a session_id, a device_id, or any other session context, so it starts answering as early as the SDK can answer anything.

It is deliberately decoupled from the recording lifecycle and stays accurate for the whole life of the page, including:

  • before recording has started,
  • while a session is restarting (after a Pulse session_id rotation),
  • after the 30-minute session time limit has frozen recording,
  • after window.autopilot.stop() has been called.

Masked rectangles describe what is on screen, not what is being recorded. An empty list would tell a host to lift a blur off live personal data, so the API never blanks out just because recording stopped.

Treat undefined as "not ready", never as "nothing to mask"

window.autopilot.getMaskState does not exist until the Autopilot bundle has downloaded and booted. Until then the property is undefined.

// WRONG — an unredacted screenshot during page load
const rects = window.autopilot?.getMaskState?.().rects ?? [];

// RIGHT — no answer means do not keep the capture
const state = window.autopilot?.getMaskState?.();
if (!state) {
return; // skip or discard this frame
}

Defaulting to an empty array silently converts "the SDK is not up yet" into "there is nothing to hide."

Return shape

interface MaskState {
scrolling: boolean;
sinceLastScrollMs: number;
rects: MaskedElementRect[];
}

interface MaskedElementRect {
id: number;
x: number;
y: number;
pageX: number;
pageY: number;
width: number;
height: number;
}

Everything is measured at call time. There is no cache to go stale: a CSS transform or an animation that moved an element fires no event a cache could listen for, so each call reads the live layout. Calling the API roughly once a second costs a single layout flush over a handful of elements.

MaskState properties

PropertyTypeMeaning
scrollingbooleanThe page scrolled within the last 150 ms — the rectangles may not line up with an image captured a moment earlier
sinceLastScrollMsnumberMilliseconds since the most recent scroll event, or -1 if nothing has scrolled yet
rectsMaskedElementRect[]One entry per currently masked element, in the order the elements entered the masked set

scrolling

true when a scroll event landed within 150 ms of the call. It is the answer to "can I trust the geometry below?" — when it is true, a host redacting a screenshot should obscure the whole viewport rather than trust per-element rectangles.

This matters because of a race that is otherwise invisible. The host captures an image at one instant and asks for rectangles a few milliseconds later; if the page scrolled in between, every rectangle describes a position the image does not show, and a mispositioned blur exposes exactly what it was meant to hide. scrolling catches that window.

Detection is time-based rather than driven by the scrollend event, deliberately:

  • scrollend does not exist in Safari, so iOS webviews would behave differently from Android.
  • It fires per scroll container, so on a page with nested scrollers it can announce an end while another container is still moving.

A fixed time window behaves identically on every engine and errs toward reporting movement, which is the safe direction for anything that blurs on the strength of it. Scroll events are observed in the capture phase, so scrolling inside nested containers counts too — not just the document.

sinceLastScrollMs

The age of the most recent scroll event in milliseconds, or -1 when nothing has scrolled since the page loaded.

This exists so the host can apply its own threshold without an SDK change. If 150 ms proves too twitchy, or you want the full-viewport blur to linger for 400 ms after scrolling stops, read this number instead of the boolean:

const settled = state.sinceLastScrollMs === -1 || state.sinceLastScrollMs > 400;

rects

One entry per masked element. The array and its entries are copies — mutating them does not corrupt Autopilot's internal state.

Order is the order in which elements entered the masked set, which is stable between calls for elements that stay masked. Do not rely on the index of a given element as an identifier; use id where you need one.

Elements that are hidden, clipped, or scrolled out of view still appear, reporting whatever geometry the browser gives them (a display: none element reports a zero-size rectangle). This is intentional: over-obscuring a region is harmless, under-obscuring one is not. Clip the rectangles to your viewport as needed.

MaskedElementRect properties

PropertyTypeCoordinate spaceMeaning
idnumberAutopilot's node id for the element, matching the id used in the recorded stream; 0 when no id has been assigned yet
xnumberViewportDistance from the left edge of the viewport, in CSS pixels
ynumberViewportDistance from the top edge of the viewport, in CSS pixels
pageXnumberDocumentDistance from the left edge of the document, in CSS pixels
pageYnumberDocumentDistance from the top edge of the document, in CSS pixels
widthnumberElement width in CSS pixels
heightnumberElement height in CSS pixels

id

The node id Autopilot assigns to the element while walking the DOM — the same id that identifies the element in the recorded message stream. Use it to correlate a masked region with what you see in a session replay.

It is 0 when the element has not been assigned an id yet, which happens when it was discovered before the recording walk reached it. Node ids are also reissued on session restart, so treat id as valid within a session, not as a durable identifier across sessions.

x and y

Viewport-relative offsets — the same values as getBoundingClientRect().x and .y. These are what you want when redacting a screenshot of the visible viewport, since that is the coordinate space the image is in.

They shift as the page scrolls, which is what makes them vulnerable to the tearing race described under scrolling.

pageX and pageY

Document-relative offsets: the viewport offsets plus the current scroll position.

These are scroll-invariant, which makes them the more robust choice when your capture and your API call cannot be simultaneous. Record the scroll offset alongside the screenshot, and you can compute the correct viewport position afterwards regardless of how much the page scrolled in between:

// at capture time
const shot = { image, scrollX: window.scrollX, scrollY: window.scrollY };

// afterwards — position as it was in the captured image
const left = rect.pageX - shot.scrollX;
const top = rect.pageY - shot.scrollY;

The one exception is elements fixed to the viewport (position: fixed), whose pageX/pageY change as the page scrolls while x/y stay put.

width and height

The element's rendered size in CSS pixels, from the same getBoundingClientRect() measurement. Border-box dimensions, including padding and border, excluding margin.

Coordinate spaces and screenshot pixels

All values are CSS pixels, measured against the document that owns the element. Two conversions are usually needed before they can be drawn on a captured image:

  1. Device pixel ratio. A screenshot is in device pixels, and on a mobile webview those are not CSS pixels. Multiply by window.devicePixelRatio — or derive the factor from the ratio of your captured image's width to the webview's CSS width.
  2. Visual viewport, if pinch-zoom is possible. getBoundingClientRect() returns layout viewport coordinates and ignores pinch-zoom entirely. If users can pinch-zoom, also account for window.visualViewport's scale, offsetLeft, and offsetTop.
Coordinates are relative to the element's own document

In iframe mode the values are relative to the iframe's document, not the top-level page. A top-level Autopilot instance does not see masked elements inside child frames, and a frame's own instance has no way to know where it sits in the parent. If you redact a screenshot of a whole webview whose content includes iframes, you must offset each frame's rectangles by that frame's position yourself.

Polling from a host application

The API is designed to be polled — roughly once a second is the intended cadence, and matches a host that captures a screenshot on the same interval.

function redactionRegions() {
const state = window.autopilot?.getMaskState?.();

// The SDK has not booted: refuse to produce a redacted image at all.
if (!state) {
return null;
}

// The page is moving; per-element geometry cannot be trusted for this frame.
if (state.scrolling) {
return 'whole-viewport';
}

const dpr = window.devicePixelRatio || 1;

return state.rects.map((rect) => ({
left: rect.x * dpr,
top: rect.y * dpr,
width: rect.width * dpr,
height: rect.height * dpr,
}));
}
Capture and read as close together as possible

Ask for the mask state immediately before or after the capture, never seconds apart. If you cannot make them simultaneous, prefer pageX/pageY with the scroll offset recorded at capture time, and fall back to obscuring the whole viewport whenever scrolling is true.

What is included

The masked set is exactly the set described in Masking Sensitive Inputs:

  • <input type="password"> (case-insensitive)
  • any element carrying autopilot-mask or data-autopilot-mask

The set is kept in step with the page as it changes: elements are added when they appear in the DOM, removed when they leave it, and re-evaluated when a mask attribute is added or removed or an input's type changes. Elements that have left the document are dropped on the next call.

Shadow DOM and cross-origin frames are not covered

Autopilot walks the light DOM of its own document. Masked elements inside a shadow root, or inside a frame running its own Autopilot instance, do not appear in this document's rects.

Behaviour by mode

Moderects maintained byid valuesNotes
standardThe recording DOM walkReal node idsDefault for a top-level frame
iframeThe recording DOM walkReal node idsCoordinates are relative to the iframe's own document
mobileThe DOM walk, with all recording work switched offReal node ids, but no recording to correlate withNo Worker, no uploads, no session context — the SDK exists only to answer this API

mobile mode is for a native shell that records the screen itself and only needs to know what to obscure. Autopilot still walks the DOM and follows mutations, so the masked set stays consistent, but nothing is encoded, batched, or uploaded, and stop() merely tears down the walk. The mode is chosen by the Pulse loader, not by application code.

Summary

QuestionAnswer
When does the function exist?As soon as the Autopilot bundle boots — before the Worker and before any session context
Does it stop working when recording stops?No. It stays accurate after stop(), after the 30-minute freeze, and during session restarts
Are the rectangles cached?No — measured live on every call
How often should I call it?About once a second; pair each call with your capture
What if it returns undefined?The SDK is not ready. Discard the capture; do not treat it as "nothing to mask"
What if scrolling is true?Obscure the whole viewport for that frame instead of using per-element geometry
Which coordinates for a viewport screenshot?x/y, scaled by devicePixelRatio
Which coordinates when capture and read are not simultaneous?pageX/pageY, with the scroll offset recorded at capture time