How Reward Validation Works: Server-Side Architecture for Rewarded Monetization

·

Offerwall Placement Strategy

How Reward Validation Works: Server-Side Architecture for Rewarded Monetization

Why This Article Exists

If you integrate a rewarded monetization platform and get reward validation wrong, you lose money. Either you credit users who didn’t earn rewards (revenue leak), or you fail to credit users who did (trust damage).

There is no middle ground.

This article explains how server-side reward validation works — the architecture, the data flow, the security measures, and the edge cases every developer should understand before integrating any offerwall SDK.


The Core Problem

A rewarded monetization platform pays users for completing actions. The developer needs to know, with certainty, that a user actually completed the action before granting a reward.

The challenge: the user’s device is untrusted. Anything that happens client-side can be spoofed, replayed, or manipulated.

Client-side validation cannot defend against any of these. The only reliable approach is server-side validation, where the reward is granted based on data that originates outside the user’s device.


The Validation Flow

Step 1: User opens offerwall
        └── SDK presents offers from the marketplace

Step 2: User clicks an offer
        └── Tracking engine records the click
        └── A unique click_id is generated

Step 3: User completes the required action
        └── (e.g., installs the advertised app, completes a survey)

Step 4: Advertiser reports the conversion
        └── Advertiser sends conversion event to the tracking engine

Step 5: Tracking engine validates the conversion
        └── Checks: click happened? Within conversion window? Duplicate? Fraud signals?

Step 6: Postback sent to publisher server
        └── HTTP GET to the publisher's postback URL
        └── Contains: {player_id}, {click_id}, {offer_id}, {reward_amount}, {status}

Step 7: Publisher server validates the postback
        └── Checks: legitimate source? Duplicate click_id? Status is 'approved'?

Step 8: Publisher server credits the user
        └── Updates user balance in database
        └── Returns HTTP 200

The user’s device is involved in steps 1–3. Everything from step 4 onward happens server-to-server. The user’s app does not participate in the validation or credit process.

The fundamental principle: the app triggers the offer, but the server grants the reward.


Why SDK Callbacks Aren’t Enough

Every offerwall SDK provides client-side callbacks. Developers sometimes use these to grant rewards directly:

// ❌ DO NOT DO THIS
offerwall.onReward = { reward ->
    val amount = reward["amount"] as Double
    user.balance += amount  // Granting reward client-side
    saveUserBalance(user)
}

This fails in four scenarios:

1. App closed before callback fires. The user opens the offerwall, clicks an offer that requires installing another app, and switches away. Your app gets suspended. The offer is completed, the postback is sent to your server, but the SDK callback never fires.

2. Client-side manipulation. A rooted device can hook into the SDK’s reward callback and fire it with arbitrary amounts.

3. Duplicate callbacks. The SDK may fire onReward multiple times with different statuses (pending, then approved). If you credit on the first callback, you might credit for a pending status that later becomes rejected.

4. Network conditions. The callback depends on real-time connectivity. The postback doesn’t.

The Correct Pattern

// ✅ Correct: SDK callback for UI feedback only
offerwall.onReward = { reward ->
    val amount = reward["amount"] as Double
    val status = reward["status"] as String

    // UI feedback only
    showRewardNotification("Reward pending: $amount")

    // Do NOT credit the user here.
    // The actual credit happens on your server via postback.
}

Postback Architecture in Detail

The Postback Request

When a conversion is validated, Perkox sends an HTTP request to the postback URL:

GET https://yourdomain.com/perkox/postback
    ?user_id={player_id}
    &click_id={click_id}
    &offer_id={offer_id}
    &reward={reward_amount}
    &payout={payout}
    &status={status}
Placeholder What it contains Example
{player_id} Player ID passed to the SDK player123
{click_id} Unique click identifier d4246e2ca2894efc79df7b4b4
{offer_id} Completed offer ID 10101
{reward_amount} Reward amount to credit 50.00
{payout} Publisher payout amount 10.00
{status} Conversion status approved

Example Implementation (Node.js)

app.get('/perkox/postback', async (req, res) => {
  const { user_id: userId, click_id: clickId, offer_id: offerId,
          reward: rewardAmount, payout: payoutAmount, status } = req.query;

  // 1. Validate required fields
  if (!userId || !clickId || !status) {
    return res.status(400).send('Missing required fields');
  }

  // 2. Only process approved rewards
  if (status === 'pending') return res.status(200).send('OK');
  if (status === 'rejected') return res.status(200).send('OK');

  // 3. Handle reversals
  if (status === 'reversed') {
    const existing = await db.query(
      'SELECT * FROM rewards WHERE click_id = $1', [clickId]
    );
    if (existing.rows.length > 0) {
      const originalAmount = existing.rows[0].amount;
      await db.query('UPDATE users SET balance = balance - $1 WHERE id = $2',
        [originalAmount, userId]);
      await db.query('UPDATE rewards SET status = $1 WHERE click_id = $2',
        ['reversed', clickId]);
    }
    return res.status(200).send('OK');
  }

  // 4. Check for duplicates
  const existing = await db.query(
    'SELECT * FROM rewards WHERE click_id = $1', [clickId]
  );
  if (existing.rows.length > 0) return res.status(200).send('OK');

  // 5. Credit the user
  const reward = parseFloat(rewardAmount) || 0;
  const payout = parseFloat(payoutAmount) || 0;

  await db.query(
    'INSERT INTO rewards (user_id, click_id, offer_id, amount, payout, status) VALUES ($1,$2,$3,$4,$5,$6)',
    [userId, clickId, offerId, reward, payout, status]
  );
  await db.query('UPDATE users SET balance = balance + $1 WHERE id = $2',
    [reward, userId]);

  // 6. Always return 200
  res.status(200).send('OK');
});

Reward Status Lifecycle

┌─────────┐
│ PENDING  │  →  Under review. Do NOT credit.
└────┬─────┘
     │
     ▼
┌──────────┐
│ APPROVED │  →  Validated. Credit the user.
└────┬─────┘
     │
     ├─── (normal flow ends)
     │
     ▼
┌──────────┐
│ REVERSED │  →  Remove the credit (fraud, chargeback)
└──────────┘
Status Meaning Action
pending Reward under review Do not credit yet
approved Reward validated Credit the user
rejected Reward not approved Do not credit
reversed Previously approved, now reversed Remove or adjust credit

Security Considerations

IP Allowlisting: Restrict your postback endpoint to Perkox server IPs.

Parameter Validation: Validate reward amounts are reasonable and player_id matches expected format.

Idempotency: Use click_id as a UNIQUE constraint in your database to prevent double-credits.

CREATE TABLE rewards (
    id SERIAL PRIMARY KEY,
    user_id VARCHAR(255) NOT NULL,
    click_id VARCHAR(255) UNIQUE NOT NULL,
    offer_id VARCHAR(255),
    amount DECIMAL(10, 2) NOT NULL,
    payout DECIMAL(10, 2),
    status VARCHAR(50) NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

Logging: Log every postback for audit and debugging.


Testing Checklist

Before going live, test all four scenarios:

Manual test:

curl "https://yourdomain.com/perkox/postback?user_id=test_user&click_id=test_001&offer_id=999&reward=50.00&payout=10.00&status=approved"

Duplicate test: Send the same request again. Verify no double credit.

Reversal test: Send status=reversed for the same click_id. Verify balance reduced.

Rejected test: Send status=rejected. Verify no credit granted.


Frequently Asked Questions

What’s the difference between the SDK callback and the postback?
The SDK callback fires inside the app for UI feedback. The postback is an HTTP request sent from Perkox’s servers to your server after validation. The postback is the source of truth.

Can I use SDK callbacks instead of postbacks?
No. SDK callbacks only fire while the offerwall is open. If the user closes the app, the callback won’t fire, but the postback will still be sent.

What HTTP status should my endpoint return?
Always return HTTP 200, even for duplicates or rejected statuses. Non-200 responses trigger retry attempts.

What if my server is down when the postback arrives?
Perkox will retry. Your endpoint must be idempotent — use click_id for deduplication.


Key Takeaways

  1. The app triggers the offer; the server grants the reward.
  2. SDK callbacks are for UI feedback only. Never credit users based on client-side events.
  3. Postbacks are the source of truth. Your server credits the user based on validated conversion data.
  4. Handle all four reward statuses. approved, pending, rejected, reversed.
  5. Make your handler idempotent. Use click_id for deduplication.
  6. Always return HTTP 200.
  7. Log everything.

Further Reading


Perkox provides rewarded monetization infrastructure — SDKs, tracking, analytics, and reward validation — for mobile apps and games across Android, iOS, Unity, Flutter, and React Native. Get started →

Start monetizing your app with Perkox.

One SDK. Android, iOS, React Native, Flutter, Unity. A premium reward layer for your non-paying users — live in about 10 minutes.

Related articles