Contrato v1 de juegos externos
Esta página describe el contrato postMessage entre tu paquete de juego (cargado en un iframe aislado) y el portal del socio de IncentIA. El paquete informa de lo ocurrido; el servidor decide el resultado.
1. Ciclo de vida
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
La página anfitriona hace las dos llamadas HTTP. Tu paquete solo intercambia mensajes con su ventana padre.
2. Handshake
La página anfitriona añade estos parámetros a la URL del paquete:
| Parámetro | Valor |
|---|---|
sessionId | ext-<uuid> emitido por el servidor |
playToken | Token de vida corta (5 minutos) que autoriza la llamada de finalización |
host | El window.location.origin de la página anfitriona |
scoreSecret | Solo en juegos con umbral de puntuación (puntuación mínima mayor que 0): el secreto de firma de la sesión (apartado 8). Si no hay umbral, no aparece. |
Léelos al cargar. Devuelve el sessionId en el mensaje complete.
3. Mensajes del paquete al anfitrión
Envía un mensaje a window.parent una sola vez, cuando termine la partida:
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: obligatorio, debe serincentia.complete.v1.sessionId: obligatorio, debe coincidir con elsessionIddel handshake.signal: un objeto o una cadena JSON. El servidor lo valida con el esquema de señal del juego. Máximo 16 KB ya serializado.
El anfitrión ignora los mensajes que incumplan alguna de estas condiciones.
4. Mensajes del anfitrión al paquete
Cuando el servidor resuelve la partida, el anfitrión envía el resultado al iframe:
{
type: 'incentia.outcome.v1',
sessionId: 'ext-...',
outcome: 'win' | 'lose',
prize: { name?: string, type?: string, couponCode?: string },
pointsAwarded?: number,
}
Escucha los eventos message y muestra tu pantalla de premio o de consolación.
5. Aislamiento del iframe
El anfitrión muestra tu paquete así:
<iframe sandbox="allow-scripts"
referrerpolicy="no-referrer"
loading="lazy"
src="<bundle url>?sessionId=...&playToken=...&host=..."></iframe>
Solo se concede allow-scripts. No hay allow-same-origin, allow-top-navigation ni allow-forms. Tu paquete se ejecuta en un origen opaco: sin cookies y con localStorage bloqueado. La URL del paquete también se sirve con la cabecera Content-Security-Policy: sandbox allow-scripts.
6. Comprobación de origen
Como el iframe tiene origen opaco, los mensajes que envía tienen event.origin === "null". Por eso el anfitrión comprueba la identidad de la ventana emisora (debe ser el iframe del juego) y no la cadena de origen. En tu paquete, al recibir el resultado, compara event.origin con el parámetro host.
El anfitrión solo carga paquetes por https://, desde su propio dominio o desde un dominio de paquetes autorizado.
7. Límites y caducidad del token
- Un socio puede iniciar 10 partidas por juego y hora. A partir de ahí,
sessions/startdevuelve429. - Solo puede haber una sesión en curso por socio y juego (si no,
429). - El
playTokendura 5 minutos. Pasado ese tiempo, la finalización se rechaza. - La finalización es idempotente por
sessionId: una segunda llamada devuelve el resultado ya decidido sin volver a ejecutar el motor.
8. Umbral de puntuación y firma
La mayoría de los juegos externos son de resultado por sorteo: la score es informativa y el servidor elige el premio de la tabla de probabilidades del juego. Deja Minimum score for prize (skill gate) en 0 (el valor por defecto) y envía un { score } simple. Es la única opción sensata para paquetes de terceros en los que no confías.
Un juego puede fijar una puntuación mínima N. Entonces una partida solo opta a premio si la puntuación enviada es >= N. La puntuación se calcula en el navegador del socio y se puede falsificar, así que el servidor exige que vaya firmada.
Modelo de confianza
- El servidor nunca envía una clave global al navegador. En
sessions/startderiva un secreto por sesión y lo devuelve comoscoreSecret(el anfitrión lo reenvía en la URL). Ese secreto solo sirve para firmar la puntuación de esa sesión. - Firmar limita el daño; no hace fiable la puntuación. El secreto está en el navegador del jugador, así que un jugador decidido puede firmar la puntuación que quiera en su propia sesión.
- Usa una puntuación mínima solo con premios de poco valor o de participación y solo con paquetes que hayas escrito tú y en los que confíes. Un paquete que recibe el secreto puede firmar la puntuación máxima para todos los jugadores.
Una puntuación sin firma o mal firmada se trata como sin premio. Así se bloquean los paquetes ingenuos, la manipulación en la red y la reutilización entre sesiones, socios o juegos.
Calcular la firma
scoreSig = base64url( HMAC_SHA256( key = UTF8(scoreSecret),
msg = UTF8( decimalString(score) ) ) )
- Usa
scoreSecrettal como lo recibes, como bytes UTF-8 de la clave. No lo decodifiques de base64. scorees el entero que pones ensignal.score, como cadena decimal simple (String(score)).base64urles base64 estándar con+→-,/→_y sin los=finales.
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 es un campo reservado. El servidor lo quita antes de validar tu esquema de señal, así que un esquema con "additionalProperties": false sigue funcionando.
9. Paquete mínimo
<!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>