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

RouteCalled fromAuthentication
GET /api/sdk/loyalty/me/cardGame clientAuthorization: Bearer <player token>
GET /api/sdk/loyalty/me/offersGame clientPlayer token
GET /api/sdk/loyalty/me/vaultGame clientPlayer token
POST /api/sdk/loyalty/me/vault/{vault_entry_id}/redeemGame clientPlayer token
POST /api/sdk/loyalty/me/rewards-pageGame clientPlayer token
POST /api/sdk/loyalty/eventsGame clientPlayer token
POST /api/sdk/loyalty/server-eventsYour game serverX-Game-Secret-Key
GET /api/sdk/loyalty/players/rewards?player_email=...Your game serverX-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.

RouteWeb SDKPython SDK
GET /me/cardInvoClient.getMyRewards()Client route
GET /me/vaultInvoClient.getMyVault()Client route
POST /me/vault/{vault_entry_id}/redeemInvoClient.redeemReward(vaultEntryId)Client route
POST /me/rewards-pageInvoClient.getRewardsPageLink()Client route
GET /players/rewardsInvoServer.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
curl https://invo.network/api/sdk/loyalty/me/card \
  -H "Authorization: Bearer $PLAYER_TOKEN"
Browser, @invonetwork/web-sdk (3.20.0 or later)
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
200 response (live card)
{
  "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": []
    }
  ]
}
FieldMeaning
cards[]One card per program offered to this player in this game. Empty when there is nothing to show.
statusavailable (not started), in_progress, completed, expired, or in_review (finished, and the reward is being checked before it is released).
order_modeordered or any_order.
steps[].statecomplete, 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_requiredThe overall bar: how many steps are done, and how many are needed to unlock the reward.
summaryA ready-to-show line for the step, such as “Buy currency (at least $10.00)”.
window_ends_atOnce the player has started, when their time to finish runs out.
completion_noteA 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.

Weekend Warrior1 of 3 steps

Reward: $25 gift card

✓ Buy currency (at least $10.00)Done
Complete 5 matches3 / 5 matches
Transfer currency to another gameLocked
Rendering a step's progress label
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.

GET /api/sdk/loyalty/me/offers
{
  "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
    }
  ]
}
GET /api/sdk/loyalty/me/vault
{
  "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
curl -X POST https://invo.network/api/sdk/loyalty/me/vault/6f1d2c0e-8b7a-4e7c-9a51-0d3f6a2b9c11/redeem \
  -H "Authorization: Bearer $PLAYER_TOKEN"
200 response
{
  "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_again tells you whether calling redeem again will show it once more (revealed_again: true). After reveal_until it 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.
Browser, @invonetwork/web-sdk (3.20.0 or later)
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
curl -X POST https://invo.network/api/sdk/loyalty/me/rewards-page \
  -H "Authorization: Bearer $PLAYER_TOKEN"
Browser, @invonetwork/web-sdk (3.20.0 or later)
const { url } = await invo.getRewardsPageLink();
window.open(url);
200 response
{
  "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_typepayload
game.session.startedsession_ref
game.session.heartbeatsession_ref
game.session.endedsession_ref, duration_seconds
game.milestone.reachedmilestone_key, optional value, optional session_ref
game.custom.recordedevent_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
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" }
  }'
Node, raw HTTP
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(() => ({})) };
}
Python, raw HTTP
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:

StatuscodeRemedy
401INVALID_GAME_SECRETSend your game’s server secret in X-Game-Secret-Key.
400INVALID_BODY, MISSING_PLAYER_EMAIL, MISSING_EVENT_TYPE, EVENT_TYPE_NOT_ALLOWED, INVALID_EVENT_ID, INVALID_OCCURRED_AT, INVALID_PAYLOAD, INVALID_SURFACE_IDFix 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.
404PLAYER_NOT_FOUNDThe email is not a player in this game yet.
422validation_failedRead message; for example a custom event name you have not registered, or a value outside the bounds you registered.
409AnyThe game is not set up for loyalty. Contact INVO support.
429AnyToo many requests. Treat any 429 as retryable: pause, then retry with the same event_id. Do not match on a code.
503AnyTemporarily unavailable. Retry later with the same event_id.
502 / 504AnyThe 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
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
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"
Node, @invonetwork/web-sdk (3.20.0 or later)
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);
  }
}
Node, raw HTTP
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();
Python, invonetwork (3.18.0 or later)
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)
Python, raw HTTP
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:

StatuscodeRemedySDK
400INVALID_PLAYER_EMAILSend the player’s email address in player_email.isInvalidPlayerEmail / is_invalid_player_email
401INVALID_GAME_SECRETUse your game’s server secret key in X-Game-Secret-Key.
404PLAYER_NOT_FOUNDNo player with this email in this game.isPlayerNotFound / is_player_not_found
409AnyThe game is not set up for loyalty. Contact INVO support.
429AnyToo many requests, retry later. Treat any 429 as retryable after a pause; do not match on a code.retryAfter / retry_after
503AnyTemporarily unavailable. Retry later.
502 / 504AnyThe 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_id on 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 state and progress as returned; when progress is null, show the step’s state alone. Do not recompute progress yourself.
  • Run the whole loop in sandbox first: a purchase, a reported event, a completed card, a redeem.