# Flutter SDK

> Show the offerwall in your Flutter app. Your server creates a short, single-use session, and the plugin opens the offerwall with it, so no key ever ships inside your app.

Source: https://docs.moonetix.com/flutter-sdk

## How it works

1. When the user taps your Earn button, your app asks your own server for a session.
2. Your server calls `POST /v1/session` with your API key and the user’s ID, and passes the token to the app.
3. The app calls `Moonetix.open(token)`. The plugin opens the offerwall in a native screen.
4. Rewards reach your server through your postback, exactly as with the offerwall link. See [Postbacks](https://docs.moonetix.com/postbacks).

A token works once and for 10 minutes. Create a new one each time the user opens the offerwall. Your API key and your link hash salt stay on your server, so nothing in your app can be used to open another user’s offerwall.

| Platform | Status |
| --- | --- |
| Android 5.0 (API 21) or newer | Supported. The plugin uses the [Android SDK](https://docs.moonetix.com/android-sdk) inside. |
| iOS 13 or newer | Supported. The plugin uses the [iOS SDK](https://docs.moonetix.com/ios-sdk) inside. |

## Before you start

- An approved publisher account and an app. Every app you add is live right away.
- Two values from your dashboard, under Apps, your app, Integration. The **App ID** goes into your app, for `Moonetix.init`. The **API key** stays on your server, for `POST /v1/session`. They are different values: never put the API key in your app.
- Your postback URL in the Postback tab, so rewards reach your server. See [Postbacks](https://docs.moonetix.com/postbacks).
- Your currency in the Currency tab, so users see the right rewards. See [Rewards and currency](https://docs.moonetix.com/rewards).

## Install

Flutter 3.27 or newer. The plugin is in the Moonetix package repository. Add it to your `pubspec.yaml` and run `flutter pub get`:

```
dependencies:
  moonetix_offerwall:
    hosted: https://moonetix.com/pub
    version: ^2.0.0
```

On iOS there is nothing to add: the plugin brings the Moonetix iOS SDK with it, for CocoaPods and for Swift Package Manager. Build with Xcode 16 or newer. For the App Store, see [App Store privacy details](https://docs.moonetix.com/ios-sdk#privacy).

On Android there is nothing else to add: the plugin brings the Moonetix Maven repository, the Android SDK and its R8 rules with it. If your `android/settings.gradle` blocks project repositories (`repositoriesMode`), add `maven { url = uri("https://moonetix.com/maven") }` to its `dependencyResolutionManagement` repositories.

## Create a session on your server

`POST https://api.moonetix.com/v1/session`

Send the fields as a form or as JSON, with your API key in the `Authorization` header. This is the same request as for the [Android SDK](https://docs.moonetix.com/android-sdk#session).

| Field | Description |
| --- | --- |
| `user_id` (required, string) | The same user ID you receive in postbacks. 1 to 100 characters: letters, numbers, and `. _ @ : + -`. |
| `sub1` to `sub5` (optional, string) | Your own tracking values. They are returned in the postback as `{sub1}` to `{sub5}`. |

Your app does not call this itself, because it needs the API key. Add one endpoint to your own server that checks who is signed in, creates the session for that user, and returns only the token. The [token endpoint example](https://docs.moonetix.com/android-sdk#token-endpoint) on the Android page works for Flutter too, in PHP, Node.js, and Python.

Errors: 400 `invalid_user_id`, 401 `invalid_api_key`, 403 `app_not_live` or `user_blocked` (do not show the offerwall), 405 `method_not_allowed`, 429 `rate_limited` (retry after the time in the `Retry-After` header).

## Open the offerwall

Call `init` once with the App ID from the Integration tab. Then open the offerwall with a fresh token from your server.

```
import 'package:moonetix_offerwall/moonetix_offerwall.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  Moonetix.init('YOUR_APP_ID');
  runApp(const MyApp());
}

// When the user taps Earn, with a token from your server:
await Moonetix.open(token);
```

`open` never throws for offerwall problems. They arrive as events, see [Events](https://docs.moonetix.com/flutter-sdk#events).

## Full example

The whole app side: a tap on Earn asks your server for a token, and the offerwall opens with it. Call `Moonetix.init` in `main()` as shown above. The token request uses the `http` package: add it with `flutter pub add http`.

Ask your token endpoint for a token. Replace the URL with yours, and send your own sign-in credential so your server knows who the user is:

```dart
import 'dart:convert';

import 'package:http/http.dart' as http;

// Asks YOUR server for a fresh token. Your server calls POST /v1/session with the API key.
Future<String> fetchMoonetixToken(String authToken) async {
  final response = await http
      .post(
        Uri.parse('https://your-server.example/api/moonetix/session'),
        headers: {'Authorization': 'Bearer $authToken'},
      )
      .timeout(const Duration(seconds: 10));
  if (response.statusCode != 200) {
    throw Exception('Token request failed: HTTP ${response.statusCode}');
  }
  return (jsonDecode(response.body) as Map<String, dynamic>)['token'] as String;
}
```

A button that listens to the events and opens the offerwall:

```dart
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:moonetix_offerwall/moonetix_offerwall.dart';

import 'moonetix_token.dart';

class EarnButton extends StatefulWidget {
  const EarnButton({super.key, required this.authToken});

  // Your own sign-in credential, so your server knows who the user is.
  final String authToken;

  @override
  State<EarnButton> createState() => _EarnButtonState();
}

class _EarnButtonState extends State<EarnButton> {
  late final StreamSubscription<MoonetixEvent> _events;

  @override
  void initState() {
    super.initState();
    _events = Moonetix.events.listen((MoonetixEvent event) {
      switch (event) {
        case MoonetixOpened():
          break;
        case MoonetixClosed():
          refreshBalance();
        case MoonetixErrorEvent(:final MoonetixError error):
          debugPrint('Moonetix: ${error.code.name}: ${error.message}');
      }
    });
  }

  @override
  void dispose() {
    _events.cancel();
    super.dispose();
  }

  Future<void> _openOfferwall() async {
    final String token;
    try {
      token = await fetchMoonetixToken(widget.authToken);
    } catch (_) {
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('The offerwall is not available right now.')),
      );
      return;
    }
    await Moonetix.open(token);
  }

  // Load the user's balance from your server. Rewards arrive there by postback.
  void refreshBalance() {}

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: _openOfferwall,
      child: const Text('Earn coins'),
    );
  }
}
```

The widget stays on screen under the offerwall, so its listener still gets `MoonetixClosed` when the user comes back.

## Branding

The top bar of the offerwall screen shows the Moonetix mark, like the offerwall itself, with your offerwall title and colors from the Design tab of your dashboard. To set the title or color yourself, or to hide the mark, pass options:

```
await Moonetix.open(
  token,
  options: const MoonetixOptions(
    title: 'Earn coins',
    toolbarColor: Color(0xFF004ABB),
    showLogo: false,
  ),
);
```

## Events

```
Moonetix.events.listen((MoonetixEvent event) {
  switch (event) {
    case MoonetixOpened():
      break;
    case MoonetixClosed():
      refreshBalance();
    case MoonetixErrorEvent(:final MoonetixError error):
      debugPrint('${error.code.name}: ${error.message}');
  }
});
```

`Moonetix.events` is a broadcast stream that does not keep events for later, so start listening before you call `open`. Events are for your user interface only: credit rewards from the postback, never from an event.

Prefer callbacks? `Moonetix.setListener(MoonetixListener(onOpened: ..., onClosed: ..., onError: ...))` works like the Android listener.

| Event | When |
| --- | --- |
| `MoonetixOpened` | The offerwall finished loading. |
| `MoonetixClosed` | The user closed the offerwall. A good moment to load the user’s balance from your server. |
| `MoonetixErrorEvent` | `notInitialized`, `invalidAppId`, `invalidToken`, `tokenExpiredOrUsed`, `network`, `unsupported`, or `unknown`. Create a new session and try again. |

## App Store and Google Play privacy

The offerwall collects the data below, as described in our [privacy policy](https://moonetix.com/privacy). The iOS SDK inside the plugin has a privacy manifest that lists it. In App Store Connect, under App Privacy, and in Google Play’s Data safety form, add these data types for your app:

| Data type | Linked to the user | Used for tracking | Purpose |
| --- | --- | --- | --- |
| User ID | Yes | No | App Functionality |
| Device ID | Yes | No | App Functionality |
| Coarse Location | Yes | No | App Functionality |
| Product Interaction | Yes | No | App Functionality |

The user ID is the pseudonymous ID your server sends with the session, the device ID is used for fraud prevention, the coarse location comes from the IP address, and product interaction is the offers a user views, starts, and completes. The plugin does not track users across apps and does not use the advertising identifier. In Google Play’s Data safety form, the same data types are called User IDs, Device or other IDs, Approximate location, and App interactions.

## Security

- Create sessions on your server only. Never put the API key or the link hash salt in your app, because anyone can read an app’s files.
- Credit rewards only from the signed postback on your server, never from events in the app. Events can be faked on a user’s device.
- Make sure your own endpoint that returns tokens only does so for the signed-in user.

## Testing

Before you launch, create a session from your server for a test user ID and open it in a debug build of your app on an Android phone, an iPhone, or the iOS Simulator. Check that the offerwall opens, that an offer opens in the store or the browser, and that the back and close buttons work. Then send test postbacks from Integration status on the Integration tab of your app to check your server. Test on a normal home or mobile connection: on a VPN or a test device in the cloud, the offerwall shows a block page such as VPN detected instead of offers. See [Testing](https://docs.moonetix.com/testing).

Clicks that come through the plugin show as `sdk` in the `{integration}` postback macro and in the [Reporting API](https://docs.moonetix.com/reporting-api), and as Mobile SDK in your dashboard, like the Android SDK.

## Troubleshooting

| What you see | Cause and fix |
| --- | --- |
| `invalidAppId` | The value in `Moonetix.init` is not the App ID. It must be the 24-character lowercase hex value from the Integration tab. Passing the API key is a common mistake. **Fix:** Copy the App ID again from the Integration tab. |
| `notInitialized` | `open` was called before `init`. **Fix:** Call `Moonetix.init` in `main()`. |
| `unsupported` | The app runs on a platform other than Android or iOS, for example web or desktop, or the native part of the plugin is not in the build yet. **Fix:** Run on an Android or iOS device. After adding the plugin, stop the app and build it again: a hot reload or hot restart does not add native code. |
| `invalidToken` right after `open` | The value is not the token, for example the whole JSON answer or an empty string. **Fix:** Pass only the `token` field, which starts with `sst_`. |
| `invalidToken` after the screen opened | We do not know the token. Usually it was created with the API key of another app than the App ID in `init`. **Fix:** Use the App ID and the API key of the same app. |
| `tokenExpiredOrUsed`, or a Session ended page | The token is older than 10 minutes or was used already, for example because the app opened it twice or kept it from an earlier tap. **Fix:** Create a new token for every `open`, right when the user taps Earn. |
| `network`, and a No connection page with a Try again button | The device could not reach the offerwall. **Fix:** The user can tap Try again once the connection is back. |
| Your server gets 403 from `/v1/session` | `app_not_live`: the app is paused or your account is not active. `user_blocked`: we blocked this user. **Fix:** Do not show the offerwall. For `app_not_live`, check your notifications. |
| VPN detected, Proxy detected, or Network not supported | The device is on a VPN, a proxy, or a cloud network. **Fix:** Test on a normal home or mobile connection. See [Test from a normal connection](https://docs.moonetix.com/testing#vpn). |
| No events arrive | The app started listening after it called `open`, or the subscription was cancelled. **Fix:** Listen to `Moonetix.events` before you call `open`, from a widget or object that stays alive. |

For problems on one platform only, the [Android](https://docs.moonetix.com/android-sdk#troubleshooting) and [iOS](https://docs.moonetix.com/ios-sdk#troubleshooting) pages list what the native SDKs report.

## Changelog

- **2.0.0**: the SDK moves to the Moonetix name: `moonetix_offerwall`, class `Moonetix`. The wall opens on `wall.moonetix.com`. The old package stays published.
- **1.1.0**: iOS support. The offerwall opens on iOS 13 or newer with the native Moonetix iOS SDK 1.0.1, which the plugin brings with it. Same API, options, and events as on Android.
- **1.0.0**: first release, for Android. `Moonetix.init`, `Moonetix.open` with `MoonetixOptions`, the `Moonetix.events` stream and `Moonetix.setListener`, with typed error codes. It uses the Android SDK 1.1.0.

## License

The plugin is free to use under the Apache License 2.0. The license file is in the package.

## Quick reference

### pubspec.yaml

```
dependencies:
  moonetix_offerwall:
    hosted: https://moonetix.com/pub
    version: ^2.0.0
```

### Create a session

`POST api.moonetix.com/v1/session`

```bash
curl -X POST "https://api.moonetix.com/v1/session" \
  -H "Authorization: Bearer $MOONETIX_API_KEY" \
  -d "user_id=user_1234"
```

```php
$ch = curl_init('https://api.moonetix.com/v1/session');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => http_build_query(['user_id' => $userId]),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 10,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);
$token = json_decode(curl_exec($ch), true)['token'] ?? null;
```

```javascript
const res = 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: userId }),
});
const { token } = await res.json();
```

Your API key is in your dashboard under Apps, your app, Integration. Up to 600 requests per minute for each app.

Questions? [partners@moonetix.com](mailto:partners@moonetix.com)
