Key takeaways

  • Build the link on the server: HMAC-SHA256 of the user ID with the link hash salt, shown only to logged-in users on a page that is never cached.
  • Verify every postback with hash_equals() and the postback secret key, then apply each transaction ID once inside a locked database transaction.
  • Answer 200 fast, use the final URL with no redirects, and test with Check a link and Send a test postback in Integration status before going live.
Integration pageOfferwall for any websiteSetup and code

How do you add an offerwall to a PHP or Laravel site?

You need two pieces of server code: a page that builds a signed offerwall link for the logged-in user and shows it in an iframe, and a postback route that verifies each call and credits or takes back the reward once per transaction. With Moonetix there is no SDK or Composer package to install. The link is an HMAC-SHA256 of the user ID with your link hash salt, and the postback is signed with a separate postback secret key. In Laravel that is one helper class, one route with a Blade view, one table, and one controller.

Laravel comes first, plain PHP after it. Postback security explains each check in the handler, and the Postbacks and Offerwall link docs are the reference.

Who does what

PartWho handles itKey used
Signed offerwall linkYour Laravel or PHP codeLink hash salt
Offers, tracking, fraud checksMoonetixNone on your side
Postback with each credit and reversalMoonetix sends itPostback secret key
Verify, store, credit onceYour postback controllerPostback secret key

What you need first

  • A live app in your Moonetix dashboard, with your website registered. The iframe only loads on https pages of that website and its subdomains.
  • Three values from the Integration tab: the app ID in your offerwall link, the link hash salt, and the postback secret key. The salt and the key are different, and both stay on your server.
  • A stable user ID for every user. It must never change for the same person, and 1 to 100 characters from A-Z a-z 0-9 . _ @ : + - are accepted. The Integration tab suggests a random ID rather than an email address. A numeric primary key or a UUID column both work.
  • A balance column, called coins on the users table in the examples below.

Step 1: keep the keys in .env

# .env
MOONETIX_APP_ID=your-app-id
MOONETIX_LINK_SALT=your-link-hash-salt
MOONETIX_POSTBACK_SECRET=your-postback-secret-key

// config/services.php
'sharklio' => [
    'app_id'          => env('MOONETIX_APP_ID'),
    'link_salt'       => env('MOONETIX_LINK_SALT'),
    'postback_secret' => env('MOONETIX_POSTBACK_SECRET'),
],

Read them with config() in your code, never with env() directly. The Laravel configuration docs explain why: once you run php artisan config:cache, the .env file is no longer loaded, so env() outside config files returns nothing. The same page warns against committing .env to source control, so keep it out of your repository.

The hash is the HMAC-SHA256 of the exact user ID you put in the link, with the link hash salt, as lowercase hex. That is the default output of PHP’s hash_hmac(), so no extra conversion is needed:

// app/Support/MoonetixWall.php
namespace App\Support;

class MoonetixWall
{
    public static function url(string $userId): string
    {
        $hash = hash_hmac('sha256', $userId, config('services.sharklio.link_salt'));

        return 'https://wall.moonetix.com/' . config('services.sharklio.app_id')
            . '?user_id=' . rawurlencode($userId) . '&hash=' . $hash;
    }
}

Sign the same string you send. If your user IDs are integers, cast them to a string once and use that value for both the hash and the link. In Integration status on the Integration tab, Check a link has a Test a link tool that shows the exact link your server should build for any user ID, so you can compare the two.

Step 3: a route and a Blade view with the iframe

// routes/web.php
use App\Support\MoonetixWall;
use Illuminate\Support\Facades\Route;

Route::get('/earn', function () {
    $wallUrl = MoonetixWall::url((string) auth()->id());

    return response()
        ->view('earn', ['wallUrl' => $wallUrl])
        ->header('Cache-Control', 'private, no-store');
})->middleware('auth')->name('earn');

The auth middleware makes sure only logged-in users get a link, and no-store stops a cache from serving one user’s signed link to another. Then the view:

{{-- resources/views/earn.blade.php --}}
@extends('layouts.app')

@section('content')
  <h1>Earn coins</h1>
  <p>Try apps and games from our partners. Most rewards arrive within minutes.</p>
  <iframe title="Earn coins" src="{{ $wallUrl }}"
    style="border:0;width:100%;height:calc(100vh - 64px)"></iframe>
@endsection

Blade’s {{ }} escapes the & in the link, which is correct inside an HTML attribute. The iframe is as tall as the screen and scrolls on its own; calc(100vh - 64px) leaves room for a 64px fixed header, so adjust it to yours. For the copy and layout around the wall, see How to write an earn page that converts.

Prefer a button in the corner of every page instead? The optional floating button opens the same offerwall of the same app in a pop-up, with the same App ID and salt. Load the script in your layout, only for logged-in users, with the link signed the same way:

@auth
  <script src="https://wall.moonetix.com/embed.js" async
    data-button-url="{{ \App\Support\MoonetixWall::url((string) auth()->id()) }}"
    data-button-text="Earn coins"></script>
@endauth

If your site sends a Content Security Policy, allow https://wall.moonetix.com in frame-src, because both the iframe and the button’s pop-up frame the wall, and in script-src for the button script. All button options are in Add a floating Earn button to any website.

Step 4: a table that remembers every transaction

Every event of one completion carries the same transaction ID: credited (1) and, if the credit is taken back later, reversed (2). Store the last status you applied for each one, so a retry or a duplicate can never pay twice. A migration:

Schema::create('moonetix_transactions', function (Blueprint $table) {
    $table->string('transaction_id', 100)->primary();
    $table->string('user_id', 100);
    $table->decimal('reward', 16, 3);
    $table->unsignedTinyInteger('last_status')->nullable();
    $table->timestamps();
});

Step 5: the postback controller

In your app’s Postback tab, set a URL with the macros this controller reads, without a trailing slash, because Moonetix does not follow redirects:

https://yoursite.com/moonetix/postback?tx={transaction_id}&user={user_id}&reward={reward}&status={status}&hash={hash}
// app/Http/Controllers/MoonetixPostbackController.php
namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class MoonetixPostbackController extends Controller
{
    public function __invoke(Request $request)
    {
        $q = fn (string $key) => is_string($v = $request->query($key)) ? $v : '';
        [$tx, $userId, $reward, $status] = [$q('tx'), $q('user'), $q('reward'), $q('status')];

        $expected = hash_hmac('sha256', "$tx:$userId:$reward:$status",
            config('services.sharklio.postback_secret'));
        if (!hash_equals($expected, $q('hash'))) {
            return response('Invalid hash', 403);
        }
        if (str_starts_with($tx, 'test_')) {
            Log::info('Sharklio test postback', $request->query());
            return response('OK');
        }

        $s = (int) $status;
        DB::transaction(function () use ($tx, $userId, $reward, $s) {
            DB::table('moonetix_transactions')->insertOrIgnore([
                'transaction_id' => $tx, 'user_id' => $userId, 'reward' => $reward,
                'created_at' => now(), 'updated_at' => now(),
            ]);
            $row = DB::table('moonetix_transactions')
                ->where('transaction_id', $tx)->lockForUpdate()->first();
            $last = $row->last_status === null ? null : (int) $row->last_status;

            $next = null;
            if ($s === 1 && $last !== 1 && $last !== 2) {
                if (User::whereKey($userId)->increment('coins', $reward) === 0) {
                    Log::warning('Sharklio postback for unknown user', ['tx' => $tx, 'user' => $userId]);
                }
                $next = 1;
            } elseif ($s === 2 && $last === 1) {
                User::whereKey($row->user_id)->decrement('coins', $row->reward);
                $next = 2;
            }

            if ($next !== null) {
                $update = ['last_status' => $next, 'updated_at' => now()];
                if ($s !== 2) {
                    $update += ['user_id' => $userId, 'reward' => $reward];
                }
                DB::table('moonetix_transactions')->where('transaction_id', $tx)->update($update);
            }
        });

        return response('OK');
    }
}

What the controller does, in order:

  1. Reads the values as strings, exactly as they arrived. The hash covers the transaction ID, user ID, reward, and status joined with colons, so do not cast or reformat them before hashing. The is_string check stops a repeated parameter from turning into an array.
  2. Verifies the signature with hash_equals(), a timing-safe comparison that does not leak how much of a guessed hash was right, and answers 403 on a mismatch.
  3. Skips test postbacks. A test sent from Integration status has a transaction ID that starts with test_, so the hash is checked without crediting anyone.
  4. Locks the transaction row inside a database transaction. insertOrIgnore creates the row the first time, and lockForUpdate() makes two simultaneous copies of one event wait for each other.
  5. Applies only valid moves: credit on 1 unless already credited or reversed, and take back on 2 only after a credit and using the stored amount.
  6. Answers 200 fast, including for repeats it ignored. If anything throws, Laravel answers 500, the database transaction rolls back, and Moonetix retries the event later.

Moonetix waits up to 6 seconds for a 2xx answer and retries a failed call up to 5 times, after about 1 minute, 5 minutes, 30 minutes, 2 hours, and 6 hours. Do anything slow, such as emails or notifications, in a queued job after the balance change.

Step 6: the route, and the CSRF exception

// routes/web.php
use App\Http\Controllers\MoonetixPostbackController;

Route::get('/moonetix/postback', MoonetixPostbackController::class);

Laravel only checks CSRF on requests that change state. The Laravel 13 CSRF docs list POST, PUT, PATCH, DELETE, and QUERY, so a GET postback passes as it is (checked on November 4, 2026). Add the path to the exceptions anyway, so the route keeps working if it ever accepts other methods and the intent is documented in your code. The method name depends on your version:

// Laravel 13: bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->preventRequestForgery(except: [
        'sharklio/postback',
    ]);
})

// Laravel 11 and 12: bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: [
        'sharklio/postback',
    ]);
})

// Laravel 10 and earlier: app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
    'sharklio/postback',
];

Do not put the route behind auth, rate limits tuned for users, or a bot protection rule: the call comes from Moonetix’s server, not from a browser. The signature check is what protects it.

The plain PHP equivalent

Without a framework, the earn page is a few lines with your own session check:

<?php
// earn.php
session_start();
if (!isset($_SESSION['user_id'])) {
    header('Location: /login');
    exit;
}
header('Cache-Control: private, no-store');

$appId  = getenv('MOONETIX_APP_ID');
$userId = (string) $_SESSION['user_id'];
$hash   = hash_hmac('sha256', $userId, getenv('MOONETIX_LINK_SALT'));
$wall   = 'https://wall.moonetix.com/' . $appId . '?user_id=' . rawurlencode($userId) . '&hash=' . $hash;
?>
<h1>Earn coins</h1>
<iframe title="Earn coins" src="<?= htmlspecialchars($wall, ENT_QUOTES) ?>"
  style="border:0;width:100%;height:100vh"></iframe>

For the postback, use the complete PDO handler in Postback security as your postback.php. It follows the same steps as the Laravel controller: signature check with hash_equals(), one row per transaction ID with INSERT IGNORE and SELECT ... FOR UPDATE, the same status moves, a 200 answer, and a 500 on errors so the event is retried. Add the same test_ check if you do not want test postbacks to credit anyone.

Test before you go live

  1. In Integration status on the Integration tab, click Check a link and compare your link with the one from Test a link for the same user ID. They must be identical.
  2. Open /earn logged in and logged out. You should see the wall in the first case and your login page in the second.
  3. Use Send a test postback in Integration status and check that your server answered 200.
  4. Copy a postback URL from Reports, Postbacks, change one character of the hash, and open it: your route must answer 403.
  5. Check every attempt and its status code in that report. The Testing and troubleshooting docs list the common errors.

The three mistakes we see most in PHP integrations

Signing the link with the postback secret key instead of the link hash salt, which shows Link not valid. A postback URL that redirects, such as http to https or a trailing slash, which fails every call. And crediting on every call without storing the transaction ID, which pays twice after a retry.

Two free tools help while you test: the offerwall link builder shows the link your server should build, and the postback hash checker verifies the postbacks you receive.

Everything above runs on a Moonetix app

Each Moonetix app has its own keys, an Integration status panel that checks links and sends test postbacks, and a log of every postback attempt. Moonetix is a pay-per-result affiliate network: your users earn your currency for completed offers, and you earn per result. Prepare your site with how to get approved as a publisher, and read Offerwall integration: iframe, link or API? if you are still choosing how to show the wall.

Frequently asked questions

Is there a Laravel package or SDK for the Moonetix offerwall?

No, and you do not need one. The link is one hash_hmac() call and the postback is one controller, as shown above.

Why does my offerwall link show Link not valid?

The hash does not match the user ID. Sign exactly the string you put in the link, with the link hash salt, not the postback secret key, and output lowercase hex.

Do I need to exclude the postback route from CSRF protection?

For a GET route, Laravel does not check CSRF, so it works either way. Adding it to the exceptions (preventRequestForgery in Laravel 13, validateCsrfTokens in 11 and 12) keeps it working if the route ever accepts POST.

Why are my postbacks failing with a 301 or 302?

Your URL redirects, for example from http to https, to www, or to remove a trailing slash. Moonetix does not follow redirects, so enter the final URL in the Postback tab.

Can I test the postback route on localhost?

Not directly. Postbacks come from Moonetix’s servers, so the URL must point to a public server on port 80, 443, 8080, or 8443. Use a staging server with a public https address.

Can I put the postback route in routes/api.php?

Yes. API routes have no session or CSRF middleware, which suits a server-to-server call. They get the /api prefix, so use that path in the Postback tab. Also check whether your api group applies a rate limit, as throttle:api does by default in Laravel 10 and earlier: all postbacks come from Moonetix’s servers, so a per-IP limit can block them on a busy day.