admoai 0.6.0 copy "admoai: ^0.6.0" to clipboard
admoai: ^0.6.0 copied to clipboard

Flutter SDK for AdMoai

AdMoai Flutter SDK #

pub package Platform Dart Flutter Conventional Commits

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.video placement filter
  • Journey Ads - Multi-stage, single-brand trip journeys via sessionId / journeyOpt and read-only creative.journey metadata (requires apiVersion: "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 - defaultLanguage config propagates Accept-Language on 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) or fireClick(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 fireImpression twice 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 trackerId only).

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?"

  1. Wrong API version — the field is only served under X-Decision-Version: 2025-11-01.
  2. The key had no canonical URL — an unknown key is a safe no-op for everything.
  3. A specific tracker on a different key — it only fires when the reported key equals its eventKey.
  4. The URL is not a plain, final HTTPS URL — macro placeholders and malformed URLs are discarded (check the campaign configuration).
  5. 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 an apiVersion. 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 sessionId values, 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 journeyOpt is permissive, not neutral. With a sessionId present and no journeyOpt, journeys may serve and an already-active journey continues. Only JourneyOpt.optOut suppresses 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 sessionId at all. An "opt-out" that merely omits journeyOpt does 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 journeyOpt do not re-enable journeys for it — the session must re-consent with an explicit JourneyOpt.optIn. So a consent toggle that sends optOut when 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. optStatus is an open set — an unrecognized value parses to null (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 uses custom_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 absent creatives identically — isNoAd covers 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.

  1. 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 sessionId is changing (or the session's server-side state expired).

  2. The stage NODE id must never repeat within one instance.

    debugPrint('stage: ${creative.journeyStageKey}  node: ${creative.journeyStageNodeId}');
    

    journeyStageNodeId must never repeat while the same journeyInstanceId is active — each node serves at most once per journey. journeyStageKey may 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 is pre_ride on 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 sessionId never changed. The next request starts a new journey at stage 1 with a new journeyInstanceId. An unchanged sessionId is 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.
  • ❌ One sessionId per activity (see What a session actually is), not per screen and not per login.
  • ❌ journeyInstanceId is constant across one activity; journeyStageNodeId never repeats.
  • ❌ Completion key is read from creative.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 explicit JourneyOpt.optIn to re-enable, not an omitted field.
  • ❌ Nothing fires automatically; VAST beacons left to the player.
  • ❌ UI renders from contents / 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.


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:

  1. 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.
  2. A tap is not a click. Opening your detail card is a tap and nothing more.
  3. trackPointClick replaces fireClick for 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 #

  • apiVersion is 2025-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.
  • clickUrl is 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 #

  1. Native OM SDK (IAB) — Full control. Bundle the IAB-namespaced Open Measurement SDK into your app and pass each VerificationScriptResource to its VerificationScriptResource API. Publisher owns the namespace and the integration.
  2. Google IMA SDK — IMA handles <AdVerifications> automatically when fed a VAST tag/XML. The verification resources are processed inside IMA; no extra wiring needed.
  3. 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 #

License #

This project is licensed under the MIT License - see the LICENSE file for details.


Built with ❤️ by the AdMoai Team

0
likes
130
points
478
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Flutter SDK for AdMoai

Homepage
Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

device_info_plus, flutter, flutter_timezone, http, logging, package_info_plus

More

Packages that depend on admoai