AdMoai Flutter SDK
AdMoai Flutter SDK is a cross-platform advertising solution that enables seamless integration of native and video ads into Flutter applications. The SDK provides a robust API for requesting and displaying various ad formats with advanced targeting capabilities.
Features
- Native Ads - Multiple template types (wide, image+text, text-only, carousel)
- Video Ads - JSON, VAST Tag, and VAST XML delivery methods, with
Format.videoplacement filter - Journey Ads - Multi-stage, single-brand trip journeys via
sessionId/journeyOptand read-onlycreative.journeymetadata (requiresapiVersion: "2025-11-01") - Sponsored Pin Locations – Promote an advertiser's real places as pins on your map. Read Sponsored Pin Locations before integrating: the SDK renders nothing and never infers that a pin was seen, so the measurement contract is yours to honour
- Rich Targeting - Geo, current-location, destination, and custom key-value targeting
- GDPR Compliance - Built-in user consent management
- Event Tracking - Impressions, clicks, video quartiles, and custom events
- Open Measurement - Pass-through support for third-party verification scripts (IAS, DoubleVerify, …)
- Flexible Templates - Customizable ad layouts and formats
- Locale-aware -
defaultLanguageconfig propagatesAccept-Languageon every request - Configurable transport - Per-knob timeouts (request / connect / receive) and pluggable
http.Client - Per-Request Control - Override user/device data collection per request
Requirements
- Flutter 3.0+
- Dart 3.0+
- iOS 14.0+ / Android API 21+
Installation
Add this to your package's pubspec.yaml file:
dependencies:
admoai: ^0.6.0
Then run:
flutter pub get
Quick Start
1. Initialize the SDK
final config = SDKConfig(
baseUrl: "https://api.admoai.com",
apiVersion: "2025-11-01",
);
final sdk = await AdMoai.initialize(config: config);
2. Configure User Settings (Optional)
sdk.setUserConfig(
id: "user_123",
ip: "203.0.113.1",
timezone: "UTC",
consent: Consent(gdpr: true),
);
3. Build and Send a Request
final request = sdk.createRequestBuilder()
.addPlacement(key: "home")
.addPlacement(key: "promotions", format: Format.native)
.addGeoTargeting(2643743)
.addCustomTargeting(key: "category", value: "news")
.build();
final response = await sdk.requestAds(request);
response.body.data?.forEach((decision) {
decision.creatives?.forEach((creative) {
// Render creative
});
});
4. Extract Content
final headline = creative.contents.getContent("headline")?.value;
final imageUrl = creative.contents.getContent("coverImage")?.value;
final videoAsset = creative.contents.getContent("video_asset")?.value;
5. Track Events
// Impressions
sdk.fireImpression(creative.tracking);
// Clicks
sdk.fireClick(creative.tracking);
// Video quartiles
sdk.fireVideoEvent(creative.tracking, "start");
sdk.fireVideoEvent(creative.tracking, "first_quartile");
sdk.fireVideoEvent(creative.tracking, "midpoint");
sdk.fireVideoEvent(creative.tracking, "third_quartile");
sdk.fireVideoEvent(creative.tracking, "complete");
// Custom events
sdk.fireCustomEvent(creative.tracking, "companionOpened");
Nothing is fired automatically — you decide when each event happens. All fire*
methods are fire-and-forget: they return void, dispatch in the background, and
never throw into your code.
6. Clean Up on Logout
sdk.clearUserConfig();
sdk.clearDeviceConfig();
sdk.clearAppConfig();
Configuration Reference
SDKConfig
| Parameter | Type | Default | Description |
|---|---|---|---|
baseUrl |
String | Required | Decision Engine API endpoint |
apiVersion |
String? | null |
Engine API version. "2025-11-01" gates Journey Ads, video ads, POI / destination targeting, mid-flight campaign changes, Open Measurement and the format placement filter |
defaultLanguage |
String? | null |
Sent as Accept-Language on every request |
requestTimeout |
Duration | 10s | Per-request timeout |
connectTimeout |
Duration | 10s | Connection timeout (default HTTP client only) |
receiveTimeout |
Duration | 10s | Idle timeout (default HTTP client only) |
logger |
Logger | SDK default | Custom logger instance |
AdMoai.initialize additionally accepts sessionId (the sticky Journey session —
see Journey Ads) and httpClient (a custom
http.Client).
Video Ad Support
The SDK supports three video delivery methods:
| Delivery | Response Field | Tracking |
|---|---|---|
| JSON | video_asset content key |
SDK methods (fireVideoEvent) |
| VAST Tag | vast.tagUrl |
Manual HTTP GET |
| VAST XML | vast.xmlBase64 |
Manual HTTP GET |
Detecting Video Ads
creative.isJsonDelivery()
creative.isVastTagDelivery()
creative.isVastXmlDelivery()
final videoUrl = creative.contents.getContent("video_asset")?.value;
final vastTagUrl = creative.getVastTagUrl();
final vastXmlBase64 = creative.getVastXmlBase64();
Video Tracking Events
Important: Always fire the impression event first when the ad is displayed, then fire video-specific events as playback progresses.
| Event | When to Fire | Key |
|---|---|---|
| Impression | Ad displayed (before playback) | default |
| Start | Video begins playing (0%) | start |
| First Quartile | 25% progress | first_quartile |
| Midpoint | 50% progress | midpoint |
| Third Quartile | 75% progress | third_quartile |
| Complete | Video ends (98%) | complete |
| Skip | User skips | skip |
sdk.fireImpression(creative.tracking);
sdk.fireVideoEvent(creative.tracking, "start");
sdk.fireVideoEvent(creative.tracking, "first_quartile");
sdk.fireVideoEvent(creative.tracking, "midpoint");
sdk.fireVideoEvent(creative.tracking, "third_quartile");
sdk.fireVideoEvent(creative.tracking, "complete");
Video Helper Methods
final isSkippable = creative.isSkippable();
final skipOffset = creative.getSkipOffset(); // String?
Both read the engine-owned creative.metadata first and fall back to the creative's
content fields. For a typed value, read creative.metadata?.isSkippable (bool?)
and creative.metadata?.skipOffsetSeconds (int?) directly.
Event Tracking
The SDK never fires anything automatically. You call a fire* method when the
event happens in your UI. Each one fires the exact URL the server returned,
verbatim — never parse, rebuild, or append to a tracking URL; the identity is
inside the opaque ?e= token.
All fire* methods are fire-and-forget: they return void, dispatch on the
SDK's own scope, and never throw into the caller. Network failures are logged, not
surfaced — so there is no return value to retry on. If you need a retry, call the
method again with the same Tracking object; the identical URL is re-fired.
A click beacon records the click and nothing else. The tracking endpoint answers
a click with a 302 to the creative's destination, which is meant for browsers;
the SDK takes that response as final and never requests the destination. Opening
it is your app's job — call fireClick and navigate from the creative's contents.
Available Methods
sdk.fireImpression(creative.tracking); // optional: key: "default"
sdk.fireClick(creative.tracking); // optional: key: "default"
sdk.fireVideoEvent(creative.tracking, "start");
sdk.fireCustomEvent(creative.tracking, "companionOpened");
sdk.fireCompletion(creative.tracking, key: "journey_complete"); // Journey custom_event deals
sdk.fireTracking(url); // fire a URL you already hold
fireCustom is a deprecated alias of fireCustomEvent, kept for compatibility and
removed in 1.0.0.
Tracking Keys
Each tracking type supports multiple keys. Use "default" for standard events or specify custom keys defined in your campaign configuration.
Third-party Event Trackers (automatic)
Why this exists commercially
Advertisers often buy through an agency, and the agency counts impressions and
clicks on its own ad server (Google CM360 and similar) before it will activate
or renew a campaign. Their trafficking sheet supplies fixed measurement URLs —
"GET this URL of mine every time the ad is shown". Without a safe way to fire
those URLs, campaigns stall at activation. This feature closes that gap, and it
asks nothing of your integration: if your app already calls
fireImpression/fireClick at the right moments, agency measurement works the
day the campaign is configured.
What the SDK does — and never does
- It does fire each matching third-party URL automatically when you call
fireImpression(tracking)orfireClick(tracking, key: …). Your call sites do not change. - It never fires anything on its own — no view-lifecycle hooks, no visibility detection. You still decide when an impression or click happened, exactly as before.
- It never lets a third-party tracker affect your app: dispatch is fire-and-forget on a separate, dedicated HTTP client, so a slow or dead agency server cannot delay rendering, the Admoai beacon, or the other trackers.
What arrives in the response
Under X-Decision-Version: 2025-11-01, a creative that carries trackers gains one
extra, read-only list. There are exactly three shapes:
"tracking": {
"impressions": [...], "clicks": [...], // canonical — unchanged
"thirdPartyTrackers": [ // absent when the campaign has none
{ "trackerId": "tpt_…", "eventType": "impression", "url": "https://…" },
{ "trackerId": "tpt_…", "eventType": "click", "matchType": "any", "url": "https://…" },
{ "trackerId": "tpt_…", "eventType": "click", "matchType": "specific",
"eventKey": "cta_tap", "url": "https://…" }
]
}
You can read creative.tracking.thirdPartyTrackers (e.g. to display in a debug
screen), but you never fire these yourself — the helpers own dispatch.
When exactly a tracker fires — and why
| Your call | What fires alongside the canonical beacon |
|---|---|
fireImpression(tracking) |
every eventType: "impression" tracker |
fireClick(tracking, key: 'anyValidKey') |
every matchType: "any" click tracker |
fireClick(tracking, key: 'cta_tap') |
the above plus every matchType: "specific" tracker whose eventKey == 'cta_tap' |
| A key with no canonical URL | nothing — canonical or third-party |
The "specific" shape exists because an operator can tie a tracker to one named click event (say, the CTA button) instead of every tap on the card — the agency then counts CTA engagement, not card touches.
Worked example — same code you already have:
// Ad displayed:
sdk.fireImpression(creative.tracking);
// → 1 Admoai impression beacon + 1 GET per impression tracker
// User taps the CTA (a click event your template names "cta_tap"):
sdk.fireClick(creative.tracking, key: 'cta_tap');
// → 1 Admoai click beacon + every any-click tracker + the cta_tap-specific trackers
The guarantees behind the counts
Agencies reconcile their numbers against Admoai reporting, so dispatch is deliberately strict:
- One attempt per matching unique URL per invocation. No retries, no queuing,
no re-fire on app lifecycle. Calling
fireImpressiontwice fires everything twice — by design, the SDK never second-guesses your event reporting. - Third-party can never out-count Admoai: trackers fire only when the canonical beacon for that key fires.
- The URL goes out byte-identical to what the operator stored. A URL the
platform cannot represent verbatim (e.g. a raw
%%MACRO%%placeholder) is discarded rather than fired mutated — a corrupted URL would silently skew the agency's counts. - Credential isolation: a plain GET on a dedicated client — no Admoai or SDK identity headers, no cookies, redirects never followed. The agency sees an anonymous hit, never your users' or Admoai's identity.
- Privacy: tracker URLs never appear in SDK logs (outcomes log the
trackerIdonly).
What can you configure? Nothing — by design
There is no opt-in, no opt-out, and no per-tracker setting on the SDK side. Which trackers exist, on which events, is entirely a campaign-configuration decision made in the Ad Manager by the people trafficking the campaign — the SDK is a faithful dispatcher. Older SDK versions simply ignore the field, so upgrading is the only integration step that exists.
"Why didn't my tracker fire?"
- Wrong API version — the field is only served under
X-Decision-Version: 2025-11-01. - The key had no canonical URL — an unknown key is a safe no-op for everything.
- A
specifictracker on a different key — it only fires when the reported key equals itseventKey. - The URL is not a plain, final HTTPS URL — macro placeholders and malformed URLs are discarded (check the campaign configuration).
- VAST-delivered video — the engine never serves third-party trackers with VAST creatives; the player owns those beacons.
Journey Ads
A Journey Ad is a single-advertiser takeover spanning several screens
of one user session (e.g. pre-ride → in-ride → post-ride). Instead of an
independent decision per placement, one advertiser holds the journey: the engine
walks the user through an ordered set of stages, keeps progress against the
sessionId you supply, and suppresses competing ads on the placements it owns
until the journey ends.
Requires
apiVersion: "2025-11-01"or later. Without it the engine silently ignores the Journey fields and serves normal ads — no error, no empty response. The SDK logs a warning if you send Journey context without anapiVersion. Journey fields are fully additive, so existing integrations keep working unchanged.
2025-11-01 is not only about Journey Ads. It gates several capabilities, so
unless you have a specific reason not to, set it: Journey Ads, video ads,
POI / destination targeting, mid-flight campaign changes, Open
Measurement support, and the format filter.
Why your integration matters commercially
A Journey Ad is one advertiser buying one user's activity as a single deal, usually priced CPT (cost per trip) — the advertiser pays once for the whole journey, not per impression. In exchange, competing ads are suppressed on the placements the journey owns.
That moves real commercial weight into your app:
| If your app… | Consequence |
|---|---|
Sends a different sessionId per screen |
The journey restarts at stage 1 forever. The advertiser's multi-screen story never happens and the campaign under-delivers. |
Never fires the completion beacon (on custom_event deals) |
The journey never completes, so CPT revenue is never recorded. |
| Fills a journey-owned slot with a different advertiser's ad when the journey returns no ad | Breaks the single-brand exclusivity the advertiser paid for. |
| Uses journey metadata (stage keys, node ids) to drive app logic or layout | Breaks silently the moment someone edits the campaign. |
None of this raises an error at request time. Requests succeed, ads appear, and the problem only shows up later in reporting — which is why the checklist and the checks at the end of this section matter more than usual.
What the SDK does, and never does
Everything about journeys is server-owned. The SDK has exactly three jobs.
| The SDK does | The SDK never does |
|---|---|
Forwards sessionId and journeyOpt on the request |
Generate, rotate, or persist a sessionId |
| Exposes read-only journey metadata off the served creative | Decide which stage serves next, or track progress |
| Fires the exact tracking URLs the engine returned, when you tell it to | Fire anything automatically, or on a timer |
| Reports a no-ad faithfully | Substitute a cached, local, or house ad |
| — | Infer completion, eligibility, or billing |
If you find yourself running a state machine, inferring a stage, or rebuilding a tracking URL, something has gone wrong.
The one rule: one session id, every call
This is the first Admoai feature where a sequence of calls behaves differently from a set of unrelated ones — and the only part you have to get right.
Every request belonging to one user session must carry the same sessionId.
Two ways to get it wrong, both silent:
| Mistake | What happens |
|---|---|
A different sessionId on a later screen |
The engine sees a new session and restarts the journey from stage 1. The user sees stage 1 again and never reaches the end. |
No sessionId at all |
Journeys never activate. You keep serving normal ads. This is the safe default — nothing breaks, you just get no journeys. |
What a session actually is
The most common mistake is reaching for something identity-shaped — a login, a user id, an app launch. A session is one activity, not one user and not one app run. Define it as the thing that starts and finishes in your product, because that is the unit the advertiser is buying.
Examples, not prescriptions — pick the equivalent in your own product:
| Product | One sessionId = |
Starts | Ends |
|---|---|---|---|
| Ride-hailing | one ride | user opens the booking flow | ride completed or cancelled |
| Delivery | one order | checkout begins | order delivered |
| Micromobility | one trip | unlock | lock / trip ends |
Two consequences worth internalising:
- Login is usually the wrong boundary. A user who takes three rides today should
produce three
sessionIdvalues, not one — otherwise all three collapse into a single journey. - Concurrent activities need separate ids. If your product allows two live orders at
once, give each its own
sessionId(a per-request override is the simplest way). One id for both makes them share one journey and one stage pointer.
1. The sessionId contract
final sdk = await AdMoai.initialize(
config: SDKConfig(baseUrl: '...', apiVersion: '2025-11-01'),
sessionId: 'sess_abc123', // sticky: inherited by every builder from here on
);
-
You own it. The SDK never creates or changes it.
-
It is sticky. Set it once and every builder created afterwards carries it; rebuilding a request never regenerates it.
-
Override or clear per request when you need to:
final request = sdk .createRequestBuilder() .addPlacement(key: 'in_ride_map_banner') .setSessionId('sess_other') // this request only .build(); final noJourney = sdk .createRequestBuilder() .addPlacement(key: 'home') .clearSessionId() // this request only .build(); -
When to rotate it (i.e. start a new journey): a new app launch, login, logout, or when the business activity genuinely ends (the trip finished, the order was delivered). Rotating on every screen restarts the journey; never rotating across genuinely separate activities blends them into one.
-
Maximum 256 UTF-8 bytes. Longer values are sent as-is, but the engine treats them as absent and Journey is disabled for that request. The SDK logs a PII-safe warning — a reason token only, never the value.
-
It is PII, and it is not a user id. Use an opaque, rotating identifier. Do not reuse your
user.id, and do not log it.
2. The three journeyOpt states
.setJourneyOpt(JourneyOpt.optIn) // "give me a journey"
.setJourneyOpt(JourneyOpt.optOut) // "no journeys for this session"
// or omit setJourneyOpt entirely
⚠️ Omitting
journeyOptis permissive, not neutral. With asessionIdpresent and nojourneyOpt, journeys may serve and an already-active journey continues. OnlyJourneyOpt.optOutsuppresses them. This is the single most likely misreading of this API.So if you want a request that definitely has no journey — a control, a screenshot, a diagnostic — send no
sessionIdat all. An "opt-out" that merely omitsjourneyOptdoes the opposite of what it looks like.
Opt-out ends a journey rather than pausing it. A later opt-in cannot resume
it — the engine mints a new journeyInstanceId and starts over. Completed
journeys are terminal for the same reason.
The exception to "omitted is permissive". Once a session has explicitly opted out, that refusal is remembered. Later requests that simply omit
journeyOptdo not re-enable journeys for it — the session must re-consent with an explicitJourneyOpt.optIn. So a consent toggle that sendsoptOutwhen off and nothing when on will leave journeys permanently disabled for that session.
3. Read journey metadata (read-only)
final creative = response.body.data?.first.creatives?.first;
if (creative != null && creative.isJourneyAd()) {
creative.journeyDealId; // the commercial Journey Ad
creative.journeyInstanceId; // this journey attempt — constant for the journey
creative.journeyDefinitionKey; // the journey definition
creative.journeyStageId; // current stage id
creative.journeyStageKey; // current stage key, e.g. "pre_ride"
creative.journeyStageNodeId; // current node (stage + surface)
creative.journeySessionId; // the session id echoed back
creative.journeyOptStatus; // JourneyOpt.optIn / optOut / null
creative.journeyPricingModel; // e.g. "cpt"
creative.journeyFallbackBillingMode; // e.g. "bill_per_stage"
creative.isJourneyCompletion; // true only on a final_stage completion
creative.hasCompletionUrl; // true only on a custom_event deal
}
On a normal ad every one of these is null (or false) — never a crash and never
a default object. isJourneyAd() is true only when the engine issued a real deal
or instance id.
creative.metadata.impId carries the engine's render-level attribution key, minted
per served creative and present on every Journey serve (null on normal ads). Use it to
reconcile a specific render against reporting; it is not a substitute for the tracking
token, and the SDK derives nothing from it.
Do not branch your UI on stage keys or node ids. They are server-owned identifiers that change when a campaign is reconfigured; a UI keyed to
"pre_ride"breaks the day someone renames a stage. Render whatever the creative contains.optStatusis an open set — an unrecognized value parses tonull(meaning "unknown"), not to a business state.
4. Completion — two modes, chosen by the campaign
The two are mutually exclusive per deal. Which one applies is configured per campaign in the Ad Manager by Admoai ad-ops — the engine reads that configuration and applies it in the ad response; it does not decide it. Both are additive to the normal impression rather than a replacement, so always fire the impression as usual.
custom_event — you must fire it, and it is what bills.
creative.tracking.completions is populated. The completion is recorded only
when you fire the beacon. If you never fire it, the advertiser is never charged for
the completion.
Do not hard-code the completion key. The key is campaign-specific — it is the event name the Admoai ad operator configured for this deal, so it differs between campaigns and is not a fixed string you can rely on. Read it from
creative.tracking.completions, which carries exactly one entry when the deal usescustom_event. Passing a key that is not in that list logs a warning and fires nothing — no error, no completion, no revenue.
// Read the key the engine returned; never invent it locally.
final completions = creative.tracking.completions;
if (completions != null && completions.isNotEmpty) {
sdk.fireCompletion(creative.tracking, key: completions.first.key);
}
Fire it once, when the action you agreed with the advertiser actually happens — not on render. Which app action that is, is a commercial decision the SDK cannot tell you; get it from your Admoai contact before you ship (see Before you ship).
Completion is idempotent server-side, so firing twice will not double-charge the advertiser — but still fire once: repeated callbacks add confusing analytics rows and make debugging harder.
final_stage — there is nothing to fire.
creative.isJourneyCompletion == true and there is no completion URL.
Completion was recorded server-side the moment the final stage served. Just fire the
normal impression. Never synthesize or infer a completion locally.
5. No-ad is correct behaviour, not a failure
While a journey holds its surfaces, the engine returns no ad rather than a competing brand. That is takeover protection working.
final decision = response.body.data?.first;
if (decision == null || decision.isNoAd) {
// Collapse the slot. Render nothing.
}
- Treat
creatives: [],creatives: null, and an absentcreativesidentically —isNoAdcovers all three. Do not branch on which empty shape came back; the reason is server-side only. - Do not substitute another ad on a journey surface — it breaks the takeover you sold.
- Do not immediately retry the same placement in a loop. Collapse the slot and carry on; request again when your app naturally reaches its next ad opportunity. A later request may well serve — the answer depends on placement, stage, time and campaign state. See Why a journey can stop serving.
6. Journey ads with video
For vast_tag and vast_xml delivery, the impression, quartile and click beacons
live inside the VAST document and belong to your player. The SDK deliberately
surfaces no VAST-owned video events. If you fire creative.tracking and let the
player fire the VAST beacons, everything is counted twice.
For json delivery the beacons come from creative.tracking.videoEvents and are
yours to fire from your player's callbacks.
Worked example: a multi-screen session
One session id across every screen. Nothing else is required of you.
// A session begins — the user starts a ride, opens an order, whatever your
// product calls a session. Mint one id and hold it.
final sessionId = 'sess_${DateTime.now().microsecondsSinceEpoch}';
sdk.setSessionId(sessionId);
// Screen 1 — vehicle selection.
var request = sdk
.createRequestBuilder()
.addPlacement(key: 'vehicleSelection')
.setJourneyOpt(JourneyOpt.optIn)
.build();
var response = await sdk.requestAds(request);
var creative = response.body.data?.first.creatives?.firstOrNull;
if (creative != null) {
render(creative);
sdk.fireImpression(creative.tracking);
}
// Screen 2 — during the ride. Same session id (inherited from setSessionId),
// so the engine advances the journey instead of restarting it.
request = sdk
.createRequestBuilder()
.addPlacement(key: 'journey')
.setJourneyOpt(JourneyOpt.optIn)
.build();
response = await sdk.requestAds(request);
// Screen 3 — ride summary. Still the same session id.
request = sdk
.createRequestBuilder()
.addPlacement(key: 'rideSummary')
.setJourneyOpt(JourneyOpt.optIn)
.build();
response = await sdk.requestAds(request);
creative = response.body.data?.first.creatives?.firstOrNull;
if (creative != null) {
sdk.fireImpression(creative.tracking);
// custom_event deals hand you a beacon; final_stage deals do not.
if (creative.hasCompletionUrl) {
sdk.fireCompletion(creative.tracking, key: 'journey_complete');
}
}
// The session ends — rotate for the next one.
sdk.setSessionId('sess_${DateTime.now().microsecondsSinceEpoch}');
Worked example: honouring a personalisation toggle
Opting out of journeys does not mean opting out of ads. Normal ads keep serving.
final request = sdk
.createRequestBuilder()
.addPlacement(key: 'vehicleSelection')
.setJourneyOpt(
userSettings.personalisedJourneys ? JourneyOpt.optIn : JourneyOpt.optOut,
)
.build();
Remember that optOut ends any active journey rather than pausing it. If the
user turns the setting back on, the engine starts a new journey — it cannot
resume the one it closed.
Common mistakes
| Mistake | Consequence | Do this instead |
|---|---|---|
A new sessionId per screen or per request |
The journey restarts at stage 1 forever and never progresses | One id per activity, rotated only when the activity ends |
Reusing a user id, account id or login as the sessionId |
Every activity by that user collapses into a single journey | Mint an opaque id per activity |
No sessionId at all |
Journeys never activate; the feature is silently off (ordinary ads still serve) | Set it once via AdMoai.initialize(sessionId: …) |
Omitting journeyOpt to mean "no journeys" |
Permissive — journeys still serve, and an active one continues | Send JourneyOpt.optOut explicitly |
| Expecting opt-out to pause a journey | The instance is closed; a later opt-in starts a brand-new one | Treat opt-out as terminal |
Sending JourneyOpt.optOut, then omitting journeyOpt to re-enable |
A stored opt-out persists, so journeys stay off for that session | Send JourneyOpt.optIn explicitly to re-consent |
Missing or older apiVersion |
Journey fields are ignored silently, and completions do not record | Set 2025-11-01 |
Not firing the custom_event completion beacon |
The journey never completes and CPT never bills | Fire it once when the agreed action happens |
| Hard-coding the completion key | The key is campaign-specific; a wrong key fires nothing and only logs a warning | Read it from creative.tracking``.completions |
Firing a completion on a final_stage deal |
Nothing to fire; the call is a no-op | Check isJourneyCompletion and fire only the impression |
| Firing the completion on render instead of on the action | Completions and CPT revenue are reported for journeys that never delivered the outcome | Fire on the real user action |
| Filling a journey-owned slot with another ad when the journey returns no ad | Breaks the single-brand takeover the advertiser paid for | Collapse the slot |
| Immediately re-requesting the same placement in a loop after a no-ad | Wasted calls; a placement the journey is holding will not free up mid-loop | Collapse, then request again at your next natural ad opportunity |
Treating a repeated journeyStageKey as a bug |
One stage can own several surfaces, so it legitimately repeats | Use journeyStageNodeId for the no-repeat rule |
| Using journey metadata to drive app logic or layout | Breaks silently the moment someone edits the campaign | Render from contents / template |
| Rebuilding or appending to a tracking URL | Invalidates the encrypted token; attribution is lost | Fire the string verbatim |
| Firing SDK video events for VAST delivery | Every event counts twice | Let the player's VAST beacons do it |
Verifying your integration
Two checks catch most integration bugs.
-
The instance id must stay constant across a session. Log it on every serve:
debugPrint('journey instance: ${creative.journeyInstanceId}');If it changes mid-session, your
sessionIdis changing (or the session's server-side state expired). -
The stage NODE id must never repeat within one instance.
debugPrint('stage: ${creative.journeyStageKey} node: ${creative.journeyStageNodeId}');journeyStageNodeIdmust never repeat while the samejourneyInstanceIdis active — each node serves at most once per journey.journeyStageKeymay repeat, and that is not a bug: one stage can own several surfaces, and the engine will serve another unserved node from a stage it already served without advancing. The real red flag ispre_rideon every screen with a changing instance id — that means each request is starting a new journey.
How long a journey stays alive
A journey's server-side progress has an idle expiry, configured by the Admoai ad operator per journey definition — not per campaign, and not by your app. It defaults to 24 hours and can be set to anything from minutes to days.
Two things follow, and the second surprises people:
- The clock runs from the last activity, not from the start. Every serve refreshes it, so an active journey does not expire mid-use.
- After the idle window the journey is gone — even if your
sessionIdnever changed. The next request starts a new journey at stage 1 with a newjourneyInstanceId. An unchangedsessionIdis not enough to keep a journey alive.
Pick the window to match how long your activity realistically pauses. As an example only: a delivery product whose orders complete within a couple of hours has no reason to keep state for a day. Agree the value with your Admoai contact — there is no universally correct number.
Why a journey can stop serving
Frequency capping decides whether a new journey may start. It is set per deal by Admoai ad-ops, as "X times per Y period". There is no default — if it is not configured, no cap applies. Two properties matter to you:
- It never interrupts a journey already in progress. A cap filling up mid-journey cannot cut the user off.
- It is scoped per user + deal, so it only applies when you send a user id. Without one, journey frequency capping cannot apply.
Edge cases — uncommon, but they explain a journey that stops mid-session. An active journey can end early if the deal's budget runs out, its pacing limit is reached, an event cap is hit, or it falls outside its parting window (the times of day and days of week the campaign is allowed to serve). Add the idle expiry above, a mandatory stage with nothing servable on the requested placement, and an explicit opt-out, and you have the full list.
Your app's response is identical in every case: collapse the slot and carry on. The distinction only matters when someone asks ad-ops why delivery stopped.
How your integration shows up in reporting
Journey reporting is computed from the events your app fires and the session boundaries you chose, so these numbers are only as good as the integration. What a publisher and an advertiser will be looking at:
| Metric | What it is | What your app affects |
|---|---|---|
| Journeys started | How many journeys began | Your session boundaries. A sessionId per screen inflates this; one per login collapses many activities into one. |
| Completions | Journeys that reached the finish | Firing the completion beacon on custom_event deals. Ad-ops chooses which stages must be served for a journey to count as finished — any combination — and that is reconciled against billing. |
| Avg duration | Average time between a journey's first and last event | Whether the journey actually progresses. A restarting journey reports many short ones instead of one real one. |
| Avg attention time | Estimated time the ad was on screen: average duration × an attention percentage set on the journey definition | Same as duration. This is estimated from configuration, not measured viewability — it is not Open Measurement and never affects billing. |
| CTR | Clicks ÷ impressions, counted per event | Firing impressions and clicks. |
| Journey CTR | Journeys clicked at least once ÷ journeys started | Each journey counts once however many times it was clicked, and completion is irrelevant. It reads higher than CTR because a journey spans several placements — that is expected, not a bug. |
Before you ship
Get these from your Admoai contact. Guessing any of them produces an integration that looks fine and reports wrongly:
| What | Why you need it |
|---|---|
| Placement keys owned by the journey | Which of your surfaces participate |
| The billable app action | Which real user action fires completion |
Completion mode (custom_event or final_stage) |
Whether you fire anything at all |
| Idle expiry for the definition | How long a paused activity survives |
| Video delivery mode (JSON or VAST) | Who owns the video beacons |
| Whether a user id is expected | Frequency capping needs one |
Then check your own integration:
apiVersion: '2025-11-01'is set.OnesessionIdper activity (see What a session actually is), not per screen and not per login.journeyInstanceIdis constant across one activity;journeyStageNodeIdnever repeats.Completion key is read fromcreative.tracking.completions, never hard-coded.Completion fires once, on the agreed action — not on render.Normal impression still fires on journey serves.A journey no-ad collapses the slot; no substitute ad, no retry loop.Consent toggle sends an explicitJourneyOpt.optInto re-enable, not an omitted field.Nothing fires automatically; VAST beacons left to the player.UI renders fromcontents/template, never from journey metadata.
Glossary
For product and ops readers.
| Term | Meaning |
|---|---|
| Journey | One advertiser's multi-screen experience across a single user activity. |
| Journey definition | The reusable template: ordered stages, and the idle-expiry setting. |
| Journey deal | A campaign bought against a definition: pricing, budget, caps, flight dates. |
Instance (journeyInstanceId) |
One concrete run of a deal for one session. The unit reporting and billing reconcile on. |
| Stage | One ordered step of a journey. May own several surfaces. |
| Stage node | One placement + template within a stage. Serves at most once per instance. |
| Takeover protection | Suppression of competing ads on placements a journey owns. |
| CPT | Cost per trip — the advertiser pays once per journey, not per impression. |
| Completion | The moment the journey is considered fulfilled; what CPT bills on. |
| Parting | The times of day / days of week a campaign may serve. |
| Pacing | Spreading delivery over the flight instead of spending at once. |
Questions publishers ask
We do not implement sessionId at all. Can we still request ads?
Yes. Everything works exactly as before — only Journey Ads are ineligible. Journeys never
activate, ordinary ads keep serving. It is a valid, safe default.
Is apiVersion only about Journey Ads?
No. 2025-11-01 also gates video ads, the format filter, mid-flight campaign changes and
Open Measurement support. Journey Ads are one of several reasons to set it — set it unless
you have a specific reason not to.
Two users share a device. Same sessionId?
No. A session is one activity by one user. On account switch, rotate it.
The app was killed mid-activity. Reuse the id or rotate? Reuse it if the activity is still the same one — that is the whole point of it being yours to persist. Rotate only when the activity itself ends.
Our sessionId never changed, but the journey restarted at stage 1. Why?
Almost certainly the idle expiry — see
How long a journey stays alive.
How do we tell the completion beacon apart from our own template custom events?
They are separate lists. Your template's events are in tracking.custom and unchanged.
The journey completion beacon is the single entry in tracking.completions, fired with
fireCompletion.
We fired the completion twice. Did we double-charge the advertiser? No — completion is idempotent server-side. Still fire once; duplicates only clutter analytics.
We never fire it. What happens?
On a custom_event deal the journey never completes and CPT revenue is never recorded.
Nothing errors. This is the most expensive mistake on this page.
The user opted out, then we stopped sending journeyOpt. Why no journeys?
A stored opt-out is remembered and an omitted field does not clear it. Send an explicit
JourneyOpt.optIn.
A journey stopped serving mid-session and we never opted out. See Why a journey can stop serving.
Sponsored Pin Locations
An advertiser buys a format, not a template: a Sponsored Pin Location campaign promotes real places — their shops, branches, pickup points — as pins on your map.
Those places are Advertiser Locations: an inventory the advertiser maintains in the Ad Manager, each with a name, coordinates, an optional address and an optional destination URL. They group them into Location Sets ("all airport counters", "stores open past 10pm") and attach a selection to a campaign as its Distance Targeting.
Two halves make it work, and they belong to different people:
- the advertiser says which places — their Locations, in the Ad Manager;
- you say where to look and how far — the distance search, on the request.
That is why the Ad Manager has no radius or bounds control anywhere. The campaign cannot know where your user is looking; only your request does.
Why your integration matters commercially
A Sponsored Pin campaign is bought on the promise that a real person saw a real shop near them. Three parts of that promise live in your code and nowhere else:
- Whether a pin is on screen. We cannot see your map. If you report a view for every pin you drew, the advertiser pays attention to numbers that describe nothing.
- Which pin the user chose. Attribution is per location, so "someone clicked" is worth far less than "someone clicked Providencia".
- Whether an action was a look or a decision. A tap that opens a card is free. A click that opens the shop is billable. Confusing them bills the advertiser for curiosity.
Get those three right and the campaign reports honestly. Get them wrong and the numbers are confidently false, which is worse than missing.
What the SDK does, and never does
| The SDK does | The SDK never does |
|---|---|
| Send your distance search and validate it before the network call | Render a map, a pin, a cluster or a card |
| Give you typed, tolerant access to the places that came back | Decide whether a pin is visible |
| Fire the beacon you ask it to, exactly once per call | Fire anything because a response was parsed |
| Resolve nothing about the destination — the server already did | Re-derive a pin's click URL from the creative |
Rendering is yours. Deliberately: you know your map, your clustering, your sheets and your scroll position, and we do not. The whole measurement contract rests on that honesty.
Before anything else: the API version
final sdk = await AdMoai.initialize(
config: SDKConfig(
baseUrl: 'https://api.admoai.com',
apiVersion: '2025-11-01', // REQUIRED — see below
),
);
Without apiVersion set to 2025-11-01, no pins come back at all. The decision API is
versioned by header; a request that omits it falls back to the older default, which has no concept
of Matched Points. The response is a perfectly ordinary, perfectly empty-of-pins ad — no error, no
warning. If matchedPoints is always empty, check this first.
Asking for pins
Dart has no method overloading, so the two shapes are two named constructors — Distance.radius and Distance.bounds — passed to one setDistanceTargeting. The effect is the same one iOS and Android get from overloads: a call naming both a radius and a rectangle, or neither, cannot be written.
A radius, when you have a point and a sensible distance — a user's location, a searched address:
final request = (sdk.createRequestBuilder()..addPlacement(key: 'map'))
.setDistanceTargeting(Distance.radius(
latitude: viewer.latitude,
longitude: viewer.longitude,
radiusMeters: 8000, // metres
))
.build();
Bounds, when the user is looking at a region — the natural call for a map that pans and zooms, because it matches what is actually on screen:
final visible = await mapController.getVisibleRegion();
final centre = await mapController.getLatLng(const ScreenCoordinate(x: 0, y: 0));
final request = (sdk.createRequestBuilder()..addPlacement(key: 'map'))
.setDistanceTargeting(Distance.bounds(
latitude: centre.latitude,
longitude: centre.longitude,
bounds: DistanceBounds(
north: visible.northeast.latitude, south: visible.southwest.latitude,
east: visible.northeast.longitude, west: visible.southwest.longitude,
),
))
.build();
The origin stays required for a bounds search. "Nearest first" needs somewhere to measure from, and the centre of the viewport is not necessarily where the user is.
limit is optional and narrows the campaign's own cap — it can never widen it. The
advertiser sets a maximum (20 by default); your limit asks for no more than N of those. Omit it to
take as many as the campaign allows:
Distance.radius(latitude: lat, longitude: lng, radiusMeters: 5000, limit: 10)
clearDistanceTargeting() removes the search and leaves every other targeting axis alone.
A Sponsored Pin campaign does not serve without a distance search — at all. Not "serves with no pins": the response carries no ad. A publisher who asked no spatial question has no map, and rendering the creative with an empty pin list would bill for something nobody can act on, so the campaign is dropped from the auction. The practical consequence: only request the Sponsored Pin placement from a surface that can send a search. Asking for it from a screen with no map looks exactly like a fill problem and is not one.
What the SDK checks, and what the server decides
Checked locally, before any network call, because they are mistakes no round trip should be spent
on — each throws ArgumentError:
- latitude in
-90, 90, longitude in-180, 180; - a radius greater than zero;
- bounds with north above south and west below east (a rectangle crossing the antimeridian is refused in this version);
- a limit greater than zero.
Not checked locally, on purpose:
- The radius and diagonal ceilings (50 km and 100 km today). They are server policy. An SDK that hard-codes them ships a client that refuses what a newer engine would accept, and every publisher would need an app release to benefit from a raised limit. The server's error is the authority.
- Whether any pins will match. That is the question the request exists to ask.
distance is not setLocationTargeting
The one thing every integrator asks. They look alike on the wire and are completely different questions:
| what it answers | what it affects | |
|---|---|---|
setLocationTargeting |
where is the viewer? | whether a campaign is eligible to serve at all |
distance |
where am I looking, and how far? | which of an eligible campaign's places come back |
They compose, and neither implies the other. A Sponsored Pin request often sends both: one says the user is in Santiago, the other says show me what is within 8 km of this corner.
Reading the pins
final response = await sdk.requestAds(request);
for (final decision in response.body.data ?? const <Decision>[]) {
final creative = decision.creatives?.firstOrNull;
if (creative == null) continue;
for (final point in creative.matchedPoints) {
markers.add(Marker(
markerId: MarkerId(point.id),
position: LatLng(point.latitude, point.longitude),
infoWindow: InfoWindow(title: point.name, snippet: point.address), // may be null
));
}
}
| field | type | notes |
|---|---|---|
id |
String | the Advertiser Location's public id — the unit of attribution |
name |
String | the shop's name, as the advertiser maintains it |
address |
String? | null when the location has none |
latitude, longitude |
Double | where to draw it |
distance |
Int | metres from the point you searched around |
clickUrl |
String? | where this pin goes — already resolved, see below |
tracking |
object? | this point's own beacons |
Points come back nearest first. They hang off the creative, so with several winning ads each creative keeps its own — there is no flat list to confuse.
A creative that is not a Sponsored Pin creative returns an empty list, never an error. Code that
does not know about pins keeps working unchanged: the points ride inside contents as one more
entry, and every version of this SDK has tolerated entries it does not recognise.
The click URL is already decided
point.clickUrl is the answer. The server has already applied the precedence — the location's own
URL, unless the campaign deliberately overrides every pin with the creative's default.
Do not fall back to the creative's URL when it is null. This is the single most likely place for a well-meaning integration to go wrong, because the creative also has a URL and it is right there. A pin with no destination is a pin you render and do not make tappable-through. Falling back silently defeats a campaign that points each shop at its own page, and the bug surfaces months later as "our clicks all go to the homepage".
The three events, and what each one costs
| call | fires when | cost |
|---|---|---|
fireImpression(creative.tracking) |
the creative is on screen — once, however many pins it shows | the campaign's CPM |
trackPointView(point) |
that specific pin became visible to the user | free |
trackPointTap(point) |
the user tapped that marker | free |
trackPointClick(point) |
the user activated that pin's destination | the campaign's CPC |
Three consequences worth stating plainly:
- A screenful of pins is one impression. Five pins is one impression and five views. Pin views are in addition to the impression, never summed into it.
- A tap is not a click. Opening your detail card is a tap and nothing more.
trackPointClickreplacesfireClickfor that action. Never both — both would count and charge the same click twice.
Nothing in the SDK encodes a price. The cost rides inside the signed token the server minted, so a pricing change never needs an app release.
Worked example 1 — a map screen, end to end
Ask, render, then report only what was genuinely seen:
class SponsoredPinMap extends StatefulWidget {
@override
State<SponsoredPinMap> createState() => _SponsoredPinMapState();
}
class _SponsoredPinMapState extends State<SponsoredPinMap> {
final Map<String, MatchedPoint> _pins = {}; // marker id -> point
Creative? _creative;
bool _reportedImpression = false;
final Set<String> _viewed = {};
// 1. ASK. One request per meaningful map move, not per frame.
Future<void> _loadPins(LatLng centre) async {
final request = (sdk.createRequestBuilder()..addPlacement(key: 'map'))
.setDistanceTargeting(Distance.radius(
latitude: centre.latitude, longitude: centre.longitude, radiusMeters: 8000,
))
.build();
final response = await sdk.requestAds(request);
final creative = response.body.data?.firstOrNull?.creatives?.firstOrNull;
if (creative == null) {
setState(_clearPins); // no ad: show no sponsored pins at all
return;
}
setState(() {
_creative = creative;
_reportedImpression = false;
_viewed.clear();
// 2. RENDER. Entirely your code — the SDK draws nothing.
_clearPins();
for (final point in creative.matchedPoints) {
_pins[point.id] = point;
_markers.add(_markerFor(point));
}
});
}
// 3. REPORT what the user actually saw, never what you merely drew.
Future<void> _onCameraIdle() async {
final creative = _creative;
if (creative == null) return;
// The creative's own impression: ONCE per served creative, however many pins it shows.
if (!_reportedImpression) {
sdk.fireImpression(creative.tracking);
_reportedImpression = true;
}
// One view per pin that is genuinely on screen, first time only.
final bounds = await _controller.getVisibleRegion();
final nowVisible = _pins.values
.where((p) => _within(bounds, p) && _viewed.add(p.id))
.toList();
sdk.trackPointViews(nowVisible);
}
}
The three numbered comments are the whole contract. Note what is missing: nothing fires when the response arrives, and nothing fires when a marker is added.
Worked example 2 — a detail card, and the one expensive mistake
// The user taps a marker and your detail sheet slides up.
void _onMarkerTapped(MatchedPoint point) {
sdk.trackPointTap(point); // FREE. The sheet opening is not a click.
showModalBottomSheet(context: context, builder: (_) => _DetailSheet(point: point));
}
// The user taps "Order now" inside that sheet.
Future<void> _onDetailCtaPressed(MatchedPoint point) async {
sdk.trackPointClick(point); // BILLABLE at the campaign's CPC.
// Do NOT also call sdk.fireClick(...) — that would charge the click twice.
final url = point.clickUrl;
if (url != null) await launchUrlString(url);
}
If your marker navigates straight to the destination with no card in between, that is a
trackPointClick — and a trackPointTap beside it if you want the interaction counted, which
remains free.
Reporting a screenful at once
A map that draws twenty pins reports twenty views. The bulk call saves you the loop:
sdk.trackPointViews(nowVisiblePoints);
It de-duplicates by point id within the call, so a list naming the same pin twice reports it once. It does not de-duplicate across calls: two renders are two views, which is what the number means — keep your own "already reported" set, as both worked examples do.
There is deliberately no bulk tap and no bulk click. Each of those is one user gesture, so a plural form could only report something that did not happen.
Journey Ads
Identical. A Journey screen whose creative is a Sponsored Pin creative exposes matchedPoints the
same way and takes the same three calls. A returned Matched Point is not a Journey stage
impression — the stage's own impression event is, exactly as for every other screen type.
Common mistakes
| Mistake | Consequence | Do this instead |
|---|---|---|
No apiVersion, or an older one |
No pins ever come back; the response looks like an ordinary ad | Set 2025-11-01 |
| Reporting a view when the response arrives | Views for pins nobody saw; the advertiser's numbers describe nothing | Report from your map's visibility signal |
| Reporting a view when a marker is added | Same, including pins inside a collapsed cluster or off screen | Check the pin is genuinely on screen |
| Re-reporting a view on every camera idle | One render inflates to dozens of views | Keep a per-render set of already-reported ids |
trackPointClick when a card opens |
The advertiser is charged CPC for someone who only looked | trackPointTap for the card, trackPointClick for the destination |
trackPointClick and fireClick for one action |
The click is counted and charged twice | Fire only trackPointClick |
| Firing the creative impression once per pin | A five-pin render bills five impressions instead of one | Fire it once per served creative |
Falling back to the creative's URL when clickUrl is null |
Silently defeats per-location destinations | Treat null as "this pin has no destination" |
| Re-deriving or rebuilding a tracking URL | Invalidates the signed token; the event is lost | Fire it verbatim |
| Hard-coding 50 km or 100 km in your app | You refuse searches a newer server would accept | Let the server answer |
| Sending a new request on every camera frame | Hammers the API and churns pins under the user | One request per settled map move |
Treating an empty matchedPoints as an error |
Normal: no campaign matched that search | Render no sponsored pins |
| Requesting the placement without a distance search | No ad at all, which reads as a fill problem | Only request it where you can send a search |
Using distance to decide whether the user is in a region |
Wrong tool; it does not gate eligibility | Use setLocationTargeting |
Two self-checks that catch most integration bugs
1. Your view count should exceed your impression count, and never equal it by accident.
// Self-check 1 — print what you are about to report.
debugPrint('impression: 1, views: ${nowVisible.length}, '
'pins drawn: ${creative.matchedPoints.length}');
One served creative showing five visible pins should print one impression and five views. If impressions and views are always equal, you are reporting one view per creative rather than per pin. If views equal the number of pins drawn rather than seen, your visibility check is not doing anything.
2. Open a card, close it, and check nothing was charged.
Tap a marker, let the card open, close it without touching the CTA. Exactly one pin_tap should
have been reported and zero clicks. If a click appears, trackPointClick is wired to the card
instead of the destination — the one mistake in this API that costs the advertiser money.
How your integration shows up in reporting
The advertiser sees, per location: pin views, pin taps, clicks, and the rates between them. A location they attached but that never matched shows a row of zeros with a dash for its rates — deliberately, because "never seen" and "seen and ignored" are different facts and only one of them is a reason to drop a shop.
A location the campaign later stops targeting keeps its history and is labelled as no longer targeted. Dropping a shop does not erase what it earned.
Before you ship
apiVersionis2025-11-01.- Your distance search is sent on the placement the campaign is booked against.
- Pins render from
matchedPoints, and the screen renders nothing sponsored when it is empty. - A view is reported from real visibility, once per pin per render.
- The creative impression is fired once per served creative.
- Tap and click are wired to different gestures, and the click is not doubled with
fireClick. clickUrlis used verbatim, and a null one is handled.- A no-ad response leaves no sponsored pins on the map.
Glossary
| term | meaning |
|---|---|
| Advertiser Location | One real place in the advertiser's inventory. The unit of attribution. |
| Location Set | A named group of Advertiser Locations, attached to campaigns as a unit. |
| Distance Targeting | The campaign's attached selection of Locations and Sets. Set by the advertiser, not by you. |
| Matched Point | One Advertiser Location that answered your distance search, with its distance and beacons. |
| Max matched points | The campaign's own cap on how many points it will return. Your limit narrows it. |
| pin view | One Advertiser Location seen by the user inside one served creative. Free. |
| pin tap | The user tapped that marker. Free. |
| click | The user activated the destination. Billable, attributed to that location. |
Questions publishers ask
Do I have to use a map? No. Pins are places with coordinates, a name and a distance; a sorted list of nearby shops is a perfectly good rendering. The measurement rules do not change — a row the user scrolled past is a view, a row they never reached is not.
How often should I request? When the map settles, not while it moves. Each request is a fresh decision; churning them changes the pins under the user and spends quota for nothing.
What if the user denies location permission? Send the centre of whatever region you are
showing. The search is about where you are looking, not where the user is — only setLocationTargeting
carries that claim.
Can I filter or re-sort the points? Render fewer if you must, and report views only for the
ones you actually showed. Do not re-sort by your own idea of relevance and call the result
"nearest"; distance is there if you want to show it.
Why is matchedPoints empty when I can see the campaign is live? In order of likelihood:
apiVersion is not 2025-11-01; the search is centred somewhere the advertiser has no locations;
the radius is too small; or the placement you asked for is not the one the campaign is booked
against.
And why did I get no ad at all? If you sent no distance search, that is the expected answer rather than a fill problem — see above. Otherwise it is an ordinary no-fill: no campaign was eligible for that placement.
Does any of this fan out third-party trackers? No. Third-party trackers stay at the creative level and are not duplicated per pin.
Request Builder
The DecisionRequestBuilder provides a fluent API:
final request = sdk.createRequestBuilder()
.addPlacement(key: "home")
.addPlacement(key: "promotions", format: Format.native)
.setUserId("user_123")
.setUserIp("203.0.113.1")
.setUserTimezone("America/New_York")
.setUserConsent(Consent(gdpr: true))
.addGeoTargeting(2643743)
.addLocationTargeting(latitude: 37.7749, longitude: -122.4194)
.addCustomTargeting(key: "category", value: "news")
.disableAppCollection()
.disableDeviceCollection()
.build();
clearAll() resets a builder for reuse: it drops placements, targeting and user, stops automatic
app and device collection, and clears journeyOpt. It deliberately keeps the sticky
sessionId — that is session-scoped state, not per-request state, so a journey survives a builder
reset. Call clearSessionId() to drop it explicitly.
Response Structure
APIResponse<DecisionResponse>
├── response: http.Response
├── body: APIResponseBody<DecisionResponse>
│ ├── success: bool
│ ├── data: List<Decision>?
│ │ └── Decision
│ │ ├── placement: String
│ │ └── creatives: List<Creative>? // null or [] ⇒ no ad
│ │ └── Creative
│ │ ├── contents: List<Content>
│ │ ├── advertiser: Advertiser
│ │ ├── template: Template
│ │ ├── tracking: Tracking
│ │ │ ├── impressions: List<TrackingItem>?
│ │ │ ├── clicks: List<TrackingItem>?
│ │ │ ├── custom: List<TrackingItem>?
│ │ │ ├── videoEvents: List<TrackingItem>?
│ │ │ └── completions: List<TrackingItem>? // Journey custom_event
│ │ ├── metadata: Metadata?
│ │ ├── delivery: String? // json | vast_tag | vast_xml
│ │ ├── vast: VastData?
│ │ ├── journey: CreativeJourney? // Journey serves only, else null
│ │ └── verificationScriptResources: List<VerificationScriptResource>?
│ ├── errors: List<AdMoaiError>?
│ └── warnings: List<AdMoaiWarning>?
└── rawBody: String?
Open Measurement (OM)
The Admoai Flutter SDK surfaces third-party verification resources (e.g. IAS, DoubleVerify, Moat) returned by the decision engine, so publishers can plug them into their own OM integration.
Important: Admoai is not OM-certified and does not bundle the IAB OM SDK. The SDK only exposes the verification metadata — the publisher is responsible for loading the scripts into a verified, namespaced OM SDK in their app.
Accessing verification resources
import 'package:admoai/admoai.dart';
response.body.data?.forEach((decision) {
decision.creatives?.forEach((creative) {
if (creative.hasOMVerification()) {
final resources = creative.getVerificationResources()!;
for (final r in resources) {
// Pass r.vendorKey, r.scriptUrl, r.verificationParameters
// to your OM SDK integration.
}
}
});
});
Integration paths
- Native OM SDK (IAB) — Full control. Bundle the IAB-namespaced Open Measurement SDK into your app and pass each
VerificationScriptResourceto itsVerificationScriptResourceAPI. Publisher owns the namespace and the integration. - Google IMA SDK — IMA handles
<AdVerifications>automatically when fed a VAST tag/XML. The verification resources are processed inside IMA; no extra wiring needed. - Third-party players (e.g. JW Player) — Commercial players ship with OM support; consult their docs for how to feed the verification metadata.
VerificationScriptResource shape
| Field | Type | Description |
|---|---|---|
vendorKey |
String |
Vendor identifier (e.g. "ias", "doubleverify"). |
scriptUrl |
String |
JavaScript URL the verification provider hosts. |
verificationParameters |
String? |
Optional opaque parameters the SDK passes through unchanged. |
Contributing
We welcome contributions! Please see our Contributing Guidelines for details on:
- How to submit Pull Requests
- Commit message conventions (Conventional Commits)
- Code style and testing requirements
- Development workflow
Example App
For a complete example implementation, check out the example app.
Documentation
For detailed documentation, please visit our documentation site.
Support
- Issues: GitHub Issues
- Email: support@admoai.com
License
This project is licensed under the MIT License - see the LICENSE file for details.
Built with ❤️ by the AdMoai Team