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:
- Read the raw query string. The signed message is everything before
&signature=, exactly as sent. Re-encoding or reordering the parameters breaks the signature. - Verify the signature. It's an ECDSA signature over SHA-256, under the key that
key_idnames in Google's published keys. Cache the keys for no more than 24 hours, since Google rotates them. - Match it to something you issued. Before the ad shows, put your own session id in
custom_dataand a player id inuser_id. Refuse a callback that doesn't name an open session of that player. - 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.
- 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. - 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_coinspaying 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/5224354917on Android andca-app-pub-3940256099942544/1712485313on 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 hastestAdsset 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.