This document defines the unified technical requirements for playable creatives: the mandatory set of tracking macros, the order in which they are substituted and fired, working with MRAID, navigation to the store, and notifying the parent window via postMessage. The requirements are the same for all playable formats — fullscreen, MREC 300×250, banners 320×50/728×90, etc. — regardless of the game mechanic.
The "Filwords" playable (a word-search on a 5×5 grid) is used as a reference — all requirements below are applied correctly in it, and the code examples in this document are taken from it. The full tracking skeleton that creatives pass upload with is given in the "Reference tracking skeleton" section — build new creatives on top of it and change only the game part.
Requirements marked "Checked on upload" are verified by the platform validator: a creative that violates them will not be accepted.
All macros are declared as string variables at the very beginning of the main script, before any game logic — so the ad platform is guaranteed to find and substitute the {{...}} tokens before the creative is served:
var IMPRESSION_URL = "{{IMPRESSION_URL}}";
var VISIBLE_URL = "{{VISIBLE_URL}}";
var FIRST_INTERACTION_URL = "{{FIRST_INTERACTION_URL}}";
var START_URL = "{{START_URL}}";
var COMPLETE_URL = "{{COMPLETE_URL}}";
var CLICK_URL = "{{CLICK_URL}}";
var VIEW_URL = "{{VIEW_URL}}";
var ERROR_URL = "{{ERROR_URL}}";
| Macro | Purpose | When it must fire |
|---|---|---|
| IMPRESSION_URL | Ad impression loaded | First, synchronously at the very start of script execution, before the MRAID/visibility check. Once per session. |
| VIEW_URL | Records the view | Immediately after IMPRESSION_URL, also synchronously at start. Once. |
| START_URL | Playable start | Before MRAID init and before the first user interaction — must precede FIRST_INTERACTION_URL. Once. |
| VISIBLE_URL | Creative became viewable | On actual visibility: via mraid.isViewable()/the viewableChange event, or immediately if MRAID is unavailable. Once. |
| FIRST_INTERACTION_URL | User's first touch/click | On the first touchstart or click anywhere in the creative. Once. |
| COMPLETE_URL | Game scenario finished | On any completion: successful pass, loss, "Skip" button, gameplay timeout. Once, regardless of how it ended. |
| CLICK_URL | CTA click to the store | Not a tracking pixel: the value is a link (the CTA button href) and the argument of mraid.open()/window.open(). If the platform did not substitute the macro, the STORE_URL fallback is used instead (see "Navigation to the store"). May fire multiple times — per number of clicks. |
| ERROR_URL | Error message | On any uncaught JS error (window.onerror), unhandled promise rejection (unhandledrejection) or an error reported by the MRAID SDK. The error text is appended to the macro value. May fire multiple times. |
Additionally:
fired flag object (exact form in the "fireEvent function" section), checked before the call and set to true right after. CLICK_URL and ERROR_URL do not require such deduplication.var <NAME> = "{{<NAME>}}";, and each variable is actually used in the code: a macro that is declared but never called is an error.{{...}}), no request must be made for it — before the call, verify the value does not start with the { character.ERROR_URL + encodeURIComponent(message)), without automatic ?/& insertion — the macro must already arrive with the required separator/parameter name at the end if the platform expects the error text as a query-parameter value.The server parses {{...}} in the creative HTML as macro tags. Therefore:
{{ and }} are allowed only as part of the eight tokens listed above. They must not appear in comments, strings, CSS or minified code (a minifier easily merges } } into }} — keep a space or a line break between closing braces).{{CLICK_URL}} token is allowed in two places: in the variable declaration and in the href of the CTA link on the final screen (see "Navigation to the store"). The other seven tokens appear in the file exactly once each — in their declaration.url.charAt(0) === "{" — not by comparing with the string "{{": such a string would itself be parsed by the server as a tag.Mandatory sequence:
load notification to the parent (see "Parent notifications") → START_URL. All of this must run before control is passed to MRAID/visibility handling, and certainly before reacting to user actions.click/download notifications to the parent → navigate to getClickUrl() (CLICK_URL, or STORE_URL if it was not substituted) via mraid.open() with a fallback to window.open()/location.href.close notification to the parent → mraid.close().error notification to the parent, without interrupting the rest of the scenario.Steps 1–3 are implemented as a boot block at the end of the tracking script:
fireOnce("impression", IMPRESSION_URL);
fireOnce("view", VIEW_URL);
notifyLoad();
fireOnce("start", START_URL);
initMraid();
A single entry point for firing all tracking pixels — via an invisible image, without waiting for a response and without CORS restrictions:
function fireEvent(url) {
if (!url || url.charAt(0) === "{") return;
try {
var img = new Image();
img.src = url;
} catch (e) {}
}
Implementation requirements:
url.charAt(0) === "{" check guards against sending a request for an unsubstituted macro (when the platform did not replace {{...}} with a real URL) — otherwise a broken request with literal curly braces reaches the server.try/catch so that rare exceptions (e.g. an invalid URL in old WebViews) do not crash the rest of the script.new Image(); img.src = url is the standard tracking-pixel pattern: the request is sent via GET, the response is not processed, onload/onerror are not needed.fireOnce(key, url) wrapper, not inside fireEvent — fireEvent stays simple and is called for both one-time and multi-time (CLICK_URL, ERROR_URL) events.Together with the pixel, fireOnce itself sends the matching notification to the parent — via the eventNotifiers table. This way the pixel and the postMessage of the same event can never get out of sync:
var fired = {impression: false, visible: false, firstInteraction: false, start: false, complete: false, view: false,};
var eventNotifiers = {
impression: notifyImpression,
visible: notifyVisible,
firstInteraction: notifyFirstInteraction,
start: notifyStart,
complete: notifyComplete,
};
function fireOnce(key, url) {
if (fired[key]) return;
fired[key] = true;
fireEvent(url);
var notifier = eventNotifiers[key];
if (notifier) notifier();
}
From the game code, completion is a single line — fireOnce("complete", COMPLETE_URL); — with no separate postMessage next to it.
The order is mandatory:
mraid.js is included in <head> with a regular blocking <script src="mraid.js"></script> tag (no async/defer) — this guarantees that by the time the next inline script runs, the SDK has either already defined window.mraid or its loading has definitely failed.<script> with no other code in it — a safe fallback stub, installed only if window.mraid is not yet defined:window.mraid = window.mraid || {
fireEvent: function () {}, addEventListener: function () {}, removeEventListener: function () {},
isViewable: function () { return true; },
getState: function () { return "default"; },
getVersion: function () { return "1.0"; },
useCustomClose: function () {},
open: function (url) {
try { var w = window.open(url, "_blank"); if (!w) window.location.href = url; }
catch (e) { window.location.href = url; }
},
close: function () {},
};
Important: the stub must not be declared before attempting to load mraid.js — some SDKs skip injecting the real bridge if window.mraid is already defined by someone else, and then mraid.open() silently degrades to window.open(), which in some WebViews does nothing. For the same reason no extra fields are added to the stub, and it is not mixed in one script with macros and tracking.
mraid.getState() === "loading", subscribe to the ready event (mraid.addEventListener("ready", onMraidReady)), otherwise run the readiness logic immediately.mraid.useCustomClose(true) — the creative has its own close button (see "Close button"), so the container must not draw its own on top.viewableChange — to send VISIBLE_URL when the creative becomes visible.error — to send ERROR_URL on errors reported by the SDK itself.mraid.isViewable() check — in case the creative is already visible at readiness time (otherwise the first viewableChange may never arrive).mraid.open(), not fired as a separate pixel — this is the official MRAID way to hand a link navigation to the container. The argument is getClickUrl(), i.e. CLICK_URL or the STORE_URL fallback (see "Navigation to the store").mraid.close(), also wrapped in try/catch with a send to ERROR_URL on exception.try/catch: both the stub and some real SDKs may not implement certain methods or may throw, and this must not crash the playable.Every creative contains a STORE_URL constant — a direct link to the advertised app's store page — and a getClickUrl() function. They are declared right after the macros:
/* Fallback store link: used only when the platform did not substitute the click macro
(the value is empty or still starts with "{"), so the CTA always leads somewhere. */
var STORE_URL = "https://play.google.com/store/apps/details?id=<package.name>";
function getClickUrl() {
return (CLICK_URL && CLICK_URL.charAt(0) !== "{") ? CLICK_URL : STORE_URL;
}
{{CLICK_URL}}, navigation goes to STORE_URL.click/download notifications to the parent. The fallback is a safety net against losing the user, not a replacement for the macro: on the platform CLICK_URL must always be substituted.CLICK_URL is never passed directly to mraid.open()/window.open()/location.href — only through getClickUrl().function goToStore(e) {
if (e && e.preventDefault) e.preventDefault();
notifyClick();
notifyDownload();
var url = getClickUrl();
try {
if (window.mraid && typeof mraid.open === "function") {
mraid.open(url);
return;
}
} catch (e) {
reportError(e && e.message ? e.message : e);
}
try {
var w = window.open(url, "_blank");
if (!w) window.location.href = url;
} catch (e) {
window.location.href = url;
}
}
<a> link with the macro right in the markup, so the click works even if JS has failed:<a id="ctaEnd" class="cta-big js-cta" href="{{CLICK_URL}}" target="_blank" rel="noopener">PLAY FREE</a>
href="#". On load, every CTA link whose href is empty, equals # or starts with { (macro not substituted) gets getClickUrl() as its href.goToStore. It calls preventDefault, otherwise the browser opens the href and mraid.open() opens the store once more, and the click is counted twice.Breaking any of these rules means part of the clicks never reach the store or are not counted:
mraid.open() is called synchronously inside the click handler — no setTimeout, promises, await or animations before the navigation. Otherwise the WebView loses the link to the user gesture and blocks the navigation.stopPropagation. If the whole container is clickable (final screen, strip banner), its handler skips clicks already handled by a button: if (!e.defaultPrevented) goToStore(e);.preventDefault is not called in touchstart on CTA elements and their parents — on mobile this cancels the following click.pointer-events: none so they do not intercept taps.Besides tracking macros, the creative must also notify the parent window in parallel (a creative always runs inside an iframe) via postMessage — this is a separate channel for SDKs that integrate through messages, not only via MRAID/pixels:
try { parent.postMessage("<event>", "*"); } catch (e) {}
Mandatory list of events and their macro mapping:
| Message | When it is sent | Corresponding macro |
|---|---|---|
load |
Right after load, together with the boot block (before START_URL) | — (no separate macro) |
impression |
Together with IMPRESSION_URL | IMPRESSION_URL |
visible |
Together with VISIBLE_URL | VISIBLE_URL |
firstInteraction and first_interaction (both variants, camelCase and snake_case, for compatibility with different SDKs) |
Together with FIRST_INTERACTION_URL | FIRST_INTERACTION_URL |
start |
Together with START_URL | START_URL |
complete |
Together with COMPLETE_URL | COMPLETE_URL |
click and download |
On CTA click, before calling mraid.open() (including navigation via the fallback) |
CLICK_URL |
close |
On custom close button press, before mraid.close() |
— (no separate macro) |
error |
On any handled error | ERROR_URL |
All postMessage calls use the wildcard origin "*" and are wrapped in try/catch so that the absence of a parent window or environment restrictions do not crash the script.
The platform validator checks the source text of the HTML, not the creative's behavior: it looks for the literal call string of each event in the file. Therefore:
parent.postMessage("complete", "*") — the event name in double quotes, a comma, a space, "*" in double quotes.// NOT ALLOWED: the source has no parent.postMessage("complete", "*") string
function notifyParent(msg) {
try { parent.postMessage(msg, "*"); } catch (e) {}
}
notifyParent("complete");
The error the platform returns in this case:
invalid request parameter: missing 'parent.postMessage("complete", "*")' in creative html, 'complete' event is required
function notifyComplete() {
try {
parent.postMessage("complete", "*");
} catch (e) {
}
}
complete. To avoid getting rejections one event at a time, the file must contain literals for all 11 messages from the table: load, impression, visible, firstInteraction, first_interaction, start, complete, click, download, close, error. Hence the close button requirement (see below): without it there is no close call."comp" + "lete") — none of these are recognized by the validator.mraid.useCustomClose(true) is called.opacity: 0; pointer-events: none), while the CTA is visible from the first frame.notifyClose() (parent.postMessage("close", "*")) → mraid.close() in try/catch. Navigation to the store is not triggered.The first touch is caught at the document level in the capture phase, so that game or CTA handlers cannot "swallow" it:
function markFirstInteraction() {
fireOnce("firstInteraction", FIRST_INTERACTION_URL);
}
document.addEventListener("touchstart", markFirstInteraction, {passive: true, once: true, capture: true});
document.addEventListener("click", markFirstInteraction, {once: true, capture: true});
Error handling:
function reportError(message) {
if (ERROR_URL) fireEvent(ERROR_URL + encodeURIComponent(String(message)));
notifyErrorEvent();
}
window.onerror = function (message, source, lineno, colno, error) {
reportError(message);
return true;
};
window.addEventListener("unhandledrejection", function (e) {
var r = e && e.reason;
reportError("unhandledrejection: " + (r && r.message ? r.message : r));
});
window.onerror returns true: the error has already been sent to ERROR_URL, there is no need to print it to the console again. Game handlers (timers, taps) are wrapped in try/catch with a reportError call, so that an error in the game does not stop tracking and the CTA.
A creative is a single self-contained HTML file. Only one external resource is allowed — mraid.js. Block order:
<head>: <script src="mraid.js"></script> → a separate <script> with the MRAID stub → styles.<body>: markup (game, CTA, final screen, close button).<script> (the skeleton from the next section): macros → fired → STORE_URL/getClickUrl → fireEvent → notify* functions → eventNotifiers/fireOnce → errors → goToStore/closeAd → CTA wiring → MRAID → first interaction → boot block.<script> with the game logic. Completion — fireOnce("complete", COMPLETE_URL);.The tracking script is separate from the game script and placed before it: a syntax error in the game will not prevent the impression from being sent, and by the time the boot block runs, the CTA markup already exists.
<head> block:
<script src="mraid.js"></script>
<script> /* Safe MRAID stub, installed only AFTER mraid.js had its chance to run. Defining it before mraid.js is dangerous: some SDKs skip injecting their bridge when window.mraid already exists, and then mraid.open() silently degrades to window.open(), which does nothing in many WebViews. Here the stub only covers environments without MRAID at all (mraid.js 404'd). */
window.mraid = window.mraid || {
fireEvent: function () {
}, addEventListener: function () {
}, removeEventListener: function () {
}, isViewable: function () {
return true;
}, getState: function () {
return "default";
}, getVersion: function () {
return "1.0";
}, useCustomClose: function () {
}, open: function (url) {
try {
var w = window.open(url, "_blank");
if (!w) window.location.href = url;
} catch (e) {
window.location.href = url;
}
}, close: function () {
},
}; </script>
Tracking script. Only STORE_URL changes and, if needed, the element ids in the "CTA wiring" block (endcard, bannerView, closeBtn):
<script> var IMPRESSION_URL = "{{IMPRESSION_URL}}";
var VISIBLE_URL = "{{VISIBLE_URL}}";
var FIRST_INTERACTION_URL = "{{FIRST_INTERACTION_URL}}";
var START_URL = "{{START_URL}}";
var COMPLETE_URL = "{{COMPLETE_URL}}";
var CLICK_URL = "{{CLICK_URL}}";
var VIEW_URL = "{{VIEW_URL}}";
var ERROR_URL = "{{ERROR_URL}}";
/* Fallback store link: used only when the platform did not substitute the click macro
(the value is empty or still starts with "{"), so the CTA always leads somewhere. */
var STORE_URL = "https://play.google.com/store/apps/details?id=<package.name>";
function getClickUrl() {
return (CLICK_URL && CLICK_URL.charAt(0) !== "{") ? CLICK_URL : STORE_URL;
}
var fired = {impression: false, visible: false, firstInteraction: false, start: false, complete: false, view: false,};
function fireEvent(url) {
/* An unrendered macro placeholder still starts with "{" rather than a scheme —
never turn that into a bogus request. (Checked one char at a time on purpose:
a literal double brace here would be parsed as a macro tag server-side.) */
if (!url || url.charAt(0) === "{") return;
try {
var img = new Image();
img.src = url;
} catch (e) {
}
}
function notifyLoad() {
try {
parent.postMessage("load", "*");
} catch (e) {
}
}
function notifyImpression() {
try {
parent.postMessage("impression", "*");
} catch (e) {
}
}
function notifyVisible() {
try {
parent.postMessage("visible", "*");
} catch (e) {
}
}
function notifyFirstInteraction() {
try {
parent.postMessage("firstInteraction", "*");
} catch (e) {
}
try {
parent.postMessage("first_interaction", "*");
} catch (e) {
}
}
function notifyStart() {
try {
parent.postMessage("start", "*");
} catch (e) {
}
}
function notifyComplete() {
try {
parent.postMessage("complete", "*");
} catch (e) {
}
}
function notifyClick() {
try {
parent.postMessage("click", "*");
} catch (e) {
}
}
function notifyDownload() {
try {
parent.postMessage("download", "*");
} catch (e) {
}
}
function notifyClose() {
try {
parent.postMessage("close", "*");
} catch (e) {
}
}
function notifyErrorEvent() {
try {
parent.postMessage("error", "*");
} catch (e) {
}
}
var eventNotifiers = {
impression: notifyImpression,
visible: notifyVisible,
firstInteraction: notifyFirstInteraction,
start: notifyStart,
complete: notifyComplete,
};
function fireOnce(key, url) {
if (fired[key]) return;
fired[key] = true;
fireEvent(url);
var notifier = eventNotifiers[key];
if (notifier) notifier();
}
function reportError(message) {
if (ERROR_URL) fireEvent(ERROR_URL + encodeURIComponent(String(message)));
notifyErrorEvent();
}
window.onerror = function (message, source, lineno, colno, error) {
reportError(message);
return true;
};
window.addEventListener("unhandledrejection", function (e) {
var r = e && e.reason;
reportError("unhandledrejection: " + (r && r.message ? r.message : r));
});
function goToStore(e) {
/* CTA anchors point at the click macro, so the click still works if JS dies.
When we do handle it, cancel the default navigation — otherwise the anchor and
mraid.open() both open the store and the click is counted twice. */
if (e && e.preventDefault) e.preventDefault();
notifyClick();
notifyDownload();
var url = getClickUrl();
try {
if (window.mraid && typeof mraid.open === "function") {
mraid.open(url);
return;
}
} catch (e) {
reportError(e && e.message ? e.message : e);
}
try {
var w = window.open(url, "_blank");
if (!w) window.location.href = url;
} catch (e) {
window.location.href = url;
}
}
function closeAd(e) {
if (e && e.preventDefault) e.preventDefault();
notifyClose();
try {
if (window.mraid && typeof mraid.close === "function") {
mraid.close();
}
} catch (e) {
reportError(e && e.message ? e.message : e);
}
}
/* ---------- CTA wiring ---------- */
(function () {
var ctas = document.querySelectorAll(".js-cta");
for (var i = 0; i < ctas.length; i++) {
/* the end-card button carries the macro literally; the rest (and an unsubstituted one) get it here */
var href = ctas[i].getAttribute("href") || "";
if (href === "#" || href.charAt(0) === "{") ctas[i].setAttribute("href", getClickUrl());
ctas[i].addEventListener("click", goToStore);
}
/* whole end card / strip banner is a click target; buttons inside handle their own click */
var areas = [document.getElementById("endcard"), document.getElementById("bannerView")];
for (var j = 0; j < areas.length; j++) {
areas[j].addEventListener("click", function (e) {
if (!e.defaultPrevented) goToStore(e);
});
}
document.getElementById("closeBtn").addEventListener("click", closeAd);
/* the store CTA is visible from the first frame; only the close button is delayed */
setTimeout(function () {
document.getElementById("closeBtn").classList.add("visible");
}, 5000);
})();
function checkViewable() {
try {
if (window.mraid && typeof mraid.isViewable === "function") {
if (mraid.isViewable()) {
fireOnce("visible", VISIBLE_URL);
}
} else {
fireOnce("visible", VISIBLE_URL);
}
} catch (e) {
fireOnce("visible", VISIBLE_URL);
}
}
function onMraidReady() {
try {
if (typeof mraid.useCustomClose === "function") {
mraid.useCustomClose(true);
}
if (typeof mraid.addEventListener === "function") {
mraid.addEventListener("viewableChange", function (viewable) {
if (viewable) fireOnce("visible", VISIBLE_URL);
});
mraid.addEventListener("error", function (message) {
reportError(message);
});
}
} catch (e) {
reportError(e && e.message ? e.message : e);
}
checkViewable();
}
function initMraid() {
if (window.mraid) {
try {
if (mraid.getState && mraid.getState() === "loading") {
mraid.addEventListener("ready", onMraidReady);
} else {
onMraidReady();
}
} catch (e) {
onMraidReady();
}
} else {
setTimeout(function () {
fireOnce("visible", VISIBLE_URL);
}, 300);
}
}
function markFirstInteraction() {
fireOnce("firstInteraction", FIRST_INTERACTION_URL);
}
document.addEventListener("touchstart", markFirstInteraction, {passive: true, once: true, capture: true});
document.addEventListener("click", markFirstInteraction, {once: true, capture: true});
/* ---------- Boot sequence ----------
fireOnce('start', ...) is called here, synchronously, as part of the page's initial
script run — BEFORE control ever returns to the browser's event loop. Since JS is
single-threaded, no touchstart/click handler (and therefore no first_interaction
event) can possibly run before this line finishes executing. */
fireOnce("impression", IMPRESSION_URL);
fireOnce("view", VIEW_URL);
notifyLoad();
fireOnce("start", START_URL);
initMraid();
</script>
Macros:
var <NAME> = "{{<NAME>}}"; variables with the exact names, each exactly once, at the start of the tracking script, before game logic. Each variable is used in the code.{{ and }} appear in the file only as part of the 8 tokens; {{CLICK_URL}} additionally in the href of the final-screen CTA.IMPRESSION_URL, VIEW_URL, START_URL, VISIBLE_URL, FIRST_INTERACTION_URL, COMPLETE_URL fire strictly once per session (the fired object, fireOnce).fireEvent call, it is verified that the macro was substituted by the platform (does not start with {).START_URL is called synchronously in the boot block, before any interaction handler — guarantees the start → first_interaction order.COMPLETE_URL is called on any completion type (success, loss, skip, timeout), not only on a successful pass.ERROR_URL is called on any caught error, including window.onerror and unhandledrejection.Navigation to the store:
STORE_URL (link to the advertised app) and getClickUrl() are declared; navigation always goes through getClickUrl(), CLICK_URL is never passed directly to mraid.open()/window.open().CLICK_URL is not fired as a pixel but passed to mraid.open() (with a fallback to window.open()/location.href).<a href="{{CLICK_URL}}">; for other CTAs and for an unsubstituted macro the href is set from getClickUrl().goToStore calls preventDefault, navigation is synchronous, no stopPropagation, no debounce; clickable containers check e.defaultPrevented.pointer-events: none; the CTA is visible from the first frame; the final screen appears with a delay after the last game tap.MRAID:
mraid.js is included with a blocking tag in <head>; the fallback stub is in a separate <script> right after it and only if window.mraid did not get defined.getState()/ready), all calls wrapped in try/catch.viewableChange and error are implemented, plus an isViewable() check right after readiness.mraid.useCustomClose(true) is called, on press — close to the parent and mraid.close().Parent notifications:
postMessage notifications are sent to the parent per the list in the "Parent notifications" section.parent.postMessage("<event>", "*"); the event name is never passed through a variable.Before uploading to the platform, run the script below on the file (bash, from the creative's folder). No output except the OK line means the creative is ready:
f=creative.html; err=0
for ev in load impression visible firstInteraction first_interaction start complete click download close error; do
grep -qF "parent.postMessage(\"$ev\", \"*\")" "$f" || { echo "missing parent.postMessage(\"$ev\", \"*\")"; err=1; }
done
for m in IMPRESSION_URL VISIBLE_URL FIRST_INTERACTION_URL START_URL COMPLETE_URL CLICK_URL VIEW_URL ERROR_URL; do
[ "$(grep -cF "var $m = \"{{$m}}\";" "$f")" = 1 ] || { echo "macro $m is not declared exactly once"; err=1; }
# the declaration contains the name twice (var and token), so usage is the third occurrence onwards
[ "$(grep -oE "\\b$m\\b" "$f" | wc -l)" -ge 3 ] || { echo "macro $m is declared but not used"; err=1; }
done
tok=$(grep -oE '\{\{(IMPRESSION|VISIBLE|FIRST_INTERACTION|START|COMPLETE|CLICK|VIEW|ERROR)_URL\}\}' "$f" | wc -l)
[ "$(grep -o '{{' "$f" | wc -l)" = "$tok" ] && [ "$(grep -o '}}' "$f" | wc -l)" = "$tok" ] || { echo "the file contains {{ or }} outside macros"; err=1; }
grep -qE 'var STORE_URL = "https://[^"]+";' "$f" || { echo "missing STORE_URL fallback"; err=1; }
grep -qF 'mraid.open(CLICK_URL)' "$f" && { echo "navigation bypasses getClickUrl()"; err=1; }
grep -qF 'mraid.useCustomClose(true)' "$f" || { echo "missing mraid.useCustomClose(true)"; err=1; }
grep -qE '<script[^>]*mraid\.js[^>]*(async|defer)' "$f" && { echo "mraid.js is included with async/defer"; err=1; }
[ $err = 0 ] && echo OK
Manual check in a browser (the creative opened inside an <iframe>):
%7B%7B in the URL; a CTA click opens STORE_URL.postMessage messages go in the order impression → view → (load) → start → visible → first_interaction → … → complete; a CTA click calls mraid.open() with CLICK_URL exactly once per tap.