Mobile SDK: show Adfacten ads in your app
The Adfacten mobile SDK shows ads in Android and iOS apps. It requests the ad, renders it, measures impressions and clicks, and for rewarded ads has Adfacten notify your server of the reward. This page is for publishers (app developers). Advertisers who want to measure app installs should read MMP integration.
Türkçe sürüm: Mobil SDK.
Source code and samples: Android (
github.com/adfacten/adfacten-android-sdk), iOS (github.com/adfacten/adfacten-ios-sdk), Flutter (github.com/adfacten/adfacten_ads_flutter), React Native (github.com/adfacten/adfacten-react-native-ads) and Unity (github.com/adfacten/adfacten-unity-ads). The panel also shows a ready-made code sample for every placement.
On this page:
- Overview: formats and billing
- Getting started in the panel: app, app-ads.txt, approval, going live
- Android
- iOS
- Flutter, React Native and Unity
- Rewarded ads: server-side verification
- Privacy and consent (TCF, GPP, COPPA, KVKK)
- Direct REST (custom integrations / mediation)
- Troubleshooting
1. Overview
| Format | When to use it |
|---|---|
| Banner | A persistent ad in part of the screen. Sizes: 320x50, 320x100, 300x250, 728x90 (tablets only). Refreshes every 30–120 s while visible (placement setting; 0 = no refresh) and pauses when hidden. |
| Interstitial | Full screen, at natural breaks (end of a level, screen transitions). |
| Rewarded | The user watches the ad to the end and earns a reward (e.g. 10 coins). The reward type and amount are set on the placement. |
| Native | The ad's assets (title, text, image, icon, button) are laid out in your own design. |
| App open | A full-screen ad shown at launch or when the app returns from the background. |
How billing is counted
- Impression (CPM): counted when the ad is actually rendered on screen (
render). An ad that is loaded but never shown is not counted. - Viewable impression (vCPM): counted when at least 50% of the ad is on screen for at least 1 second (2 seconds for video).
- Click (CPC): counted when the user taps the ad.
- Rewarded / video completion: counted when the video is watched to the end (
complete); the reward depends on it. - Test ads are never billed.
The SDK sends each event once. Offline, it queues events and retries them for up to 24 hours.
2. Getting started in the panel
2.1 Add your app
Panel → Apps → Add app.
- Pick the platform (Android or iOS) and enter the store URL:
- Google Play:
https://play.google.com/store/apps/details?id=com.company.app - App Store:
https://apps.apple.com/us/app/…/id123456789
- Google Play:
- If the app is not in the store yet, enter its bundle id (package name), e.g.
com.company.app. You can start integrating right away; approval requires the app to be live in the store. - Store details (name, icon, developer, developer website) are filled in automatically.
- Each app gets an app key (
apk_…). It is not a secret: it is embedded in the app and identifies it. - An app (platform + bundle id) can be registered only once. If the app is yours and appears under another account, contact support.
Then add placements to the app (one per ad slot). Each placement has an id (plc_…) and exactly one
format. For banners you choose the size(s) and the refresh interval; for rewarded placements the reward
type and amount. You can also set a daily per-device impression cap.
2.2 Child-directed apps are not supported
Adfacten ads are not served in child-directed apps (US COPPA and the Google Play Families policy). An
app marked child-directed cannot be added; if it is marked later, every ad request is refused (coppa).
Only the network team can remove that flag.
2.3 Test mode and test ads
New apps start in test mode. Test ads behave like real ones (render, viewability and click events
work; the click goes to https://adfacten.com/) but are never billed and are labelled "Test ad".
The server returns test ads when any of these is true:
| Condition | How to clear it |
|---|---|
| The app is pending review | Network approval (2.5) |
| The app-ads.txt line was not found | Add the line (2.4) |
| The app's test mode is on | Switch it off in the panel (2.6) |
testMode is on in the SDK (test=true in the request) |
Release builds must have testMode off |
| The device is an emulator / simulator | Test on a real device |
Test creatives are fixed: banner = image of the requested size, interstitial = HTML, rewarded = 5-second video with reward, native = sample assets, app open = 320x480 image.
2.4 The app-ads.txt line
app-ads.txt proves that the app is yours and that Adfacten is authorised to sell its inventory (IAB app-ads.txt standard). Real ads serve once the line is verified.
Where: at the root of the developer website listed on the app's store page:
https://<developer domain>/app-ads.txt. A leading www., m. or mobile. is ignored
(https://www.company.com → https://company.com/app-ads.txt). HTTPS is tried first, then HTTP.
The line (shown ready to copy on your app's page in the panel):
adfacten.com, <your publisher id>, DIRECT
<your publisher id>is your seller id; it is theseller_idin sellers.json and never changes.- The file must be plain text (
text/plain). An HTML page (a "not found" page or the home page) does not count as the file. - Lines of other networks, comments (
#) and variables such ascontact=are fine.
Verification: the file is checked as soon as you add the app, then re-checked about every 6 hours. In the panel, Check again runs a check at most once a minute.
| Status | Meaning |
|---|---|
found |
The line is there. Real ads can serve (if approved and test mode is off). |
wrong_id |
An adfacten.com line exists, but with someone else's publisher id (copied from elsewhere). |
no_line |
The file exists but has no adfacten.com line. |
missing |
No app-ads.txt on the website (404, or an HTML page instead of the file). |
no_website |
The store page lists no developer website. Add it in the store console. |
unreachable |
The website did not answer. Retried later; the last good result is kept. |
unknown |
Not checked yet (e.g. the app was not found in the store). |
If you changed the developer website in the store listing, the store may take a while to publish it. Then press Check again.
2.5 Network approval
Your app goes into the Adfacten network team's review queue. Approval requires the app to be live in the
store. You are notified when it is approved or rejected; the rejection reason is shown on the app's page.
Rejected apps receive no ads (app_not_approved).
2.6 Going live
Real ads serve only when all four hold:
- the app is approved,
- the app-ads.txt status is
found, - the app's test mode is off in the panel,
- the SDK's
testModeis off in your release build.
The Serving status on the app's page in the panel shows what is missing (live, test or
blocked, with the reason).
2.7 sellers.json
Adfacten's list of authorised sellers: https://adfacten.com/sellers.json
(IAB sellers.json). Publishers with at least one approved app are listed. Companies appear with their name
and domain; individuals are listed as confidential (is_confidential) under Turkish data-protection law
(KVKK), so your name is not published.
3. Android
Requirements: Android 5.0 (API 21) or later. Package: com.adfacten.ads. Source code and sample app:
https://github.com/adfacten/adfacten-android-sdk (Apache 2.0).
3.1 Installation
settings.gradle.kts:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven("https://cdn.adfacten.com/maven/") {
content { includeGroup("com.adfacten") }
}
}
}
The app module's build.gradle.kts:
dependencies {
implementation("com.adfacten:ads:1.0.1")
// Optional: advertising id (GAID) and App Set ID
implementation("com.adfacten:ads-gaid:1.0.1")
// Optional: video ads (Media3 ExoPlayer). Recommended if you use rewarded ads.
implementation("com.adfacten:ads-video:1.0.1")
}
- The core artifact (
ads) has no Google Play services dependency; it only adds theINTERNETandACCESS_NETWORK_STATEpermissions. - With
ads-gaidthe advertising id is read and thecom.google.android.gms.permission.AD_IDpermission is added. Without it the SDK uses its own install id; ads still serve, but frequency capping and install attribution are less accurate. - Without
ads-video, interstitial and rewarded placements request image/HTML creatives only.
3.2 Initialize
Call once in Application.onCreate. It does no work on the main thread.
class App : Application() {
override fun onCreate() {
super.onCreate()
Adfacten.initialize(
this,
"apk_XXXXXXXXXXXXXXXXXXXXXXXX",
AdfactenConfig.Builder()
.setTestMode(BuildConfig.DEBUG) // false in release builds
.setLogLevel(LogLevel.WARN)
.setChildDirected(false)
.build()
)
}
}
When the user signs in (for rewarded ads), pass your user id:
Adfacten.setUserId("user-123") // arrives as user_id in the server-side reward callback
3.3 Consent
The SDK reads by itself the values your IAB-compliant consent tool (CMP) stores
(IABTCF_gdprApplies, IABTCF_TCString, IABTCF_PurposeConsents, IABGPP_HDR_GppString,
IABUSPrivacy_String). Nothing else to do; run your CMP before loading ads. Details: section 6.
3.4 Banner
In XML:
<com.adfacten.ads.AdfactenBannerView
android:id="@+id/banner"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:adf_placementId="plc_XXXXXXXX"
app:adf_adSize="banner" />
adf_adSize: banner (320x50), large_banner (320x100), medium_rectangle (300x250), leaderboard
(728x90, tablets).
In code:
val banner = AdfactenBannerView(context).apply {
placementId = "plc_XXXXXXXX"
adSize = AdSize.BANNER
listener = object : BannerAdListener {
override fun onLoaded() {}
override fun onFailed(error: AdError) { Log.w("ads", error.toString()) }
}
}
container.addView(banner)
banner.load()
// Activity/Fragment lifecycle:
override fun onPause() { banner.pause(); super.onPause() }
override fun onResume() { super.onResume(); banner.resume() }
override fun onDestroy() { banner.destroy(); super.onDestroy() }
While visible, the banner refreshes itself at the placement's interval.
3.5 Interstitial
private var interstitial: InterstitialAd? = null
fun loadInterstitial() {
InterstitialAd.load(context, "plc_XXXXXXXX", object : AdLoadCallback<InterstitialAd> {
override fun onLoaded(ad: InterstitialAd) { interstitial = ad }
override fun onFailed(error: AdError) { interstitial = null }
})
}
fun showInterstitial(activity: Activity) {
val ad = interstitial ?: return
if (ad.isExpired) { loadInterstitial(); return }
ad.listener = object : FullScreenAdListener {
override fun onClosed() { loadInterstitial() } // preload the next one
}
ad.show(activity)
interstitial = null // an ad can be shown only once
}
3.6 Rewarded
RewardedAd.load(context, "plc_XXXXXXXX", object : AdLoadCallback<RewardedAd> {
override fun onLoaded(ad: RewardedAd) { rewarded = ad }
override fun onFailed(error: AdError) { rewarded = null }
}, customData = "level=12") // optional, ≤ 256 chars, echoed as 'custom' in the server callback
rewarded?.show(activity) { reward ->
// UI only (e.g. "Your reward is on its way").
// Credit the user from the callback YOUR SERVER receives (section 6).
}
3.7 Native
Put your own layout inside a NativeAdView and tell it which view shows which asset:
<com.adfacten.ads.NativeAdView
android:id="@+id/native_ad"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:adf_titleView="@id/ad_title"
app:adf_bodyView="@id/ad_body"
app:adf_iconView="@id/ad_icon"
app:adf_imageView="@id/ad_image"
app:adf_callToActionView="@id/ad_cta"
app:adf_adLabelView="@id/ad_label">
<!-- your own TextView / ImageView / Button views -->
</com.adfacten.ads.NativeAdView>
NativeAd.load(context, "plc_XXXXXXXX", object : AdLoadCallback<NativeAd> {
override fun onLoaded(ad: NativeAd) { nativeAdView.setNativeAd(ad) }
override fun onFailed(error: AdError) { nativeAdView.visibility = View.GONE }
})
NativeAdView measures the impression, viewability and clicks itself. Always keep the "Ad" label
visible. Call ad.destroy() when the view goes away.
3.8 App open
AppOpenAd.load(context, "plc_XXXXXXXX", object : AdLoadCallback<AppOpenAd> {
override fun onLoaded(ad: AppOpenAd) { appOpen = ad }
override fun onFailed(error: AdError) {}
})
// When the app comes to the foreground:
appOpen?.takeIf { !it.isExpired }?.show(activity)
Show the app open ad after your splash screen on a cold start, or when the app returns from the background; do not interrupt the user mid-task.
3.9 Google Play "Data safety" form
Data the SDK collects and sends to Adfacten (combine it with your own app's data when filling in the form):
| Data | When | Purpose |
|---|---|---|
| Device or other IDs: SDK install id | Always | Advertising, fraud prevention |
| Device or other IDs: advertising id (GAID), App Set ID | Only with ads-gaid and when allowed |
Advertising, analytics |
| App interactions (ad impressions, clicks) | Always | Advertising, analytics |
User ID (userId) |
Only if you set it (rewarded) | App functionality (rewards) |
| Approximate location (country) derived from the IP address | On the server | Ad targeting, fraud prevention |
Data is encrypted in transit (HTTPS) and shared with a third party (Adfacten).
4. iOS
Requirements: iOS 15 or later. Module: AdfactenAds. No third-party dependencies; video uses AVPlayer.
4.1 Installation
The SDK is distributed as a prebuilt XCFramework. Pick one of three ways:
Source code: https://github.com/adfacten/adfacten-ios-sdk (Apache 2.0).
Swift Package Manager: Xcode → File → Add Package Dependencies… → enter
https://github.com/adfacten/adfacten-ios-sdk and add the AdfactenAds product.
// Projects with a Package.swift:
.package(url: "https://github.com/adfacten/adfacten-ios-sdk.git", from: "1.0.0")
CocoaPods:
pod 'AdfactenAds', :git => 'https://github.com/adfacten/adfacten-ios-sdk.git', :tag => '1.0.0'
Manual (XCFramework): download https://cdn.adfacten.com/ios/AdfactenAds-1.0.0.xcframework.zip, unzip it
and add it to your target as Embed & Sign. SHA-256: 6ab9883dc8d92597f2f0c7391c4b2434c15950548e7e465e16367539290b883f
To try it right away, run the sample app in the repository: it ships with the keys of Adfacten's public test app and shows (unbilled) test ads with no setup.
4.2 Initialize
Call once at launch, on the main thread:
import AdfactenAds
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let config = AdfactenConfig()
#if DEBUG
config.testMode = true // false in release builds
#endif
config.childDirected = false
Adfacten.initialize(appKey: "apk_XXXXXXXXXXXXXXXXXXXXXXXX", config: config)
return true
}
// When the user signs in (for rewarded ads):
Adfacten.setUserId("user-123")
4.3 App Tracking Transparency (ATT) and the privacy manifest
- The SDK does not request ATT permission; it only reads the current status. If you want to ask,
add the
ATTrackingManager.requestTrackingAuthorizationcall and theNSUserTrackingUsageDescriptiontext yourself. - Without permission the IDFA is not used; the SDK uses the IDFV or its own install id. Ads still serve.
- The SDK ships its own
PrivacyInfo.xcprivacy(collected data types: device id, advertising data, product interaction, user id; accessed APIs: UserDefaults, system boot time). Xcode merges it into your app's privacy report. Update your "App Privacy" answers in App Store Connect accordingly.
4.4 Banner
let banner = AdfactenBannerView(placementId: "plc_XXXXXXXX", size: .banner)
banner.rootViewController = self
banner.delegate = self // AdfactenBannerViewDelegate (optional)
view.addSubview(banner) // it knows its own size (intrinsicContentSize)
banner.load()
Sizes: .banner (320x50), .largeBanner (320x100), .mediumRectangle (300x250), .leaderboard
(728x90, tablets). In SwiftUI:
AdfactenBanner(placementId: "plc_XXXXXXXX", size: .banner)
4.5 Interstitial
var interstitial: InterstitialAd?
func loadInterstitial() {
InterstitialAd.load(placementId: "plc_XXXXXXXX") { [weak self] ad, error in
self?.interstitial = ad
}
}
func showInterstitial() {
guard let ad = interstitial, ad.isReady else { loadInterstitial(); return }
ad.onDismissed = { [weak self] in self?.loadInterstitial() }
ad.present(from: self)
interstitial = nil
}
There is an async variant too: let ad = try await InterstitialAd.load(placementId: "plc_…").
4.6 Rewarded
RewardedAd.load(placementId: "plc_XXXXXXXX", customData: "level=12") { [weak self] ad, error in
self?.rewarded = ad
}
rewarded?.present(from: self) { reward in
// UI only. Credit the user from the callback your server receives (section 6).
}
4.7 Native
In your NativeAdView subclass (in code or Interface Builder) connect the outlets titleLabel,
bodyLabel, brandLabel, callToActionButton, mediaView, iconView, adLabel.
NativeAd.load(placementId: "plc_XXXXXXXX") { [weak self] ad, error in
self?.nativeAdView.nativeAd = ad // binds the assets, measures impression and clicks
}
Set nativeAdView.nativeAd = nil when a cell is reused. If you connect no adLabel, the SDK adds its
own "Ad" badge.
4.8 App open
AppOpenAd.load(placementId: "plc_XXXXXXXX") { [weak self] ad, error in
self?.appOpen = ad
}
// When the app comes to the foreground:
if let ad = appOpen, ad.isReady { ad.present() }
5. Flutter, React Native and Unity
Official packages built on the Android and iOS SDKs; formats, test mode and reward callbacks work as described above.
5.1 Flutter
Source code and sample app: https://github.com/adfacten/adfacten_ads_flutter (Apache 2.0).
pubspec.yaml:
dependencies:
adfacten_ads:
git:
url: https://github.com/adfacten/adfacten_ads_flutter
ref: 1.0.1
await Adfacten.initialize('apk_XXXXXXXXXXXXXXXXXXXXXXXX');
AdfactenBanner(placementId: 'plc_XXXXXXXX', size: AdSize.banner);
final ad = await RewardedAd.load('plc_XXXXXXXX');
await ad.show(onUserEarnedReward: (reward) => grant(reward));
Native ads are drawn with the SDK's templates (small row, medium card); that is how impressions and clicks are counted correctly. See the package README for details.
5.2 React Native and Expo
Source code and sample app: https://github.com/adfacten/adfacten-react-native-ads (Apache 2.0).
React Native 0.80+ (New Architecture).
npm install github:adfacten/adfacten-react-native-ads#1.0.1
cd ios && pod install
import { Adfacten, AdfactenBanner, RewardedAd } from '@adfacten/react-native-ads';
Adfacten.initialize('apk_XXXXXXXXXXXXXXXXXXXXXXXX');
<AdfactenBanner placementId="plc_XXXXXXXX" size="banner" />;
Expo: add "plugins": ["@adfacten/react-native-ads"] to app.json and use npx expo prebuild or a dev
client. Expo Go can't load native modules, so it is not supported.
Both sample apps ship with the keys of Adfacten's public test app and show (unbilled) test ads with no setup.
5.3 Unity
Source code and demo scene: https://github.com/adfacten/adfacten-unity-ads (Apache 2.0). Unity 2021.3 LTS or
later. In Window › Package Manager › + › Add package from git URL, enter:
https://github.com/adfacten/adfacten-unity-ads.git#1.0.0
Android and iOS dependencies are installed by External Dependency Manager (EDM4U); enter your app keys in Project Settings › Adfacten.
Adfacten.Initialize("apk_XXXXXXXXXXXXXXXXXXXXXXXX");
var banner = new BannerView("plc_XXXXXXXX", AdSize.Banner, AdPosition.Bottom);
banner.Load();
RewardedAd.Load("plc_XXXXXXXX", (ad, error) => {
if (ad != null) ad.Show(reward => { /* UI only; credit the user from your server's callback */ });
});
In the Editor you get placeholder ads, so you can build the game without a device.
6. Rewarded ads: server-side verification
Credit rewards only from the Adfacten callback your server receives. The in-app
onUserEarnedReward callback is for UI only; a tampered app can fire it at will.
6.1 Setup
Panel → Apps → your app → Reward callback:
- Callback URL: an endpoint on your server, e.g.
https://api.yourgame.com/adfacten/reward. It must behttpsin production. It may carry your own query parameters (e.g.?env=prod) but notsig. Callbacks are never sent to private-network addresses. - Secret (
rws_…): created when you first enter a URL. It is shown only once in the panel; store it in a secret environment variable on your server. Never put it in the app.
6.2 The callback
When a user validly completes a rewarded ad, Adfacten sends a GET request to your server:
GET https://api.yourgame.com/adfacten/reward?env=prod&user_id=user-123&custom=level%3D12&reward_type=coins&reward_amount=10&transaction_id=imp_…&placement=plc_XXXXXXXX&ts=1794160000&sig=5f1c…e9
| Parameter | Value |
|---|---|
| (your parameters) | Whatever your callback URL contains, first |
user_id |
The id set with Adfacten.setUserId / userId (max 128 chars; empty if none) |
custom |
The customData passed when loading the ad (max 256 chars; empty if none) |
reward_type |
The placement's reward type, e.g. coins |
reward_amount |
The placement's reward amount (integer) |
transaction_id |
Unique id of this impression. One per reward. |
placement |
Placement id (plc_…) |
ts |
When the reward was earned (Unix seconds). Unchanged on retries. |
test |
Test ads only: 1 |
sig |
Signature, always last |
Parameters arrive in this order and values are percent-encoded (e.g. a space is %20).
6.3 Verifying the signature
sig = hex( HMAC-SHA256( secret, raw_query ) )
raw_query is the query string exactly as received: everything after ?, minus the trailing
&sig=…. Your own parameters (if any) and test=1 (if present) are covered by the signature.
- Use the query without decoding or reordering it. Do not rebuild it from your framework's parsed parameters; take the raw string.
- Compare in constant time.
- Only after the signature is valid, parse and use the parameters.
Node.js (Express)
import crypto from "node:crypto";
const SECRETS = [process.env.ADFACTEN_REWARD_SECRET, process.env.ADFACTEN_REWARD_SECRET_OLD].filter(Boolean);
function verifyAdfacten(rawQuery) {
const i = rawQuery.lastIndexOf("&sig=");
if (i < 0) return false;
const signed = rawQuery.slice(0, i);
const sig = rawQuery.slice(i + 5);
if (!/^[0-9a-f]{64}$/.test(sig)) return false;
const given = Buffer.from(sig, "hex");
return SECRETS.some((s) =>
crypto.timingSafeEqual(crypto.createHmac("sha256", s).update(signed, "utf8").digest(), given));
}
app.get("/adfacten/reward", async (req, res) => {
const url = req.originalUrl;
const raw = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
if (!verifyAdfacten(raw)) return res.sendStatus(403);
const q = new URLSearchParams(raw);
await grantOnce(q.get("transaction_id"), q.get("user_id"), q.get("reward_type"),
Number(q.get("reward_amount")), q.get("test") === "1");
res.sendStatus(200);
});
Python (FastAPI / Starlette)
import hashlib, hmac, os, re
from urllib.parse import parse_qs
from fastapi import FastAPI, Request, Response
SECRETS = [s for s in (os.environ.get("ADFACTEN_REWARD_SECRET"), os.environ.get("ADFACTEN_REWARD_SECRET_OLD")) if s]
def verify_adfacten(raw_query: str) -> bool:
i = raw_query.rfind("&sig=")
if i < 0:
return False
signed, sig = raw_query[:i], raw_query[i + 5:]
if not re.fullmatch(r"[0-9a-f]{64}", sig):
return False
return any(
hmac.compare_digest(hmac.new(s.encode(), signed.encode(), hashlib.sha256).hexdigest(), sig)
for s in SECRETS
)
app = FastAPI()
@app.get("/adfacten/reward")
async def reward(request: Request) -> Response:
raw = request.scope["query_string"].decode("latin-1") # Flask: request.query_string.decode()
if not verify_adfacten(raw):
return Response(status_code=403)
q = {k: v[0] for k, v in parse_qs(raw, keep_blank_values=True).items()}
await grant_once(q["transaction_id"], q["user_id"], q["reward_type"], int(q["reward_amount"]), q.get("test") == "1")
return Response(status_code=200)
PHP
<?php
function verify_adfacten(string $raw, array $secrets): bool {
$i = strrpos($raw, '&sig=');
if ($i === false) return false;
$signed = substr($raw, 0, $i);
$sig = substr($raw, $i + 5);
if (!preg_match('/^[0-9a-f]{64}$/', $sig)) return false;
foreach ($secrets as $s) {
if (hash_equals(hash_hmac('sha256', $signed, $s), $sig)) return true;
}
return false;
}
$uri = $_SERVER['REQUEST_URI']; // raw URI (QUERY_STRING may have been rewritten)
$raw = ($p = strpos($uri, '?')) === false ? '' : substr($uri, $p + 1);
$secrets = array_filter([getenv('ADFACTEN_REWARD_SECRET'), getenv('ADFACTEN_REWARD_SECRET_OLD')]);
if (!verify_adfacten($raw, $secrets)) { http_response_code(403); exit; }
parse_str($raw, $q);
grant_once($q['transaction_id'], $q['user_id'], $q['reward_type'], (int)$q['reward_amount'], ($q['test'] ?? '') === '1');
http_response_code(200);
Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"net/url"
"os"
"strings"
)
var secrets = nonEmpty(os.Getenv("ADFACTEN_REWARD_SECRET"), os.Getenv("ADFACTEN_REWARD_SECRET_OLD"))
func verifyAdfacten(raw string) bool {
i := strings.LastIndex(raw, "&sig=")
if i < 0 {
return false
}
signed, given := raw[:i], raw[i+5:]
want, err := hex.DecodeString(given)
if err != nil || len(want) != sha256.Size {
return false
}
for _, s := range secrets {
m := hmac.New(sha256.New, []byte(s))
m.Write([]byte(signed))
if hmac.Equal(m.Sum(nil), want) {
return true
}
}
return false
}
func rewardHandler(w http.ResponseWriter, r *http.Request) {
raw := r.URL.RawQuery
if !verifyAdfacten(raw) {
w.WriteHeader(http.StatusForbidden)
return
}
q, _ := url.ParseQuery(raw)
if err := grantOnce(r.Context(), q.Get("transaction_id"), q.Get("user_id"), q.Get("reward_type"),
q.Get("reward_amount"), q.Get("test") == "1"); err != nil {
w.WriteHeader(http.StatusInternalServerError) // transient error: Adfacten retries
return
}
w.WriteHeader(http.StatusOK)
}
6.4 Never grant the same reward twice
- A callback may arrive more than once (network errors, timeouts, manual resends).
transaction_idis unique per reward. Put a unique constraint on it in your database and write the reward in the same transaction. If an already-processedtransaction_idarrives, return 200 without granting again.tsdoes not change on retries, and callbacks can arrive up to 72 hours late. Do not reject on a short time window; catch duplicates bytransaction_id.- Optionally check that
placementandreward_typeare values you expect.
6.5 Responses, retries, log
- Any 2xx response counts as delivered; the body is ignored. The timeout is 10 seconds.
- On a non-2xx response or a timeout the callback is retried after about 1 min, 5 min, 15 min, 1 h, 3 h, 6 h, 12 h, 24 h, 24 h (10 attempts, about 72 hours in total). After that it is marked dead.
- The app's reward callback log in the panel shows every attempt (status code, error, next attempt);
you can resend a callback from there (with the same
transaction_id). - If the signature is invalid, return 403; fix your secret and resend from the log.
6.6 Test ads
Reward callbacks for test ads carry test=1. They are for testing your integration: do not grant real
value for them (or grant it only in a test environment).
6.7 Rotating the secret
Panel → Reward callback → New secret. The new secret takes effect immediately and is shown once; pending retries are signed with the new secret too. For a seamless switch:
- Get the new secret and add it to your server alongside the old one (
…_OLDin the examples above). - Remove the old secret a few hours later.
Callbacks whose signature fails during the switch are not lost; they are retried.
7. Privacy and consent
| Situation | What the SDK / server does |
|---|---|
GDPR / TCF: the CMP stored IABTCF_gdprApplies=1 |
The advertising id (GAID/IDFA) is not used; frequency and event binding use the SDK install id; device-id macros in click URLs stay empty. Without consent for Purpose 1 (store/access information on a device) the SDK does not send the advertising id or IDFV at all. |
GPP: IABGPP_HDR_GppString is set |
The advertising id is not used; no device-id macros. |
US privacy: the 3rd character of IABUSPrivacy_String is Y (opt-out of sale) |
Same: the advertising id is not used. |
| Limited ad tracking (LMT): advertising id deleted / limited on Android, no ATT permission on iOS | The advertising id is not used; ads keep serving. |
COPPA: childDirected = true or the app is child-directed in the panel |
No ads; the server answers 403 coppa. |
- The CMP must write its values to the standard IAB keys on the device (Android SharedPreferences, iOS UserDefaults). The SDK reads them on every request.
- Except for COPPA, none of these blocks ads; only identifiers are dropped. This may lower revenue somewhat.
- Children: if your app is child-directed or in the Google Play Families program, do not add the Adfacten SDK.
- KVKK (Turkey): the SDK processes data that may count as personal data (device identifiers, IP address, ad interactions). Name Adfacten as an advertising service provider in your app's privacy notice (aydınlatma metni) and privacy policy. As the publisher you provide the legal basis (explicit consent where required); ask your legal adviser if unsure.
8. Direct REST (custom integrations / mediation)
If you cannot use the SDK (your own mediation layer, a game-engine plugin…) you can call the same endpoints directly. This is a public, backward-compatible interface: fields are only added; ignore fields you do not know.
8.1 Ad request
POST https://trk.adfacten.com/m/v1/ad
Content-Type: application/json
User-Agent: AdfactenSDK/1.0.0 (android 15; SM-A546B) com.company.app/1.2.3
X-Adfacten-SDK: android/1.0.0
{
"v": 1,
"app_key": "apk_XXXXXXXXXXXXXXXXXXXXXXXX",
"placement": "plc_XXXXXXXX",
"format": "banner",
"sizes": ["320x50"],
"device": {
"os": "android", "osv": "15", "make": "Samsung", "model": "SM-A546B",
"sdk_id": "3f6c0a52-8f7e-4d1b-9a0c-1b2c3d4e5f60",
"ifa": "", "ifa_type": "sdk", "lmt": false,
"lang": "en", "w": 1080, "h": 2340, "pxratio": 2.75,
"connection": "wifi", "carrier": "", "tz_offset_min": 180
},
"app": { "bundle": "com.company.app", "version": "1.2.3", "sdk_version": "1.0.0" },
"regs": { "coppa": false, "gdpr": 0, "tcf": "", "gpp": "", "us_privacy": "" },
"user": { "id": "user-123", "custom": "level=12" },
"test": false
}
- Required fields:
app_key,placement,format,device.os,device.sdk_id,app.bundle.vdefaults to 1. device.sdk_id: a persistent per-install UUID (8–64 chars). Generate it on first launch and store it.device.ifa/ifa_type:gaid,idfa,idfv,appsetorsdk. Leaveifaempty when there is no advertising id orlmt=true.regs.coppaandregs.gdpraccepttrue/falseor0/1.useris needed for rewarded ads only.- The body is at most 16 KB; it may be sent with
Content-Encoding: gzip(at most 64 KB inflated). placementalso accepts the placement'szon_…id.
8.2 Response
204 No Content: no fill (no inventory, paused placement, or a refused request). Wait at least 30 seconds (with random jitter) before requesting the same placement again.200:
{
"ad": {
"id": "opaque, signed impression token",
"format": "banner",
"kind": "image",
"test": false,
"w": 320, "h": 50,
"image": { "url": "https://cdn.adfacten.com/….webp", "url_2x": "…", "fallback": "….jpg" },
"click_url": "https://trk.adfacten.com/a/click?imp=…",
"trackers": {
"render": "https://trk.adfacten.com/i?imp=…&e=render",
"view": "https://trk.adfacten.com/i?imp=…&e=view",
"complete": "https://trk.adfacten.com/i?imp=…&e=complete",
"close": "https://trk.adfacten.com/i?imp=…&e=close"
},
"view_rule": { "min_ratio": 0.5, "min_ms": 1000 },
"close_after_s": 0,
"reward": { "type": "coins", "amount": 10 },
"label": "Reklam",
"expires_at": 1794160000
}
}
Content by kind:
kind |
Field | How to render |
|---|---|---|
image |
image |
An image; on tap open click_url |
html |
html |
A sandboxed WebView with no JavaScript bridge. The WebView must not navigate itself: intercept every navigation and window.open and open it externally (browser). |
video |
vast |
VAST 4.1 (interstitial / rewarded). |
native |
native |
Assets title, body, brand, cta, image, icon |
- Always keep
labelvisible ("Reklam"; "Test reklamı" / "Test ad" for test ads). close_after_s: the close button appears after this many seconds.- Expiry: the ad is valid until
expires_at(Unix seconds, about 55 minutes). After that, discard it unused and request a new one.
8.3 Event beacons
Append &sid=<device.sdk_id>&ts=<milliseconds> to each tracker URL and send a GET (or a POST with
an empty body). sid must be the same sdk_id that requested the ad; events with another sid are
not billed.
| Event | When |
|---|---|
render |
When the ad is actually drawn on screen. This is the billable impression. |
view |
When view_rule is met (≥ 50% visible for ≥ min_ms; 2 s for video) |
complete |
When a video is watched to the end. For non-video rewarded ads: once close_after_s has elapsed while the ad was on screen. The reward depends on it; early complete events are ignored. |
close |
When the user closes the ad (accepted, not billed) |
- Send each event once. Offline, queue and retry for up to 24 hours; duplicates are ignored server-side.
- For
kind=video, the VAST's own Impression / Tracking URLs point at the same endpoints; fire either the VAST events ortrackers. - Click: open
click_urlin the system browser or the relevant app (e.g. the Play Store); never open store links in an in-app WebView. The URL redirects to the store or landing page.
8.4 Error codes
Failed requests return {"code": "...", "message": "..."}. Do not retry these; retry with backoff
only on 5xx and network errors.
| HTTP | code |
Meaning |
|---|---|---|
| 400 | invalid_request |
Unreadable body or a required field is missing |
| 400 | placement_mismatch |
Unknown placement, a placement of another app, or format differs from the placement's format |
| 403 | unknown_app |
No such app key |
| 403 | app_not_approved |
The app was rejected |
| 403 | platform_mismatch |
The key belongs to the other platform (e.g. an iOS key on Android) |
| 403 | bundle_mismatch |
app.bundle differs from the registered bundle id |
| 403 | coppa |
The app is child-directed or regs.coppa is set |
| 403 | test_only |
Reserved (not sent in v1) |
9. Troubleshooting
In the SDK, the error's serverCode carries the server code above. For detailed logs set logLevel
temporarily to LogLevel.DEBUG on Android or .debug on iOS (Android Logcat tag: Adfacten).
| Symptom | Cause | Fix |
|---|---|---|
unknown_app |
The app key is mistyped or belongs to another environment | Copy the apk_… key from the panel |
bundle_mismatch |
The build's package name (e.g. a debug build with a .debug suffix) differs from the registered one |
Build with the registered bundle id; avoid applicationIdSuffix in debug builds |
platform_mismatch |
Android key used on iOS (or vice versa) | Add one app per platform and use each one's key |
placement_mismatch |
Wrong placement id, another app's placement, an archived one, or a format mismatch (e.g. a banner placement loaded with RewardedAd) |
Use the right plc_… id and the matching ad class |
app_not_approved |
The app was rejected | Read the reason in the panel, fix it and contact support |
coppa |
childDirected is on or the app is marked child-directed |
Adfacten cannot be used in child-directed apps |
| Always test ads | One of the conditions in 2.3 | Check Serving status in the panel: pending review → wait for approval; app-ads.txt → add the line, Check again; test mode → switch it off; SDK testMode on → switch it off; emulator → use a real device |
No ads (204; Android NO_FILL, iOS .noFill) |
No matching ad right now, paused placement, daily cap reached, or the traffic looks like a bot / data centre | Normal; the SDK waits at least 30 s before asking again. Check that the placement is active and test on a real device without a VPN |
VIDEO_NOT_SUPPORTED (Android) |
A video ad arrived but ads-video is not included |
Add the com.adfacten:ads-video dependency |
Android AD_EXPIRED, iOS .expired |
The ad was not shown within about 55 minutes of loading | Load shortly before showing; load the next one on close |
| No reward callbacks | No callback URL set, the URL is not https, your server does not return 2xx, or signature verification fails |
Read the status code and error in the reward callback log in the panel |