Campaign embed and public ranking
This guide is for brand web developers who want to:
- embed an IncentIA campaign (registration, login, photo upload) on their own landing page; and
- 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>
| Attribute | Required | Meaning |
|---|---|---|
data-slug | yes | Campaign slug. Without it nothing is rendered. |
data-lang | no | Two lowercase letters (^[a-z]{2}$, e.g. es, it). Any other value is ignored. |
data-height | no | Initial iframe height in pixels. Default 600. |
data-mode | no | form shows only the participation form (no hero or marketing sections). Any other value is ignored. |
data-primary | no | Brand colour for the form, as #RRGGBB (^#[0-9a-fA-F]{6}$). Any other value is dropped. |
data-accent | no | Accent 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 ownsrc, 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"andloading="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=0to the iframe address (you can try it by opening{platform}/landing/c/{slug}?embed=1&motion=0). Withcompact=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.
Cookie consent (optional)
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.participaciondepends on the campaign's mode:percentOfFirst:round(100 × count / count_of_first), so the first entry is always100.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:
| Topic | Detail |
|---|---|
| Freshness | The result is cached for 60 s. Responses carry Cache-Control: public, max-age=60. Polling more often than once a minute gains nothing. |
| CORS | Access-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. |
| Credentials | Never allowed. Don't send cookies (credentials: "omit", the default for cross-origin fetch). |
| Preflight | Not 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 / unknown | 404 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 limit | Fixed 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://hostorhttps://host:port; - scheme and host only, with no path and no trailing slash (
https://www.example.it, nothttps://www.example.it/promo/); www.example.itandexample.itare different origins, so send both if both are served.
4. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Blank or refused iframe | Your 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 scrollbar | The <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 ranking | Your 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 404 | Wrong slug, ranking not enabled, or campaign not live (before start / after end). | Check the slug, then ask us to enable it. |
| Wrong language | data-lang missing, not two lowercase letters, or not configured for the campaign. | Use data-lang="es" / "it". |
| Terms link does nothing | The 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 chose | setConsent 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) andyatekomo-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.