Skip to main content

Campaign embed and public ranking

This guide is for brand web developers who want to:

  1. embed an IncentIA campaign (registration, login, photo upload) on their own landing page; and
  2. show the campaign's public ranking, read as JSON.

{platform} stands for the IncentIA host we give you (for example https://staging.incentia.app). {slug} stands for the campaign slug (for example yatekomo-es).

1. Embedding the campaign​

Paste one tag where the campaign should appear:

<script src="{platform}/embed/landing.js"
data-slug="yatekomo-it"
data-lang="it"
data-height="600"></script>
AttributeRequiredMeaning
data-slugyesCampaign slug. Without it nothing is rendered.
data-langnoTwo lowercase letters (^[a-z]{2}$, e.g. es, it). Any other value is ignored.
data-heightnoInitial iframe height in pixels. Default 600.
data-modenoform shows only the participation form (no hero or marketing sections). Any other value is ignored.
data-primarynoBrand colour for the form, as #RRGGBB (^#[0-9a-fA-F]{6}$). Any other value is dropped.
data-accentnoAccent colour, same format as data-primary.

What the loader does:

  • It inserts an <iframe> just before the <script> tag, at full width and with no border, pointing at {platform}/landing/c/{slug}?embed=1, plus &lang=, &compact=1, &primary= and &accent= when you set the matching attributes. The platform host comes from the script's own src, so the same tag works on staging and production.
  • The iframe has sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox", title="IncentIA" and loading="lazy".
  • The campaign page posts {type: "incentia:resize", height} to your page whenever its content height changes. The loader resizes the iframe to match, so you don't get an inner scrollbar. Only positive numeric heights are applied.
  • In embed mode the campaign page hides its own header and footer. Your page provides the chrome.
  • Animations: if the landing uses the v2 editor with entrance animations, they work inside the iframe and fire when the block enters the screen of your page (the browser measures visibility against the parent document's viewport), with no change to the loader. Hover effects work too. They respect the visitor's "reduce motion" setting. To check your page without animations, add ?motion=0 to the iframe address (you can try it by opening {platform}/landing/c/{slug}?embed=1&motion=0). With compact=1 (form only), avoid animating the form.

Language​

data-lang wins over the visitor's stored or browser language. It only applies if the campaign is configured for that language; otherwise the campaign's first language is used.

If your site runs its own consent manager, pass the visitor's choice to the embed so they are not asked twice:

window.IncentiaLandingEmbed.setConsent(consentObject);

When the embedded page loads it asks the loader for consent (incentia:cookie-consent:query). The loader replies with incentia:cookie-consent:state to that iframe only. If you never call setConsent, the embedded page shows its own consent dialog after about 800 ms. The embedded page only accepts relayed consent from origins we have allow-listed for it (see §3).

What the participant sees​

The embed shows an account flow with four screens:

  • Register: alias (3–30 characters: letters, digits, _, . and -), email, password (twice) and acceptance of the terms (the terms link opens in a new tab).
  • Login.
  • Forgot password: sends an email with a reset link.
  • Home: after login. This is where the participant uploads photos.

Upload rules are set per campaign. For example, on the YaTeKomo campaigns:

  • JPEG only (.jpg / .jpeg) when the campaign requires it. Other formats are rejected before upload.
  • Maximum size in MB comes from the campaign and is shown on screen. The platform's hard cap is about 7.5 MB.
  • Lot number when the campaign requires it: free text of up to 32 characters once whitespace is removed. It is stored upper-cased.

Password reset links in the email open {platform}/landing/c/{slug}/reset-password as a normal top-level page, not inside your iframe. After setting a new password the participant goes back to your landing and logs in. The reset page does not redirect, because it cannot know which page embedded the campaign.

2. Public ranking API​

GET {platform}/api/public/campaign/{slug}/ranking

Anonymous, no API key. Response 200:

{
"total": 1342,
"top": [
{ "alias": "lucia_92", "participacion": 100 },
{ "alias": "marco.r", "participacion": 64 }
]
}
  • total: number of valid photo submissions in the campaign. Rejected and duplicate submissions, and those from blocked participants, are not counted.
  • top: the top N participants who have an alias (N is set per campaign, 1–50, default 5), ordered by number of valid submissions. Ties go to whoever uploaded first.
  • participacion depends on the campaign's mode:
    • percentOfFirst: round(100 × count / count_of_first), so the first entry is always 100.
    • percentOfTotal: round(100 × count / total, 2) — the share of all counted uploads (10 of 100 → 10); a decimal with up to 2 places.
    • position: the rank, 1..N.
  • No IDs, emails or lot numbers are ever returned.

Behaviour:

TopicDetail
FreshnessThe result is cached for 60 s. Responses carry Cache-Control: public, max-age=60. Polling more often than once a minute gains nothing.
CORSAccess-Control-Allow-Origin is sent only when the request Origin exactly matches an allow-listed origin. It echoes that origin, never *. Every response has Vary: Origin.
CredentialsNever allowed. Don't send cookies (credentials: "omit", the default for cross-origin fetch).
PreflightNot needed. A plain GET with no custom headers is a "simple" request. Don't add Authorization, Content-Type or X-* headers. (OPTIONS is answered with 204 and the same CORS headers if you do need it.)
Disabled / unknown404 with {"error": "not_found"}. This covers an unknown slug, a disabled ranking, and a campaign that is a draft, cancelled or outside its start/end dates (the ranking disappears when the campaign ends).
Rate limitFixed window per client IP and campaign (default 100 requests/minute), plus an aggregate cap per client IP across campaigns (default 60 requests/minute). Above either you get 429.

fetch example​

async function loadRanking() {
const res = await fetch('{platform}/api/public/campaign/yatekomo-es/ranking');
if (!res.ok) return null; // 404 = ranking off; 429 = slow down
return res.json(); // { total, top: [{ alias, participacion }] }
}

Static HTML example​

<ol id="ranking"></ol>
<p>Total: <span id="ranking-total">–</span></p>
<script>
fetch('{platform}/api/public/campaign/yatekomo-it/ranking')
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (!data) return;
document.getElementById('ranking-total').textContent = data.total;
var list = document.getElementById('ranking');
data.top.forEach(function (e) {
var li = document.createElement('li');
li.textContent = e.alias + ' – ' + e.participacion; // textContent: aliases are user input
list.appendChild(li);
});
});
</script>

Always render alias as text, never as HTML.

3. What you need to send us​

The list of origins that will embed the campaign or call the ranking (we use it for both the ranking CORS list and the consent relay), one per landing (ES, IT, staging/preview hosts):

  • format: https://host or https://host:port;
  • scheme and host only, with no path and no trailing slash (https://www.example.it, not https://www.example.it/promo/);
  • www.example.it and example.it are different origins, so send both if both are served.

4. Troubleshooting​

SymptomLikely causeFix
Blank or refused iframeYour page's Content-Security-Policy blocks the platform host (script-src or frame-src/child-src), or a proxy or CMS injects X-Frame-Options: SAMEORIGIN / frame-ancestors into the iframe response.Add {platform} to script-src and frame-src. Check the browser console for the blocked directive.
Iframe stays 600 px / has a scrollbarThe <script> was copied without loading landing.js, or a script blocks message events.Use the tag from §1 unchanged. Raise data-height as a fallback.
CORS error on the rankingYour exact origin is not on the allow-list (www vs. apex, port, http vs. https), or you added a custom header that triggers a preflight.Send us the exact origin (§3). Use a plain GET.
Ranking returns 404Wrong slug, ranking not enabled, or campaign not live (before start / after end).Check the slug, then ask us to enable it.
Wrong languagedata-lang missing, not two lowercase letters, or not configured for the campaign.Use data-lang="es" / "it".
Terms link does nothingThe terms open in a new tab. Some browsers or wrappers block pop-ups from sandboxed iframes.Allow pop-ups for your site and test in a normal browser tab.
Consent dialog shows although the visitor already chosesetConsent was not called, or was called after the iframe asked.Call IncentiaLandingEmbed.setConsent(...) as soon as your consent manager resolves.

5. Testing against staging​

  • Loader: https://staging.incentia.app/embed/landing.js
  • Campaigns: yatekomo-es (Spanish) and yatekomo-it (Italian)
<script src="https://staging.incentia.app/embed/landing.js" data-slug="yatekomo-es" data-lang="es"></script>
<script src="https://staging.incentia.app/embed/landing.js" data-slug="yatekomo-it" data-lang="it"></script>
curl -i -H "Origin: https://your-test-host.example" \
https://staging.incentia.app/api/public/campaign/yatekomo-es/ranking

Check for HTTP 200, Vary: Origin, Cache-Control: public, max-age=60, and Access-Control-Allow-Origin: https://your-test-host.example. That last header only appears once we have added your test origin. Staging data is reset without notice. Use test emails only.