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 Test a link and Send a test postback before going live.
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 Sharklio there is no SDK or 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.
This guide shows the Laravel version first and the plain PHP equivalent after it. Postback security explains the reasoning behind each check in the handler, and the Postbacks and Offerwall link docs are the reference.
What you need first
- An approved app in your Sharklio 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
coinson the users table in the examples below.
Step 1: keep the keys in .env
# .env
SHARKLIO_APP_ID=your-app-id
SHARKLIO_LINK_SALT=your-link-hash-salt
SHARKLIO_POSTBACK_SECRET=your-postback-secret-key
// config/services.php
'sharklio' => [
'app_id' => env('SHARKLIO_APP_ID'),
'link_salt' => env('SHARKLIO_LINK_SALT'),
'postback_secret' => env('SHARKLIO_POSTBACK_SECRET'),
],
Read them with config() in your code, never with env() directly, so they still work after php artisan config:cache. Keep .env out of your repository.
Step 2: build the signed link
The hash is the HMAC-SHA256 of the exact user ID you put in the link, with the link hash salt, as lowercase hex, which is what PHP’s hash_hmac() returns:
// app/Support/SharklioWall.php
namespace App\Support;
class SharklioWall
{
public static function url(string $userId): string
{
$hash = hash_hmac('sha256', $userId, config('services.sharklio.link_salt'));
return 'https://wall.sharklio.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. The Test a link tool in the Integration tab 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\SharklioWall;
use Illuminate\Support\Facades\Route;
Route::get('/earn', function () {
$wallUrl = SharklioWall::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 finish tasks from our partners. Most rewards arrive within minutes.</p>
<iframe title="Earn coins" src="{{ $wallUrl }}"
style="border:0;width:100%;height:calc(100vh - 64px)" allow="clipboard-write"></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. Keep allow="clipboard-write" so the copy buttons inside the wall work. 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? That is a separate app of the Floating button type, with its own App ID and salt. Load the script in your layout, only for logged-in users, with a link signed the same way:
@auth
<script src="https://wall.sharklio.com/embed.js" async
data-button-url="{{ \App\Support\SharklioWall::url((string) auth()->id()) }}"
data-button-text="Earn coins"></script>
@endauth
If you run both, give each app its own config values and its own postback route, because each app signs its postbacks with its own secret key. Make sure pages with the button are not cached for other users either.
If your site sends a Content Security Policy, allow https://wall.sharklio.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: pending (3), credited (1), rejected (4), and 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('sharklio_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 Sharklio does not follow redirects:
https://yoursite.com/sharklio/postback?tx={transaction_id}&user={user_id}&reward={reward}&status={status}&hash={hash}
// app/Http/Controllers/SharklioPostbackController.php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class SharklioPostbackController 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('sharklio_transactions')->insertOrIgnore([
'transaction_id' => $tx, 'user_id' => $userId, 'reward' => $reward,
'created_at' => now(), 'updated_at' => now(),
]);
$row = DB::table('sharklio_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;
} elseif ($s === 3 && $last === null) {
$next = 3;
} elseif ($s === 4 && ($last === null || $last === 3)) {
$next = 4;
}
if ($next !== null) {
$update = ['last_status' => $next, 'updated_at' => now()];
if ($s !== 2) {
$update += ['user_id' => $userId, 'reward' => $reward];
}
DB::table('sharklio_transactions')->where('transaction_id', $tx)->update($update);
}
});
return response('OK');
}
}
What the controller does, in order:
- 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_stringcheck stops a repeated parameter from turning into an array. - Verifies the signature with
hash_equals(), a constant-time comparison, and answers 403 on a mismatch. - Skips test postbacks. A test sent from the Postback tab has a transaction ID that starts with
test_, so the hash is checked without crediting anyone. - Locks the transaction row inside a database transaction.
insertOrIgnorecreates the row the first time, andlockForUpdate()makes two simultaneous copies of one event wait for each other. - Applies only valid moves: credit on 1 unless already credited or reversed, take back on 2 only after a credit and using the stored amount, record 3 and 4 without crediting anything.
- Answers 200 fast, including for repeats it ignored. If anything throws, Laravel answers 500, the database transaction rolls back, and Sharklio retries the event later.
Sharklio 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. Pending events (status 3) only arrive if you turn on Also send pending events (status 3) in the Postback tab.
Step 6: the route, and the CSRF exception
// routes/web.php
use App\Http\Controllers\SharklioPostbackController;
Route::get('/sharklio/postback', SharklioPostbackController::class);
Laravel only checks CSRF tokens on requests that change state, such as POST, so a GET postback passes the check as it is. Add the path to the exceptions anyway, so the route keeps working if it is ever changed to accept other methods, and so the intent is documented in your code:
// Laravel 11 and later: 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 Sharklio’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('SHARKLIO_APP_ID');
$userId = (string) $_SESSION['user_id'];
$hash = hash_hmac('sha256', $userId, getenv('SHARKLIO_LINK_SALT'));
$wall = 'https://wall.sharklio.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" allow="clipboard-write"></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
- Compare your link with the one from Test a link for the same user ID. They must be identical.
- Open
/earnlogged in and logged out. You should see the wall in the first case and your login page in the second. - Use Send a test postback in the Postback tab and check that your server answered 200.
- Copy a postback URL from Reports, Postbacks, change one character of the hash, and open it: your route must answer 403.
- 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.
Everything above runs on a Sharklio app
Each Sharklio app has its own link hash salt, postback secret key, and API key, a Test a link tool, a Send a test postback form, and a postback log with your server’s answer to every attempt. There is no SDK or Composer package to install, just the PHP above. Publisher applications open soon. 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 Sharklio 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 tokens, so it works either way. Adding it to the exceptions 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. Sharklio does not follow redirects, so enter the final URL in the Postback tab.
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 Sharklio’s servers, so a per-IP limit can block them on a busy day.