---
title: "Google (GPT) passback tags in a placement"
description: "A Google Ad Manager passback tag serves once and then leaves the slot blank on every reload, Google returns no ad and the impression is lost, or one localized path never fills — why each happens and how to write the tag so they don't."
type: "troubleshooting"
audience: [publisher, network-manager]
keywords: [Google Ad Manager, GAM, GPT, googletag, gpt.js, passback, backfill, defineSlot, destroySlots, slotRenderEnded, isEmpty, reload, refresh after, blank slot, unfilled, document_language, language detection, localized page, duplicate element id, ad wrapper, friendly frame]
ui_path: "Account → Advertisers → advertiser → campaign → banner (HTML Code) → Code"
---

Fetch the complete documentation index at: https://epom.com/ad-server/docs/llms.txt. Use this file to discover all available pages before exploring further. Append .md to any documentation page URL to get its markdown version.

# Google (GPT) passback tags in a placement

An **HTML Code** banner whose markup is a Google Publisher Tag hands your inventory to Google Ad
Manager and shows whatever GAM returns. Three things go wrong with such tags specifically, and
none of them is visible in Epom's reports — the placement looks filled every time:

1. the tag renders on the first impression and leaves a **blank slot on every reload**;
2. **Google returns no ad** and the request is simply lost, because Epom already considers it
   filled;
3. **one localized path fills nothing** while the rest of the site serves normally.

All three are addressed in the banner's markup. The rest of this page is the reasoning and the
finished tag.

The Epom side of each fix — the message the creative posts and how the loader and the ad server
react to it — is checked against the ad server's code. The `googletag` calls are Google's API,
not Epom's: confirm them against Google's current GPT reference before rolling a tag out,
because Epom does not control how they behave.

## Who this is for

Publishers and network managers who paste GPT passback markup into an HTML Code banner. Nothing
here needs anything beyond the banner editing you already do. A slot that Epom's own header-bidding
runtime fills is a different mechanism — see [Header bidding](https://epom.com/ad-server/docs/guides/inventory/header-bidding.md).

## Symptom: renders once, then every reload is blank

The placement has **Refresh after (sec)** set (Advanced settings), the first impression looks
right, and every reload after it leaves an empty slot.

This is **not** a duplicate-`id` problem. Before it injects the new ad, the Universal JS loader
hides the previous one and strips the `id` attribute off every element inside it, so two
`<div id="gpt-passback">` never coexist on the page.

What survives the swap is GPT's own state. `googletag` lives in the page's `window`, not in the
DOM, and it keeps a **slot registry keyed by element id**. Replacing the markup does not reset
it. So a tag written as "reuse the slot if it already exists" finds the slot left by the
previous impression, skips `defineSlot`, and `display()` on an already-displayed slot does
nothing:

```js
// renders once, then never again
var slot = googletag.pubads().getSlots().find(function (s) {
    return s.getSlotElementId() === "gpt-passback";
});
if (!slot) {
    slot = googletag.defineSlot(AD_UNIT, SIZES, "gpt-passback");
}
```

Destroy the stale slot instead of reusing it, and define a fresh one on every impression:

```js
var stale = googletag.pubads().getSlots().filter(function (s) {
    return s.getSlotElementId() === "gpt-passback";
});
if (stale.length) {
    googletag.destroySlots(stale);
}
var slot = googletag.defineSlot(AD_UNIT, SIZES, "gpt-passback");
```

`destroySlots` also removes the previous ad's iframe from the old container, so nothing is left
behind.

## Symptom: Google returns no ad and the slot stays empty

GAM answered "no ad". Epom cannot see that: from its side the request **was** filled — by the
banner carrying the passback tag — so the placement's **Default code** never fires. The blank
happens later, in the browser, and the request is written off as delivered.

Have the creative report the empty fill. The loader then re-requests the ad with that banner
excluded, and the next eligible banner takes the slot:

```js
googletag.pubads().addEventListener('slotRenderEnded', function (e) {
    if (e.isEmpty && e.slot === slot) {
        window.postMessage(JSON.stringify({
            by: "$$AJAX_INVOCATION_CODE_KEY$$",
            key: "$$PLACEMENT_KEY$$",
            bId: $$BANNER_ID$$,
            pb: true
        }), '*');
    }
});
```

The macros are substituted when the markup is generated — paste them as-is. The message format
and the parameters that control what gets excluded are documented under
[Templates](https://epom.com/ad-server/docs/guides/advertising/templates.md#target-related-templates--asking-for-the-next-body);
the same mechanism works from any creative that can detect its own failure, not only GPT.

Then make sure something can actually serve second — the exclusion is pointless if the placement
has nothing else to offer:

- Link a **backfill banner** (house, direct or programmatic) to the placement and put the
  passback banner first with [daisy chaining](https://epom.com/ad-server/docs/guides/delivery/daisy-chaining-passback.md).
  Without daisy chaining, normal weighted rotation shows the backfill on ordinary requests too,
  not only on unfilled ones.
- Or set the placement's **Default code** (Advanced settings; it needs the *Placement Default
  Code* permission) as the last resort — it is returned once no eligible banner is left after the
  exclusions.

## Symptom: one localized path fills nothing while the rest of the site serves

Fill on `/de` (or any other localized path) sits at zero while the same ad units, the same
placement and the same tag fill normally everywhere else. Nothing on your side distinguishes
those requests.

GPT decides the page's language on its own, and Google's classification of a URL can get it
wrong — a path misclassified as an *unsupported* language stops being monetized entirely, so the
slot is never filled rather than filled cheaply. The tag can override the detection per request
by reading the page's declared language:

```js
var language = document.documentElement.lang;

if (language) {
    googletag.pubads().set('document_language', language);
}
```

This runs **before `googletag.enableServices()`** — after services are enabled the value no
longer reaches the request. If the page does not declare `lang` on `<html>`, pass the language
code as a literal instead of reading the attribute.

The misclassification itself is on Google's side, and only Google can confirm it for a given
URL — the question to ask their support is what their system holds against that exact URL, not
why it is unfilled. But the override lives in the markup you paste here, so it is worth setting
on every localized site before escalating anything.

> **Corner case:** measure this with an interleaved A/B, never with single page views. When
> overall fill is around 20%, an empty slot is the *expected* outcome for a large share of views,
> and any single observation "proves" whatever you hoped for.

## The finished tag

Both fixes together. Only the element id, the ad unit path and the sizes change per placement:

```html
<script async src="https://securepubads.g.doubleclick.net/tag/js/gpt.js" crossorigin="anonymous"></script>

<div id="gpt-passback" style="width:300px; height:250px;"></div>

<script>
window.googletag = window.googletag || { cmd: [] };

googletag.cmd.push(function () {

    var passbackMessage = JSON.stringify({
        by: "$$AJAX_INVOCATION_CODE_KEY$$",
        key: "$$PLACEMENT_KEY$$",
        bId: $$BANNER_ID$$,
        pb: true
    });

    var language = document.documentElement.lang;

    if (language) {
        googletag.pubads().set('document_language', language);
    }

    if (!window.gptServicesEnabled) {
        googletag.enableServices();
        window.gptServicesEnabled = true;
    }

    var stale = googletag.pubads().getSlots().filter(function (s) {
        return s.getSlotElementId() === "gpt-passback";
    });

    if (stale.length) {
        googletag.destroySlots(stale);
    }

    var slot = googletag.defineSlot(
        "/NETWORK_CODE/AD_UNIT",
        [300, 250],
        "gpt-passback"
    );

    if (slot) {
        slot.addService(googletag.pubads());

        googletag.pubads().addEventListener('slotRenderEnded', function (e) {
            if (e.isEmpty && e.slot === slot) {
                window.postMessage(passbackMessage, '*');
            }
        });

        googletag.display("gpt-passback");
    } else {
        window.postMessage(passbackMessage, '*');
    }

});
</script>
```

The `else` branch matters: `defineSlot` returns `null` when the element id is already claimed or
the ad unit path is malformed, and in that case no `slotRenderEnded` ever arrives — without it
the request would be lost silently.

### The element id must be unique on the page

`gpt-passback` above is a placeholder. The id has to be **unique across the whole page**, not
just within the banner — give each placement its own (`gpt-passback-leaderboard`,
`gpt-passback-skyscraper-2`, …) and use that same string in all four places: the `<div>`, the
`filter`, `defineSlot` and `display`.

Reusing one id for two ads on the same page breaks all three mechanisms at once:

- `getElementById` resolves to the first matching `<div>`, so the second ad renders into its
  neighbour's container or nowhere;
- `defineSlot` returns `null` for the second slot because the id is already claimed — with the
  `else` branch above that at least passes the request back instead of losing it, but the GAM
  demand for that slot is gone;
- on reload, each tag's `destroySlots` matches by id and would tear down the *other* ad's slot.

Two placements may serve the same ad unit path — that part is fine. It is the element id that
must differ.

## Corner cases and gotchas

> **`Slot.addEventListener` is missing from some GPT builds** and throws `TypeError`. GPT catches
> it, logs it, and abandons the rest of the queued command — so a failure while wiring up a
> listener also skips the `googletag.display()` below it, and nothing renders at all. Register
> listeners on `googletag.pubads()`, and keep anything optional out of the path that leads to
> `display()`.

> **Listeners registered on `googletag.pubads()` are not removed on reload.** They accumulate for
> as long as the page lives — harmless (each closure only matches its own slot, and stale slots
> never fire), but on very long sessions consider registering one listener per page and keying
> the message by `e.slot.getSlotElementId()`.

> **Exclusions last for the current cycle only.** The next scheduled reload starts clean, so the
> passback banner gets the first chance again on every rotation.

> **The empty impression is recorded first and corrected afterwards.** The banner's tracking
> fires when the markup renders, before GPT decides it has no ad — so the impression is real at
> that moment. Sending the passback message is what corrects it: when the hour's statistics are
> processed, every passback subtracts one impression (count, gross and net) from the matching
> aggregate, so General reports settle on the corrected figure. This only happens for banners
> whose creative actually reports the empty fill — a passback tag left as-is keeps every blank
> counted as an impression.

> **Advanced reports do not subtract.** They keep both raw events, which is the honest
> record — read **Impressions** against **Valid passbacks** (a passback matched to the impression
> it corrects) and **Rejected passbacks** (no matching impression was found) there; see the
> [metrics reference](https://epom.com/ad-server/docs/reference/metrics.md). One consequence: if the impression
> was recorded by a different ad server than the passback, the correction is written as a *negative*
> impression row so the two report families agree — and only when the network runs Advanced reporting.
> Without it the correction is dropped rather than pushing a counter below zero, so a small
> over-count can survive. See [My fill rate is low](https://epom.com/ad-server/docs/troubleshooting/low-fill-rate.md).

> **The banner's own *Enable passback functionality* checkbox is not needed for this tag.** It
> generates a ready-made snippet for a creative that simply has nothing to show (see
> [HTML Code banners](https://epom.com/ad-server/docs/guides/advertising/banner-html-and-template.md#passback)), and it needs the
> **Ajax** invocation-code permission of the placement type, which the stock Advertiser role
> does not hold. The message above goes through the Universal JS
> loader instead, and the ad server logs a passback for any call that reaches it on an ajax
> (Universal JS) domain.

> **`wi: true` adds an impression that the subtraction then cancels.** It exists for flows where
> the impression beacon never fired at all, so the passback has something to subtract from. On a
> GPT tag the beacon has already fired, so leave the flag out — with it the two cancel and the
> correction is lost.

> **The *Friendly Frame* ad wrapper sidesteps the reload problem entirely.** Set it as the
> placement's **Ad wrapper** (Ad tags & API → Settings) and each reload renders into its own
> iframe document with its own `window`, so no GPT state carries over. The trade-off: the creative
> then reads the *frame's* document, so `document.documentElement.lang` is empty and language
> targeting derived from it is lost. The frame is same-origin, and the loader sets `inDapIF = true`
> inside it.

> **Libraries other than GPT need the same idea in their own vocabulary.** If a teardown API
> exists (most video players), call it before re-initialising. If none exists (`adsbygoogle` and
> similar), make the element id unique per impression with the `$$RANDOM$$` macro
> (`id="passback-$$RANDOM$$"`) and use the same value in the script.

## Related

- [Tags and preview problems](https://epom.com/ad-server/docs/troubleshooting/tags-and-preview.md)
- [Set up daisy chaining and passbacks](https://epom.com/ad-server/docs/guides/delivery/daisy-chaining-passback.md)
- [Templates](https://epom.com/ad-server/docs/guides/advertising/templates.md)
- [My fill rate is low](https://epom.com/ad-server/docs/troubleshooting/low-fill-rate.md)
- [My ad is not serving](https://epom.com/ad-server/docs/troubleshooting/ad-not-serving.md)