Integrating Loyalty in Your Game
Everything your game calls to show a player their loyalty programs, let them redeem rewards, and move their progress forward. The player routes use the player’s SDK token from your game client; event reporting and the server read use your game secret from your server. New to the feature? Start with the Loyalty & Rewards overview.
Two kinds of call
| Route | Called from | Authentication |
|---|---|---|
GET /api/sdk/loyalty/me/card | Game client | Authorization: Bearer <player token> |
GET /api/sdk/loyalty/me/offers | Game client | Player token |
GET /api/sdk/loyalty/me/vault | Game client | Player token |
POST /api/sdk/loyalty/me/vault/{vault_entry_id}/redeem | Game client | Player token |
POST /api/sdk/loyalty/me/rewards-page | Game client | Player token |
POST /api/sdk/loyalty/events | Game client | Player token |
POST /api/sdk/loyalty/server-events | Your game server | X-Game-Secret-Key |
GET /api/sdk/loyalty/players/rewards?player_email=... | Your game server | X-Game-Secret-Key |
Base URL: https://invo.network in production and https://sandbox.invo.network/sandbox in sandbox. The player is always the one the token (or, on server routes, the player_email in your game) names: no route takes a player id in its path or body, and no route can read another game’s players.
Keep the game secret on your server. Never ship X-Game-Secret-Key in a game client. The player token is the only credential a client should hold.
SDK methods
The SDKs wrap these routes from @invonetwork/web-sdk 3.20.0 and invonetwork (Python) 3.18.0. Each method returns the same fields as the route, and throws the SDK’s usual error on a refusal.
| Route | Web SDK | Python SDK |
|---|---|---|
GET /me/card | InvoClient.getMyRewards() | Client route |
GET /me/vault | InvoClient.getMyVault() | Client route |
POST /me/vault/{vault_entry_id}/redeem | InvoClient.redeemReward(vaultEntryId) | Client route |
POST /me/rewards-page | InvoClient.getRewardsPageLink() | Client route |
GET /players/rewards | InvoServer.getPlayerRewards(playerEmail) | InvoServer.get_player_rewards(player_email) |
The Web SDK returns camelCase fields (stepsComplete, codeMasked); the Python SDK returns typed objects with the route’s own names (steps_complete, code_masked). The raw body is on raw in both.
The card: GET /api/sdk/loyalty/me/card
The data behind every loyalty UI: one card per program the player can see in this game, with each step’s state and progress and the rewards the player unlocked from it. It is the same shape the console preview shows you, so a card you design in the console is the card your player gets.
curl https://invo.network/api/sdk/loyalty/me/card \
-H "Authorization: Bearer $PLAYER_TOKEN"import { InvoClient } from "@invonetwork/web-sdk";
const invo = new InvoClient({ token: playerToken, baseUrl: "https://invo.network" });
const { cards } = await invo.getMyRewards(); // cards[].steps[].progress, cards[].rewards[].codeMasked{
"card_version": 1,
"cards": [
{
"card_version": 1,
"mode": "live",
"program_id": "lpb_8f2c41",
"quest_id": "qst_51a0",
"quest_version": 2,
"campaign_id": "prg_7d11",
"title": "Weekend Warrior",
"description": "Top up, play five matches and grab a reward.",
"brand": { "name": "Example Brand", "logo_url": "https://.../brand-logo.png" },
"art_url": "https://.../card-art.png",
"reward": { "reward_id": "rwd_25gc", "name": "$25 gift card" },
"order_mode": "ordered",
"status": "in_progress",
"starts_at": "2026-10-01T00:00:00+00:00",
"ends_at": "2026-10-31T23:59:59+00:00",
"completion_hours": 168,
"completion_note": null,
"window_ends_at": "2026-10-09T18:30:00+00:00",
"completed_at": null,
"steps_required": 3,
"steps_complete": 1,
"steps": [
{ "step_id": "0e5c...", "index": 0, "action": "buy_currency",
"label": "Buy currency", "summary": "Buy currency (at least $10.00)",
"window_hours": null, "state": "complete",
"progress": { "current": 1, "target": 1 } },
{ "step_id": "7a91...", "index": 1, "action": "custom_event",
"label": "A custom game event", "summary": "A custom game event (match_completed, 5 times)",
"window_hours": null, "state": "open",
"progress": { "current": 3, "target": 5 } },
{ "step_id": "c4d2...", "index": 2, "action": "transfer_out",
"label": "Transfer currency to another game", "summary": "Transfer currency to another game",
"window_hours": null, "state": "locked",
"progress": { "current": 0, "target": 1 } }
],
"rewards": []
}
]
}| Field | Meaning |
|---|---|
cards[] | One card per program offered to this player in this game. Empty when there is nothing to show. |
status | available (not started), in_progress, completed, expired, or in_review (finished, and the reward is being checked before it is released). |
order_mode | ordered or any_order. |
steps[].state | complete, open (the player can work on it now) or locked (in an ordered program, a step after the first unfinished one). In any order, every unfinished step is open. |
steps[].progress | {current, target}, for example {"current": 3, "target": 5}. Every step has a target: 1, unless the program asks for a count. null in the console preview, and on a live card when the number could not be read this time: render the step’s state alone then. Display only: state decides whether a step is done, and current reaches target only when state is complete. |
steps_complete / steps_required | The overall bar: how many steps are done, and how many are needed to unlock the reward. |
summary | A ready-to-show line for the step, such as “Buy currency (at least $10.00)”. |
window_ends_at | Once the player has started, when their time to finish runs out. |
completion_note | A ready-to-show sentence about the time limit when the program has no fixed one; otherwise null. |
rewards[] | The rewards the player unlocked from this program: vault_entry_id, reward_id, state, unlocked_at, expires_at, redeemed_at, code_masked, can_redeem, can_reveal_again, reveal_until. |
A card never carries a code. A redeemed reward shows only its mask, for example ••••ABCD. The full code appears in the redeem response and nowhere else. Fields may be added to the card without notice; card_version changes only if a field is removed or changes meaning.
Showing progress: “3 / 5 matches”
Draw a bar per step from progress, fall back to the step’s state when progress is null, and show the overall steps_complete of steps_required at the top.
Reward: $25 gift card
function stepLabel(step) {
if (step.state === "complete") return "Done";
if (step.state === "locked") return "Locked";
if (step.progress) return step.progress.current + " / " + step.progress.target; // "3 / 5"
return "Open";
}Offers and rewards lists
Two lighter reads, if you do not need the full card. GET /api/sdk/loyalty/me/offers lists the programs this player can do or is doing in this game, with a progress count per program. GET /api/sdk/loyalty/me/vault lists every reward the player holds or held, newest first, codes masked.
{
"items": [
{
"quest_id": "qst_51a0",
"quest_version": 2,
"program_id": "prg_7d11",
"campaign": true,
"title": "Weekend Warrior",
"reward_ref": "rwd_25gc",
"xp_award": 0,
"objectives_required": 3,
"objectives_complete": 1,
"progress_state": "in_progress",
"window_ends_at": "2026-10-09T18:30:00+00:00",
"completed_at": null
}
]
}{
"items": [
{
"vault_entry_id": "6f1d2c0e-8b7a-4e7c-9a51-0d3f6a2b9c11",
"reward_ref": "rwd_25gc",
"program_id": "prg_7d11",
"quest_id": "qst_51a0",
"units": "1",
"state": "unlocked",
"unlocked_at": "2026-10-05T14:02:11+00:00",
"expires_at": "2026-12-31T23:59:59+00:00",
"redeemed_at": null,
"code_masked": null
}
]
}Reward state is one of unlocked (ready to redeem), reserved, redeemed, expired or revoked.
Redeem a reward
POST /api/sdk/loyalty/me/vault/{vault_entry_id}/redeem redeems one unlocked reward and returns its code. Show the code to the player straight away. The response is sent with Cache-Control: no-store: do not log it, cache it or store it.
curl -X POST https://invo.network/api/sdk/loyalty/me/vault/6f1d2c0e-8b7a-4e7c-9a51-0d3f6a2b9c11/redeem \
-H "Authorization: Bearer $PLAYER_TOKEN"{
"vault_entry_id": "6f1d2c0e-8b7a-4e7c-9a51-0d3f6a2b9c11",
"reward_ref": "rwd_25gc",
"state": "redeemed",
"redeemed_at": "2026-10-05T14:05:40+00:00",
"code": "GC-7Q2M-XK4P-ABCD",
"code_masked": "••••ABCD",
"revealed_again": false,
"reveal_until": "<until when this player can see the code again>"
}- If the player lost the code, the card’s
can_reveal_againtells you whether calling redeem again will show it once more (revealed_again: true). Afterreveal_untilit cannot be shown again. already_redeemed: the reward was redeemed and can no longer be shown.vault_entry_not_found: no such reward for this player.vault_entry_expired: the reward expired before it was redeemed.
try {
const { code } = await invo.redeemReward(reward.vaultEntryId);
showToPlayer(code); // show it now; never log, cache or store it
} catch (err) {
if (err.isRewardAlreadyRedeemed) { /* already redeemed and can no longer be shown */ }
else if (err.isRewardNotFound) { /* no such reward for this player */ }
else if (err.isRewardExpired) { /* expired before it was redeemed */ }
else throw err;
}The SDK never retries a redeem on its own; calling it again is safe and shows the code again while the card allows it.
The hosted “My rewards” page
No UI of your own? POST /api/sdk/loyalty/me/rewards-page returns a link to an INVO-hosted page that shows the player’s cards, progress and rewards, with a Redeem button. Open the url in the system browser or a web view. The page cannot be embedded in an iframe.
curl -X POST https://invo.network/api/sdk/loyalty/me/rewards-page \
-H "Authorization: Bearer $PLAYER_TOKEN"const { url } = await invo.getRewardsPageLink();
window.open(url);{
"url": "https://invo.network/loyalty/rewards#t=...",
"expires_at": "2026-10-05T14:20:00+00:00"
}The link is short-lived and personal to the player. Ask for a fresh one each time the player opens the page, and never share or store it.
Reporting game events
Currency steps need nothing from you. Purchases, transfers, sends, item spends and subscriptions are seen by INVO directly and count toward a step on their own. Gameplay steps (sessions played, coming back after a break, levels and milestones, custom events such as a match completed) move when your game reports what happened.
Client-reported
POST /api/sdk/loyalty/events with the player token. Useful for responsive progress in the client. A client-reported event can move a gameplay step forward and complete it, but a real-value reward is paid only when its gameplay steps were completed on server-reported events.
Server-reported
POST /api/sdk/loyalty/server-events with X-Game-Secret-Key, from your server. Server-reported events are trusted for rewards: a real-value reward is paid only when its gameplay steps were completed on these.
Event types
event_type | payload |
|---|---|
game.session.started | session_ref |
game.session.heartbeat | session_ref |
game.session.ended | session_ref, duration_seconds |
game.milestone.reached | milestone_key, optional value, optional session_ref |
game.custom.recorded | event_name (a name you registered in the console), optional value |
Send a UUID event_id with each event and resend the same id if you retry, so an event is never counted twice. occurred_at is optional (ISO 8601 with a timezone); omit it to use the time INVO receives the event. Games report game.* types only. The server route refuses any other type (EVENT_TYPE_NOT_ALLOWED); the client route refuses the event types INVO reports itself (RESERVED_EVENT_TYPE).
Server-reported: POST /api/sdk/loyalty/server-events
Identify the player by the email they have in your game. The player must already exist in that game.
curl -X POST https://invo.network/api/sdk/loyalty/server-events \
-H "X-Game-Secret-Key: $INVO_GAME_SECRET" \
-H "Content-Type: application/json" \
-d '{
"player_email": "player@example.com",
"event_type": "game.custom.recorded",
"event_id": "3b9e7c2a-1f4d-4a8e-9c6b-5d2e8f1a7b30",
"occurred_at": "2026-10-05T14:00:00Z",
"payload": { "event_name": "match_completed" }
}'import { randomUUID } from "node:crypto";
const BASE = process.env.INVO_BASE_URL; // https://invo.network | https://sandbox.invo.network/sandbox
const GAME_SECRET = process.env.INVO_GAME_SECRET; // server-side only
async function reportMatchCompleted(playerEmail, eventId = randomUUID()) {
const res = await fetch(BASE + "/api/sdk/loyalty/server-events", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Game-Secret-Key": GAME_SECRET },
body: JSON.stringify({
player_email: playerEmail,
event_type: "game.custom.recorded",
event_id: eventId, // keep it, and resend the same id on retry
payload: { event_name: "match_completed" },
}),
});
return { status: res.status, json: await res.json().catch(() => ({})) };
}import os, uuid, requests
BASE = os.environ["INVO_BASE_URL"] # https://invo.network | https://sandbox.invo.network/sandbox
GAME_SECRET = os.environ["INVO_GAME_SECRET"] # server-side only
def report_match_completed(player_email, event_id=None):
event_id = event_id or str(uuid.uuid4()) # keep it, and resend the same id on retry
r = requests.post(BASE + "/api/sdk/loyalty/server-events",
headers={"X-Game-Secret-Key": GAME_SECRET},
json={"player_email": player_email,
"event_type": "game.custom.recorded",
"event_id": event_id,
"payload": {"event_name": "match_completed"}},
timeout=30)
return r.status_code, r.json()202 means the event was recorded. Refusals:
| Status | code | Remedy |
|---|---|---|
| 401 | INVALID_GAME_SECRET | Send your game’s server secret in X-Game-Secret-Key. |
| 400 | INVALID_BODY, MISSING_PLAYER_EMAIL, MISSING_EVENT_TYPE, EVENT_TYPE_NOT_ALLOWED, INVALID_EVENT_ID, INVALID_OCCURRED_AT, INVALID_PAYLOAD, INVALID_SURFACE_ID | Fix the body: a JSON object, a game.* type, a UUID event_id, a timezone on occurred_at, a JSON object payload, a string surface_id if you send one. |
| 404 | PLAYER_NOT_FOUND | The email is not a player in this game yet. |
| 422 | validation_failed | Read message; for example a custom event name you have not registered, or a value outside the bounds you registered. |
| 409 | Any | The game is not set up for loyalty. Contact INVO support. |
| 429 | Any | Too many requests. Treat any 429 as retryable: pause, then retry with the same event_id. Do not match on a code. |
| 503 | Any | Temporarily unavailable. Retry later with the same event_id. |
| 502 / 504 | Any | The loyalty service could not be reached, or timed out. Retry later with the same event_id. |
Client-reported: POST /api/sdk/loyalty/events
The same event types and payloads, with the player token. No player_email: the token names the player. An event type INVO reports itself is refused with 400 RESERVED_EVENT_TYPE.
curl -X POST https://invo.network/api/sdk/loyalty/events \
-H "Authorization: Bearer $PLAYER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"event_type": "game.milestone.reached",
"event_id": "9a4c1e2b-7d3f-4b6a-8e5c-2f1a0b9d8c77",
"payload": { "milestone_key": "level_10" }
}'Reading a player’s progress from your server
GET /api/sdk/loyalty/players/rewards?player_email=... with X-Game-Secret-Key returns the same card shape as /me/card, for that player in your game. Use it when your game server renders loyalty UI, sends its own notifications (“2 matches to go”), or needs progress without a player token. Codes never appear here either; rewards carry their mask only. The response is sent with Cache-Control: no-store.
Server secret only. Use your game’s server secret key; a player token is never accepted here. To show a player their own cards in your game client, use /me/card with the player token instead.
curl -G https://invo.network/api/sdk/loyalty/players/rewards \
--data-urlencode "player_email=player@example.com" \
-H "X-Game-Secret-Key: $INVO_GAME_SECRET"import { InvoServer } from "@invonetwork/web-sdk/server";
const invo = new InvoServer({
gameSecret: process.env.INVO_GAME_SECRET!, // server-side only
baseUrl: process.env.INVO_BASE_URL!, // https://invo.network | https://sandbox.invo.network/sandbox
});
const { cards } = await invo.getPlayerRewards("player@example.com");
for (const card of cards) {
for (const step of card.steps) {
if (step.progress) console.log(step.summary, step.progress.current + " / " + step.progress.target);
}
}const url = new URL(process.env.INVO_BASE_URL + "/api/sdk/loyalty/players/rewards");
url.searchParams.set("player_email", "player@example.com");
const res = await fetch(url, { headers: { "X-Game-Secret-Key": process.env.INVO_GAME_SECRET } });
const { cards } = await res.json();import os
from invonetwork import InvoServer
invo = InvoServer(
game_secret=os.environ["INVO_GAME_SECRET"], # server-side only
base_url=os.environ["INVO_BASE_URL"], # https://invo.network | https://sandbox.invo.network/sandbox
)
result = invo.get_player_rewards("player@example.com")
for card in result.cards:
print(card.title, card.steps_complete, "/", card.steps_required)
for step in card.steps:
if step.progress is not None: # None: render step.state alone
print(" ", step.summary, step.progress.current, "/", step.progress.target)import os, requests
r = requests.get(os.environ["INVO_BASE_URL"] + "/api/sdk/loyalty/players/rewards",
params={"player_email": "player@example.com"},
headers={"X-Game-Secret-Key": os.environ["INVO_GAME_SECRET"]},
timeout=30)
cards = r.json()["cards"]200 returns {"card_version": 1, "cards": [...]}, exactly the player’s own /me/card. Refusals:
| Status | code | Remedy | SDK |
|---|---|---|---|
| 400 | INVALID_PLAYER_EMAIL | Send the player’s email address in player_email. | isInvalidPlayerEmail / is_invalid_player_email |
| 401 | INVALID_GAME_SECRET | Use your game’s server secret key in X-Game-Secret-Key. | |
| 404 | PLAYER_NOT_FOUND | No player with this email in this game. | isPlayerNotFound / is_player_not_found |
| 409 | Any | The game is not set up for loyalty. Contact INVO support. | |
| 429 | Any | Too many requests, retry later. Treat any 429 as retryable after a pause; do not match on a code. | retryAfter / retry_after |
| 503 | Any | Temporarily unavailable. Retry later. | |
| 502 / 504 | Any | The loyalty service could not be reached, or timed out. Retry later. |
Both SDKs retry a 429 or a temporary server error on this read before they throw.
Before you go live
- Report gameplay events from your server. Client-only reporting shows progress but never unlocks a reward from a gameplay step.
- Register every custom event name in the console before your server reports it.
- Resend the same
event_idon every retry. - Show a redeemed code immediately and never log or cache the redeem response.
- Mint a fresh “My rewards” link each time the player opens the page.
- Render from
stateandprogressas returned; whenprogressisnull, show the step’sstatealone. Do not recompute progress yourself. - Run the whole loop in sandbox first: a purchase, a reported event, a completed card, a redeem.