External games contract v1
This page describes the postMessage contract between your game bundle (loaded in a sandboxed iframe) and the IncentIA member portal. The bundle reports what happened; the server decides the outcome.
1. Lifecycle
Member opens the game → POST /api/member/portal/external-games/{gameId}/sessions/start
Host renders the iframe → the bundle loads with the handshake query string
Bundle plays, then → postMessage({ type: "incentia.complete.v1", ... })
Host forwards the signal → POST /api/member/portal/external-games/sessions/{sessionId}/complete
Server resolves outcome → host posts incentia.outcome.v1 back to the iframe
The host page makes both HTTP calls. Your bundle only exchanges messages with its parent window.
2. Handshake
The host appends these query-string parameters to the bundle URL:
| Param | Value |
|---|---|
sessionId | ext-<uuid> issued by the server |
playToken | Short-lived token (5 minutes) that authorises the completion call |
host | The host page's window.location.origin |
scoreSecret | Only for skill-gated games (minimum score above 0): the per-session score-signing secret (section 8). Absent otherwise. |
Read these on load. Echo sessionId back in the complete message.
3. Messages: bundle → host
Post to window.parent once, when the play is over:
window.parent.postMessage({
type: 'incentia.complete.v1',
sessionId: '<the sessionId from the handshake>',
signal: { score: 100 }, // JSON validated against the game's signal schema
}, '*');
type: required, must beincentia.complete.v1.sessionId: required, must equal the handshakesessionId.signal: an object or a JSON string. The server validates it against the game's signal schema. Maximum 16 KB once serialised.
The host ignores messages that fail any of these checks.
4. Messages: host → bundle
When the server has resolved the play, the host posts the outcome to the iframe:
{
type: 'incentia.outcome.v1',
sessionId: 'ext-...',
outcome: 'win' | 'lose',
prize: { name?: string, type?: string, couponCode?: string },
pointsAwarded?: number,
}
Listen for message events and show your celebration or consolation screen.
5. Iframe sandbox
The host renders your bundle like this:
<iframe sandbox="allow-scripts"
referrerpolicy="no-referrer"
loading="lazy"
src="<bundle url>?sessionId=...&playToken=...&host=..."></iframe>
Only allow-scripts is granted. There is no allow-same-origin, allow-top-navigation or allow-forms. Your bundle runs in an opaque origin: no cookies, and localStorage throws. The bundle URL is also served with a Content-Security-Policy: sandbox allow-scripts header.
6. Origin and source check
Because the iframe has an opaque origin, messages from it have event.origin === "null". The host therefore checks the sender's window identity (it must be the game iframe), not the origin string. In your bundle, compare event.origin with the host parameter when you receive the outcome.
The host only loads bundles over https://, from its own host or from an allow-listed bundle host.
7. Limits and token expiry
- A member can start 10 plays per game per hour. Beyond that,
sessions/startreturns429. - Only one session per member and game can be in progress at a time (otherwise
429). - The
playTokenlives 5 minutes. After that, completion is rejected. - Completion is idempotent per
sessionId: a second call returns the outcome already decided, without running the engine again.
8. Skill-score gate and score signing
Most external games are outcome-by-lottery: the reported score is informational and the server draws the prize from the game's probability table. Leave Minimum score for prize (skill gate) at 0 (the default) and post a plain { score }. This is the only sound option for untrusted third-party bundles.
A game can instead set a minimum score N. A play is then eligible for a prize only when the reported score is >= N. The score is computed in the member's browser, so it can be forged. The server therefore requires it to be signed.
Trust model
- The server never sends a global key to the browser. At
sessions/startit derives a per-session secret and returns it asscoreSecret(the host forwards it in the query string). The secret can only sign this one session's score. - Signing limits the damage; it does not make the score trustworthy. The secret lives in the player's browser, so a determined player can sign any score for their own session.
- Use a minimum score only for low-value or engagement prizes and only on bundles you wrote and trust. A bundle that receives the secret can sign a maximum score for every player.
An unsigned or wrongly signed score is treated as no prize. This blocks naive bundles, network tampering and replay across sessions, members or games.
Computing the signature
scoreSig = base64url( HMAC_SHA256( key = UTF8(scoreSecret),
msg = UTF8( decimalString(score) ) ) )
- Use
scoreSecretexactly as received, as raw UTF-8 key bytes. Do not base64-decode it. scoreis the integer you put insignal.score, as a plain decimal string (String(score)).base64urlis standard base64 with+→-,/→_and trailing=removed.
async function signScore(scoreSecret, score) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw', enc.encode(scoreSecret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(String(score)));
return btoa(String.fromCharCode(...new Uint8Array(sig)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
window.parent.postMessage({
type: 'incentia.complete.v1',
sessionId,
signal: { score: 4200, scoreSig: await signScore(scoreSecret, 4200) },
}, '*');
scoreSig is a reserved field. The server removes it before validating your signal schema, so a schema with "additionalProperties": false still passes.
9. Minimal bundle
<!doctype html>
<html><body><button id="play">Play</button>
<script>
const params = new URLSearchParams(location.search);
const sessionId = params.get('sessionId');
document.getElementById('play').onclick = () => {
parent.postMessage(
{ type: 'incentia.complete.v1', sessionId, signal: { score: 100 } },
'*');
};
window.addEventListener('message', (e) => {
if (e.data?.type === 'incentia.outcome.v1') alert('Outcome: ' + e.data.outcome);
});
</script></body></html>