Rewarded Ads Granting Twice? Trace the Bug and Prevent Duplicate Credits

A practical application-level diagnostic for finding duplicate reward paths, separating reward signals from ad lifecycle events, and enforcing one accepted wallet mutation per stable grant key.

By
Hookin Team, Performance Editorial
Published
September 10, 2026
Reading time
12 min read
Views
47 views
On this page
  1. Start with the wallet, not the callback count
  2. Open the reproducible bad and fixed paths
  3. Draw the complete write graph
  4. The duplicate paths to inspect first
  5. Put the invariant where every path converges
  6. Log the grant decision, not only the ad event
  7. Run the full regression matrix
  8. Use the symptom to choose the first investigation
  9. Application-level release checklist
  10. Sources

A player accepts one rewarded-ad offer for 50 gems. The balance moves from 400 to 500. That does not, by itself, prove the ad SDK fired the same reward callback twice. It shows that the balance rose by twice the advertised reward. Inspect the grant ledger to distinguish two accepted writes from one write with the wrong amount, an unrelated balance change, or a display error.

To diagnose duplicate credits, trace every path that can change the wallet, separate ad lifecycle events from reward decisions, and enforce a stable grant key at the authoritative write. The examples below show the faulty path, the correction, and the tests that should keep the bug from returning. They exercise application logic; SDK integration, mediation behavior, and server-side verification signatures still require their own checks.

The central rule is simple:

For one player and one stable grant key, the authoritative wallet may accept the reward at most once.

That rule must hold even when callbacks arrive in an unexpected order, the same request is retried, two managers observe the same event, or many workers race at once.

Start with the wallet, not the callback count

Google's current Unity rewarded-ad guide separates the callback passed to RewardedAd.Show(...) from full-screen lifecycle events such as OnAdFullScreenContentClosed and OnAdFullScreenContentFailed. The same guide describes a rewarded ad as a one-time-use object and uses close or failure to prepare the next ad, not to define a second player reward. Google's Android guide similarly separates OnUserEarnedRewardListener from FullScreenContentCallback. It also notes that callback order can differ for mediated ads because the third-party source may determine the order. The current Android Next-Gen example likewise passes a reward listener to show(...) while keeping lifecycle in an ad-event callback. See Google's Unity rewarded guide and Android rewarded guide.

That documentation supports a clean ownership model:

Signal or layer What it can establish What it should own in the application What it cannot safely replace
Client reward callback The SDK's client-side reward signal Submit one candidate grant under a client-authority policy Durable deduplication or server authentication
Full-screen close/fail Ad presentation lifecycle Restore UI, clear consumed state, load or request the next ad Player currency, energy, revive, or loot credit
Impression, click, paid event Measurement or monetization telemetry Analytics Reward eligibility
Verified SSV callback A server callback whose signed parameters were validated Validate/reconcile a client grant, or become the sole server grant authority Automatic deduplication of repeated delivery
Idempotent grant store Whether a stable key was already accepted Accept once or reject as duplicate, atomically with the wallet update Proof that the player was eligible or the request was genuine

The last distinction matters. Idempotency is not authentication. A uniqueness rule can stop the same key twice. It cannot tell whether a new key was invented by a compromised client, whether the reward amount is allowed, whether the player owns the attempt, or whether an SSV signature is valid. Eligibility and authenticity must be checked by the authority model you choose; idempotency then prevents replay of an accepted transaction.

Open the reproducible bad and fixed paths

Open the interactive duplicate-reward demo.

The left panel deliberately gives both reward_earned and ad_closed permission to mutate the wallet. Running one normal completion produces two writes and a 100-gem balance from a 50-gem offer.

The right panel gives lifecycle events no wallet authority. The reward event submits a stable key such as player-42:revive:attempt-0001; an in-memory teaching ledger accepts that key once. A repeated reward event is rejected, close only records lifecycle, and the SSV button records validation without a second credit. Creating attempt-0002 permits a new legitimate 50-gem grant.

The demo is synthetic, but its JavaScript was exercised in actual headless Chromium rather than judged only by reading the source. The browser test reproduced the 100-gem bug, held the fixed balance at 50 through 32 same-key replays, checked all six orderings of reward, close, and SSV validation, accepted a second distinct attempt, ran a 390×844 viewport smoke check, and observed no browser console or page errors. Those results describe this standalone demo only; they are not device or SDK test results.

Draw the complete write graph

Do not begin by searching for OnUserEarnedReward and stopping at the first match. Search for every function that can change the authoritative value:

  • AddGems, CreditEnergy, GrantLoot, CommitRevive, and equivalent wallet methods;
  • helpers that complete an offer, level, revive, or purchase;
  • event subscribers that react after an ad manager publishes “reward complete”;
  • queued or retried API calls;
  • SSV processors and reconciliation jobs;
  • restore-on-resume code;
  • admin or support adjustments that reuse the same grant endpoint.

For one deliberately chosen attempt, assign an attempt ID before the ad is shown and log every arrow from the button tap to the wallet commit. A useful trace looks like this:

reward_signal     attempt=revive-781 source=client_reward
wallet_submit     grant_key=player-42:revive-781 source=client_reward
wallet_accept     grant_key=player-42:revive-781 400->450
ad_closed         attempt=revive-781 grant_attempted=false
wallet_submit     grant_key=player-42:revive-781 source=queued_retry
wallet_reject     grant_key=player-42:revive-781 reason=duplicate

The rejection is valuable evidence. It shows that a second path exists while also proving that the second path cannot change the economy.

The duplicate paths to inspect first

Reward plus close

This produces the classic, nearly constant 2× symptom. The smallest deliberately wrong model is:

on_reward_earned -> wallet.add(50)
on_ad_closed     -> wallet.add(50)

The fix is not “hope close arrives later.” Close must not own the reward at all.

Repeated subscriptions or duplicate manager instances

A handler attached each time an offer opens can accumulate across attempts. The visible pattern may be 1×, then 2×, then 3×. Another version is two live ad-manager objects, each subscribed once and each forwarding the same logical reward.

C# uses += to subscribe and -= to unsubscribe. Microsoft notes that anonymous handlers are difficult to unsubscribe unless their delegate is retained, and recommends unsubscribing when the subscriber is disposed. See Microsoft's event subscription guidance.

This is an illustrative lifecycle pair, not a complete ad integration:

void OnEnable()  => rewardService.RewardCommitted += HandleRewardCommitted;
void OnDisable() => rewardService.RewardCommitted -= HandleRewardCommitted;

Symmetry alone is not sufficient. Log a manager instance ID, scene ID, and subscription count so that “one handler per object” does not hide “two objects.”

Two show routes or stale loaded objects

A disabled button prevents some rapid taps, but another input route, stale scene, or second controller can still present or process a second opportunity. Consume the application's loaded-ad reference before presentation and assign a distinct attempt ID to each accepted show request. The SDK's one-time-use object rule protects a particular ad object; it does not define your cross-object economic transaction.

Client credit plus server credit

Google describes rewarded SSV callbacks as URL requests sent to an external system and says they provide extra protection against spoofed client callbacks. The callback includes a transaction_id described as a unique identifier for each AdMob reward grant event. Google's guidance recommends immediate client reward with later validation for responsiveness, while noting that waiting for verified SSV can be appropriate when reward validity is critical and delay is acceptable. See Validate SSV callbacks — Unity.

Choose one explicit authority policy:

Client authority. The client reward signal submits the grant immediately. A cryptographically verified SSV callback validates or reconciles the existing transaction. It does not independently add the same reward again.

Server authority. The client shows a pending state. Only a verified SSV callback may submit the grant, with the provider transaction_id used in the idempotency key. Repeated delivery of the same verified transaction is rejected.

Do not run both as unconditional credit paths.

Retry, reconnect, or queue replay

A server can commit a grant while the client times out before receiving the response. The client retries; a background job later retries again. Google also says that if an SSV endpoint is unreachable or does not return HTTP 200, it can retry the callback up to five times at one-second intervals. See the SSV FAQ.

Retries are expected delivery behavior. A stable key turns them into harmless duplicate submissions. Generating a fresh random key on every retry defeats idempotency because every delivery looks like a new transaction.

Duplicate presentation without duplicate value

Sometimes the wallet has one accepted grant while two toasts, animations, sounds, or analytics events fire. Measure these separately:

  1. authoritative balance delta;
  2. accepted grant-record count;
  3. UI and analytics notification count.

A duplicate animation is still a bug, but it is not the same incident as a duplicate economic write.

Put the invariant where every path converges

A process-local boolean such as hasRewarded can reduce one symptom. It usually fails across restarts, two devices, background jobs, or two server workers. The grant-once rule belongs in the authoritative persistence boundary.

A practical key must be stable for retries and distinct for legitimate new opportunities. Common shapes include:

client authority: player_id + backend_issued_attempt_id
server authority: player_id + provider + verified_transaction_id

The database operation must combine two decisions:

  1. insert the grant record only if its unique key does not exist;
  2. increase the wallet only if that insert actually succeeded.

The example below makes the gate executable rather than leaving “only when the insert affected one row” as a comment. The wallet UPDATE is actually conditional. It is an excerpt from the included executable SQLite idempotency demo:

con.execute("BEGIN IMMEDIATE")
insert = con.execute(
    """
    INSERT INTO reward_grants
        (player_id, grant_key, reward_amount, source)
    VALUES (?, ?, ?, ?)
    ON CONFLICT(player_id, grant_key) DO NOTHING
    """,
    (player_id, grant_key, amount, source),
)

inserted = insert.rowcount == 1
if inserted:
    update = con.execute(
        "UPDATE player_wallets SET gems = gems + ? WHERE player_id = ?",
        (amount, player_id),
    )
    if update.rowcount != 1:
        raise RuntimeError("wallet row missing")

con.commit()

SQLite documents that ON CONFLICT ... DO NOTHING can turn a uniqueness violation into a no-op, and that explicit transactions group writes until COMMIT or ROLLBACK. Python's sqlite3 reference defines Cursor.rowcount as the number of rows modified by a completed INSERT, UPDATE, DELETE, or REPLACE statement; that is the explicit condition used above. See the official SQLite UPSERT documentation, transaction documentation, and Python 3.13 sqlite3.Cursor.rowcount reference.

The included test ran this exact conditional function on Python 3.13.5 with SQLite 3.46.1. A sequential case accepted key A, rejected a replay without changing the balance, and accepted key B. It then ran 12 fresh database rounds with 32 simultaneous same-key transactions per round. Every round ended with one accepted grant row and a 50-gem balance.

This is a compact persistence demonstration, not a universal schema. Adapt locking, isolation, error handling, ledger design, and wallet ownership to your production database. Preserve the invariant.

Log the grant decision, not only the ad event

For every accepted or rejected submission, record enough context to reconstruct the write graph:

grant_key
player_id_hash
placement
attempt_id
source                 # client_reward, verified_ssv, retry, reconcile
reward_type
reward_amount
eligibility_policy     # client_authority or server_authority
verification_status    # where applicable
accepted               # true or false
rejection_reason
old_balance
new_balance
ad_response_id         # when available
provider_transaction_id
manager_instance_id
queue_or_thread
occurred_at_utc

Avoid raw personal identifiers in telemetry. More importantly, do not let logging become the guard. The uniqueness decision and wallet update must remain authoritative even if logs are delayed or lost.

Run the full regression matrix

The package includes the same matrix as HB99_reward_regression_matrix_v2.csv. The standalone Java harness was compiled and run on OpenJDK 21.0.11. The independent Python model exhaustively checked 185,037 callback sequences. The SQLite concurrency case used real transactions rather than the in-memory model.

Scenario Sequence for one economic opportunity Expected accepted grants Expected wallet delta
Deliberate bug reward, close; both write 2 +100
Fixed normal completion reward, close 1 +50
Duplicate reward delivery reward, same reward again 1 +50
Alternate order close, reward 1 +50
Lifecycle only failure, close 0 +0
Two manager instances manager A reward, manager B same-key reward 1 +50
Queued retry request, retry, retry with same key 1 +50
Client authority with SSV client reward, verified validation 1 +50
Server authority with SSV pending, verified transaction, same transaction replay 1 +50
In-memory race 32 simultaneous same-key submissions 1 +50
Database race 32 simultaneous same-key transactions 1 +50
New legitimate opportunity reward key A, reward key B 2 +100
Duplicate presentation accepted reward, second UI notification 1 +50
Idempotency-scope check two distinct unverified keys 2 +100

The final row is intentional. A bare idempotency store accepts two different keys, which demonstrates why deduplication cannot be presented as eligibility verification.

Use the symptom to choose the first investigation

Observed pattern First application path to inspect
Almost always exactly 2× Reward plus close/completion helper, or client plus server credit
1×, then 2×, then 3× Accumulating subscriptions or manager instances
Duplicates after timeout, resume, or reconnect Retried request without a stable key
Duplicates only under rapid interaction Two show routes, two attempt creations, or stale ad state
Duplicates concentrated in mediation Callback-order assumption; do not infer adapter fault from order alone
One balance change but two celebrations Presentation subscriber duplication
Same provider transaction appears repeatedly Replay handling missing at the server grant boundary
Different new keys all grant Eligibility or authentication gap, not an idempotency failure

Application-level release checklist

Before shipping the economy fix:

  1. There is one searchable authoritative function that commits rewarded-ad value.
  2. Close, failure, impression, click, paid, reload, and UI callbacks cannot directly change that value.
  3. Every opportunity receives a stable key before the first grant submission.
  4. Legitimate new opportunities receive new keys; retries reuse the original key.
  5. The grant record has an enforced uniqueness rule.
  6. The wallet update runs only after a new grant record is accepted, in the same transaction or equivalent atomic operation.
  7. Duplicate submissions are logged as rejected and leave the balance unchanged.
  8. Client-versus-server authority is written down; SSV is not a second unconditional credit path.
  9. Eligibility, amount, player ownership, and SSV signature checks are separate from idempotency.
  10. The complete matrix passes in the application layer, then the product team also runs its own SDK, platform, mediation, and device tests for the integration it actually ships.

The diagnostic question is no longer “Did a callback fire twice?” It is “How could the same economic opportunity reach the wallet twice, and why did the store accept it?” Once every path converges on one conditional grant decision, duplicate delivery becomes observable instead of expensive.

Sources

Back to blog

Keep reading

Build for the next campaign

Create and test a playable in Hookin, then prepare it for the platform where it will run.

Open Hookin