Key takeaways
- Build the signed offerwall link in PHP for the logged-in user, never in a static block or in JavaScript.
- Keep the link hash salt and the postback secret key in wp-config.php, and show no offerwall to logged-out visitors.
- Credit points only from verified postbacks, once per transaction ID, and answer with a 2xx status within 6 seconds.
How do you add an offerwall to WordPress?
To add an offerwall to WordPress, create a small site-specific plugin with three parts: a shortcode that builds the signed offerwall link for the logged-in user and outputs the iframe, a page that holds the shortcode, and a REST route that receives postbacks and credits points. The secrets stay in wp-config.php on your server, and logged-out visitors get a log-in prompt instead of the offerwall.
This guide builds that for the Moonetix offerwall in under 100 lines of PHP, with no plugin to install from us. It assumes your users already have WordPress accounts, because rewards need a stable account to land in.
What you need before you start
| Item | Where it comes from | Why |
|---|---|---|
| A live Moonetix app | Your Moonetix dashboard, Apps | Registered for your WordPress site’s address, which the embed needs |
| App ID | Next to the app name at the top of the app page | Part of every offerwall link |
| Link hash salt | Integration tab, Keys and security | Signs each user’s link |
| Postback secret key | Integration tab, Keys and security | Verifies the postbacks we send you |
| An https site on your own domain | Your host | The iframe only loads on https pages of the registered website and its subdomains |
| Access to add a plugin | Your host or WordPress admin | The shortcode and the postback route are PHP |
This guide needs a WordPress site that can run your own PHP plugin, such as a self-hosted site. Plans that do not allow custom plugins cannot sign links per user. If you are still deciding between an iframe, a link, and the API, read Offerwall integration: iframe, link or API? first.
Step 1: put the keys in wp-config.php
Open wp-config.php and add three lines above the line that says to stop editing. Copy the values from the Integration tab:
define('MOONETIX_APP_ID', 'your_app_id');
define('MOONETIX_LINK_SALT', 'your_link_hash_salt');
define('MOONETIX_POSTBACK_SECRET', 'your_postback_secret_key');
This file runs on the server and is never sent to browsers, so it is the right home for secrets. Do not paste the salt or the secret key into a block, a theme option, a page builder field, or any JavaScript. Anyone who has the salt can open the offerwall as any of your users, and anyone who has the postback secret can fake rewards.
Step 2: create a small site-specific plugin
You could put the code in your theme’s functions.php, but it disappears when you switch themes, and a theme update can overwrite it. A one-file plugin is safer. Create the folder wp-content/plugins/sharklio-wall/ with a file sharklio-wall.php, and start it with a plugin header. Per the WordPress plugin header requirements, Plugin Name is the only required field:
<?php
/*
Plugin Name: Sharklio Offerwall
Description: Offerwall shortcode and postback endpoint.
*/
if (!defined('ABSPATH')) {
exit;
}
Everything below goes in the same file. Activate the plugin under Plugins once the code is complete, because activation also creates the small table from Step 5.
Step 3: a shortcode that signs the link
The offerwall link for each user is https://wall.moonetix.com/{APP_ID}?user_id={USER_ID}&hash={HASH}, where the hash is an HMAC-SHA256 of the user ID with your link hash salt, in lowercase hex. WordPress gives you the logged-in user with wp_get_current_user(), and PHP’s hash_hmac() builds the hash:
function moonetix_wall_link(): string
{
$user = wp_get_current_user();
if (!$user->exists()) {
return '';
}
$userId = (string) $user->ID;
$hash = hash_hmac('sha256', $userId, MOONETIX_LINK_SALT);
return 'https://wall.moonetix.com/' . MOONETIX_APP_ID . '?user_id=' . rawurlencode($userId) . '&hash=' . $hash;
}
add_shortcode('moonetix_wall', function () {
$link = moonetix_wall_link();
if ($link === '') {
return '<p><a href="' . esc_url(wp_login_url(get_permalink())) . '">Log in</a> to see offers and earn points.</p>';
}
return '<iframe title="Offerwall" src="' . esc_url($link) . '" style="border:0;width:100%;height:100vh"></iframe>';
});
Three choices in this code matter:
- The WordPress user ID is the offerwall user ID. It never changes for the same account, it is not an email address, and it fits the allowed characters (1 to 100 letters, numbers, and
. _ @ : + -). Postbacks send it back to you, so crediting is a direct lookup. - No user, no link. A logged-out visitor gets a log-in link, never an offerwall with an empty or shared ID. Rewards earned without an account would have nowhere to go.
- Shortcodes return, they do not echo. The example in the WordPress shortcode handbook always returns its content. WordPress places the returned HTML where the shortcode sits, and
esc_url()keeps the attribute safe.
Step 4: create the earn page
- Add a new page, for example “Earn points”.
- Write a short intro above the offerwall: what users can earn, what points are worth on your site, and that some rewards need review before they arrive.
- Add a Shortcode block and type
[moonetix_wall]. - Publish, log in as a normal user, and open the page.
A Custom HTML block is static, so it cannot carry a signed link per user.
Check your caching too. The page now holds a link signed for one user, so it must never be cached and served to someone else. Many caching plugins skip logged-in users, but confirm yours does, or exclude the earn page. Give the page its own menu item, and see Offerwall placement: where to put it to earn more for where entry points work best.
Prefer a floating button?
If you would rather not give the offerwall its own page, the offerwall also comes with an optional floating button: one script tag adds a corner button that opens the offerwall in a pop-up on any page. It uses the same app, App ID, and salt as your earn page, so the same helper signs its link. The output then looks like this, added for logged-in users through the wp_footer hook:
add_action('wp_footer', function () {
$link = moonetix_wall_link();
if ($link === '') {
return;
}
echo '<script src="https://wall.moonetix.com/embed.js" async data-button-url="' . esc_url($link) . '" data-button-text="Earn points"></script>';
});
The button opens the offerwall of the same app, so the postback route below credits rewards from both the page and the button. All button options are in Add a floating Earn button to any website.
Step 5: a REST route that credits points
When a result is credited or reversed, Moonetix calls your postback URL with a GET request. In the app’s Postback tab, enter:
https://yoursite.com/wp-json/sharklio/v1/postback?user={user_id}&tx={transaction_id}&status={status}&reward={reward}&hash={hash}
If your site uses plain permalinks, the REST API lives at https://yoursite.com/?rest_route=/sharklio/v1/postback instead, followed by &user= and the other parameters. Now add the route and a small table that remembers the last status of each transaction:
register_activation_hook(__FILE__, function () {
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta("CREATE TABLE {$wpdb->prefix}moonetix_tx (
tx varchar(191) NOT NULL,
user_id bigint(20) unsigned NOT NULL,
status tinyint(4) NOT NULL,
PRIMARY KEY (tx)
) {$wpdb->get_charset_collate()};");
});
add_action('rest_api_init', function () {
register_rest_route('sharklio/v1', '/postback', [
'methods' => 'GET',
'callback' => 'moonetix_postback',
'permission_callback' => '__return_true',
]);
});
function moonetix_postback(WP_REST_Request $r)
{
global $wpdb;
$tx = (string) $r->get_param('tx');
$user = (string) $r->get_param('user');
$reward = (string) $r->get_param('reward');
$status = (int) $r->get_param('status');
$expected = hash_hmac('sha256', $tx . ':' . $user . ':' . $reward . ':' . $status, MOONETIX_POSTBACK_SECRET);
if (!hash_equals($expected, (string) $r->get_param('hash'))) {
return new WP_REST_Response(null, 403);
}
$uid = (int) $user;
if ((string) $uid !== $user || !get_userdata($uid)) {
return new WP_REST_Response(null, 200);
}
$t = $wpdb->prefix . 'moonetix_tx';
$n = 0;
if ($status === 1) {
$n = $wpdb->query($wpdb->prepare("INSERT IGNORE INTO $t (tx, user_id, status) VALUES (%s, %d, 1)", $tx, $uid));
if ($n > 0) {
moonetix_add_points($uid, (float) $reward);
}
} elseif ($status === 2) {
$n = $wpdb->query($wpdb->prepare("UPDATE $t SET status = 2 WHERE tx = %s AND status = 1", $tx));
if ($n > 0) {
moonetix_add_points($uid, -(float) $reward);
}
}
return new WP_REST_Response(null, $n === false ? 500 : 200);
}
function moonetix_add_points(int $uid, float $delta): void
{
global $wpdb;
add_user_meta($uid, 'moonetix_points', 0, true);
$wpdb->query($wpdb->prepare("UPDATE {$wpdb->usermeta} SET meta_value = meta_value + %f WHERE user_id = %d AND meta_key = 'moonetix_points'", $delta, $uid));
wp_cache_delete($uid, 'user_meta');
}
What each part does:
- The hash check comes first. The postback hash is an HMAC-SHA256 of transaction ID, user ID, reward, and status joined with colons, signed with your postback secret key. A call that fails it gets a 403 and changes nothing.
- The table makes every transaction count once. One transaction keeps the same ID for its whole life, and the same event can arrive twice after a retry. The insert credits only the first time a transaction arrives as credited, and a reversal only takes points back from a transaction that was credited.
- Points change in one SQL statement, so two postbacks for the same user at the same moment cannot overwrite each other.
- The route is public on purpose. The REST API handbook says a public endpoint can use
__return_trueas itspermission_callback, and the route is registered onrest_api_init, as register_rest_route() requires. The hash check is the protection. - Unknown users get a 200 but no points. That also covers the test postbacks from the dashboard, which use
test_userby default. - A database error answers 500, so the postback is retried instead of lost.
Show the balance anywhere with get_user_meta(get_current_user_id(), 'moonetix_points', true). If you already run a points or loyalty plugin, call its own function for adding points inside moonetix_add_points() instead, and check its developer documentation for the exact name. For the reasoning behind each rule, see Postback security: verify every reward call.
Make sure postbacks can reach WordPress
- Answer fast. Moonetix waits up to 6 seconds for any 2xx answer, and the handler above needs only a few small queries.
- No redirects. Redirects are not followed, so use the final https address of your site, with or without
wwwexactly as your site serves it. - Let the request through. Security plugins, firewalls, and bot protection sometimes block server requests to
/wp-json/or switch off the REST API for visitors who are not logged in. Allow this one route. The Postbacks docs list the addresses our calls come from, but always keep the hash check as well.
Test before you go live
- In Integration status on the Integration tab, click Check a link, use Test a link with the ID of a WordPress test account, and compare it with the link in your page source. They must match exactly.
- Open the earn page logged in and logged out. You should see the offerwall in the first case and the log-in prompt in the second.
- In Integration status, use Send a test postback with your test account’s number as the user ID and status 1. The points should appear once.
- In Reports, Postbacks, open that test row, copy the URL under Details, and open it in your browser. It is the same transaction with a valid hash, so the points must not change again.
- Send a test with status 2. Every test gets a new transaction ID starting with
test_, so this reversal is for a transaction you never credited, and the points must stay as they are. - Change one character of the hash in a copied URL and open it: your route must answer 403. Then remove the test points.
The Testing and troubleshooting page lists every error page, such as Link not valid, with its fix.
Mistakes that cost WordPress sites money
- Crediting points when a user clicks an offer, instead of when a verified status 1 postback arrives.
- A full-page cache that serves one user’s signed link to everyone.
- Mixing up the link hash salt and the postback secret key. They are different values with different jobs.
- Letting people below the minimum age earn. End users must be at least 16, so ask for age at sign-up.
To check the link your server builds, compare it with the one from the offerwall link builder for the same user ID.
Running the Moonetix offerwall on WordPress
Moonetix is a pay-per-result affiliate network: your users earn your points for completed offers, and signed postbacks report every credit and reversal. Get ready with how to get approved as a publisher, and read how the Moonetix offerwall works to plan your earn page.
Frequently asked questions
Can I add an offerwall to WordPress without a plugin?
Not safely for logged-in users. The link must be signed per user with a secret salt, and that takes PHP on the server. The site-specific plugin in this guide is the smallest way to do it, and it survives theme changes.
Can I paste the offerwall iframe into a Custom HTML block?
Only a static link, and that would show every visitor the same user ID. Use a Shortcode block with a shortcode that builds the link for the logged-in user.
Why is my offerwall iframe blank on WordPress?
Usually the page is not on the website registered for the app, or it is not served over https. The iframe only loads on https pages of that website and its subdomains, so a staging copy on another domain stays blank.
Where should I store points in WordPress?
User meta works for a simple balance, as shown above, as long as each transaction is recorded once. If you already use a points plugin, credit through that plugin so balances stay in one place.
Do I need to monetize a WordPress plugin the same way?
No. This guide is for a WordPress site whose own users earn points. If you sell or distribute a plugin, read how to monetize a WordPress plugin instead.
Does the postback endpoint need a logged-in user?
No. Postbacks come from our server, not from the user’s browser, so the route is public and is protected by the hash check instead.


