Why this rejection is confusing

The file plays fine locally. It's one .html file, it's under the size cap, the game loop works, the CTA button is there. From where you're sitting, it looks exactly like what Meta asked for: a single self-contained file with everything embedded. So the rejection reads as a false positive — until you remember that Meta's check isn't watching the ad play, it's inspecting what the file actually does at the network level once it's live. Something in that file is still reaching outside itself, and it doesn't have to be a large or obvious thing to trip the check.

What actually counts as an "external request"

Meta's spec (see the full breakdown on Meta playable ads: requirements, formats & common errors) requires zero network calls once the ad is live — everything has to be embedded in the one HTML file. In practice, "external request" covers more ground than most people expect:

  • Any HTTP call at all. Fetch, XHR, WebSocket, beacon, image ping, a stray <iframe src="https://..."> — the transport doesn't matter, any outbound network call is a violation.
  • Relative asset paths pointing outside the file. A raw Unity Playworks/Luna export runs from a folder of sibling files by design — src="assets/sprite.png", src="./audio/bg.mp3". That's normal and correct for a build meant to run from disk. It's exactly the shape Meta's single-file rule prohibits the moment the HTML is uploaded on its own without that folder alongside it.
  • Absolute paths to your own or a third party's server. Same problem, just less obvious — an image tag or script pointing at your own CDN instead of a sibling folder still reaches outside the file.
  • CDN-hosted fonts and libraries. A Google Fonts <link>, a CDN-hosted copy of jQuery, GreenSock, or a physics library — common in Google Ads builds where Google's own allow-list permits a few hosted libraries — is not on any equivalent Meta allow-list. Meta has no exceptions here.
  • Analytics or tracking snippets left in accidentally. This is the category that catches teams off guard most often, because nobody added it on purpose. A pipeline step, a template, or a leftover debug build can inject an analytics beacon or tracking snippet into the exported HTML without it ever showing up as a visible feature. The ad still runs, the game loop still works, and the review still fails — because that snippet fires a request the moment the page loads, regardless of whether anyone is looking at what it sends.
The pattern to watch for: every one of these can be invisible from just playing the build. A missing texture would break the game and you'd catch it instantly. A network call that succeeds — or fails silently — changes nothing about what you see on screen. That's why "it plays fine" and "it's rejected for external requests" aren't actually in tension.

How to audit a build for this

You don't need Meta's review pipeline to find these — they're all visible from the browser you already have open.

  1. Open dev tools and go to the Network tab before loading the file. Clear it, then open the HTML file directly (double-click it, or drag it into the browser — don't load it from a local dev server that might be serving sibling assets you'd otherwise be missing).
  2. Play through the entire ad — every scene, every interaction, the end card, the CTA tap. A request that only fires on a specific path (an analytics event on level-complete, for instance) won't show up if you don't reach that path.
  3. Check every row in the Network panel. The only entry that should ever appear is the HTML document request itself. Anything else — an image, a font, a script, a beacon, a favicon lookup — is a violation, no matter how small.
  4. Search the raw HTML source for src=, href=, fetch(, XMLHttpRequest, and <link rel="stylesheet"> pointing anywhere other than a data: URI. This catches code paths a quick playthrough might not trigger.
  5. Test the file in true isolation. Move it out of its original export folder into an empty directory by itself before opening it. If anything breaks or looks different, something was still being loaded from that sibling folder — exactly the reference Meta's single-file rule prohibits.

The fix

Once you've found what's reaching outside the file, the fix is mechanical:

  • Embed every real asset as base64 (or base122) inline — images, audio, fonts, and any JSON or config data the build reads. A data: URI inside a src or url() is not an external request; a relative or absolute path is. Every reference has to become the former.
  • Remove any injected third-party script entirely — don't try to inline an analytics or tracking snippet, delete it. It serves no purpose inside a playable ad and there's no compliant way to keep it firing.
  • Re-run the Network-tab check after the fix, not just a visual playthrough, to confirm the only request left is the document load itself.
  • Re-verify the CTA still calls FbPlayableAd.onCTAClick() after re-encoding — a large re-encode pass is also a common point where the click handler gets clobbered or duplicated.

Compressing assets before encoding them keeps the rebuilt file under Meta's 5 MB cap (2 MB recommended) — base64 adds roughly 33% overhead on top of the original binary size, so an asset set that was fine as loose files can push past the limit once every reference has to be inlined.

Before resubmitting, run the file through PlayableKit's free Playable Ad Validator — it checks a build for exactly this: any reference outside the single HTML file, remaining external scripts, and the CTA wiring, all before you spend another review cycle finding out the hard way. If the build started as a Unity Playworks/Luna export, exporting it for Meta covers the full path from a raw multi-file export to a single compliant HTML file, including this exact embedding step.