Skip to content
Guide · Rewarded ads

Rewarded ads in Godot with server-side verification

Godot AdMob rewarded ads done safely: show the ad with a plugin, grant the reward only after server-side verification. GDScript steps, plus H5 for web.

Updated

This guide adds AdMob rewarded ads to a Godot 4 game the safe way: the game shows the ad, and the reward lands only after AdMob's server-side verification confirms the player earned it. You'll see why the game's own callback isn't enough, what a verification server has to check if you build one yourself, and the GDScript for a Godot AdMob rewarded ad with PowerUp and Poing Studios' plugin. The last part covers H5 Games Ads for web exports, which have no server-side callback at all.

Why the game can't hand out the reward

AdMob plugins for Godot tell you when the player has earned a reward. That callback runs on the player's device, and anything on the device can be faked: a modified build, a memory editor or a replayed request can claim the reward without an ad ever playing. For a hint button that's fine. For coins, boosts or anything else in your economy, it isn't.

AdMob's server-side verification (SSV) fixes this. When a player earns a reward, Google calls a URL you register on the rewarded ad unit, with the details signed by Google's key. Your server checks the signature and grants the reward. The game's own callback becomes a hint for the UI, not the thing that pays.

Google's docs (as of October 2026) note that callbacks can be delayed, and suggest either rewarding on the client and validating later, or waiting for the callback when the reward's validity matters. For in-game currency, waiting is the safer default, and a "your reward is on its way" message covers the gap.

Server-side verification for rewarded ads: what your server does

If you build it yourself, this is the job, based on Google's SSV docs and what PowerUp checks:

  1. Read the raw query string. The signed message is everything before &signature=, exactly as sent. Re-encoding or reordering the parameters breaks the signature.
  2. Verify the signature. It's an ECDSA signature over SHA-256, under the key that key_id names in Google's published keys. Cache the keys for no more than 24 hours, since Google rotates them.
  3. Match it to something you issued. Before the ad shows, put your own session id in custom_data and a player id in user_id. Refuse a callback that doesn't name an open session of that player.
  4. Check the ad unit. Google signs callbacks for every app's ad units, so a valid signature alone proves nothing about your game. Accept only your own rewarded units, or someone can point a genuine callback from their own app's ad unit at your server.
  5. Grant once. Deduplicate on transaction_id, re-check your cooldown and daily cap under a lock (two ads can finish at the same moment), then grant.
  6. Answer 200. Google retries a callback that doesn't get one, up to five times, a second apart.

Two more things are easy to miss. The game needs a way to ask whether its session paid out, since the callback goes to your server, not the device. And the user_id you hand Google shouldn't be the id your game uses for the player: a per-game pseudonym works just as well and tells the ad network nothing.

Doing it with PowerUp

PowerUp is that server. It runs the checks above, plus the player's age, cooldowns, caps and the match a reward belongs to (the rewarded ads overview explains the rules). You need:

  • The PowerUp addon set up, with a player registered. Getting started covers it.
  • An AdMob plugin. This guide uses Poing Studios' plugin, because it sets the SSV options on an ad that has already loaded, so the PowerUp session can open on the tap. godot-sdk-integrations/godot-admob is another maintained choice, built around a node and signals. It takes the user id and custom data on the load request, so with it you open the PowerUp session before you load the ad. Both were current as of October 2026; check each plugin's docs for the Godot version it needs.
  • A rewarded placement in the PowerUp console, say free_coins paying 20 coins with a cooldown. Set AdMob as the provider on Android and iOS, with your app id and the placement's ad unit id, and switch on Server-side verification required.
  • The SSV callback URL shown on that same console screen, pasted into each rewarded ad unit in AdMob under server-side verification.

Step 1: read the ads config

fetch_ads_config() returns what this player may see right now: the privacy flags to give AdMob, and each rewarded placement with its ad unit, its reward and whether it's available.

# rewarded_ads.gd: the Watch button in your lobby, on Android and iOS
extends Control

var _placement: Dictionary = {}   # one entry of ads.rewarded
var _ad: RewardedAd = null        # a loaded AdMob ad, ready to show
var _session: Dictionary = {}     # the PowerUp session for the ad on screen
var _earned := false              # AdMob's on-device reward callback fired

func _ready() -> void:
    MobileAds.initialize()
    %WatchButton.pressed.connect(_on_watch_pressed)
    await refresh("free_coins")

func refresh(key: String) -> void:
    var r: Dictionary = await PowerUp.fetch_ads_config()
    _placement = {}
    if r.ok:
        _apply_privacy(r.ads.privacy)
        for p: Dictionary in r.ads.rewarded:
            if p.key == key:
                _placement = p
    if _placement.is_empty():
        %WatchButton.hide()   # not live here, or this platform has no ad network
        return
    %WatchButton.show()
    if _placement.available:
        %WatchButton.text = PowerUpDisclosure.ad_reward_prompt(_placement)   # "Watch an ad: +20 coins"
        %WatchButton.disabled = false
        _preload()
    else:
        %WatchButton.text = PowerUpDisclosure.ad_unavailable_text(_placement)   # "Come back tomorrow"
        %WatchButton.disabled = _placement.reason != "age_gate"

The button names the reward before the ad. A cooldown or cap greys it out with the reason, and PowerUp.seconds_until(_placement.nextAvailableAt) gives you a countdown on the server's clock, so a wrong phone clock can't move it. While the player's age is unknown, every placement reads age_gate: ask for the birth year at a calm moment, such as when they tap Watch, never on a death screen, then call PowerUp.set_birth_year().

Step 2: give AdMob the privacy flags

func _apply_privacy(privacy: Dictionary) -> void:
    var rc := RequestConfiguration.new()
    if privacy.childDirected:
        rc.tag_for_child_directed_treatment = RequestConfiguration.TagForChildDirectedTreatment.TRUE
    elif privacy.underAgeOfConsent:
        rc.tag_for_under_age_of_consent = RequestConfiguration.TagForUnderAgeOfConsent.TRUE
    match privacy.maxAdContentRating:
        "G":
            rc.max_ad_content_rating = RequestConfiguration.MAX_AD_CONTENT_RATING_G
        "PG":
            rc.max_ad_content_rating = RequestConfiguration.MAX_AD_CONTENT_RATING_PG
        "T":
            rc.max_ad_content_rating = RequestConfiguration.MAX_AD_CONTENT_RATING_T
        _:
            rc.max_ad_content_rating = RequestConfiguration.MAX_AD_CONTENT_RATING_MA
    MobileAds.set_request_configuration(rc)

A child gets child-directed treatment and G-rated ads. A teen is marked under the age of consent, which Google's targeting docs say turns off personalized ads, and gets T-rated ads at most. Google advises against setting both tags at once, so the child case sets only the first. As of October 2026, those docs mark both tags as deprecated in favor of a single age setting in Google's newest SDK, so use whichever your plugin version exposes. For adults in regions that require consent for personalized ads, the consent form still comes from Google's own consent tools, which both plugins include.

Step 3: preload the ad, then open a session on tap

func _preload() -> void:
    if _ad != null:
        return
    var cb := RewardedAdLoadCallback.new()
    cb.on_ad_loaded = func(ad: RewardedAd) -> void:
        _ad = ad
    cb.on_ad_failed_to_load = func(_error: LoadAdError) -> void:
        %WatchButton.disabled = true   # no ad right now; the next refresh tries again
    RewardedAdLoader.new().load(_placement.unitId, AdRequest.new(), cb)

func _on_watch_pressed() -> void:
    if _placement.get("reason") == "age_gate":
        %AgePrompt.show()   # your neutral birth-year question
        return
    if _ad == null:
        return
    %WatchButton.disabled = true
    var r: Dictionary = await PowerUp.create_ad_session(_placement.key)
    if not r.ok:
        _toast(_refusal(r.code))
        await refresh(_placement.key)
        return
    _session = r.session
    var ssv := ServerSideVerificationOptions.new()
    ssv.user_id = _session.ssv.userId           # a per-game pseudonym, never your player id
    ssv.custom_data = _session.ssv.customData   # the session id, which the callback names
    _ad.set_server_side_verification_options(ssv)
    _show()

func _refusal(code: String) -> String:
    match code:
        "cooldown":
            return "Available again soon"
        "daily_cap":
            return "Come back tomorrow"
        "per_run_cap", "already_claimed":
            return "Already used this game"
        _:
            return "Not available right now"

Opening the session runs every check the reward will run, before anyone watches anything. If the player hit a cap since the config loaded, r.code says which (cooldown and daily_cap also carry details.nextAvailableAt). A teen or child asking for a boost the studio hasn't opened to them gets minor_restricted. The error reference lists every code.

Step 4: show the ad, then wait for the verified reward

func _show() -> void:
    _earned = false
    var on_reward := OnUserEarnedRewardListener.new()
    on_reward.on_user_earned_reward = func(_item: RewardedItem) -> void:
        _earned = true   # a hint for the UI: the server decides
    var screen := FullScreenContentCallback.new()
    screen.on_ad_dismissed_full_screen_content = func() -> void:
        _finish()
    screen.on_ad_failed_to_show_full_screen_content = func(_error: AdError) -> void:
        _finish()
    _ad.full_screen_content_callback = screen
    _ad.show(on_reward)

func _finish() -> void:
    _ad.destroy()
    _ad = null
    var done: Dictionary = await PowerUp.complete_ad_session(_session.sessionId, "admob", _earned)
    if not done.ok and done.code == "transport_error":
        # The answer got lost on the way: a retry with the same key can't pay twice.
        done = await PowerUp.complete_ad_session(_session.sessionId, "admob", _earned, done.idempotency_key)
    if not done.ok:
        _toast("Couldn't confirm the reward. If you earned it, it will still arrive.")
        return
    var s: Dictionary = done.session
    for _i in 6:
        if s.status != "pending":
            break
        await get_tree().create_timer(1.5).timeout
        var polled: Dictionary = await PowerUp.poll_ad_session(_session.sessionId)
        if polled.ok:
            s = polled.session
    match s.status:
        "granted":
            _toast(PowerUpDisclosure.ad_reward_text(_placement))   # "+20 coins"
            if s.reward.has("wallet"):
                _set_coins(s.reward.wallet)   # polling fires no wallet_changed, so update here
        "pending":
            _toast("Your reward is on its way")   # it lands when Google's callback does
        _:
            _toast("No reward this time")
    await refresh(_placement.key)

_toast() and _set_coins() stand for your own UI. GDScript lambdas copy local variables when they're created, which is why _earned is a member variable: a local would never see the change.

With verification switched on, complete_ad_session() usually answers pending, and the session turns granted when Google's signed callback reaches PowerUp. The callback alone is enough: if it arrives first, or the game closes before it reports back, the reward still lands, as long as the session hasn't expired. Reporting false (the player closed the ad early) rejects the session. A session nobody completes expires after 10 minutes, and one waiting for verification gets a day longer for its callback.

The same flow pays other rewards. A placement that doubles a match's coins needs {"runId": run_id} as the second argument to create_ad_session(), and works once per online match within 30 minutes of its report. A revive needs {"runId": ..., "ticketId": ..., "mode": ...} and comes back as a token for your game server.

H5 Games Ads in a web export

The AdMob plugins don't run in a web export. Google's H5 Games Ads serves rewarded and interstitial ads in HTML5 games through the Ad Placement API, and as of October 2026 it's a product you apply for. It has no server-side verification, so the reward rests on the game's report. PowerUp keeps that report in bounds: one open session per player (opening a new one cancels the old), no reward sooner than a minimum time after the session opened, and a per-player daily cap across every placement. Publishing a production game warns about it, so you know what you're accepting.

Add this to your web export preset's Head Include, with your AdSense client id (PowerUp's ads config returns it as appId):

<script async data-adbreak-test="on"
  src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-XXXXXXXXXXXXXXXX"
  crossorigin="anonymous"></script>
<script>
  window.adsbygoogle = window.adsbygoogle || [];
  window.adBreak = window.adConfig = function (o) { adsbygoogle.push(o); };
  // Offer one rewarded break: ready() once an ad can show, done(watched) after it.
  window.offerReward = function (name, ready, done) {
    adBreak({
      type: 'reward',
      name: name,
      beforeReward: function (showAdFn) { window.showReward = showAdFn; ready(); },
      adViewed: function () { done(true); },
      adDismissed: function () { done(false); },
      adBreakDone: function () { window.showReward = null; },
    });
  };
</script>

Then add this to the same script. On web builds, skip the AdMob calls (MobileAds.initialize(), _apply_privacy() and _preload()), call offer_h5() from refresh() instead of _preload(), and connect the Watch button to _on_h5_watch_pressed.

var _js_ready: JavaScriptObject   # keep references, or Godot frees the callbacks
var _js_done: JavaScriptObject

func offer_h5() -> void:
    if not OS.has_feature("web") or not _placement.get("available", false):
        return   # refresh() already shows why it's unavailable
    %WatchButton.hide()   # until H5 has an ad to show
    _js_ready = JavaScriptBridge.create_callback(_on_h5_ready)
    _js_done = JavaScriptBridge.create_callback(_on_h5_done)
    var window = JavaScriptBridge.get_interface("window")   # untyped: its methods are JavaScript's
    # The break is named by the placement key: H5 needs no ad unit ids.
    window.offerReward(_placement.key, _js_ready, _js_done)

func _on_h5_ready(_args: Array) -> void:
    %WatchButton.text = PowerUpDisclosure.ad_reward_prompt(_placement)
    %WatchButton.disabled = false
    %WatchButton.show()   # an ad is ready, so offer it now

func _on_h5_watch_pressed() -> void:
    %WatchButton.disabled = true
    var r: Dictionary = await PowerUp.create_ad_session(_placement.key)
    if not r.ok:
        _toast(_refusal(r.code))
        await refresh(_placement.key)
        return
    _session = r.session
    var window = JavaScriptBridge.get_interface("window")
    window.showReward()

func _on_h5_done(args: Array) -> void:
    var watched := bool(args[0])
    var done: Dictionary = await PowerUp.complete_ad_session(_session.sessionId, "h5", watched)
    if not done.ok and done.code == "too_fast":
        # Sooner than the minimum watch time: wait, then retry with the same key.
        await get_tree().create_timer(done.get("retry_after", 2)).timeout
        done = await PowerUp.complete_ad_session(_session.sessionId, "h5", watched, done.idempotency_key)
    if done.ok and done.session.status == "granted":
        _toast(PowerUpDisclosure.ad_reward_text(_placement))
    elif watched:
        _toast("No reward this time")
    await refresh(_placement.key)   # offers the next break, or shows why not

The Watch button only appears once H5 has an ad to show, and the PowerUp session opens on the tap, so the minimum watch time counts from just before the ad starts. Add beforeAd and afterAd to the break if you want to pause and mute the game while the ad plays.

The privacy flags from fetch_ads_config() matter on the web too. AdSense lets you tag a site or an ad request for child or teen treatment (Google's AdSense help, as of October 2026), but that page doesn't mention H5 Games Ads: check with Google how it applies there before you show ads to younger players on the web.

Test it before real ads

  • PowerUp's test provider. In a sandbox, set a platform's provider to Test. The ads config then carries test (a fill rate and a length), your game draws its own stand-in ad, and rewards grant only with sandbox keys. Publishing a production game fails while any platform still uses it.
  • Google's test ad units. Google's sample rewarded units are ca-app-pub-3940256099942544/5224354917 on Android and ca-app-pub-3940256099942544/1712485313 on iOS. Poing Studios' docs warn that testing with live ads can get your AdMob account suspended. PowerUp's checks warn about real ad units in a sandbox and fail on Google's test ids in production.
  • H5 test ads. data-adbreak-test="on" serves test ads. Remove it before release. If you'd rather decide in code, PowerUp's ads config has testAds set for every sandbox game and never in production.
  • Watch the sessions. The console's Ads page lists recent ad sessions with their status, reward and, for a rejected one, the reason.

Before you ship

  • Server-side verification is on for every AdMob platform with a placement that pays something. Publishing a production game fails without it.
  • The callback URL is pasted into every rewarded ad unit in AdMob.
  • Every live placement has its real ad unit id, and none of Google's test ids remain.
  • The age gate is on. Ads can't publish without it.
  • The web export no longer serves test ads.
  • If you sell No Ads, there's an interstitial or banner for it to remove, in a format your build shows.

For the rules behind all this, see rewarded ads for Godot. The SDK reference lists every ad method and what it returns.

Questions

Should I grant the reward on AdMob's client callback or wait for server-side verification?

Google's docs suggest rewarding on the client callback and validating when the server-side callback arrives, or waiting for the callback when the reward's validity matters. For coins, boosts or anything else in your economy, wait. Callbacks can arrive late, so tell the player the reward is on its way instead of leaving them on a spinner.

Do I need my own server for AdMob server-side verification?

Something has to receive Google's callback, check its signature and grant the reward exactly once. You can build that yourself (this guide lists what it has to do), or let PowerUp receive it by pasting the callback URL from the PowerUp console into each rewarded ad unit in AdMob.

Can I use rewarded ads in a Godot web export?

Yes, through Google's H5 Games Ads, which you apply for. It has no server-side callback, so the reward rests on the game's report. PowerUp limits that with a minimum watch time, one open session per player and a daily cap.

What should I pass to AdMob as the user id?

Not the id your game knows the player by. PowerUp hands out a per-game pseudonym for each player, plus the session id as custom data, so the callback names the right session without Google ever seeing your player ids.

See it running on a real game.

We’ll walk you through the console with buggs.io’s setup.