iOS offerwall SDK. Swift, SwiftUI, and Objective-C.
Open the Moonetix offerwall in your iPhone and iPad app with one Swift package. It uses only UIKit and WebKit, has no third-party dependencies, and ships with a privacy manifest.
- 13+iOS and iPadOS
- 2.0.0Current version
- 0Third-party dependencies
import MoonetixOfferwall
Moonetix.configure(appId: "YOUR_APP_ID")
// When the user taps Earn
Moonetix.open(token: token)Go live in four steps.
Copy the code, replace the placeholders with the values from your dashboard under Apps, your app, Integration, and test before you launch.
-
Install the SDK
Add the package in Xcode with File, Add Package Dependencies, or use CocoaPods. You can also download the xcframework and embed it by hand. Build with Xcode 16 or newer.
Package.swiftdependencies: [ .package(url: "https://moonetix.com/ios/moonetix-ios.git", from: "2.0.0"), ], targets: [ .target(name: "MyApp", dependencies: [ .product(name: "MoonetixOfferwall", package: "moonetix-ios"), ]), ]Podfilepod 'MoonetixOfferwall', :podspec => 'https://moonetix.com/ios/2.0.0/MoonetixOfferwall.podspec' -
Add a token endpoint to your server
Your app never holds the API key. When a user taps Earn, your app asks your server for a session, and your server asks Moonetix for a single-use token for that signed-in user. A token works once, for 10 minutes.
session.php<?php // POST /api/moonetix/session: a fresh token for the signed-in user $user = current_user(); if (!$user) { http_response_code(401); exit; } $key = getenv('MOONETIX_API_KEY'); $ch = curl_init('https://api.moonetix.com/v1/session'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query(['user_id' => (string)$user->id]), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key], ]); $data = json_decode((string)curl_exec($ch), true); $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); header('Content-Type: application/json'); if ($code === 201 && !empty($data['token'])) { echo json_encode(['token' => $data['token']]); exit; } http_response_code($code === 403 ? 403 : 503); echo json_encode(['error' => 'offerwall_unavailable']);session.js// POST /api/moonetix/session: a fresh token for the signed-in user app.post('/api/moonetix/session', requireLogin, async (req, res) => { const r = await fetch('https://api.moonetix.com/v1/session', { method: 'POST', headers: { Authorization: `Bearer ${process.env.MOONETIX_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ user_id: String(req.user.id) }), signal: AbortSignal.timeout(10000), }); const data = await r.json().catch(() => ({})); if (r.status === 201 && data.token) { return res.json({ token: data.token }); } const status = r.status === 403 ? 403 : 503; res.status(status).json({ error: 'offerwall_unavailable' }); });session.py# POST /api/moonetix/session: a fresh token for the signed-in user @app.post("/api/moonetix/session") @login_required def moonetix_session(): key = os.environ["MOONETIX_API_KEY"] r = requests.post( "https://api.moonetix.com/v1/session", data={"user_id": str(current_user.id)}, headers={"Authorization": f"Bearer {key}"}, timeout=10, ) data = r.json() if r.status_code == 201 and data.get("token"): return {"token": data["token"]} status = 403 if r.status_code == 403 else 503 return {"error": "offerwall_unavailable"}, status -
Open the offerwall
Configure once with the App ID from your dashboard, then open the offerwall with a fresh token. Without a view controller, the SDK presents from the top screen of your app, so it works from SwiftUI too.
EarnViewController.swiftMoonetix.configure(appId: "YOUR_APP_ID") Moonetix.onClosed = { [weak self] in self?.refreshBalance() } Moonetix.onError = { error in print(error.code.name, error.message) } Moonetix.open(token: token, from: self)EarnButton.swiftButton("Earn coins") { Task { let token = try await api.fetchMoonetixToken() Moonetix.open(token: token) } }EarnViewController.m@import MoonetixOfferwall; [Moonetix configureWithAppId:@"YOUR_APP_ID"]; [Moonetix openWithToken:token from:self options:nil]; -
Credit rewards with the postback
Every reward reaches your server as a signed postback. Check the hash, add the reward once per transaction ID, and take it back if a later postback reverses it. Failed postbacks are retried up to 5 times.
Dashboard, Postback tabhttps://yoursite.com/postback?user={user_id}&tx={transaction_id}&status={status}&reward={reward}&hash={hash}postback.php<?php $tx = $_GET['tx'] ?? ''; $userId = $_GET['user'] ?? ''; $reward = $_GET['reward'] ?? ''; $status = $_GET['status'] ?? ''; $secret = getenv('MOONETIX_POSTBACK_SECRET'); $expected = hash_hmac('sha256', "$tx:$userId:$reward:$status", $secret); if (!hash_equals($expected, $_GET['hash'] ?? '')) { http_response_code(403); exit; } if ($status === '1') { credit_once($userId, $tx, $reward); // once per transaction ID } elseif ($status === '2') { reverse_if_credited($userId, $tx, $reward); } http_response_code(200);postback.jsconst crypto = require('crypto'); app.get('/postback', (req, res) => { const { tx, user, reward, status, hash } = req.query; const secret = process.env.MOONETIX_POSTBACK_SECRET; const expected = crypto.createHmac('sha256', secret) .update(`${tx}:${user}:${reward}:${status}`) .digest('hex'); const valid = typeof hash === 'string' && hash.length === expected.length && crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(expected)); if (!valid) return res.sendStatus(403); if (status === '1') creditOnce(user, tx, reward); // once per transaction ID if (status === '2') reverseIfCredited(user, tx, reward); res.sendStatus(200); });postback.pyimport hmac, hashlib, os from flask import request, abort @app.get("/postback") def postback(): a = request.args parts = [a.get(k, "") for k in ("tx", "user", "reward", "status")] msg = ":".join(parts) secret = os.environ["MOONETIX_POSTBACK_SECRET"].encode() expected = hmac.new(secret, msg.encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, a.get("hash", "")): abort(403) if a.get("status") == "1": credit_once(a["user"], a["tx"], a["reward"]) # once per transaction ID elif a.get("status") == "2": reverse_if_credited(a["user"], a["tx"], a["reward"]) return "", 200
- Castle MergeReach level 10+1,500
- FitTrackStart a free trial+2,000
- Word RushPlay 3 days in a row+450
- Budget PalSet up your first budget+800
What your users see
A native screen over your app, with your title and colors, your currency, and every reward confirmed by a postback.
What the SDK handles for you
- Offers open in the right place
Offers that lead to the App Store open in the store app, and other sites open in the browser, so installs are tracked correctly.
- No key inside your app
Your app holds only the App ID. Sessions are created on your server and work once, so nobody can open the offerwall as another user.
- A locked-down screen
Only offerwall pages load inside it, with no JavaScript bridge into your app.
- Native navigation
The close button returns to your app, swiping from the left edge goes back inside the offerwall, and it resumes where the user was after the background.
- Accessible by default
Light and dark mode, larger text sizes, and VoiceOver work out of the box.
- Privacy manifest included
The SDK ships its privacy manifest, uses no device fingerprinting, and does not use the advertising identifier.
Test it, then launch.
Everything you need to check before real users see your offerwall, with the tools built into your dashboard.
Open as a test user
In Integration status on the Integration tab, open your offerwall as the user owner-test. Test visits are not counted as users.
Run it on a real device
Build for Android or iOS and open the offerwall from your Earn button with a fresh token each time.
Send a test postback
Choose the user, status, and payout, and see exactly what your server answered, with the response code and time. Test transaction IDs start with test_.
Test from a normal connection
VPNs, proxies, Tor, and cloud networks are blocked, so test on home Wi-Fi or mobile data, in a country where offers run.
Launch checklist
0 of 11 ready
Ready to launch. Your progress is saved in this browser.
Common errors, fixed fast.
What you might see while you integrate the iOS SDK, what causes it, and the fix.
INVALID_APP_IDThe value passed to init is not the App ID. Passing the API key here is the most common mistake.
FixCopy the 24-character App ID again from the Integration tab.
INVALID_TOKENThe app passed something other than the token, or a token created with the API key of another app.
FixPass only the token field, which starts with sst_, and use the App ID and API key of the same app.
TOKEN_EXPIRED_OR_USEDThe token is older than 10 minutes or was already used, for example kept from an earlier tap.
FixCreate a new token for every open, right when the user taps Earn.
NOT_INITIALIZEDopen was called before init.
FixCall Moonetix.configure when your app starts.
UNKNOWNThe offerwall is already open, or no view controller was on screen to present from.
FixOpen it once at a time, and pass a visible view controller with open(token:from:).
Delegate is never calledMoonetix.delegate is a weak reference, so the object was already released.
FixKeep a strong reference to your delegate, or use the closures.
403 from /v1/sessionapp_not_live means the app is paused or your account is not active. user_blocked means we blocked this user.
FixDo not show the offerwall. For app_not_live, check your notifications.
VPN or Proxy detectedThe device is on a VPN, a proxy, or a cloud network, where offers cannot be tracked.
FixTest on a normal home or mobile connection, in a country where offers run.
Postback log shows 403 or 404Your script rejected the postback, or the URL is wrong.
FixOpen the URL from the log in a browser and check your hash code.
Postback log shows a timeoutYour script takes longer than 6 seconds to answer.
FixAnswer with 200 first, and run slow work in the background.
Postback log shows 301 or 302Your URL redirects, for example from http to https.
FixUse the final URL. Postbacks do not follow redirects.
No match. See the full list in the docs or contact us.
At a glance
- Minimum version
- iOS 13, iPhone and iPad, built with Xcode 16 or newer
- Languages
- Swift, SwiftUI, and Objective-C
- Install
- Swift Package Manager, CocoaPods, or an xcframework
- Dependencies
- Only UIKit and WebKit
- Privacy
- Privacy manifest included, no advertising identifier
- Price
- Free for approved publishers
Other integrations
- WebsiteA link, an iframe, or a floating Earn button. No SDK needed.
- Offers APIGet the offers as JSON and show them in your own design.
- Android SDKA native offerwall screen for Kotlin and Java apps.
- Unity SDKRewarded offers for Android and iOS games, from C#.
- Flutter SDKOne Dart plugin for Android and iOS.
- React Native SDKFor React Native and Expo apps, with TypeScript types.
- All integrationsCompare them side by side.
Which iOS versions does the Moonetix SDK support?
iOS 13 or newer on iPhone and iPad, built with Xcode 16 or newer. The SDK uses only UIKit and WebKit and has no third-party dependencies.
Can I use it with SwiftUI?
Yes. Call Moonetix.open(token:) without a view controller and the SDK presents from the top screen of your app, which works from SwiftUI buttons.
How do I install it?
With Swift Package Manager from https://moonetix.com/ios/moonetix-ios.git, with CocoaPods from our podspec, or by embedding the xcframework by hand.
What do I declare in App Store privacy details?
User ID, Device ID, Coarse Location, and Product Interaction, all linked to the user, not used for tracking, for App Functionality. The SDK’s privacy manifest lists the same data.
Does the offerwall break App Store review rules?
Apple has rules for apps that reward users. The Academy has a guide on running an offerwall in an iOS app within those rules, including how to word your rewards.
How are users rewarded?
Your server receives a signed postback for every reward and credits the user. Never credit from app events, because they can be faked on a device.
Turn your traffic into earnings.
Create a free publisher account and earn from every offer your users complete on your website, app, game, or community.