Unlimited Text Ads

Developers

Show ads from your account in your own Android app, video/audio player, or any standards-based ad integration — no WebView, no iframe. Uses the same slot keys as your website embed code. Android SDK below (§1–10); VAST/DAAST/Native for any other player at §11.

1. Overview

The Android SDK talks to a JSON version of the same ad-serving endpoint your website embed uses (serve.php / click.php). It renders the returned ad with native Android views — TextView, ImageView, or a Media3 player for audio/video — styled to match the colors and font you picked when creating the site/placement.

2. Requirements

  • Android minSdk 24 or higher
  • Kotlin
  • <uses-permission android:name="android.permission.INTERNET"/> in your app's manifest — easy to forget, and ads will silently fail to load without it

3. Installation

The SDK currently ships as source — a Gradle module you include directly in your project, not yet a published package.

Download SDK (v1.0.1, .zip)

  1. Unzip it — you'll get an adsdk folder.
  2. Copy that folder into the root of your Android project, next to your app module.
  3. Add it to settings.gradle.kts:
    include(":adsdk")
  4. Add it as a dependency in your app module's build.gradle.kts:
    dependencies { implementation(project(":adsdk")) }

The zip also includes a short README.md with the same steps plus the quick-start code below, in case you're setting this up without this page open.

4. Quick start

Initialize once, e.g. in your Application class:

AdServerSdk.init(context = this, baseUrl = "https://adserver.unlimitedtextads.com/")

Add an AdView to your layout:

<com.adserver.sdk.view.AdView android:id="@+id/adView" android:layout_width="match_parent" android:layout_height="wrap_content" />

Load an ad:

val adView = findViewById<AdView>(R.id.adView) adView.slotKey = "YOUR_SLOT_KEY" adView.adListener = object : AdListener { override fun onAdLoaded() {} override fun onAdFailedToLoad(reason: String) {} override fun onAdClicked() {} } adView.loadAd()

5. Your slots

Log in to see your real slot keys here. Everything above this section works the same whether you're logged in or not — this is just a shortcut so you don't have to copy keys from My Sites by hand.

6. Ad types & renderers

Ad typeRendered as
textStyled headline + body text
imageImage with optional headline/body text
audioMedia3 (ExoPlayer) audio player with title/CTA
videoMedia3 (ExoPlayer) video surface, autoplay muted with tap-to-unmute

Every video ad is server-side transcoded to one consistent resolution/frame-rate/bitrate (admin-configured) before it can air, and capped at 30 seconds — so a player showing several video ads back to back (e.g. a VAST pod) never sees a resolution, frame-rate, or audio jump between them.

A placement's ad format (text/image, video, or audio) is fixed per slot — the same choice you made under My Sites — so a given AdView only ever loads compatible ads.

7. Styling

Background, text, accent, and border colors plus corner radius come from your slot's style settings automatically — no extra code needed. Two things don't map perfectly to native Android and are documented limitations, not bugs:

  • Font — best-effort mapped to a small set (default/serif/monospace/rounded); there's no generic native equivalent of a web font stack. A slot set to one of the Google Fonts options falls back to the system default font in the SDK.
  • "Open in new tab" — has no meaning in a native app; every click always opens the destination via Chrome Custom Tabs.

Teaser display style — if a site's display style is set to "Teaser" under My Sites, AdView continuously animates the whole ad unit the same way the web embed does, picking the animation from the slot's teaser animation setting:

Teaser animationNative behavior
pulseGently scales the ad up and down (1.6s cycle)
glowA pulsing colored border overlay (1.8s cycle) — Android has no direct equivalent of a CSS box-shadow glow, so this is a deliberate visual approximation, same fixed color the web version uses
shakeA small side-to-side shake (2.4s cycle)

A "Standard" display style (the default) has no animation. This applies to every ad type — text, image, audio, and video all animate the same way when teaser is on, matching the web embed's behavior of animating the whole ad container regardless of what's inside it. A slot set to one of the newer web-only animations (bounce, wobble, flash) renders statically in the SDK — no crash, just no motion — until a future SDK release adds them.

8. Click-through behavior

When a user taps an ad, the SDK fires the click-tracking request in the background (never blocking or delaying anything) and immediately opens the destination URL in a Chrome Custom Tab, falling back to a normal browser intent if Custom Tabs isn't available. A slow or dropped tracking ping never affects what the user sees.

9. Auto-refresh

If a slot has auto-refresh enabled, AdView automatically loads a new ad on that interval while it's attached to the window, and stops when it's detached (e.g. the screen is left).

10. Error handling

Any failure — network error, no eligible ad, malformed response — is handled the same way: AdView hides itself (visibility = GONE) and calls onAdListener.onAdFailedToLoad(reason). It never throws or crashes your app.

11. Standards-based delivery: VAST, DAAST & Native

For video and audio you don't need the Android SDK at all — any player that speaks the industry-standard ad templates can pull ads directly. Text/image already is a native ad format, via the same JSON API below.

Video — VAST 4.0

GET https://adserver.unlimitedtextads.com/api/vast.php?slot={key}&pid={optional}&max={optional}

Works with Google IMA SDK (web/Android/iOS), ExoPlayer's IMA extension, Video.js + videojs-contrib-ads, JW Player, and any other VAST 4.0-compliant player. Only slots with ad format "Video" return a real ad — anything else, or nothing eligible, returns a spec-valid empty <VAST/> document, never an error.

Ad pods: a request returns every currently eligible video ad for that slot, one per <Ad> element with an incrementing sequence, up to max (default 4, hard-capped at 10 regardless of what you pass — protects both your player and this server from an oversized response). Fewer eligible ads than the cap is fine, that's just a smaller pod; house ads fill any remaining slots the same way they fill a single-ad request. Each <Ad> is its own real impression with its own credit, exactly as if the player had made that many separate requests — a genuine 3-ad pod shown to one viewer is 3 real impressions, not the same one 3 times. Most players play a pod back-to-back as one ad break; if yours only understands a single ad, pass max=1 to get exactly one <Ad>.

<?xml version="1.0" encoding="UTF-8"?> <VAST version="4.0"> <Ad id="4821" sequence="1"> <InLine> <AdSystem version="1.0">Ad Server</AdSystem> <AdTitle><![CDATA[Ad title]]></AdTitle> <Impression><![CDATA[https://adserver.unlimitedtextads.com/api/vast-pixel.php?imp=...]]></Impression> <Creatives> <Creative id="4821" sequence="1"> <Linear> <Duration>00:00:30</Duration> <VideoClicks> <ClickThrough><![CDATA[https://adserver.unlimitedtextads.com/click.php?imp=...]]></ClickThrough> </VideoClicks> <MediaFiles> <MediaFile delivery="progressive" type="video/mp4" width="640" height="360"><![CDATA[https://adserver.unlimitedtextads.com/uploads/video/....mp4]]></MediaFile> </MediaFiles> </Linear> </Creative> </Creatives> </InLine> </Ad> <Ad id="4822" sequence="2"> ...second ad in the pod, same shape... </Ad> </VAST>

Audio — DAAST 1.0

GET https://adserver.unlimitedtextads.com/api/daast.php?slot={key}&pid={optional}&max={optional}

The IAB's audio-ad counterpart to VAST — same idea, <DAAST> root, <AudioClicks> instead of <VideoClicks>, same pod/max behavior described above. Works with podcast/streaming ad-insertion tools and any DAAST-aware player. Only slots with ad format "Audio" return a real ad.

Text & image — already a native ad format

No separate endpoint needed — the JSON from /api/serve.php (§12 below) already returns exactly what the IAB's Native Ad concept describes: a headline, body text, an image URL, and a destination link, as plain JSON instead of XML. Any native-ad rendering integration can consume it directly.

Scope, honestly

  • Crediting happens when the ad is selected — same timing as every other delivery method on this page. The <Impression> pixel is included because compliant players expect one, but nothing depends on it actually firing.
  • Per-quartile tracking events (start/firstQuartile/midpoint/complete) aren't implemented — this system doesn't currently record playback-progress analytics.
  • Video duration comes from the actual transcoded file (server-side transcoding is mandatory for every video ad — see §6 above); audio duration is whatever the advertiser entered when uploading, defaulting to 30s if left blank. Video's <MediaFile> width/height always reflect the real, current admin-configured target resolution — every video ad is transcoded to exactly that size, never a guess.

12. Raw JSON API reference

For reference, or if you're integrating from something other than the Android SDK. Requests are unauthenticated aside from the slot key itself, matching the website embed.

GET https://adserver.unlimitedtextads.com/api/serve.php?slot={key}&pid={optional}&app_id={optional}

  • slot — required, your 32-character slot key
  • pid — optional correlation id used to cap credited impressions per logical page/screen (the SDK generates one automatically per AdView)
  • app_id — optional, your app's package name, used only as a fallback if pid is absent
// ad available { "status": "ok", "impression_token": "5b1e7c2a-1234-4abc-9def-0123456789ab", "ad": { "id": 4821, "type": "image", "is_house": false, "title": "Ad title", "text": { "headline": "Headline copy", "body": "Body copy" }, "media": { "url": "https://adserver.unlimitedtextads.com/uploads/image/ab12cd34ef.jpg" }, "destination_url": "https://advertiser.example.com/landing", "open_in_new_tab": true }, "slot": { "ad_format": "text_image", "display_style": "standard", "teaser_animation": null, "auto_refresh_seconds": 30, "style": { "bg_color": "#ffffff", "transparent_bg": false, "text_color": "#222222", "accent_color": "#3366ff", "border_color": "#dddddd", "border_radius": 8, "font": "default" } } } // nothing to serve right now (unknown/inactive slot, or no eligible ad) { "status": "no_ad" } // malformed request { "status": "error", "message": "missing or invalid 'slot' parameter" }

POST https://adserver.unlimitedtextads.com/api/click.php?imp={impression_token}

Always responds {"status":"ok"} (HTTP 200), even for an unknown or expired token — fire this in the background and open the ad's destination_url immediately, don't wait for the response.

13. Fraud prevention

API traffic goes through the same fraud checks as the website embed (bot filtering, rate limiting, duplicate-impression detection, self-traffic detection). A flagged impression or click still renders/redirects normally — it's simply not credited.

14. Placement types

Every site/slot you create under My Sites has a placement type, chosen when you create or edit it. All of them use the exact same embed tag shape — <script async src=".../embed.js?slot={key}"></script> — the server decides how to render it based on what you picked, so there's nothing extra to configure in your page's code.

PlacementMarkup neededBehavior
InlineA sized <div id="adserver-{key}">, same as alwaysRenders in that exact spot — the original placement type, unchanged.
Sticky bannerJust the script tag, no divPinned to the top or bottom of the viewport, full width, stays visible while scrolling.
In-page pushJust the script tagA notification-style card appears in the bottom-right corner a couple seconds after load, auto-dismisses, frequency-capped per visitor.
InterstitialJust the script tagCovers the full page; a close button appears after your configured delay. Frequency-capped — this is the most intrusive format, use sparingly.
PopunderJust the script tag, anywhere on the pageNo visible ad unit at all. On the visitor's first click anywhere on the page, opens a new tab to the advertiser's page. Frequency-capped per visitor.
Rewarded videoThe script tag, plus your own trigger calling AdServerRewarded.show() (§15)Not shown automatically — you call it from your own UI (e.g. a "Watch an ad for a bonus" button).

Frequency caps are tracked client-side (localStorage) per browser, not server-enforced — good enough to stop the same visitor being hit repeatedly, not meant to be unbeatable.

15. Rewarded video API

Rewarded video is the one placement type your own page code triggers directly, since only you know when a visitor has earned it (e.g. clicked "Watch an ad for 10 extra credits"). embed.js exposes a global for this — include the same script tag as any other slot, then call:

AdServerRewarded.show("YOUR_SLOT_KEY", { onStart: function () { // player opened }, onReward: function () { // fires ONLY after our server confirms a real, near-complete watch — // grant your reward here (points, unlock, extra credits, whatever you control) }, onClose: function (info) { // info.rewarded is false if they closed/skipped before earning it }, onError: function (reason) { // "no_ad" — nothing eligible right now; handle gracefully (hide your button, etc.) } });

The player shows a small "× Skip" button throughout — closing early is always allowed, it just means onReward never fires for that view. Watch progress is reported to the server every few seconds during playback and checked against the server's own record of the video's real duration; onReward only fires once that crosses ~90% watched, never from the player's local "video ended" event alone — see the source comments in api/rewarded-progress.php if you're curious why that distinction matters.

16. Overview & authentication

This is a different API from everything above §1–15. The Android SDK and VAST/DAAST/JSON endpoints let any app display ads from your slots — no login, keyed only by a slot's public key. The Member Account API below is the opposite: it's how you monitor and manage your own account (ads, sites, balance, stats) from your own app, the same things you'd otherwise do by logging into this website. Build a personal Android app against it, or use it from a script — anything that can send an HTTP request.

The website itself authenticates with a session cookie, which a native app has no equivalent of. This API uses a Bearer token instead: log in once, get back a long-lived token, then send it on every other request. Log in again from a second device (e.g. a phone and a tablet) and both tokens stay valid independently — logging out on one doesn't affect the other.

POST https://adserver.unlimitedtextads.com/api/member/login.php Content-Type: application/json { "email": "[email protected]", "password": "your-password", "device_label": "Pixel 8" }

Response:

{ "status": "ok", "token": "3681876ad0b64752e0db9f59919fcb2941f5c4c520caff745fd23c84b42bf22b", "member": { "id": 2, "email": "[email protected]", "display_name": "...", "credit_balance": 58 } }

device_label is optional — it's just a note stored with the token so a future "manage logged-in devices" screen could tell them apart; nothing reads it today. Store the token somewhere secure on-device (Android: EncryptedSharedPreferences, not plain SharedPreferences) and send it as a header on every other /api/member/* call:

Authorization: Bearer 3681876ad0b64752e0db9f59919fcb2941f5c4c520caff745fd23c84b42bf22b

A missing or invalid token gets a 401 with { "status": "error", "message": "..." } — treat that as "send the user back to the login screen." POST /api/member/logout.php (with the header) revokes just that one token.

Prefer "Continue with Google" instead of a password? login-google.php issues the same kind of token, given a Google ID token instead of a password:

POST https://adserver.unlimitedtextads.com/api/member/login-google.php Content-Type: application/json { "id_token": "<ID token from Google Identity Services / Credential Manager>", "device_label": "Pixel 8" }

Response shape is identical to login.php above. On Android, request the ID token via Credential Manager's Sign in with Google, passing this site's own Google client ID as serverClientId — 388009720513-vt61i8r4tp3d2fbj4uqcjs7cp7a24u9b.apps.googleusercontent.com — so the token's audience matches what the server verifies against. First sign-in creates a member automatically (same signup bonus as the regular signup form); an existing password-based account with the same email is matched and reused rather than duplicated.

17. Endpoint reference

All requests/responses are JSON. All except login.php and login-google.php require the Authorization: Bearer header above. POST endpoints accept either a JSON body or regular form fields.

EndpointBody / queryReturns
POST login.phpemail, password, device_label?token + your member profile
POST login-google.phpid_token, device_label?token + your member profile (same shape as login.php)
POST logout.php(none)revokes the current token
GET me.php(none)your profile & current credit balance
GET dashboard.php(none)balance, earned/spent totals, active ad/site counts, 14-day impressions/clicks chart, top pages, browser/country breakdowns — same numbers as your web dashboard
GET ads.php(none)all your ads — status, creative, weight, lifetime impressions/clicks
POST ads-status.phpad_id, action (pause|resume)the updated ad. 409 if the ad isn't in a state that allows it — e.g. you can't resume an ad still pending admin review
GET slots.php(none)all your sites/placements — status, format, lifetime stats, and the exact embed_snippet to paste on a site
POST slots-status.phpslot_id, action (pause|resume)the updated site/slot
GET ledger.php?limit=limit optional, default 50, max 200recent credit ledger entries (earnings, spend, bonuses) — same feed as the dashboard's activity list

Creating new ads/sites or editing their creative and styling isn't exposed here yet — those still go through the web app, which also handles file uploads (images/audio/video) that a JSON API would need a separate multipart flow for. Everything about monitoring and pausing/resuming what you already have is covered above.

18. Kotlin quick start

A minimal reference client — plain OkHttp + org.json, the same libraries the Android SDK above uses, so there's nothing new to add if you're already building against it. This is deliberately small: enough to log in, list ads/sites, and toggle status, so you can see the shape of it and build your own screens around it.

class MemberApiClient(private val baseUrl: String) { private val http = OkHttpClient() var token: String? = null private fun request(path: String, body: JSONObject? = null): JSONObject { val url = "$baseUrl/api/member/$path" val req = Request.Builder().url(url).apply { token?.let { header("Authorization", "Bearer $it") } if (body != null) { post(body.toString().toRequestBody("application/json".toMediaType())) } }.build() http.newCall(req).execute().use { resp -> val json = JSONObject(resp.body?.string() ?: "{}") if (!resp.isSuccessful) throw ApiException(json.optString("message", "Request failed"), resp.code) return json } } fun login(email: String, password: String, deviceLabel: String): JSONObject { val result = request("login.php", JSONObject().apply { put("email", email); put("password", password); put("device_label", deviceLabel) }) token = result.getString("token") return result.getJSONObject("member") } fun dashboard(): JSONObject = request("dashboard.php") fun ads(): JSONArray = request("ads.php").getJSONArray("ads") fun slots(): JSONArray = request("slots.php").getJSONArray("slots") fun setAdStatus(adId: Int, action: String): JSONObject = request("ads-status.php", JSONObject().put("ad_id", adId).put("action", action)).getJSONObject("ad") fun setSlotStatus(slotId: Int, action: String): JSONObject = request("slots-status.php", JSONObject().put("slot_id", slotId).put("action", action)).getJSONObject("slot") } class ApiException(message: String, val httpCode: Int) : Exception(message)

Run every call off the main thread (e.g. inside viewModelScope.launch(Dispatchers.IO)) — OkHttpClient.newCall().execute() above is blocking, same as the SDK's own network layer. Persist token across app restarts (encrypted storage) so the user isn't asked to log in every time; call me.php on launch to confirm it's still valid before showing cached data.