The Complete Offerwall Postback Guide: Setup, Testing, and Troubleshooting (2026)

·

Offerwall Placement Strategy






The Complete Offerwall Postback Guide: Setup, Testing, and Troubleshooting (2026)



The Complete Offerwall Postback Guide: Setup, Testing, and Troubleshooting (2026)

If you are integrating an offerwall into your app or game, the postback is the single most critical component to get right. It is the mechanism that tells your server a user completed an offer and earned a reward. Get it wrong, and users don’t get paid — or worse, they get paid twice. Get it right, and you have a reliable, fraud-resistant reward pipeline that scales.

This guide covers everything from postback fundamentals to production-grade security, testing, and troubleshooting. Whether you are doing a fresh integration or hardening an existing one, you will find actionable code examples, configuration details, and answers to the questions publishers ask most.

1. What Is a Postback and Why It Matters

A postback is a server-to-server (S2S) HTTP request — typically a GET or POST — that the offerwall platform sends to your backend when a user completes an offer. Instead of trusting the client app to report that “user X earned 50 coins,” the platform sends a cryptographically signed notification directly to your server. Your server validates the signature, checks for duplicates, and credits the user’s account.

This architecture matters for three reasons:

  • Trust: The client can be tampered with, decompiled, or spoofed. A server-to-server postback removes the client from the reward loop, making it far harder for a malicious user to fabricate rewards.
  • Reliability: Even if the user’s device loses connectivity at the exact moment of completion, the postback still reaches your server. The platform retries failed deliveries, so rewards are not lost to flaky networks.
  • Auditability: Every postback carries a unique transaction ID, timestamp, and offer metadata. This gives you a complete, auditable trail for finance reconciliation and dispute resolution.

If you skip server-side validation and rely on client callbacks alone, you are leaving your virtual economy wide open. Read more about why this matters in our SDK security best practices guide.

2. Postback URL Anatomy: Parameters and Format

A typical Perkox postback URL looks like this:

https://your-server.com/postback?
  user_id={user_id}
  &offer_id={offer_id}
  &transaction_id={transaction_id}
  &amount={amount}
  &currency={currency}
  &payout={payout}
  &signature={signature}
  &timestamp={timestamp}

Here is what each parameter means:

Parameter Description Example
user_id The unique ID you passed to the SDK when initializing the offerwall for this user. user_84729
offer_id The identifier of the completed offer. offer_12045
transaction_id A globally unique ID for this completion. Used for idempotency. tx_9f2a1b7c
amount The reward amount in your virtual currency. 250
currency The virtual currency label. coins
payout The real-money payout to you (USD). 0.45
signature Cryptographic hash of the payload, computed with your secret key. a1b2c3d4e5f6...
timestamp Unix timestamp of the completion event. 1724332800

The signature is the security backbone. It is computed by concatenating the key parameters in a defined order, appending your secret key, and hashing the result. Your server recomputes the hash and compares it to the received signature. If they match, the postback is authentic.

The exact parameter set and signature algorithm are configurable in the Perkox publisher dashboard. See the Perkox documentation for the full parameter reference.

3. Setting Up Your Postback Endpoint

Your postback endpoint is a lightweight HTTP handler that receives the request, validates the signature, checks for duplicates, and credits the user. Below are production-ready examples in PHP and Node.js.

PHP Example

<?php
// postback.php — Perkox postback handler

header('Content-Type: text/plain');

$secret = getenv('PERKOX_SECRET_KEY');

// Collect parameters (works for GET or POST)
$params = $_REQUEST;

$required = ['user_id', 'offer_id', 'transaction_id', 'amount', 'signature'];
foreach ($required as $field) {
    if (!isset($params[$field]) || $params[$field] === '') {
        http_response_code(400);
        echo 'MISSING_PARAM:' . $field;
        exit;
    }
}

// --- Signature verification (SHA256) ---
// Build the signature base string in the exact order Perkox expects.
$base = $params['user_id'] . $params['offer_id'] . $params['transaction_id']
      . $params['amount'] . $params['timestamp'];

$expectedSig = hash_hmac('sha256', $base, $secret);

if (!hash_equals($expectedSig, $params['signature'])) {
    http_response_code(403);
    echo 'INVALID_SIGNATURE';
    exit;
}

// --- Timestamp freshness check (prevent replay attacks) ---
if (abs(time() - (int)$params['timestamp']) > 300) {
    http_response_code(408);
    echo 'STALE_TIMESTAMP';
    exit;
}

// --- Duplicate detection ---
$txnId = $params['transaction_id'];
$pdo = new PDO(getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS'));

$stmt = $pdo->prepare('INSERT IGNORE INTO postback_log (transaction_id, user_id, offer_id, amount, created_at) VALUES (?, ?, ?, ?, NOW())');
$stmt->execute([$txnId, $params['user_id'], $params['offer_id'], $params['amount']]);

if ($stmt->rowCount() === 0) {
    // Transaction already processed — idempotent response
    http_response_code(200);
    echo 'DUPLICATE_OK';
    exit;
}

// --- Credit the user ---
$credit = $pdo->prepare('UPDATE user_balance SET balance = balance + ? WHERE user_id = ?');
$credit->execute([$params['amount'], $params['user_id']]);

http_response_code(200);
echo 'OK';

Node.js (Express) Example

// postback.js — Perkox postback handler (Node.js + Express)
const express = require('express');
const crypto  = require('crypto');
const { Pool } = require('pg');

const app = express();
app.use(express.urlencoded({ extended: true }));

const SECRET = process.env.PERKOX_SECRET_KEY;
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

app.post('/postback', async (req, res) => {
  const p = req.body;

  // --- Validate required fields ---
  const required = ['user_id', 'offer_id', 'transaction_id', 'amount', 'signature', 'timestamp'];
  for (const field of required) {
    if (!p[field]) return res.status(400).send(`MISSING_PARAM:${field}`);
  }

  // --- Signature verification (HMAC-SHA256) ---
  const base = `${p.user_id}${p.offer_id}${p.transaction_id}${p.amount}${p.timestamp}`;
  const expectedSig = crypto.createHmac('sha256', SECRET).update(base).digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(expectedSig), Buffer.from(p.signature))) {
    return res.status(403).send('INVALID_SIGNATURE');
  }

  // --- Timestamp freshness (5-minute window) ---
  const age = Math.abs(Date.now() / 1000 - parseInt(p.timestamp, 10));
  if (age > 300) return res.status(408).send('STALE_TIMESTAMP');

  // --- Duplicate detection with idempotency ---
  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    const ins = await client.query(
      'INSERT INTO postback_log (transaction_id, user_id, offer_id, amount) VALUES ($1, $2, $3, $4) ON CONFLICT (transaction_id) DO NOTHING RETURNING id',
      [p.transaction_id, p.user_id, p.offer_id, p.amount]
    );

    if (ins.rowCount === 0) {
      await client.query('COMMIT');
      return res.status(200).send('DUPLICATE_OK');
    }

    await client.query('UPDATE user_balance SET balance = balance + $1 WHERE user_id = $2', [p.amount, p.user_id]);
    await client.query('COMMIT');
    res.status(200).send('OK');
  } catch (err) {
    await client.query('ROLLBACK');
    console.error('Postback error:', err);
    res.status(500).send('INTERNAL_ERROR');
  } finally {
    client.release();
  }
});

app.listen(3000, () => console.log('Postback server on :3000'));

Both examples share the same logic flow: validate inputs → verify signature → check timestamp → deduplicate → credit. Adapt the database layer to your stack, but keep this order intact.

4. Security: Signature Verification, IP Whitelisting, Duplicate Detection

Security is not optional. A postback endpoint exposed to the internet without protection is an open faucet for free currency. Three layers of defense are essential.

4.1 Signature Verification

Every postback includes a signature parameter. Your server must recompute the signature using the same algorithm and secret key, then compare it to the received value using a constant-time comparison function (hash_equals in PHP, crypto.timingSafeEqual in Node.js). Never use == or === for signature comparison — it is vulnerable to timing attacks.

Perkox supports HMAC-SHA256 (recommended), HMAC-MD5 (legacy), and plain hash modes. Always prefer HMAC-SHA256 for new integrations. Store your secret key in an environment variable, never in source code.

4.2 IP Whitelisting

Restrict your postback endpoint to only accept requests from Perkox’s IP ranges. In Nginx:

location /postback {
    allow 203.0.113.10;
    allow 203.0.113.11;
    allow 203.0.113.12;
    deny all;

    proxy_pass http://127.0.0.1:3000;
}

Retrieve the current IP list from the Perkox docs and update it when notified of changes. IP whitelisting is a first line of defense — it does not replace signature verification, but it stops random actors from even reaching your application logic.

4.3 Duplicate Detection

The platform retries failed postbacks. Without duplicate detection, a retried postback credits the user twice. The solution is idempotency: store each transaction_id in a database table with a UNIQUE constraint. Before crediting, attempt to insert the transaction ID. If the insert is a no-op (row already exists), return 200 OK without re-crediting. This tells the platform “we got this, stop retrying” without double-paying.

For a deeper dive into fraud-resistant architecture, see our fraud signals guide for publishers.

5. Testing Your Postback

Before going live, test your endpoint thoroughly. The Perkox dashboard includes a Postback Simulator that sends a signed test request to your URL with dummy data. Here is how to use it effectively.

5.1 Postback Simulator

  1. Navigate to your app settings in the Perkox dashboard.
  2. Enter your postback URL (e.g., https://your-server.com/postback).
  3. Select the signature algorithm (SHA256 recommended).
  4. Click Send Test Postback.
  5. The simulator sends a request with a fake transaction_id like test_tx_0001.
  6. Check the response code and body in the simulator log.

A successful test returns 200 OK with body OK. If you see 403, your signature is wrong. If you see 500, your server errored.

5.2 Manual Testing with curl

# Compute the expected signature for your test payload
BASE="user_123offer_456tx_test_0011001724332800"
SIG=$(echo -n "$BASE" | openssl dgst -sha256 -hmac "$PERKOX_SECRET" | awk '{print $2}')

curl -v "https://your-server.com/postback" \
  -d "user_id=user_123" \
  -d "offer_id=offer_456" \
  -d "transaction_id=tx_test_001" \
  -d "amount=100" \
  -d "timestamp=1724332800" \
  -d "signature=$SIG"

5.3 Edge-Case Tests

  • Duplicate test: Send the same transaction_id twice. The second request should return 200 DUPLICATE_OK without crediting again.
  • Bad signature test: Flip one character in the signature. Expect 403 INVALID_SIGNATURE.
  • Stale timestamp test: Send a timestamp 10 minutes old. Expect 408 STALE_TIMESTAMP (if you implement freshness checks).
  • Missing param test: Omit amount. Expect 400 MISSING_PARAM:amount.

6. Common Postback Errors and Fixes

Error HTTP Code Cause Fix
INVALID_SIGNATURE 403 Secret key mismatch, wrong parameter order, or wrong hash algorithm. Verify the secret key in your dashboard matches the one in your environment variable. Confirm the base-string parameter order matches the docs.
MISSING_PARAM 400 A required parameter was not sent or was empty. Check that your dashboard parameter mapping includes all required fields. Inspect the raw request body.
DUPLICATE_OK 200 The transaction ID was already processed. This is expected behavior on retries — not an error. No action needed.
STALE_TIMESTAMP 408 The postback arrived more than your freshness window after the event. Check server clock sync (NTP). Widen the window if your processing queue has latency.
INTERNAL_ERROR 500 Database connection failure, unhandled exception, or timeout. Check logs, verify DB connectivity, add connection pooling. Return 500 (not 200) so the platform retries.
DNS / connection timeout Your server is unreachable or the URL is wrong. Verify the postback URL in the dashboard. Check DNS, firewall, and TLS certificate validity.
SSL handshake error Expired or self-signed certificate. Use a valid certificate from a recognized CA. Let’s Encrypt is free.

7. Best Practices for Reliability

7.1 Retry Logic (Server-Side)

The platform retries failed postbacks with exponential backoff (typically 1m, 5m, 15m, 1h, 6h, 24h). Your job is to return the correct HTTP status codes:

  • 200 — Successfully processed. Stop retrying.
  • 4xx (except 408) — Client error. The platform may stop retrying since the problem is on your side.
  • 5xx or 408 — Transient error. The platform will retry.

Never return 200 for a failure. If your database is down, return 503 so the postback is retried after you recover.

7.2 Idempotency

As covered in Section 4.3, every postback must be processed exactly once. Use the transaction_id as the idempotency key. The insert-or-skip pattern (INSERT … ON CONFLICT DO NOTHING in Postgres, INSERT IGNORE in MySQL) is the cleanest implementation.

7.3 Logging and Monitoring

Log every postback with its full parameter set, response code, and processing time. Set up alerts for:

  • Signature failures spiking (possible attack or key mismatch).
  • 5xx error rate exceeding 1%.
  • Postback latency exceeding 2 seconds (the platform may time out).
  • Duplicate rate exceeding 5% (normal is 1–3% due to retries).

7.4 Keep the Endpoint Fast

The platform expects a response within 10 seconds. If your endpoint is slow — due to heavy DB transactions or external API calls — return 200 immediately after persisting the postback to a queue, then process asynchronously. This decouples delivery from processing and prevents timeouts.

// Async pattern: persist first, process later
app.post('/postback', async (req, res) => {
  const p = req.body;
  // ... signature validation ...
  await db.query('INSERT INTO postback_queue (payload) VALUES ($1)', [JSON.stringify(p)]);
  res.status(200).send('OK');  // Respond immediately
  // Worker process picks up from postback_queue and credits the user
});

7.5 Use HTTPS Only

Never accept postbacks over HTTP. A plaintext postback can be intercepted and replayed. Use HTTPS with a valid certificate. Redirect HTTP to HTTPS at the load balancer level, and reject any request that arrives on port 80.

8. FAQ

What is an offerwall postback URL?

A postback URL is a server-to-server (S2S) HTTP request sent by the offerwall platform to your backend when a user completes an offer. It carries transaction data — user ID, offer ID, payout amount, and a signature — so your server can validate and credit the reward without trusting the client.

How do I test my postback endpoint without real offers?

Use the Perkox postback simulator in the publisher dashboard. It sends a signed test request to your endpoint with dummy transaction data. You can also use curl to replay known-good payloads and verify your signature validation and idempotency logic handle them correctly.

Should I always return HTTP 200 from my postback endpoint?

Return HTTP 200 only when you have successfully processed and stored the transaction. Return a non-200 status (e.g., 500 or 503) if a transient error occurs so the platform retries. Never return 200 for a duplicate you intentionally skipped — return 200 only for successful processing, and handle duplicates with idempotency keys.

What is the difference between MD5 and SHA256 postback signatures?

MD5 is faster but cryptographically weaker; SHA256 is the modern standard and recommended for all new integrations. Perkox supports both for backward compatibility, but SHA256 with HMAC provides the strongest guarantee that the postback originated from Perkox and was not tampered with in transit.

How do I prevent duplicate rewards from postback retries?

Store the unique transaction ID from each postback in a database table with a unique constraint. Before crediting a reward, check whether the transaction ID already exists. If it does, return HTTP 200 without re-crediting. This idempotency pattern ensures retries never result in double-payments.

Ready to Get Started?

Now you have the full picture: anatomy, implementation, security, testing, and troubleshooting. The next step is to put it into practice.

Build it right the first time, and your reward pipeline will run silently in the background — exactly as it should.


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