# MoMoney API Reference

> LLM-optimised version of https://momoney.co/docs.html
> Last updated: 2026-03-26

The MoMoney API lets you retrieve and display personalized offers within your mobile game. It is an alias for the MomentScience MomentPerks API. It provides a simple REST endpoint to fetch targeted offers based on user context and placement.

**Base endpoint:**

```
POST https://api.momoney.co/native/v4/offers.json
```

---

## Getting Started

Before making API calls, you need a MoMoney account and an API key. The two walkthroughs below cover both steps.

### Sign in to MoMoney

Interactive demo: https://play.momentscience.com/embed/cmnk0r9lu26zoaburtmzirz1j

1. Click **Get Started** to begin creating your MoMoney account.
2. If you already have an account, enter your email and click **Send One-Time Code**. We'll email you an OTP to log in.
3. If it's your first time, click **Sign Up** to create a new account.
4. Fill in your details: email, name, company, and country.
5. Click **Create Account**.
6. Check your inbox for a 6-digit verification code and enter it in the input field.
7. Click **Verify & Sign In** to complete your account verification.
8. You're in. Start earning incremental revenue for mobile games — SDK-free and API-first.

### Create your first app and get your API key

Interactive demo: https://play.momentscience.com/embed/cmnk2o2ee28gqaburjzog93ye

1. On your MoMoney dashboard, click the **Apps** tab to view and manage your linked applications.
2. Click **Create App** to register a new application.
3. Enter your app details: name, platforms, and optional links for the App Store, Play Store, and your website.
4. Click **Create App**.
5. Once your app is created, find your API key, click **Copy**, and use it in your integration.

> Your API key is tied to your app. Keep it private and never expose it in client-side code or public repositories.

---

## Integration Workflow

Getting MoMoney into your game is a three-step process:

**1. Plan your integration**

Decide where and how you want to show offers. Common moments are level-complete, achievement unlocked, and consolation screens — anywhere the player has a natural pause. The presentation can be as lightweight as a toast notification, or as prominent as a full-screen modal or embedded offer card.

> **Name placements clearly** — a label like `level_complete` or `achievement_unlocked` is far more useful in reports than a generic `screen1`. The MoMoney dashboard breaks down performance by placement, so descriptive names let you quickly see which moments drive the most engagement.

**2. Fetch offers via the MoMoney API**

Call the endpoint at your chosen moment, passing in the player's IP address, user agent, a unique user identifier (IDFA / GAID), and your placement name.

> **Throttling is configured in the dashboard**, not in code — offers can be shown globally on a percentage of sessions, restricted per placement, or tuned at the individual user level when you pass a `pub_user_id`. Your integration code simply checks whether the response contains offers and displays them if so.

**3. Display the offer**

Render the offer according to your plan — a single card, a carousel, or however fits your UI. Fire the impression pixel as soon as the offer is visible, and handle the accept / dismiss CTAs. See the Offer Anatomy guide (https://momoney.co/llms/anatomy.md) for a full breakdown of every response field.

---

## Authentication

All API requests require an API key passed as a query parameter. Generate your key from the MomentScience Dashboard under Profile Settings → API Keys. The key must have the **Ads/Offers** permission enabled.

---

## Fetch Offers Endpoint

```
POST /native/v4/offers.json?api_key=YOUR_KEY
```

Retrieve a set of personalized offers to display to a user. The API accepts user context (IP, user agent, placement) and returns targeted offers with creative assets, tracking pixels, and call-to-action content.

---

## Request Headers

| Header       | Required | Value            |
| ------------ | -------- | ---------------- |
| Content-Type | Required | application/json |
| Accept       | Optional | application/json |

---

## Query Parameters

| Parameter    | Type   | Required | Description                                                    |
| ------------ | ------ | -------- | -------------------------------------------------------------- |
| api_key      | String | Required | Your API authentication key                                    |
| loyaltyboost | String | Optional | `0` = exclude reward offers, `1` = include, `2` = only rewards |
| creative     | String | Optional | Set to `"1"` to include offer images                           |
| campaignId   | String | Optional | Filter offers by a specific campaign                           |

---

## Request Body

Send a JSON body with the following parameters:

| Parameter   | Type   | Required    | Description                                                                                                              |
| ----------- | ------ | ----------- | ------------------------------------------------------------------------------------------------------------------------ |
| placement   | String | Required    | Location identifier where offers will be shown (e.g. `"level_complete"`)                                                 |
| pub_user_id | String | Conditional | Unique user identifier (IDFA on iOS, GAID on Android). Required for per-user throttling and PWaaS / User Selected Perks. |
| ua          | String | Recommended | End-user User-Agent string for targeting                                                                                 |
| ip          | String | Recommended | User IP address for geo-targeting                                                                                        |
| adpx_fp     | String | Recommended | Persistent anonymous identifier for frequency capping and opt-out tracking                                               |
| dev         | String | Optional    | Set to `"1"` for test mode (ignores geo, disables tracking)                                                              |

> **Tip:** The `adpx_fp` parameter is strongly recommended. It enables frequency capping and prevents re-serving offers after a user opts out.

---

## Response Structure

The response contains a top-level `data` object:

| Field       | Type    | Description                                                         |
| ----------- | ------- | ------------------------------------------------------------------- |
| session_id  | String  | Unique session identifier                                           |
| offers      | Array   | Collection of offer objects                                         |
| count       | Integer | Number of offers returned                                           |
| privacy_url | String  | Privacy policy URL                                                  |
| styles      | Object  | UI styling definitions                                              |
| settings    | Object  | Display configuration including `offerwall_url` and `offerwall_cta` |

---

## Offer Object

Each item in the `offers` array contains:

### Identification

| Field           | Type    | Description             |
| --------------- | ------- | ----------------------- |
| id              | Integer | Unique offer identifier |
| campaign_id     | Integer | Associated campaign ID  |
| advertiser_name | String  | Advertiser display name |

### Text Content

| Field             | Type   | Description                                                           |
| ----------------- | ------ | --------------------------------------------------------------------- |
| title             | String | Primary headline (max 90 chars, ideal 40). Render as plain text.      |
| description       | HTML   | Full description (max 220 chars). **Always render as HTML.**          |
| short_headline    | String | Mobile-optimized headline (max 60 chars). Use in compact/MOU layouts. |
| short_description | HTML   | Mobile description (max 140 chars). **Always render as HTML.**        |
| mini_text         | String | Legal disclaimers (max 160 chars). Shown below the CTA.               |

### Call-to-Action

| Field     | Type   | Description                                                   |
| --------- | ------ | ------------------------------------------------------------- |
| click_url | URL    | Destination URL when user accepts the offer. Open in browser. |
| cta_yes   | String | Primary button label (max 25 chars)                           |
| cta_no    | String | Secondary / dismiss button label (max 25 chars)               |

### Images & Creatives

| Field       | Type   | Description                                                                                                                        |
| ----------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------- |
| image       | URL    | Primary offer image                                                                                                                |
| qr_code_img | String | Base64-encoded QR code (228×228px)                                                                                                 |
| creatives   | Array  | Additional images. Each item: `id`, `url`, `height`, `width`, `type`, `is_primary` (bool), `aspect_ratio` (string e.g. `"1.91:1"`) |

> **Image resizing:** Append query params to any image URL: `?width=300`, `?height=200`, or `?aspect_ratio=16:9`

---

## Tracking & Events

Proper event tracking is critical for accurate reporting and revenue attribution.

| Field / Path                       | Type | When to fire                                                                                              |
| ---------------------------------- | ---- | --------------------------------------------------------------------------------------------------------- |
| `offers[].pixel`                   | URL  | **Required.** Fire as HTTP GET immediately when the offer becomes visible. Fire only once per impression. |
| `offers[].click_url`               | URL  | Open in browser when user taps the accept CTA (`cta_yes`)                                                 |
| `offers[].beacons.no_thanks_click` | URL  | Fire as HTTP GET when user taps the dismiss CTA (`cta_no`)                                                |
| `offers[].beacons.close`           | URL  | Fire as HTTP GET when user closes the offer without interacting                                           |
| `settings.offerwall_url`           | URL  | Open in browser when user taps the Perkswall / "see all offers" CTA                                       |

---

## C# Class Definitions (Unity)

Use these classes to deserialize the JSON response. Since the API returns nullable fields, use **Newtonsoft.Json** instead of Unity's built-in `JsonUtility`:

**Note:** Unity 2020.1+ includes Newtonsoft.Json by default. For Unity 2019.x and earlier, install it via **Package Manager → Add package by name → com.unity.nuget.newtonsoft-json**

```csharp
using System;
using System.Collections.Generic;

[Serializable]
public class ApiResponse
{
    public ResponseData data;
}

[Serializable]
public class ResponseData
{
    public string session_id;
    public List<Offer> offers;
    public int count;
    public string privacy_url;
    public Settings settings;
}

[Serializable]
public class Offer
{
    public int id;                          // Required
    public int? campaign_id;                // Optional
    public string advertiser_name;          // Optional
    public string title;                    // Optional
    public string description;              // Optional
    public string short_headline;           // Optional
    public string short_description;        // Optional
    public string mini_text;                // Optional
    public string click_url;                // Optional
    public string cta_yes;                  // Optional
    public string cta_no;                   // Optional
    public string image;                    // Optional
    public string qr_code_img;              // Optional
    public string pixel;                    // Optional
    public string adv_pixel_url;            // Optional
    public string terms_and_conditions;     // Optional — HTML. May be empty.
    public Beacons beacons;                 // Optional
    public List<Creative> creatives;        // Optional
}

[Serializable]
public class Beacons
{
    public string close;
    public string no_thanks_click;
}

[Serializable]
public class Creative
{
    public int id;
    public string url;                      // Optional
    public int? height;                     // Optional
    public int? width;                      // Optional
    public string type;                     // Optional
    public bool? is_primary;                // Optional
    public string aspect_ratio;             // Optional
}

[Serializable]
public class Settings
{
    public string offerwall_url;            // Optional
    public string offerwall_cta;            // Optional — button label for offerwall CTA
}
```

---

## Example Request — Unity (C#)

```csharp
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Networking;

public class MoMoneyManager : MonoBehaviour
{
    // Store API key in a ScriptableObject or Resources asset, not as a const
    [SerializeField] private string apiKey;
    [SerializeField] private MoMoneyCarousel carousel;
    private const string API_URL = "https://api.momoney.co/native/v4/offers.json";

    [System.Serializable]
    private class RequestBody
    {
        public string placement;
        public string pub_user_id;
        public string ua;
        public string ip;
        public string adpx_fp;
    }

    [System.Serializable]
    private class ApiResponse
    {
        public ResponseData data;
    }

    [System.Serializable]
    private class ResponseData
    {
        public Offer[] offers;
    }

    // Returns: AppName/version (UnityPlayer/unityVersion; Platform; DeviceModel)
    private static string GetUserAgent()
    {
        return $"{Application.productName}/{Application.version} " +
               $"(UnityPlayer/{Application.unityVersion}; {SystemInfo.operatingSystem}; {SystemInfo.deviceModel})";
    }

    // adpx_fp: persistent anonymous UUID — generated once, stored in PlayerPrefs.
    // Using a stable UUID (rather than deviceUniqueIdentifier) allows frequency
    // capping and opt-out to work correctly, and lets users reset their tracking
    // identity independently of their device hardware ID.
    private static string GetOrCreateAdpxFp()
    {
        const string key = "adpx_fp";
        if (PlayerPrefs.HasKey(key))
            return PlayerPrefs.GetString(key);
        string fp = Guid.NewGuid().ToString();
        PlayerPrefs.SetString(key, fp);
        PlayerPrefs.Save();
        return fp;
    }

    // Call early — e.g. when a new puzzle/level starts — so offers are ready
    // before the level-complete screen appears (pre-fetch pattern).
    public void PreFetchOffers(string placement = "level_complete")
    {
        StartCoroutine(FetchOffers(placement));
    }

    private IEnumerator FetchOffers(string placement)
    {
        string userAgent = GetUserAgent();

        // Optional: Fetch the user's IP address for accurate geo-targeting.
        // Unity doesn't provide direct IP access — use any IP lookup service you prefer (e.g. ipify.org).
        string userIp = null;
        yield return StartCoroutine(FetchUserIP(ip => userIp = ip));

        var body = new RequestBody
        {
            placement   = placement,
            pub_user_id = SystemInfo.deviceUniqueIdentifier,   // IDFV on iOS, Android ID on Android
            ua          = userAgent,
            ip          = userIp,
            adpx_fp     = GetOrCreateAdpxFp(),
        };

        // Use Newtonsoft.Json for both serialization and deserialization.
        // It correctly handles nullable fields (int?, bool?) in the response.
        string json = Newtonsoft.Json.JsonConvert.SerializeObject(body);
        byte[] raw = System.Text.Encoding.UTF8.GetBytes(json);

        using (UnityWebRequest req = new UnityWebRequest(
            $"{API_URL}?api_key={apiKey}&creative=1", "POST"))
        {
            req.uploadHandler   = new UploadHandlerRaw(raw);
            req.downloadHandler = new DownloadHandlerBuffer();
            req.SetRequestHeader("Content-Type", "application/json");
            req.SetRequestHeader("User-Agent", userAgent);
            req.timeout = 60;

            yield return req.SendWebRequest();

            if (req.result == UnityWebRequest.Result.Success)
                HandleResponse(req.downloadHandler.text);
            else
                Debug.LogWarning("MoMoney fetch failed: " + req.error);
        }
    }

    // Helper coroutine to fetch the user's IP address.
    // Uses ipify.org as an example — replace with any IP lookup service you prefer.
    private IEnumerator FetchUserIP(System.Action<string> callback)
    {
        using (UnityWebRequest req = UnityWebRequest.Get("https://api.ipify.org?format=text"))
        {
            req.timeout = 5;
            yield return req.SendWebRequest();
            if (req.result == UnityWebRequest.Result.Success)
                callback?.Invoke(req.downloadHandler.text);
            else
                callback?.Invoke(null);  // Gracefully handle failure
        }
    }

    private void HandleResponse(string json)
    {
        try
        {
            // Newtonsoft.Json handles nullable fields (int?, bool?) correctly.
            // Unity 2020.1+: included by default.
            // Earlier versions: Package Manager → Add package by name → com.unity.nuget.newtonsoft-json
            var response = Newtonsoft.Json.JsonConvert.DeserializeObject<ApiResponse>(json);
            if (response?.data?.offers != null && response.data.offers.Length > 0)
            {
                if (carousel != null)
                    carousel.SetupOffers(new List<Offer>(response.data.offers));
                else
                    Debug.LogError("Carousel reference is missing.");
            }
        }
        catch (Exception e)
        {
            Debug.LogError($"JSON parse error: {e.Message}");
        }
    }
}
```

## Example Request — React Native

```javascript
import DeviceInfo from "react-native-device-info";

const API_URL = "https://api.momoney.co/native/v4/offers.json";
const API_KEY = "YOUR_API_KEY";

// Call when the level-complete screen mounts
async function fetchOffersOnLevelComplete() {
  // getUniqueId() returns IDFA on iOS, GAID on Android
  const deviceId = await DeviceInfo.getUniqueId();
  const userAgent = await DeviceInfo.getUserAgent();

  const res = await fetch(`${API_URL}?api_key=${API_KEY}&creative=1`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      placement: "level_complete",
      pub_user_id: deviceId,
      adpx_fp: deviceId,
      ua: userAgent,
    }),
  });

  const { data } = await res.json();
  return data.offers ?? [];
}
```

## Example Request — Solar2D (Lua)

**momoney.lua** - MoMoney API Integration Module:

```lua
local json = require("json")

local M = {}
M.apiKey = "YOUR_API_KEY"
M.baseUrl = "https://api.momoney.co/native/v4/offers.json"
M.cachedIp = nil

function M.getUserId()
    return system.getInfo("deviceID") or ("guest_" .. system.getTimer())
end

local function fetchOffersWithIp(placement, userId, userIp, callback)
    local requestBody = {
        placement   = placement or "game_over",
        pub_user_id = userId or "guest_" .. system.getTimer(),
        ua          = system.getInfo("platform") .. "/" ..
                      system.getInfo("platformVersion") .. " (" ..
                      system.getInfo("model") .. ")",
        adpx_fp     = system.getInfo("deviceID") or ("guest_" .. system.getTimer()),
        ip          = userIp
    }

    local params = {
        headers = {["Content-Type"] = "application/json"},
        body    = json.encode(requestBody),
        timeout = 10000
    }

    local url = M.baseUrl .. "?api_key=" .. M.apiKey
    network.request(url, "POST", function(event)
        if event.isError then
            print("MoMoney API Error")
            if callback then callback({}) end
        else
            local success, response = pcall(json.decode, event.response)
            if success and response and response.data and response.data.offers then
                if callback then callback(response.data.offers) end
            else
                if callback then callback({}) end
            end
        end
    end, params)
end

function M.fetchOffers(placement, userId, callback)
    if M.cachedIp then
        fetchOffersWithIp(placement, userId, M.cachedIp, callback)
        return
    end

    -- Detect IP for geo-targeting (optional).
    -- ipify.org is used here as an example — use any IP lookup service you prefer.
    network.request("https://api.ipify.org?format=json", "GET", function(event)
        if event.isError then
            fetchOffersWithIp(placement, userId, nil, callback)
        else
            local success, ipData = pcall(json.decode, event.response)
            if success and ipData and ipData.ip then
                M.cachedIp = ipData.ip
                fetchOffersWithIp(placement, userId, M.cachedIp, callback)
            else
                fetchOffersWithIp(placement, userId, nil, callback)
            end
        end
    end, {timeout = 5000})
end

function M.trackImpression(offer)
    if offer and offer.pixel then
        network.request(offer.pixel, "GET", function(event)
            if event.isError then
                print("MoMoney: Pixel tracking failed")
            end
        end)
    end
end

function M.openOffer(offer)
    if offer and offer.click_url then
        system.openURL(offer.click_url)
    end
end

return M
```

**Usage in game scene:**

```lua
local momoney = require("momoney")

function scene:show(event)
    if event.phase == "did" then
        local userId = momoney.getUserId()
        momoney.fetchOffers("game_over", userId, function(offers)
            if #offers > 0 then
                displayOfferCarousel(offers)
            end
        end)
    end
end
```

## Example Request — Godot (GDScript)

```gdscript
extends CanvasLayer
# Attach to your LevelCompleteScreen node

const API_URL = "https://api.momoney.co/native/v4/offers.json"
const API_KEY = "YOUR_API_KEY"

@onready var http_fetch = $HTTPFetch   # HTTPRequest node

func _ready():
    http_fetch.request_completed.connect(_on_offers_received)

func on_level_complete():
    # OS.get_unique_id() — replace with IDFA/GAID via a native plugin
    var device_id  = OS.get_unique_id()
    var user_agent = "Godot/%s (%s)" % [
        Engine.get_version_info().string, OS.get_name()]

    var body = JSON.stringify({
        "placement":   "level_complete",
        "pub_user_id": device_id,
        "adpx_fp":     device_id,
        "ua":          user_agent,
    })

    var url     = "%s?api_key=%s&creative=1" % [API_URL, API_KEY]
    var headers = ["Content-Type: application/json"]
    http_fetch.request(url, headers, HTTPClient.METHOD_POST, body)

func _on_offers_received(_result, code, _headers, body):
    if code != 200:
        push_warning("MoMoney: unexpected status %d" % code)
        return
    var response = JSON.parse_string(body.get_string_from_utf8())
    var offers   = response.get("data", {}).get("offers", [])
    if offers.size() > 0:
        show_offer_carousel(offers)
```

---

## Display Example — Unity (C#)

```csharp
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Networking;
using UnityEngine.UI;
using TMPro;
using WebP; // A WebP decoding package is required — offer images may be served as WebP.
            // unity-webp is used here as an example. Other packages are available;
            // use whichever best fits your project.
            // unity-webp: Package Manager → Add package from git URL:
            // https://github.com/netpyoung/unity-webp.git

public class MoMoneyCarousel : MonoBehaviour
{
    [Header("UI References")]
    [SerializeField] private Image           offerImage;
    [SerializeField] private TextMeshProUGUI titleText;
    [SerializeField] private TextMeshProUGUI descriptionText;
    [SerializeField] private Button          positiveCTAButton;
    [SerializeField] private TextMeshProUGUI positiveCTALabel;
    [SerializeField] private Button          saveForLaterButton;
    [SerializeField] private Button          leftArrowButton;
    [SerializeField] private Button          rightArrowButton;
    [SerializeField] private TextMeshProUGUI pageIndicator;

    private List<Offer> _offers = new List<Offer>();
    private int         _index  = 0;
    private Offer       _current;
    private Texture2D   _currentTexture;
    private int         _imageLoadVersion = 0;

    public void SetupOffers(List<Offer> offers)
    {
        if (offers == null || offers.Count == 0) return;
        _offers = offers;
        _index  = 0;
        WireArrows();
        ShowOffer(_index);
    }

    private void WireArrows()
    {
        if (leftArrowButton != null)
        {
            leftArrowButton.onClick.RemoveAllListeners();
            leftArrowButton.onClick.AddListener(() => NavigateOffer(-1));
        }
        if (rightArrowButton != null)
        {
            rightArrowButton.onClick.RemoveAllListeners();
            rightArrowButton.onClick.AddListener(() => NavigateOffer(+1));
        }
    }

    private void ShowOffer(int index)
    {
        _current = _offers[index];

        // 1. Fire impression pixel (required)
        if (!string.IsNullOrEmpty(_current.pixel) &&
            Uri.IsWellFormedUriString(_current.pixel, UriKind.Absolute))
            StartCoroutine(FireBeacon(_current.pixel));

        // 2. Load image — try offer.image first, then fall back through creatives
        var imageUrls = new List<string>();
        if (!string.IsNullOrEmpty(_current.image))
            imageUrls.Add(_current.image);
        if (_current.creatives != null)
        {
            // Primary creative as first fallback
            foreach (var c in _current.creatives)
                if (c.is_primary == true && !string.IsNullOrEmpty(c.url) && !imageUrls.Contains(c.url))
                    imageUrls.Add(c.url);
            // Remaining creatives in order
            foreach (var c in _current.creatives)
                if (!string.IsNullOrEmpty(c.url) && !imageUrls.Contains(c.url))
                    imageUrls.Add(c.url);
        }
        int ver = ++_imageLoadVersion;
        if (imageUrls.Count > 0)
            StartCoroutine(LoadImageWithFallback(imageUrls, ver));

        // 3. Populate text — prefer compact short variants
        if (titleText != null)
            titleText.text = (!string.IsNullOrEmpty(_current.short_headline)
                ? _current.short_headline : _current.title) ?? "";
        if (descriptionText != null)
            descriptionText.text = (!string.IsNullOrEmpty(_current.short_description)
                ? _current.short_description : _current.description) ?? "";
        if (pageIndicator != null)
            pageIndicator.text = $"{index + 1} / {_offers.Count}";

        // 4. Wire positive CTA
        positiveCTAButton.onClick.RemoveAllListeners();
        if (!string.IsNullOrEmpty(_current.cta_yes))
        {
            if (positiveCTALabel != null) positiveCTALabel.text = _current.cta_yes;
            positiveCTAButton.gameObject.SetActive(true);
            positiveCTAButton.onClick.AddListener(OnPositiveCTAClicked);
        }
        else
        {
            positiveCTAButton.gameObject.SetActive(false);
        }

        // 5. Wire Save For Later
        if (saveForLaterButton != null)
        {
            saveForLaterButton.onClick.RemoveAllListeners();
            saveForLaterButton.onClick.AddListener(OnSaveForLaterClicked);
        }
    }

    private void OnPositiveCTAClicked()
    {
        if (_current == null || string.IsNullOrEmpty(_current.click_url)) return;
        // Open in in-app browser (gree/unity-webview) — keeps players inside the game.
        // To open in the device's system browser instead, use Application.OpenURL().
        InAppBrowser.Open(_current.click_url);
        // Navigate cyclically — wrap back to the first offer after the last
        _index = (_index + 1) % _offers.Count;
        ShowOffer(_index);
    }

    private void OnSaveForLaterClicked()
    {
        if (_current == null || string.IsNullOrEmpty(_current.click_url)) return;
        // Append save_for_later=1 — use ? or & depending on whether a query string exists
        string url = _current.click_url.Contains("?")
            ? _current.click_url + "&save_for_later=1"
            : _current.click_url + "?save_for_later=1";
        InAppBrowser.Open(url);
    }

    // Arrow navigation (left = -1, right = +1). Fires no_thanks_click for the skipped offer.
    private void NavigateOffer(int delta)
    {
        if (_current == null || _offers.Count <= 1) return;
        if (_current.beacons != null && !string.IsNullOrEmpty(_current.beacons.no_thanks_click) &&
            Uri.IsWellFormedUriString(_current.beacons.no_thanks_click, UriKind.Absolute))
            StartCoroutine(FireBeacon(_current.beacons.no_thanks_click));
        _index = (_index + delta + _offers.Count) % _offers.Count;
        ShowOffer(_index);
    }

    // Call if you have an explicit close/dismiss button on the panel.
    // Fires beacons.close for the current offer before hiding.
    public void ClosePanel()
    {
        if (_current?.beacons != null && !string.IsNullOrEmpty(_current.beacons.close) &&
            Uri.IsWellFormedUriString(_current.beacons.close, UriKind.Absolute))
            StartCoroutine(FireBeacon(_current.beacons.close));
        gameObject.SetActive(false);
    }

    private IEnumerator FireBeacon(string url)
    {
        using (UnityWebRequest req = UnityWebRequest.Get(url))
        {
            req.timeout = 60;
            yield return req.SendWebRequest();
            if (req.result != UnityWebRequest.Result.Success)
                Debug.LogWarning($"Beacon failed: {req.error}");
        }
    }

    // Tries each URL in order; displays the first that loads successfully.
    private IEnumerator LoadImageWithFallback(List<string> urls, int version)
    {
        foreach (string url in urls)
        {
            if (version != _imageLoadVersion) yield break;
            bool success = false;
            yield return StartCoroutine(LoadImage(url, version, r => success = r));
            if (success) yield break;
            Debug.LogWarning($"[MoMoneyCarousel] Image failed, trying fallback. URL: {url}");
        }
        if (offerImage != null) offerImage.gameObject.SetActive(false);
    }

    // NOTE: UnityWebRequestTexture does not support WebP images.
    // Offer images may be served as WebP. This method detects the format
    // from the raw bytes and decodes WebP using a third-party package.
    // unity-webp is used here as an example — other WebP packages are available.
    private IEnumerator LoadImage(string url, int version, Action<bool> onResult)
    {
        if (string.IsNullOrEmpty(url) || offerImage == null) { onResult(false); yield break; }

        using (UnityWebRequest req = UnityWebRequest.Get(url))
        {
            req.timeout = 60;
            req.SetRequestHeader("Accept", "image/png,image/jpeg,image/webp,*/*");

            yield return req.SendWebRequest();

            if (version != _imageLoadVersion) { onResult(false); yield break; }

            if (req.result != UnityWebRequest.Result.Success)
            {
                Debug.LogWarning($"Image load failed: {req.error}");
                onResult(false);
                yield break;
            }

            byte[] data = req.downloadHandler.data;

            // Destroy old texture/sprite before creating new ones
            if (offerImage.sprite != null) Destroy(offerImage.sprite);
            if (_currentTexture != null) Destroy(_currentTexture);

            // Detect WebP by RIFF/WEBP header signature
            bool isWebP = data.Length >= 12
                && data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46  // "RIFF"
                && data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50; // "WEBP"

            Texture2D tex;
            if (isWebP)
            {
                Error webpError;
                tex = Texture2DExt.CreateTexture2DFromWebP(data, lMipmaps: false, lLinear: false, lError: out webpError);
                if (webpError != Error.Success || tex == null)
                {
                    Debug.LogWarning($"WebP decode failed: {webpError}");
                    onResult(false);
                    yield break;
                }
            }
            else
            {
                tex = new Texture2D(2, 2, TextureFormat.RGBA32, false);
                if (!tex.LoadImage(data))
                {
                    Destroy(tex);
                    Debug.LogWarning($"Image decode failed: {url}");
                    onResult(false);
                    yield break;
                }
            }

            _currentTexture = tex;
            offerImage.sprite = Sprite.Create(
                tex,
                new Rect(0, 0, tex.width, tex.height),
                Vector2.one * 0.5f);
            onResult(true);
        }
    }

    private void OnDestroy()
    {
        // Clean up sprite and texture to prevent memory leaks
        if (offerImage != null && offerImage.sprite != null)
            Destroy(offerImage.sprite);

        if (_currentTexture != null)
            Destroy(_currentTexture);
    }
}
```

---

## InAppBrowser.cs — Full Source (Unity)

The display example calls `InAppBrowser.Open(url)` to open offer and Save For Later URLs in a fullscreen in-app webview. Copy this file into your Unity project — it requires no scene setup and works as a static call from anywhere in your code. Requires the [gree/unity-webview](https://github.com/gree/unity-webview) package.

```csharp
using System.Collections;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

/// <summary>
/// Lightweight wrapper around gree/unity-webview's WebViewObject.
/// Opens a URL in a fullscreen in-app browser overlay with a close button.
/// Usage:  InAppBrowser.Open(url)  /  InAppBrowser.Close()
/// </summary>
public class InAppBrowser : MonoBehaviour
{
    private static InAppBrowser instance;
    private WebViewObject webViewObject;
    private GameObject blockingPanel;
    private GameObject navToolbar;
    private bool isLoading = false;
    private bool eventSystemCreated = false;

    private const int TOP_MARGIN = 0;
    private const int BOTTOM_MARGIN = 120;

    // Cached icon sprites (created once, reused across opens)
    private static Sprite iconBack;
    private static Sprite iconForward;
    private static Sprite iconRefresh;
    private static Sprite iconStop;
    private static Sprite iconClose;

    /// <summary>Opens the given URL in a fullscreen in-app webview.</summary>
    public static void Open(string url)
    {
        if (string.IsNullOrEmpty(url))
        {
            Debug.LogWarning("[InAppBrowser] Empty URL, nothing to open.");
            return;
        }

        // Reuse existing instance or create one
        if (instance == null)
        {
            var go = new GameObject("InAppBrowser");
            DontDestroyOnLoad(go);
            instance = go.AddComponent<InAppBrowser>();
        }

        instance.LoadURL(url);
    }

    /// <summary>Closes the in-app browser if it is open.</summary>
    public static void Close()
    {
        if (instance != null)
            instance.Cleanup();
    }

    /// <summary>Returns true if the webview is currently visible.</summary>
    public static bool IsOpen => instance != null && instance.webViewObject != null && instance.webViewObject.GetVisibility();

    private void LoadURL(string url)
    {
        // Guard: prevents double-open while a page is mid-load on the existing instance.
        // isLoading is reset by ld/err/httpErr callbacks and Cleanup().
        if (isLoading) return;
        isLoading = true;

        // Destroy any previous webview
        if (webViewObject != null)
        {
            Destroy(webViewObject.gameObject);
            webViewObject = null;
        }

        var go = new GameObject("WebViewObject");
        go.transform.SetParent(transform);
        webViewObject = go.AddComponent<WebViewObject>();

        webViewObject.Init(
            cb: (msg) =>
            {
                Debug.Log($"[InAppBrowser] cb: {msg}");
                if (msg == "close")
                    Cleanup();
            },
            err: (msg) =>
            {
                Debug.LogError($"[InAppBrowser] err: {msg}");
                isLoading = false;
            },
            httpErr: (msg) =>
            {
                Debug.LogError($"[InAppBrowser] httpErr: {msg}");
                isLoading = false;
            },
            started: (msg) =>
            {
                if (msg != null && msg.StartsWith("unity:close")) { Cleanup(); return; }
                if (msg != null && IsStoreUrl(msg))
                {
                    Debug.Log($"[InAppBrowser] Store redirect detected, closing webview: {msg}");
                    Cleanup();
                    Application.OpenURL(msg);
                    return;
                }
                InjectLoadingIndicator(true);
            },
            hooked: (msg) =>
            {
                Debug.Log($"[InAppBrowser] hooked: {msg}");
            },
            cookies: (msg) =>
            {
                Debug.Log($"[InAppBrowser] cookies: {msg}");
            },
            ld: (msg) =>
            {
                Debug.Log($"[InAppBrowser] loaded: {msg}");
                isLoading = false;
                InjectLoadingIndicator(false);
            },
            enableWKWebView: true,
            wkContentMode: 1, // 0 = recommended, 1 = mobile, 2 = desktop
#if UNITY_IOS
            ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
#elif UNITY_ANDROID
            ua: "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
#else
            ua: null
#endif
        );

        // Create fullscreen blocking panel behind webview to catch stray taps
        CreateBlockingPanel();

        // Create navigation toolbar at bottom
        CreateNavToolbar();

        // Fullscreen with bottom margin for nav toolbar
        webViewObject.SetMargins(0, TOP_MARGIN, 0, BOTTOM_MARGIN);
        webViewObject.SetVisibility(true);
        InjectLoadingIndicator(true);
        webViewObject.LoadURL(url);
    }

    private void InjectLoadingIndicator(bool show)
    {
        if (webViewObject == null) return;

        if (show)
        {
            string js =
                "if (!document.getElementById('unity-spin-style')) {" +
                "  var s = document.createElement('style');" +
                "  s.id = 'unity-spin-style';" +
                "  s.textContent = '@keyframes unity-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }';" +
                "  document.head.appendChild(s);" +
                "}" +
                "if (!document.getElementById('unity-loading-overlay')) {" +
                "  var overlay = document.createElement('div');" +
                "  overlay.id = 'unity-loading-overlay';" +
                "  overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;" +
                "    background:rgba(255,255,255,0.95);z-index:999998;" +
                "    display:flex;align-items:center;justify-content:center;';" +
                "  overlay.innerHTML = '<div style=\"width:72px;height:72px;border:6px solid #e0e0e0;" +
                "    border-top:6px solid #6200ea;border-radius:50%;" +
                "    animation:unity-spin 0.8s linear infinite;\"></div>';" +
                "  document.body.appendChild(overlay);" +
                "} else {" +
                "  document.getElementById('unity-loading-overlay').style.display = 'flex';" +
                "}";
            webViewObject.EvaluateJS(js);
        }
        else
        {
            string js = @"
                var overlay = document.getElementById('unity-loading-overlay');
                if (overlay) overlay.style.display = 'none';
            ";
            webViewObject.EvaluateJS(js);
        }
    }

    private void Update()
    {
        // Handle Android back button (mapped to KeyCode.Escape in Unity; scoped to Android to avoid
        // triggering on physical keyboard Escape on desktop/editor)
#if UNITY_ANDROID && !UNITY_EDITOR
        if (webViewObject != null && webViewObject.GetVisibility())
        {
            if (Input.GetKeyDown(KeyCode.Escape))
                Cleanup();
        }
#endif
    }

    private void CreateBlockingPanel()
    {
        if (blockingPanel != null) Destroy(blockingPanel);

        blockingPanel = new GameObject("WebViewBlockingPanel");
        blockingPanel.transform.SetParent(transform);
        var canvas = blockingPanel.AddComponent<Canvas>();
        canvas.renderMode = RenderMode.ScreenSpaceOverlay;
        canvas.sortingOrder = 30000;
        blockingPanel.AddComponent<GraphicRaycaster>();

        var panelImage = new GameObject("BlockingImage");
        panelImage.transform.SetParent(blockingPanel.transform, false);
        var img = panelImage.AddComponent<Image>();
        img.color = new Color(0, 0, 0, 0.01f);
        img.raycastTarget = true;
        var rect = panelImage.GetComponent<RectTransform>();
        rect.anchorMin = Vector2.zero;
        rect.anchorMax = Vector2.one;
        rect.offsetMin = Vector2.zero;
        rect.offsetMax = Vector2.zero;
    }

    private void CreateNavToolbar()
    {
        if (!eventSystemCreated && FindObjectOfType<EventSystem>() == null)
        {
            var es = new GameObject("EventSystem");
            es.AddComponent<EventSystem>();
#if ENABLE_INPUT_SYSTEM
            es.AddComponent<UnityEngine.InputSystem.UI.InputSystemUIInputModule>();
#else
            es.AddComponent<StandaloneInputModule>();
#endif
            es.transform.SetParent(transform);
            eventSystemCreated = true;
        }

        if (navToolbar != null) Destroy(navToolbar);

        navToolbar = new GameObject("WebViewNavToolbar");
        navToolbar.transform.SetParent(transform);
        var canvas = navToolbar.AddComponent<Canvas>();
        canvas.renderMode = RenderMode.ScreenSpaceOverlay;
        canvas.sortingOrder = 30001;
        var scaler = navToolbar.AddComponent<CanvasScaler>();
        scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
        scaler.referenceResolution = new Vector2(1080, 1920);
        navToolbar.AddComponent<GraphicRaycaster>();

        var bar = new GameObject("ToolbarBg");
        bar.transform.SetParent(navToolbar.transform, false);
        var barImg = bar.AddComponent<Image>();
        barImg.color = new Color(0.15f, 0.15f, 0.15f, 1f);
        var barRect = bar.GetComponent<RectTransform>();
        barRect.anchorMin = new Vector2(0, 0);
        barRect.anchorMax = new Vector2(1, 0);
        barRect.pivot = new Vector2(0.5f, 0);
        barRect.sizeDelta = new Vector2(0, BOTTOM_MARGIN);

        var layout = bar.AddComponent<HorizontalLayoutGroup>();
        layout.childAlignment = TextAnchor.MiddleCenter;
        layout.spacing = 40;
        layout.padding = new RectOffset(20, 20, 10, 10);
        layout.childControlWidth = true;
        layout.childControlHeight = true;
        layout.childForceExpandWidth = true;
        layout.childForceExpandHeight = false;

        if (iconBack == null)    iconBack    = LoadIcon("arrow-left");
        if (iconForward == null) iconForward = LoadIcon("arrow-right");
        if (iconRefresh == null) iconRefresh = LoadIcon("refresh");
        if (iconStop == null)    iconStop    = LoadIcon("stop");
        if (iconClose == null)   iconClose   = LoadIcon("close");

        CreateNavButton(bar.transform, "Back", iconBack, () =>
        {
            if (webViewObject != null) webViewObject.GoBack();
        });
        CreateNavButton(bar.transform, "Forward", iconForward, () =>
        {
            if (webViewObject != null) webViewObject.GoForward();
        });
        CreateNavButton(bar.transform, "Refresh", iconRefresh, () =>
        {
            if (webViewObject != null) webViewObject.EvaluateJS("location.reload()");
        });
        CreateNavButton(bar.transform, "Stop", iconStop, () =>
        {
            if (webViewObject != null) webViewObject.EvaluateJS("window.stop()");
        });
        CreateNavButton(bar.transform, "Close", iconClose, () =>
        {
            Cleanup();
        }, true);
    }

    private void CreateNavButton(Transform parent, string name, Sprite icon, UnityEngine.Events.UnityAction action, bool isClose = false)
    {
        var btnGo = new GameObject("NavBtn_" + name);
        btnGo.transform.SetParent(parent, false);

        var bgImg = btnGo.AddComponent<Image>();
        bgImg.color = isClose ? new Color(0.7f, 0.15f, 0.15f, 1f) : new Color(0.25f, 0.25f, 0.25f, 1f);

        var btn = btnGo.AddComponent<Button>();
        var colors = btn.colors;
        colors.highlightedColor = isClose ? new Color(0.85f, 0.2f, 0.2f, 1f) : new Color(0.4f, 0.4f, 0.4f, 1f);
        colors.pressedColor = isClose ? new Color(0.5f, 0.1f, 0.1f, 1f) : new Color(0.5f, 0.5f, 0.5f, 1f);
        btn.colors = colors;
        btn.onClick.AddListener(action);

        var le = btnGo.AddComponent<LayoutElement>();
        le.preferredHeight = 80;

        var iconGo = new GameObject("Icon");
        iconGo.transform.SetParent(btnGo.transform, false);
        var iconImg = iconGo.AddComponent<Image>();
        if (icon != null)
        {
            iconImg.sprite = icon;
            iconImg.preserveAspect = true;
        }
        iconImg.raycastTarget = false;
        var iconRect = iconGo.GetComponent<RectTransform>();
        iconRect.anchorMin = new Vector2(0.2f, 0.15f);
        iconRect.anchorMax = new Vector2(0.8f, 0.85f);
        iconRect.offsetMin = Vector2.zero;
        iconRect.offsetMax = Vector2.zero;
    }

    private static bool IsStoreUrl(string url)
    {
        string lower = url.ToLowerInvariant();
        return lower.Contains("play.google.com/store") ||
               lower.Contains("market://") ||
               lower.Contains("itunes.apple.com") ||
               lower.Contains("apps.apple.com") ||
               lower.StartsWith("itms-apps://") ||
               lower.StartsWith("itms://");
    }

    // Icons are loaded from Resources and intentionally kept alive for the
    // lifetime of the app — Resources.UnloadUnusedAssets() handles cleanup.
    private static Sprite LoadIcon(string name)
    {
        var sprite = Resources.Load<Sprite>($"InAppBrowser/{name}");
        if (sprite == null)
            Debug.LogWarning($"[InAppBrowser] Icon not found: Resources/InAppBrowser/{name}");
        return sprite;
    }

    private void Cleanup()
    {
        isLoading = false;

        if (webViewObject != null)
        {
            webViewObject.SetVisibility(false);
            Destroy(webViewObject.gameObject);
            webViewObject = null;
        }

        if (navToolbar != null)
        {
            Destroy(navToolbar);
            navToolbar = null;
        }

        if (blockingPanel != null)
        {
            StartCoroutine(DestroyBlockingPanelDelayed());
        }
    }

    private IEnumerator DestroyBlockingPanelDelayed()
    {
        var panelToDestroy = blockingPanel;
        blockingPanel = null;
        // Wait 3 frames so the closing tap is fully consumed before the panel is removed
        yield return null;
        yield return null;
        yield return null;
        if (panelToDestroy != null)
            Destroy(panelToDestroy);
    }

    private void OnDestroy()
    {
        Cleanup();
        if (instance == this) instance = null;
    }
}
```

---

## See Also

- [Offer Anatomy (Markdown)](https://momoney.co/llms/anatomy.md) — complete field reference and display guide
- Contact: hello@momoney.co
