t1k:marketing:monetization:applovin-max
| Field | Value |
|---|---|
| Module | monetization |
| Version | 1.16.9 |
| Effort | high |
| Tools | — |
How to invoke
Section titled “How to invoke”/t1k:marketing:monetization:applovin-maxAppLovin MAX
Section titled “AppLovin MAX”MAX mediation setup, optimization, revenue reporting integration, and ad-unit network configuration management.
When to Use
Section titled “When to Use”- Integrating MAX SDK and network adapters
- Setting up in-app bidding
- Running MAX A/B tests / using the creative debugger
- Pulling ad-revenue data via the MAX Ad Revenue Reporting API (server-side dashboards, eCPM/ARPDAU analytics, per-network breakdown)
- Reading/creating/updating ad-unit network config via the Ad Unit Management API (programmatic waterfall + bidding setup, copying networks between ad units)
Reporting API — endpoint & auth
Section titled “Reporting API — endpoint & auth”- Endpoint:
GET https://r.applovin.com/maxReport - Auth:
api_keyquery param (account-level MAX report key — keep server-side; never ship in a client). - Format:
?format=json(orcsv). Response rows are flat records, one per dimension tuple.
estimated_revenue semantics (critical)
Section titled “estimated_revenue semantics (critical)”estimated_revenueis the TOTAL mediated revenue across ALL networks in the waterfall/bidding auction — NOT AppLovin-network-only. When you group bynetwork, the per-networkestimated_revenuevalues sum to the whole-mediation total for that (day, app) — no double-counting.- This is why a dashboard’s headline revenue (no network filter) equals
SUM(estimated_revenue)over the per-network rows.
The network ⊥ requests conflict (two-call design)
Section titled “The network ⊥ requests conflict (two-call design)”The MAX report API will 400 / reject if you combine the network dimension with the requests metric. network also conflicts with network_placement / max_placement column groupings in the same way. Consequence:
- Call A (default / requests): request columns incl.
requests,impressions,estimated_revenue, WITHOUTnetwork. Gives network-agnostic request counts. - Call B (placement / network): request columns incl.
network,attempts,responses,estimated_revenue, WITHOUTrequests. Gives the per-network revenue decomposition.
Merge rule when storing: keep Call B rows as the per-network grain (they carry the real network like ADMOB_BIDDING, APPLOVIN_NETWORK); attach requests from Call A at the network-agnostic grain only — do NOT multiply requests across networks (it inflates request totals). Preserve the invariant SUM(per-network estimated_revenue) == default-report estimated_revenue per (day, app).
Implementation reference (this platform):
apps/applovin-api/src/modules/historical/merge-applovin-responses.tsemits per-network rows from Call B and nullsrequestson them. A regression that LEFT-joined Call A as the spine silently droppednetwork(all rows became'unknown') across a multi-month backfill — seereferences/api-limits-and-auth.md.
Valid groupBy / report dimensions
Section titled “Valid groupBy / report dimensions”day, application, platform, network, ad_format, country, device_type, max_ad_unit_id, max_placement, zone_id.
Limits / quirks
Section titled “Limits / quirks”- 45 days is BOTH the max range AND the total lookback — no chunking strategy reaches older data; a too-early
startreturns HTTP 400. Anything older must come from your own warehouse or a dashboard CSV export, not this API. (An earlier claim of a 90-day lookback here was wrong — seereferences/api-limits-and-auth.md.) - ⚠️ GOTCHA — a range violation returns PLAIN TEXT, not JSON. The body is literally
Start date is beyond our 45 day look back period.with no JSON envelope, sojq '.results'dies withparse error: Invalid numeric literal at line 1, column 6and a loop that pipes straight intojqreports a jq bug instead of the real cause. Always check%{http_code}(orhead -c 200the body) before parsing. - Revenue is estimated (finalized later) — recent days can shift; reconcile after the data matures.
- Dates are returned as
YYYY-MM-DDstrings.
Rate limits — verified budget (do not exceed)
Section titled “Rate limits — verified budget (do not exceed)”| API | Base URL | Published limit | Auth |
|---|---|---|---|
| Ad Unit Management | https://o.applovin.com/mediation/v1 | 2,000 requests/hour (≈ 33/min) — documented | Api-Key: header (Management Key) |
| Revenue Reporting | https://r.applovin.com/maxReport | No published limit. Treat as unpublished, not absent — self-throttle | api_key= query param (Report Key) |
⚠️ GOTCHA — neither API returns rate-limit headers. No RateLimit-*, no Retry-After, on either host. You cannot detect that you are approaching the cap — the first signal is the failure itself, so the budget must be counted client-side; a “retry until it stops 429ing” loop is not a substitute.
Self-throttle policy (use these numbers):
- Management API — hard budget 2,000/h. Plan for ≤ 1,600/h (80%) to leave headroom for retries and for any other process sharing the account key. Sustained pacing: ≥ 2.25 s between calls (1,600/h). A bulk waterfall edit across 250 ad units at 1 GET + 1 POST + 1 verify-GET each = 750 calls — that is 37% of the hourly budget in one run, so two such runs back-to-back will breach it. Count before you loop.
- Reporting API — no published cap, so pace at ≥ 500 ms between calls and prefer one wide request over many narrow ones: a single call grouped by
application,platform,package_name,ad_format,max_ad_unit_idreturns the whole account in one request. Do not loop per-app — that turns 1 call into dozens for identical data. - On 429: stop the batch, do not hammer. Back off with full jitter (
base 2 s, doubling, cap 60 s) and resume from the last confirmed item — never restart the whole batch, which re-spends budget you already paid. AppLovin’s sibling Campaign Management API blocks for ten minutes after a breach; assume similar cooling here rather than an instant recovery. - The cap is per ACCOUNT key, not per process. A scheduled crawler plus an interactive session share one budget. Before a bulk write, check what else is running against the same key.
Auth failures — which key, which error
Section titled “Auth failures — which key, which error”The two keys are not interchangeable, and each rejects the other with a different error. Read the error to tell a wrong-key mistake from a real permission problem:
| Call | Wrong key used | Response |
|---|---|---|
o.applovin.com/mediation/v1/* | Report Key (or a bad Management Key) | 403 → {"errorMessage":{"id":"access_denied","parameters":[]}} |
o.applovin.com/mediation/v1/* | key sent as ?api_key= instead of the header | 400 → {"errorMessage":{"id":"invalid_parameter",…"description":"Missing header 'Api-Key'"}} |
r.applovin.com/maxReport | Management Key | 403 Authentication Failed |
access_denied on the Management API with a correct-looking key means the key is wrong or lacks Management access — not that the endpoint is down. Re-copy it from Account → General → Keys; do not brute-force character variants (repeated auth failures are exactly what a rate limiter punishes).
Ad Unit Management API (write — ad-unit & network config)
Section titled “Ad Unit Management API (write — ad-unit & network config)”A separate API from Reporting — used to list, read, create, and update ad-unit network configurations (waterfall + bidding), including copying networks from one ad unit to another.
- Base URL:
https://o.applovin.com/mediation/v1 - Auth:
Api-Key: <Management Key>HTTP header. The Management Key is distinct from the Reporting Key — dashboard → Account → General → Keys. It grants account-wide write access; treat as a secret and rotate if leaked. - Rate limit: 2,000 requests/hour, account-wide. See § “Rate limits — verified budget” above before any bulk loop.
Endpoints:
-
GET /ad_units?fields=ad_network_settings&limit=&offset=— list ad units (basic fields:id,name,platform,ad_format,package_name,has_active_experiment,disabled). Names are not guaranteed unique — resolve a target by(name, platform, ad_format), not name alone. In practice(package_name, ad_format, name)resolves cleanly.⚠️ GOTCHA —
/ad_unitsreturns a BARE top-level JSON array, not an object. Noad_unitswrapper key, sojq '.ad_units[]'yields nothing and a loop guarded on.ad_units|lengthexits with zero rows and no error. Usejq '.[]'/ guard onjq 'length'. Differs from/ad_unit/{id}, which does return an object. Paginatelimit=500&offset=N, stop when a page returns< limit. -
GET /ad_unit/{id}?fields=ad_network_settings— network config for the fields named.fieldsis a whitelist, not additive — only the named fields come back; anything else (includingbid_floors) is omitted from the response entirely, not returned empty. See the bid-floors GOTCHA below before treating this as “full config”. -
POST /ad_unit/{id}— update an ad unit’s network config. Partial / merge — adds/updates only the networks named in the body; networks you omit are left intact (not removed). -
POST /ad_unit/— create a new ad unit.GET|POST /ad_unit/{id}/{segment-id}— per-segment waterfalls.
ad_network_settings schema (array of single-key objects, keyed by NETWORK_API_NAME):
{ "ADMOB_BIDDING": { "disabled": false, "ad_network_app_id": "ca-app-pub-…~…", // optional, network-specific "ad_network_app_key": "…", // optional "targets": {}, "ad_network_ad_units": [ { "ad_network_ad_unit_id": "…", "disabled": false, "cpm": "30.00", // WATERFALL only — OMIT for bidding "countries": {"type": "INCLUDE|EXCLUDE", "values": ["us","ca"]} } ] } }Bidding vs waterfall:
- Bidding networks omit
cpm(the auction sets price). Names usually end_BIDDING(ADMOB_BIDDING,MINTEGRAL_BIDDING,UNITY_BIDDING, …); plusFACEBOOK_NETWORK,APPLOVIN_NETWORK,APPLOVIN_EXCHANGEare bidding. - Waterfall networks require
cpmperad_network_ad_unit(e.g.GOOGLE_AD_MANAGER_NETWORK). - Programmatic classifier: a network is waterfall iff any of its
ad_network_ad_unitscarries acpm; otherwise bidding.
⚠️ GOTCHA — the POST body must carry the destination’s own identity. A POST with only {"ad_network_settings": […]} is rejected:
HTTP 400 {"errorMessage":{"id":"invalid_parameter", "parameters":{"message":"Invalid parameters passed for: id", "description":"Ad unit id is not consistent with the request."}}}The body MUST include the top-level id (matching the URL) plus name, platform, package_name, ad_format, then ad_network_settings. With those, it returns 200.
Recipe — copy networks between two ad units:
GET /ad_units→ resolve source and dest IDs by(name, platform, ad_format).GET /ad_unit/{src}?fields=ad_network_settings.- Build body = dest identity (
id,name,platform,package_name,ad_format) +ad_network_settingsfrom source. For bidding-only, drop any network whose units carry acpm; for a full mirror, copy verbatim. POST /ad_unit/{dest}→ expect200; re-GET the dest and verify against the source.
-
Merge semantics mean a copy is additive — it won’t wipe networks already on the dest.
-
Copied lines reference the same network-side placement IDs as the source. To get independent placements, mint them on each network’s own dashboard first, then swap the IDs. Consequence: source and dest serve identical network inventory and commingle in each network’s own reporting — correct for a MAX-side split test, wrong when the new unit must be measured independently. Surface this to the user before the write, not after.
-
Verify on a re-GET, never on the POST echo — the POST response reflects what you sent, so it cannot catch a partially-applied write.
-
Assert on content, not counts.
length == lengthpasses even when CPMs or placement IDs were mangled. Diff the sorted(cpm, ad_network_ad_unit_id)tuples of the waterfall network:Terminal window gam() { jq -r '.ad_network_settings[]|to_entries[]|select(.key=="GOOGLE_AD_MANAGER_NETWORK")|.value.ad_network_ad_units[]|"\(.cpm)\t\(.ad_network_ad_unit_id)"' "$1" | sort; }diff <(gam src.json) <(gam verify.json) && echo PASSGuard the comparison itself: a word-splitting bug that feeds
difftwo empty streams reports PASS vacuously. Print line counts alongside the verdict so an empty-vs-empty pass is visible. -
A destination with
disabled: falsestarts serving the moment the config lands — no separate activation step. Confirm the unit is meant to go live before POSTing.
Money-risk: these are live writes that change served ads / revenue. Confirm the account, show a before→after diff before POSTing, and never bulk-loop writes without per-item verify (see the ad-network-mcp-write-safety rule).
Creating a NEW ad unit (POST /ad_unit)
Section titled “Creating a NEW ad unit (POST /ad_unit)”POST https://o.applovin.com/mediation/v1/ad_unit — one unit per request. Returns 200 (not 201) with the full unit object including its new id.
{ "name": "MyApp_AOA_AND", "platform": "android", "package_name": "com.my.app", "ad_format": "APPOPEN" }All four fields are required. There is no app_id field — the app is addressed by package_name + platform. Native units additionally require template_size.
ad_format values: APPOPEN, BANNER, INTER, MREC, REWARD. (The published docs list only INTER/BANNER/REWARD; APPOPEN and MREC are equally valid and in live use.)
⚠️ HARD RESTRICTION — one ad unit per (package_name, platform, ad_format). A second create for a combination that already has an active unit is refused:
HTTP 400 {"errorMessage":{"id":"invalid_parameter","parameters":{ "message":"Invalid parameters passed for: ad_format, platform, package_name", "description":"Cannot use the API to create multiple ad units for the same ad format/app/platform, Please use the UI."}}}So the API can only fill empty slots. Before offering to create anything, GET /ad_units, build the set of occupied (package, platform, format) tuples, and present the free slots — otherwise you are proposing writes that cannot succeed.
⚠️ Do not read “free slot” as “missing ad unit”. Accounts routinely run several units of the same format on one app for waterfall depth — a base unit plus _1.. _N siblings (observed in production: 8 INTER and 8 REWARD on a single package, all enabled). Those siblings are UI-only; the API refuses to make them. So the creatable-slot count is a statement about the API’s reach, not about how many units the app ought to have. Say which you mean when reporting it.
⚠️ GOTCHA — a newly created unit is NOT inert. The response ships ad_network_settings pre-populated with APPLOVIN_NETWORK and APPLOVIN_EXCHANGE, both disabled: false, and the unit itself is disabled: false. It can begin serving AppLovin demand as soon as the app requests that format — you do not get a staging state. If the unit is not meant to go live, plan the follow-up POST /ad_unit/{id} that disables it before creating it.
Not reversible: ad units can be disabled but not deleted. Confirm the target app, platform, and format with the user before the write, and match the account’s existing naming convention (read sibling unit names from GET /ad_units rather than inventing one).
Use the bundled script rather than hand-rolling curl — it enforces every guard above:
node scripts/max-ad-unit.js slots --package com.my.app # what is actually creatablenode scripts/max-ad-unit.js create --package com.my.app \ --platform android --format APPOPEN # DRY RUN by defaultnode scripts/max-ad-unit.js create ... --confirm [--disable-after]It reads APPLOVIN_MANAGEMENT_KEY from .env, paces to 80% of the hourly cap, refuses an occupied slot locally before spending a write, derives the unit name from that app’s own sibling naming, verifies on a re-GET, and can disable the unit immediately after creation.
Full verified request/response transcripts: references/create-ad-unit.md.
The price-floor ladder (N same-format units per app)
Section titled “The price-floor ladder (N same-format units per app)”A common MAX monetization shape: instead of one interstitial ad unit, run N units of the same
format on the same app — one “default” with no floor, and N-1 carrying ascending bid floors — so
demand is offered the impression at descending price points before it falls through to the open
auction. The same ladder is built for REWARD. Depth of 8 (1 default + 7 floored) is a typical
studio standard; the right N is a per-account decision.
⚠️ The API can build only ONE rung. Ad-unit creation is capped at one unit per
(package, platform, ad_format) — every additional rung must be created in the dashboard. There is
no API path, no flag, and no segment trick around it; the endpoint refuses with the 400 above.
Verified on two different formats and two different apps.
So the automation boundary for a ladder rollout is:
| Step | API? |
|---|---|
| Create rung 1 (format has no unit yet) | yes — POST /ad_unit |
| Create rungs 2..N (same format) | no — dashboard only |
| Read back the rung IDs once they exist | yes — GET /ad_units |
| Set/adjust each rung’s bid floor | yes — bid_floors on POST /ad_unit/{id} |
| Compute floors from observed eCPM | yes — Reporting API by country/ad_format |
Plan the rollout around that split. Report the creation work as a UI worklist (per app, per platform, how many rungs short of N), and automate the floor-setting, which is where the recurring cost actually is — rungs are created once, floors are retuned as eCPM moves. Quoting a ladder rollout as “the API can do it” is wrong and will strand the user mid-migration.
Clone-from-default — the rule for adding rungs
Section titled “Clone-from-default — the rule for adding rungs”Never create sibling rungs for a format whose default unit is not itself configured. The default (the first, unsuffixed unit) is the template every other rung is cloned from; without a populated one there is nothing to clone and the new units are born empty. Precondition, checked before any rung work:
The format’s default unit must carry a real waterfall —
ad_network_settingswith pricedad_network_ad_unitslines. Ifcpm_lines == 0, stop: configure the default first, create nothing else.
This is not hypothetical. A production account was found running 8 INTER and 8 REWARD units on
one app where only the first two carried waterfalls (43 and 45 priced lines); rungs _2.._7 held
2 auto-attached networks, 0 priced lines and 0 floors — inert shells that had been created and never
populated. They looked like a price ladder in the dashboard and served nothing. Order of work is
therefore: configure the default → clone it → set floors on the clones.
Where a price ladder can live
Section titled “Where a price ladder can live”Two architectures are in real-world use and they are not mutually exclusive. Which one a publisher runs is their commercial decision — surface the constraints, never argue them out of it.
Multi-unit ladders are legitimate and common. Publishers run several units of one format per app to get independent network sets, independent waterfall ordering, separate frequency capping, a separate A/B-experiment slot (one active per unit) and a separate 8-waterfall segment budget per unit. None of that is reachable by adding CPM lines to a single unit. The dashboard fully supports this; only the API declines to build it.
AppLovin’s published recommendation is nevertheless ONE ad unit per format — worth knowing, and worth stating once, but it is guidance, not a limit:
“AppLovin strongly recommends that you use a single ad unit ID for each format in an app. This ensures an ad is always cached and avoids unnecessary app/user bandwidth usage.”
The rungs live inside that unit, as priced waterfall lines:
“MAX uses the CPM values that you set here to define your waterfall.”
and the dashboard workflow for adding another rung is “If you have multiple network IDs, click Add a New Ad Unit ID to repeat the process.” Doc vocabulary confirms it — “adding a new price point” appears in the A/B-testing guidance, describing a waterfall line, never an ad unit.
So within any one unit, ad_network_settings[].ad_network_ad_units[].cpm carries the price points,
and that array is fully API-writable
via POST /ad_unit/{id}. No documented cap on line count (observed in production: 33-60 lines
across 14-20 networks per unit).
⚠️ Read-modify-write, and a partial write DESTROYS lines. Per the API reference: “To change one part of a particular ad network configuration, you must include all of the information associated with the MAX ad unit for that ad network. To add a new ad unit to an existing ad network, include all other ad unit for that ad network in the request.” Adding one rung to a network already carrying 60 lines means POSTing all 61. Blast radius is bounded to that one network — other networks are untouched — but within it, omission is deletion. Always GET, mutate the array in memory, POST the whole thing back, then re-GET and assert the line count.
The four ways to get more price points — and which are API-reachable
Section titled “The four ways to get more price points — and which are API-reachable”| Mechanism | Extra serving config? | API-creatable? | Cap |
|---|---|---|---|
| CPM lines in one ad unit | refines the existing one | yes — POST /ad_unit/{id} | none documented |
Segment waterfalls — POST /ad_unit/{id} with a segment object | yes | vendor-documented; unproven (see caveat) | 8 per ad unit (vendor-only claim) |
Experiment — POST /ad_unit_experiment/{id} | yes, temporary | yes | 1 active per unit |
| Sibling ad units, same format | yes | NO — 400, UI only | 1 via API |
Segment waterfalls are created by POSTing a singular segment object to the parent URL
(no segment ID in the path); the /{segment-id} sub-path operates on one that already exists. New
segments “start with the same waterfall as the default waterfall configured for the ad unit” —
i.e. this IS a clone-from-default primitive. Targeting is immutable after creation (“you cannot
update the segmentation… delete the waterfall and then create a new one”), so design the taxonomy
before the first POST.
⚠️ But segments partition USERS, not requests. A given user matches one segment waterfall or the default; segments do not produce “try rung 1, fall through to rung 2” semantics. Use them for per-audience pricing (LAT users, tablets, geos), not to deepen a waterfall.
⚠️ Treat segment CREATION as unproven until you have run it. The payload shape is independently
attested (a third-party OpenAPI transcription and production ETL clients both model the
singular-segment / plural-segments duality). The creation semantics are not: every third-party
client found only ever reads or updates an already-existing segment addressed by /{segment-id} in
the path, and public code search returns zero examples of POSTing a segment object with no id to
mint a new waterfall. The 8-waterfall cap is likewise attested only by AppLovin’s own pages. The
ecosystem around this API is thin — a handful of read-only clients, no wrapper package on any
registry — so this is weak evidence of absence rather than contradiction. Probe on a throwaway unit
before designing around it.
⚠️ Live API refuses some segment reads. GET /ad_unit/{id}/{segment} can return
400 invalid_state — "new waterfalls with expanded targeting options cannot be edited via the API at this time." The dashboard’s advanced targeting (gender, age, segment targeting) exceeds what the
API’s segment object models, and waterfalls built with it are API-opaque. Probe before building on
segment writes; do not assume doc coverage means API coverage.
SDK placement tagging is a separate, complementary option — not a substitute for a second ad
unit. AppLovin documents it for measurement: “If you use the same ad format in multiple placements
in your app, you can name and tag each one in the SDK. This enables you to measure the performance
of each unique placement.” Tags are SDK-side and max_placement is a valid Reporting API
dimension, so per-placement revenue and eCPM come back without extra inventory. Offer it when
measurement is the need; do not offer it as a reason to skip ad units the publisher has decided
they want.
There is no escape hatch — stop looking for one. The dashboard’s own bulk Ad Unit Manager CSV tool cannot create either: “You can update only existing networks and placements with this feature.” Export-edit-reimport is edit-only. No third-party tool creates units past the limit either, and nothing suggests the cap is liftable — no support doc, forum post, or practitioner account describes an escalation path, partner tier, or exception process, and no first-hand account of working around this error exists publicly at all. The mediation-automation vendors (SmartFloors/Metica, Bidlogic, Playwire) all drive the same public Management API and hit the same wall; MMPs (AppsFlyer, Singular, Tenjin, Adjust) do attribution only and manage no ad units; and no open-source wrapper for this API exists on GitHub or any package registry. What those vendors DO automate is bid-floor grids on ad units that already exist — which is the same conclusion this skill reaches: the recurring, automatable work is pricing existing inventory, not minting more of it.
bid_floors is NOT a ladder. One floor per country group, INCLUDE only, and it is a no-fill
threshold: “If no ads can serve above this limit for a country in this group, MAX does not fill
the ad request.” Two floors on one country is a contradiction, not two rungs. Ascending rungs are
CPM lines; floors are the cutoff beneath them.
Bid floors (per-country price floors)
Section titled “Bid floors (per-country price floors)”Country-level minimum CPMs, set via the top-level bid_floors array on POST /ad_unit/{id} (same Management API, same id-in-body requirement). Floors the bidding/waterfall auction price per geo.
Schema — each entry:
{ "country_group_name": "us tier", // label — see gotcha below "cpm": "1.20", // string; minimum CPM in USD "countries": { "type": "INCLUDE", "values": ["us"] } } // INCLUDE only- Enabling is implicit — a non-empty
bid_floorsarray turns the feature on; there is no boolean flag. countries.typesupportsINCLUDEonly. Any country without an entry gets no floor.bid_floorsis independent ofad_network_settings— a partial POST sending onlybid_floors(+ the required identity fields) leaves the networks intact.
⚠️ Gotcha — bid_floors is NOT returned unless you ask for it. GET /ad_unit/{id}?fields=ad_network_settings (the call documented above, and the one this skill’s “verify on a re-GET, never on the POST echo” rule assumes) returns no bid_floors key at all — not an empty array, the key is absent. Request it explicitly: ?fields=ad_network_settings,bid_floors for both in one call, or ?fields=bid_floors alone. Consequence: any audit or verification step that reads floors off an ad_network_settings-only GET reads zero floors on every unit, indistinguishable from “no floors configured” — confirmed live against a unit with 62 configured floors returning has("bid_floors") === false under the documented call. A floors-verification re-GET MUST carry bid_floors in its fields param, or it verifies nothing.
⚠️ Gotcha — country_group_name rejects + and %. A label like "US +5%" is rejected:
HTTP 400 {"errorMessage":{"id":"invalid_parameter","parameters":{"name":"country_group_name", …}}}Use alphanumeric/space labels only (the docs’ own examples: "t1 eng", "eea") — e.g. "us 5pct".
⚠️ Gotcha — AppLovin dedupes floors on the sent cpm value ALONE (not (country, cpm)). Two entries with the same sent cpm string collapse to one on save even when they target different countries (us@"5.00" + ca@"5.00" → only 1 stored). The dedupe runs on the exact string you POST, and rounding to cents happens after it — so sending distinct higher-precision values ("0.1570" vs "0.1640") keeps both entries even though both display as $0.16. Consequence for multi-tier / many-geo floor sets: never pre-round to 2 decimals before POSTing — that makes adjacent tiers (and low-eCPM geos that round to the same cent) share a string and silently collapse. Send raw ~4-decimal cpm ((ecpm*mult).toFixed(4)), then re-GET and assert the stored count == sent count.
Recipe — data-driven country floors: pull last-7d ecpm / estimated_revenue by country for the app from the Reporting API, take the top-N countries, then POST floors at a chosen markup over each country’s average eCPM. The Reporting API needs the separate Report Key — the Management Key returns 403 Authentication Failed on r.applovin.com/maxReport.
Gotchas
Section titled “Gotchas”- MAX in-app bidding (IAB) requires every adapter to support IAB — a single waterfall network in IAB-mediation can collapse fill in low-eCPM geos. Audit adapter compatibility per network release.
- MAX A/B experiments are placement-scoped, not segment-scoped — segmentation must be done via segments + experiments together, not experiments alone.
- Creative debugger only works on test devices — production fill cannot be inspected; pre-flight all creatives before go-live.
- ARPDAU shifts on weekends — never compare a Tuesday cohort to a Saturday cohort when judging a MAX experiment.
References
Section titled “References”- Ad Unit Management API: https://support.applovin.com/en/max/advanced-features/ad-unit-management-api — “This API is rate-limited to 2000 requests per hour.” (
developers.applovin.comandsupport.axon.aimirror it.) - Revenue Reporting API: https://support.applovin.com/en/max/reporting-apis/revenue-reporting-api — “This API has a request window of 45 days.”
- Verified request/response transcripts, dated retrievals, and the corrections behind the gotchas above:
references/api-limits-and-auth.md,references/create-ad-unit.md. - AppLovin MAX Ad Revenue Measurement (impression-level / MAX_REVENUE): https://developers.applovin.com/en/max/android/ad-revenue-measurement/