Serving Ads in an App
This page describes the mobile ad contract, contract_version: 1. Availability is enabled per
placement — confirm with the MageAds team which of your placement codes already serve it before
you start.
How it works
Your app knows one thing about an ad slot: its placement code. It does not know in advance what kind of ad will come back — a product listing, a banner image, or a rich media creative. That is decided by the auction, per request.
So the response tells you. Every ad carries a render field, and your app branches on that
one value:
render | What arrived | What the app does |
|---|---|---|
native | product identifiers | Renders them with your own product cards |
asset | a ready image | Displays it, whole tile tappable |
media | a rich creative | Fetches its content and composes it natively |
unsupported | something your app cannot show | Collapses the slot, calls blockedTag |
Never branch on the placement code or on type. The same placement returns different kinds
of ads on different requests, and new formats are added on our side over time. Branching on
render means an app already in the stores keeps working when that happens.
Requesting an ad
POST https://delivery.mageads.com/products?pc={PLACEMENT_CODE}&output=json
Send this from your backend, not from the app — the request needs an API key, and anything compiled into an app binary can be extracted from it. Your backend adds the key, forwards the request, and passes the response back. It may reshape it freely.
Recommended timeout: 400–500 ms. Render the screen first and fill the slot when the response arrives. No fill means collapse the slot completely — never leave an empty frame.
The request is the same for every format
You do not request a render mode — you request a placement. The same call can come back as
native, asset or media depending on what wins the auction. Only the site block changes,
and only because the screen differs.
POST /products?pc=30703746&output=json HTTP/1.1
Host: delivery.mageads.com
Authorization: Bearer <apikey>
Content-Type: application/json
Accept: application/json
Home screen
{"id": "home_1786090451", "number_of_prods": 6,
"user": {"id": "MjM1YzVmODQtMDBmOS00NDg2LWEwMGYtMTNlZjlmZmM1YjAw"},
"site": {"page": "app://home"},
"device": {"w": 1080, "h": 400}}
Category / listing
{"id": "category_1786090451", "number_of_prods": 6,
"user": {"id": "MjM1YzVmODQtMDBmOS00NDg2LWEwMGYtMTNlZjlmZmM1YjAw"},
"site": {"page": "app://category/sneakers", "cat": ["Obuwie", "Sneakersy"]},
"device": {"w": 1080, "h": 400}}
Product detail
{"id": "product_1786090451", "number_of_prods": 4,
"user": {"id": "MjM1YzVmODQtMDBmOS00NDg2LWEwMGYtMTNlZjlmZmM1YjAw"},
"site": {"page": "app://product/5901234567890", "gtin": "5901234567890"},
"device": {"w": 1080, "h": 400}}
Search results
{"id": "search_1786090451", "number_of_prods": 3,
"user": {"id": "MjM1YzVmODQtMDBmOS00NDg2LWEwMGYtMTNlZjlmZmM1YjAw"},
"site": {"page": "app://search", "keyword": "sneakersy"},
"device": {"w": 1080, "h": 400}}
Request fields
| Field | Type | Required | What it does |
|---|---|---|---|
id | string | ✅ | Your request identifier; echoed back so you can match response to request |
number_of_prods | int | ✅ for product formats | How many products to return. Match it to slots when the creative carries tiles |
user.id | string | ✅ | Advertising identifier, base64url encoded. Ties the request to the profile your SDK builds |
site.page | string | — | Screen identifier. Free-form; use a stable value per screen |
site.cat | array | — | Category path, most specific last. Drives category targeting |
site.keyword | string | — | Search query, verbatim. Drives keyword targeting |
site.gtin | string | — | Product being viewed, on product screens. Drives related-product selection |
device.w / device.h | int | — | Ad slot size in pixels, not screen size. Used to match creative dimensions |
device.ua / device.ip | string | — | Passed by your backend for fraud and geo signals |
bvendor | array | — | Brands to exclude from the response |
The one addition for apps:
send the advertising identifier as user.id, the same one your app reports through the
Mobile SDK. Profiles are matched on that value.
Where to request ads
A placement code is bound to one screen type. Ask MageAds for a separate code per screen — using a home-page code on a search screen returns poor results, because the ad decision has no context to work with.
| Screen in your app | Placement context | What to send in site |
|---|---|---|
| Home / start | home_page | page |
| Category, listing, brand page | category_page | page, cat[] — most important category first |
| Product detail | product_page | page, gtin of the product being viewed |
| Search results | search_page | page, keyword — the user's query, verbatim |
The same screen can host two kinds of ad: a sponsored products placement and a display placement. They are separate codes and separate requests.
Sponsored search results
This is the format with the strongest commercial performance, and the one most sensitive to getting the request right.
{
"id": "search_1786090451",
"number_of_prods": 3,
"user": {"id": "MjM1YzVmODQtMDBmOS00NDg2LWEwMGYtMTNlZjlmZmM1YjAw"},
"site": {"page": "app://search", "keyword": "sneakers", "cat": []},
"device": {"w": 1080, "h": 400},
"bvendor": []
}
Rules that matter:
- Send
keywordexactly as the user typed it. Do not stem it, translate it or strip diacritics — matching happens on our side and normalising twice loses intent. - Request the ad in parallel with your own search, not after it. The ad response and the organic results are independent; waiting for one before starting the other doubles the delay.
- Never let ads delay the results. If the ad response has not arrived by the time you render, render without it. Filling the slot slightly later is acceptable; an empty screen is not.
- Decide the positions in advance and keep them stable. Sponsored items are usually placed at
the top of the list or at fixed positions inside it. Whatever you choose, keep the returned
posorder among them. - Deduplicate against organic results. If a sponsored product also appears in your own results, show it once — as the sponsored one — and drop the organic occurrence, otherwise the user sees the same product twice.
- Label them. In search results the disclosure matters more than anywhere else, because the user reads the list as neutral.
Everything else — resolving gtin in your catalogue, pixels, ordering — works exactly as
described under render: native.
Sponsored products on other screens
Same contract, different context field:
- Category — send the full category path in
cat[], most specific last. The listing is the natural place for a sponsored strip or for items interleaved into the grid. - Product detail — send
gtin. Results are related or alternative products; place them in the "similar products" area rather than above the fold. - Home — send
pageonly. Results are a general recommendation; a horizontal strip works best.
render: native — product listings
The ad is a list of products from your own catalogue, promoted in a chosen order.
{
"id": "category_1786090451",
"contract_version": 1,
"banners": [{
"render": "native",
"sponsored_products": [
{"gtin": "5901234567890", "pos": 1,
"pixel_view": "https://delivery.mageads.com/…/v.gif?…&prid=5901234567890",
"pixel_click_nordr": "https://delivery.mageads.com/…/c.gif?…&nordr=1",
"advertiser": "Brand X",
"optout": "https://optout.mageads.com/?dsa=1&dsa_behalf=Brand+X&muid=…"}
]
}]
}
| Field | What it does |
|---|---|
gtin | Product identifier in your catalogue — resolve it there for name, price and image |
pos | Position in the promoted order, 1 first. Keep it |
pixel_view | Impression pixel for this product. Fire when its card becomes visible |
pixel_click_nordr | Click pixel that counts and returns immediately. Preferred in apps |
pixel_click | Click pixel that redirects to the product page. For browsers |
advertiser | Advertiser name. Use it in the disclosure label |
optout | DSA opt-out URL for this ad. Expose it where your disclosure UI allows |
Implementation. Resolve each gtin in your catalogue and render it with the product card you
already use. Keep the returned order — position is part of the ad decision. Label every item as
advertising and expose the optout link where your disclosure UI allows. If a product is missing
or out of stock, drop it and render the rest; never substitute another product.
Fire pixel_view when the card becomes visible and pixel_click_nordr on tap.
render: asset — image creatives
The creative is a finished image. The whole tile is the click target; any call-to-action button is part of the artwork.
{
"render": "asset",
"creative": {
"url": "https://static.mageads.com/c/18554/b/231001/r/1080w.webp",
"w": 1080, "h": 900,
"aspect_ratio": "6 / 5",
"alt": "Shop ABC — advertisement"
},
"click": {
"pixel": "https://delivery.mageads.com/…/c.gif?…&nordr=1",
"url": "https://shop.example.com/collection",
"url_app": "myshop://category/sneakers"
},
"counter": "…", "visibilityTag": "…", "blockedTag": "…"
}
| Field | What it does |
|---|---|
creative.url | The image to display |
creative.w / creative.h | Pixel size of the file, not of your slot |
creative.aspect_ratio | Design proportion. Size your slot from this, not from w/h |
creative.alt | Accessibility label. Set it as the view's content description |
click.pixel | Click pixel. Fire on tap, before navigating |
click.url | Web landing page |
click.url_app | Deeplink into your app. Use it when present and resolvable, else click.url |
Implementation. Size the slot from aspect_ratio — take the width from your layout and derive
the height. Never size from w/h; those describe the asset, not your slot. Load the image, set
alt as the accessibility label, make the whole view tappable.
On tap: fire click.pixel, then open click.url_app if present and resolvable, otherwise
click.url.
Image creatives with products
Some image creatives carry product tiles. You get the background and the tiles as separate images, and the product data alongside them:
{
"render": "asset",
"slots": 8,
"background": {"url": "…/bg/1080w.webp", "aspect_ratio": "6 / 5", "alt": "Shop ABC — advertisement"},
"tiles": [
{
"sku": "5901234567890", "pos": 1,
"image": {"url": "…/t/5901234567890/360w.webp", "aspect_ratio": "3 / 4",
"alt": "Sneakers X, 299.00 PLN — advertisement"},
"data": {"name": "Sneakers X", "price": "299.00", "old_price": "399.00",
"currency": "PLN", "image_url": "https://…/prod.jpg",
"url": "https://shop…/product"},
"click": {"pixel": "…/c.gif?…&prid=5901234567890&nordr=1", "url": "https://shop…/product"},
"pixel_view": "…/v.gif?…&prid=5901234567890"
}
],
"counter": "…", "visibilityTag": "…", "blockedTag": "…"
}
| Field | What it does |
|---|---|
slots | How many products the creative was designed for |
background | Brand layer of the creative — same fields as creative above |
tiles[].sku | Product identifier in your catalogue |
tiles[].pos | Position in the promoted order |
tiles[].image | Our rendering of this tile, if you want the creative as designed |
tiles[].data | The same product as data, if you prefer your own card |
tiles[].click | Click pixel and destination for this product |
tiles[].pixel_view | Impression pixel for this product |
You choose how to render each tile. Use our image if you want the creative exactly as
designed, or use data to build the tile with your own product card. Either way the tracking
fields are per product, so attribution works identically — and how you arrange the tiles, a
horizontal strip or a grid, is entirely your decision.
slots is how many products the creative was designed for. Ask for that many in
number_of_prods or some tiles will stay empty.
render: media — rich creatives
Rich creatives are video and image carousels with a product strip. They are not flattened, because flattening would throw away what the format is. Instead you get a reference to the creative's content:
{
"render": "media",
"creative_url": "https://api-panel.mageads.com/shopable-media/360/render.json/mobile",
"aspect_ratio": "1240 / 450",
"counter": "…", "visibilityTag": "…", "clickTagRaw": "…", "blockedTag": "…"
}
Fetch creative_url — from your backend, which can cache it aggressively; creative content
changes far less often than ad decisions:
{
"contract_version": 1,
"marketing_title": "Autumn 2026 collection",
"marketing_description": "New models available online",
"marketing_cta_text": "See the collection",
"marketing_target_url": "https://shop…",
"content_media": [
{"type": "video", "url": "https://stream.mux.com/….m3u8", "order": 1},
{"type": "image", "url": "https://…/frame-2.jpg", "order": 2}
],
"logo_media": {"type": "image", "url": "https://…/logo.png"},
"products": [
{"sku": "5901234567890", "name": "Sneakers X", "price": "299.00",
"url": "https://shop…", "image_url": "https://…/prod.jpg"}
]
}
| Field | What it does |
|---|---|
marketing_title / marketing_description | Headline and body copy |
marketing_cta_text | Label for the call-to-action button |
marketing_target_url | Where the call-to-action leads |
content_media[].type | video or image |
content_media[].url | Media file. Videos are HLS streams |
content_media[].order | Display order in the carousel |
logo_media | Advertiser logo |
products[] | Products to show in the strip: sku, name, price, url, image_url |
Implementation. A pager or carousel over content_media, ordered by order. Images load
directly; videos are HLS streams that play natively — AVPlayer on iOS, ExoPlayer on Android,
no extra library. Below or beside it, a strip of product cards from products. The CTA is a
button with marketing_cta_text opening marketing_target_url.
Size the slot from aspect_ratio, as with image creatives.
If your backend fetches creative_url and merges the result into its response, your app sees one
shape for every ad and never makes a second call. That is the simplest integration and it is what
we recommend.
render: unsupported
The auction returned a creative your app has no way to display. Collapse the slot and call
blockedTag. Do not show a placeholder.
This is also the safe default: if you receive a render value you do not recognise — because a
new format was added — treat it as unsupported.
Measurement
On the web our JavaScript library decides when an ad was seen. In an app you decide, and the quality of the reporting depends entirely on getting it right. The rules are identical across all render modes.
| Event | Field | When |
|---|---|---|
| Impression | counter, or pixel_view per product | The ad is rendered on screen |
| Viewable | visibilityTag | At least 50 % of the ad area is visible |
| Click | click.pixel / clickTagRaw / pixel_click_nordr | The user taps |
| Blocked | blockedTag | The creative arrived but could not be displayed |
Fields that control measurement
| Field | What it does |
|---|---|
counter | Impression pixel for the ad as a whole |
nocounter | 1 means the impression is counted elsewhere — do not call counter |
visibility_check | 0 means viewability is not measured for this creative |
visibilityTag | Viewability pixel. Absent when visibility_check is 0 |
clickTagRaw | Click URL, unescaped. clickTag is the same value HTML-escaped — use the raw one |
blockedTag | Call when the creative arrived but could not be displayed |
upixel | Call when a rendered creative is withdrawn before it could be seen |
The viewability pixel carries its measurement in the query string. A bare call records
nothing. The first call must include &views=1&time=<seconds> — that is what makes the impression
viewable. Every later call includes only &time=<seconds since the previous call> and accumulates
dwell time. Values outside 0.5–300 seconds are discarded, and views=1 must never be sent twice
for the same appearance.
visibilityTag is absent when visibility_check is 0 — that creative is not measured for
viewability. Likewise, skip counter when nocounter is 1.
Take clicks without the redirect. Append &nordr=1 so delivery counts the click and returns
immediately, then open the landing page yourself. Fields ending in _nordr already include it.
Deduplicate per appearance. One impression, one views=1, at most one click. Recycled cells
in a scrolling list must not re-fire pixels for an ad they already reported — the most common
source of inflated numbers in app integrations.
Call the URLs exactly as returned, query string intact. They carry the identifiers tying the event to its ad request; a rebuilt or double-escaped URL is discarded.
Verifying the integration
A successful HTTP response does not mean the impression was counted. Every pixel answers 200
regardless of what happened server-side. An impression can arrive and still be ignored —
disqualified because a campaign is inactive or a placement is not assigned to it. From the app
this is invisible.
So verification ends with the reports:
- Confirm the pixels leave the device with query strings unmodified.
- Count locally how many times you fired each one.
- Ask MageAds for that placement's figures for the same day — impressions counted, ignored, and viewable.
More ignored than counted points at campaign configuration. Fewer fired than expected points at the app missing renders — usually a recycled list cell deduplicated when it should not have been.
Checklist
- Ad requests go through your backend; the API key is not in the app binary
- The app branches on
render, never on the placement code ortype - An unrecognised
rendervalue is treated asunsupported -
user.idcarries the same advertising identifier the SDK reports - Slots are sized from
aspect_ratio - Sponsored products are labelled as advertising and keep their returned order
-
number_of_prodsmatchesslotsfor creatives that carry product tiles - Pixels are called from the device, query strings intact
- First
visibilityTagcall carriesviews=1; later calls carry onlytime -
counterskipped whennocounteris1;visibilityTagskipped when absent - Clicks use
nordr=1; the app opensurl_apporurlitself - Pixels deduplicated per appearance, including recycled list cells
- Unsupported creatives collapse the slot and call
blockedTag