The card road, end to end

A card subscription needs a card saved for later, off-session use. This page walks the whole road: save a card without ever handling card data, list the member’s cards, create the subscription, and act on every outcome the first charge can have. The clients and raw HTTP helpers the samples use are defined on the overview.

The road in four calls

  1. POST /api/currency-purchases/setup-intent (or a purchase with save_card: true) saves a card with the off-session consent a subscription needs.
  2. GET /api/currency-purchases/player-cards gives you the card’s id.
  3. POST /api/subscriptions/subscribe creates the subscription and charges period 1 before it responds.
  4. Branch on first_charge.status. Then wait for subscription.renewed.

1. Capturing a card

There are two ways to save a card for recurring billing. The subscription endpoints never receive card data; your PCI scope does not change. In both cases the card is tokenised on the client by your card form and only the token reaches your server.

Hosted checkout does not save a card for subscriptions

As deployed, a member who pays through Invo’s hosted checkout page does not thereby have a card on file for recurring billing. Card capture for a subscription is the setup-intent flow below (option A), or a purchase from your own server with save_card: true (option B). Nothing else produces a card a subscription can use.

Option A: POST /api/currency-purchases/setup-intent (charges nothing)

What this endpoint does not do. It is not a smaller /subscribe, and the two do not take the same fields. Carrying the habits across is the usual first failure here.

  • It does not create players. The member must already exist in this game or you get 404 PLAYER_NOT_FOUND. Create them with their first purchase, send or subscription.
  • It takes no player_name, and no name, phone, item or amount. It identifies an existing member and saves a card, nothing else.
  • Its idempotency anchor is setup_reference, not client_request_id (which is accepted as an alias).
  • It charges nothing. A saved card is not a subscription; you still call /subscribe.
FieldTypeRequiredNotes
player_emailstringyesMust already exist in this game. This endpoint does not create players (404 PLAYER_NOT_FOUND).
setup_referencestring, 1 to 200 chars of A-Z a-z 0-9 . _ : -yesIdempotency anchor. Resend the same value on a retry and you get the same setup back instead of a second one. client_request_id is accepted as an alias. Reusing a value with different parameters is 409 SETUP_REFERENCE_REUSED.
payment_method_idstringnoA card tokenised on the client by the card form. When present Invo confirms server-side and saves the card immediately. When absent Invo returns a client_secret for the client to confirm, after which you call /setup-intent/confirm.

Responses (all HTTP 200 unless stated)

// saved immediately (you sent payment_method_id and no authentication was needed)
{"status": "succeeded", "message": "Card saved for future payments",
 "setup_intent_id": "<opaque setup reference>",
 "card": {"id": 42, "last_four": "4242", "brand": "visa",
          "exp_month": 12, "exp_year": 2030, "created_at": "2026-09-06T10:00:00"},
 "already_saved": false}

// the issuer wants the cardholder to authenticate first
{"status": "requires_action", "message": "Additional authentication required",
 "client_secret": "<opaque client secret>", "setup_intent_id": "<opaque setup reference>",
 "publishable_key": "<client-side key>", "card": null}

// the normal hand-off when you sent no payment_method_id
{"status": "requires_confirmation" | "requires_payment_method",
 "client_secret": "<opaque client secret>", "setup_intent_id": "<opaque setup reference>",
 "publishable_key": "<client-side key>", "card": null}

For requires_action, requires_confirmation and requires_payment_method: hand client_secret to the card form on the client, confirm the setup with your card element (the form runs any cardholder authentication the issuer asks for), then call POST /api/currency-purchases/setup-intent/confirm with {"setup_intent_id": "..."}. That call is idempotent and returns {"status": "success", "setup_intent_id": "...", "card": {...}, "already_saved": bool}. While the client has not finished, confirm returns 400 {"status": "still_requires_action"}. publishable_key is the client-side key your card form initialises with; it is not a secret and it is not the game key.

HTTPerror_codeWhat to do
400MISSING_SETUP_REFERENCE, INVALID_SETUP_REFERENCESend a setup_reference within the character rules.
404PLAYER_NOT_FOUNDCreate the player first (any endpoint that creates players, for example a purchase).
409SETUP_REFERENCE_REUSEDUse a new reference for a new setup.
400CARD_DECLINED, SETUP_FAILED, INVALID_PAYMENT_METHODAsk the member for another card.
400RAW_CARD_NOT_SUPPORTEDSend a tokenised card, never raw card numbers.
500CARD_PERSIST_FAILEDThe card was authorised but not recorded; retry /setup-intent/confirm with the same setup_intent_id.
500SETUP_CONFIRMATION_FAILEDRetry confirm with the same setup_intent_id.
503flow_pausedCard setup is paused for maintenance. Retry later with the same reference.
curl
# server-side: the card was already tokenised on the client by your card form
curl -sS -X POST "$BASE/api/currency-purchases/setup-intent" \
  -H "X-Game-Secret-Key: $GAME_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "player_email": "member@example.com",
    "setup_reference": "card-setup-member-7-2026-09",
    "payment_method_id": "<token from your card form>"
  }'

# if the response was requires_action / requires_confirmation, the client confirms
# with the card element using client_secret, then your server calls:
curl -sS -X POST "$BASE/api/currency-purchases/setup-intent/confirm" \
  -H "X-Game-Secret-Key: $GAME_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"setup_intent_id": "<opaque setup reference>"}'
Node, @invonetwork/web-sdk
// 1. begin: returns the setup reference and the client secret for your card form
const setup = await invo.cards.beginSetup({
  playerEmail: "member@example.com",
  setupReference: "card-setup-member-7-2026-09",   // your idempotency anchor
  // paymentMethodId: "<token from your card form>",  // optional: saves immediately
});
// setup.clientSecret   -> hand to the client; confirm with your card element
// setup.setupIntentId  -> what step 2 needs
// setup.status         -> "saved" when nothing more is needed

// 2. confirm: only when step 1 did not already save the card, after the
//    client finished with the card element
const saved = await invo.cards.confirmSetup({
  setupIntentId: setup.setupIntentId,
});
console.log(saved.card.id, saved.alreadySaved);   // 42, false
Node, raw HTTP
const { status, json } = await invo("POST", "/api/currency-purchases/setup-intent", {
  player_email: "member@example.com",
  setup_reference: "card-setup-member-7-2026-09",
  payment_method_id: "<token from your card form>",   // omit to get a client_secret instead
});

if (status === 200 && json.status === "succeeded") {
  cardId = json.card.id;                       // 42
} else if (status === 200) {
  // requires_action | requires_confirmation | requires_payment_method:
  // send json.client_secret to the client, confirm with the card element, then:
  const done = await invo("POST", "/api/currency-purchases/setup-intent/confirm", {
    setup_intent_id: json.setup_intent_id,
  });
  if (done.status === 400 && done.json.status === "still_requires_action") {
    // the client has not finished yet; try again after it has
  } else if (done.status === 200) {
    cardId = done.json.card.id;
  }
} else {
  switch (json.error_code) {
    case "PLAYER_NOT_FOUND":        /* create the player first */ break;
    case "SETUP_REFERENCE_REUSED":  /* new reference for a new setup */ break;
    case "CARD_DECLINED":
    case "SETUP_FAILED":
    case "INVALID_PAYMENT_METHOD":  /* ask for another card */ break;
    case "CARD_PERSIST_FAILED":     /* retry /setup-intent/confirm with the same id */ break;
  }
}
Python, invonetwork
# 1. begin
setup = invo.cards.begin_setup(
    player_email="member@example.com",
    setup_reference="card-setup-member-7-2026-09",   # your idempotency anchor
    # payment_method_id="<token from your card form>",  # optional: saves immediately
)
# setup.client_secret   -> hand to the client; confirm with your card element
# setup.setup_intent_id -> what step 2 needs
# setup.status          -> "saved" when nothing more is needed

# 2. confirm: only when step 1 did not already save the card
saved = invo.cards.confirm_setup(setup_intent_id=setup.setup_intent_id)
print(saved.card.id, saved.already_saved)   # 42 False
Python, raw HTTP
status, body = invo("POST", "/api/currency-purchases/setup-intent", {
    "player_email": "member@example.com",
    "setup_reference": "card-setup-member-7-2026-09",
    "payment_method_id": "<token from your card form>",   # omit to get a client_secret instead
})

if status == 200 and body["status"] == "succeeded":
    card_id = body["card"]["id"]                    # 42
elif status == 200:
    # requires_action | requires_confirmation | requires_payment_method:
    # send body["client_secret"] to the client, confirm with the card element, then:
    s2, done = invo("POST", "/api/currency-purchases/setup-intent/confirm",
                    {"setup_intent_id": body["setup_intent_id"]})
    if s2 == 400 and done.get("status") == "still_requires_action":
        pass   # the client has not finished yet
    elif s2 == 200:
        card_id = done["card"]["id"]
else:
    code = body.get("error_code")
    # PLAYER_NOT_FOUND, SETUP_REFERENCE_REUSED, CARD_DECLINED, SETUP_FAILED,
    # INVALID_PAYMENT_METHOD, RAW_CARD_NOT_SUPPORTED, CARD_PERSIST_FAILED

Option B: save the card during a purchase

POST /api/currency-purchases/purchase-currency with a new payment_method_id and "save_card": true saves the card (with the off-session consent a subscription needs) as a side effect of the purchase. save_card must be a real JSON boolean; a string is 400 INVALID_SAVE_CARD. The purchase response carries "card_saved": true; fetch the card’s id from /player-cards. See Currency Purchase for the purchase call itself.

A card saved without off-session consent cannot back a subscription. Cards saved through saved_card_id purchases, through hosted checkout, or saved before this behaviour shipped, do not carry the consent and cannot back a subscription until the member saves the card again through option A or option B.

2. Listing a member’s cards

GET $BASE/api/currency-purchases/player-cards?player_email=member@example.com
X-Game-Secret-Key: <game secret>

200
{"cards": [{"id": 42, "last_four": "4242", "brand": "visa", "exp_month": 12,
            "exp_year": 2030, "created_at": "2026-09-06T10:00:00"}]}

Only unexpired cards are listed, newest first. An unknown player returns {"cards": []}. The list is served from a short cache (about 60 seconds); a card saved through /setup-intent appears immediately because that endpoint clears the cache. The id is the value you pass as player_card_id. Invo never returns processor identifiers for a card; the id is the only handle.

curl
curl -sS "$BASE/api/currency-purchases/player-cards?player_email=member%40example.com" \
  -H "X-Game-Secret-Key: $GAME_SECRET"
Node, @invonetwork/web-sdk
const cards = await invo.cards.list("member@example.com");
const newest = cards[0];        // { id: 42, lastFour: "4242", brand: "visa", expMonth: 12, expYear: 2030 }
Node, raw HTTP
const { json } = await invo("GET",
  "/api/currency-purchases/player-cards?player_email=" + encodeURIComponent("member@example.com"));
const newest = json.cards[0];   // undefined when the member has no unexpired card
Python, invonetwork
cards = invo.cards.list("member@example.com")
newest = cards[0] if cards else None
Python, raw HTTP
_, body = invo("GET", "/api/currency-purchases/player-cards",
               params={"player_email": "member@example.com"})
newest = body["cards"][0] if body["cards"] else None

3. POST /api/subscriptions/subscribe

Creates a card subscription and charges the first period before responding (unless it is a trial). Expect the call to take as long as a card charge. Idempotent on client_request_id.

Request body

FieldTypeReq.Limits and rulesError on violation
client_request_idstringyesUnique per subscription and per player, within your game. Max 255 chars. Must not begin with sub_ (case-insensitive; reserved). Generate it once per subscription and reuse it on every retry.CLIENT_REQUEST_ID_REQUIRED, CLIENT_REQUEST_ID_INVALID (not a string), CLIENT_REQUEST_ID_TOO_LONG, CLIENT_REQUEST_ID_RESERVED
player_emailstringyesValid email, max 255. Lower-cased.PLAYER_EMAIL_INVALID, PLAYER_EMAIL_TOO_LONG
player_namestringyesMax 255. Used only if the player is created.PLAYER_NAME_REQUIRED, PLAYER_NAME_TOO_LONG
player_phonestringnoMax 30.PLAYER_PHONE_TOO_LONG
item_idstringyesYour entitlement handle. Max 255. One live subscription per (player, item), on either rail: use the same item_id for the card and Steam versions of a membership, and a different id from any one-off pack. See the item id rule.ITEM_ID_REQUIRED, ITEM_ID_TOO_LONG
item_namestringnoMax 255. Echoed on events.ITEM_NAME_TOO_LONG
amount_usddecimal stringyes0.01 to 999999.99, two decimals. USD only; there is no coin-denominated price. Prices above 500.00 are accepted but never charged (see deferrals).AMOUNT_REQUIRED, AMOUNT_INVALID, AMOUNT_TOO_SMALL, AMOUNT_TOO_LARGE
intervalstringnomonth (default) or year.INTERVAL_INVALID
interval_countintegerno1 (default) to 36. 3 with month bills quarterly.INTERVAL_COUNT_INVALID, INTERVAL_COUNT_OUT_OF_RANGE
trial_daysintegerno1 to 365. Invo adds up to one hour of jitter to the derived end so a cohort does not convert on the same instant.TRIAL_DAYS_INVALID, TRIAL_DAYS_OUT_OF_RANGE
trial_endISO 8601noExplicit trial end, honoured to the second. Must be in the future and within 365 days. Takes precedence over trial_days.TRIAL_END_INVALID, TRIAL_END_IN_PAST, TRIAL_END_TOO_FAR
wallet_onlybooleannoDefault false. true opts the subscription out of the card backstop: renewals spend wallet balance only and go into dunning when it is short. Cannot be combined with player_card_id. Strings "true", "1", "yes", "on" and their negatives are accepted; an empty string means the default.WALLET_ONLY_SUBSCRIPTION (409, with a card)
player_card_idintegernoThe id of a card from /player-cards belonging to this player in this game, not expired. card_id is accepted as an alias. If omitted and not wallet_only, Invo picks the member’s newest unexpired saved card automatically; if they have none the subscription is created without a card.PAYMENT_METHOD_INVALID (400), PAYMENT_METHOD_NOT_FOUND (404, also when the player does not exist yet), PAYMENT_METHOD_EXPIRED (400)
metadataJSON objectnoUp to 8192 bytes when serialised. No NUL characters. Echoed on every read and every event. Do not use the key _invo; Invo reserves it.METADATA_INVALID, METADATA_TOO_LARGE
consentobjectnoEvidence the member agreed to the recurring charge. See section 6. May also be supplied as top-level fields with the same names.CONSENT_AT_INVALID, CONSENT_INVALID, CONSENT_DISCLOSED_AMOUNT_INVALID, CONSENT_DISCLOSED_INTERVAL_INVALID
revenue_shareobjectnoAttribution to a second player in your game. See section 7.REVENUE_SHARE_INVALID, REVENUE_SHARE_PERCENT_OUT_OF_RANGE, REVENUE_SHARE_RECIPIENT_REQUIRED, REVENUE_SHARE_RECIPIENT_IS_SUBSCRIBER, REVENUE_SHARE_RECIPIENT_NOT_FOUND

All validation errors are HTTP 400 with {"message": "...", "error_code": "..."} unless a different status is shown. A body that is not a JSON object is 400 INVALID_BODY.

Other refusals on create

HTTPerror_codeMeaningWhat to do
403GAME_NOT_LIVEThe game is not live.Make the game live in the console.
503body status: "error", error: "flow_paused"Subscription changes are paused for maintenance.Retry later with the same client_request_id.
400CURRENCY_NOT_CONFIGUREDThe game has no currency configured.Configure the game’s currency in the console.
409ACTIVE_SUBSCRIPTION_EXISTSThe player already has a live subscription to this item_id. Body carries subscription_id of the live one.Use the returned subscription_id. This is what you hit if you regenerate client_request_id inside a retry loop.
409CONCURRENT_REQUESTTwo creates for a brand-new player collided.Retry with the same client_request_id.
409CLIENT_REQUEST_ID_CONFLICTThis client_request_id already created a subscription for a different player or item. Nothing about that subscription is disclosed.Use a key that is unique per subscription.
409IDEMPOTENT_REPLAY_MISMATCHSame key, same player and item, but different material terms. Body carries mismatched_fields (any of amount_usd, interval, interval_count, wallet_only, funding_rail).To change terms use /amount or /payment-method; to create a new subscription use a new key.
409REVENUE_SHARE_EXISTSA revenue share already exists for this subscription.Nothing; the share is set once at create.
409PHONE_SHARE_APPROVAL_REQUIREDThe supplied phone belongs to another identity.Run the phone-share approval flow the body describes, then retry.
400DATA_CONFLICT, INVALID_FIELD_VALUEA value the database refused.Fix the field and retry with a new key.
500INTERNAL_ERRORUnexpected failure.Retry with the same client_request_id; a replay is safe.

Idempotency and replay

A repeated client_request_id for the same player and item returns HTTP 200 with the identical body shape to the 201 and "idempotent_replay": true. Nothing is charged again; first_charge is re-derived from what already happened, so a replay that arrives after the first charge settled reports paid. Two things a replay cannot do: it cannot rebuild a step-up confirmation_url (that link exists once, on the original response and on the subscription.authentication_required event, which you can replay from your webhook deliveries), and it cannot change terms.

The rule for retries: on any timeout or 5xx, replay the same client_request_id before you do anything else. Never mint a new key inside a retry loop. Persist the key before you make the call, so a crash between the call and the response still replays.

The call

curl
curl -sS -X POST "$BASE/api/subscriptions/subscribe" \
  -H "X-Game-Secret-Key: $GAME_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "client_request_id": "guild-42-member-7",
    "player_email": "member@example.com",
    "player_name": "Member Seven",
    "item_id": "guild-42-membership",
    "item_name": "Guild 42 membership",
    "amount_usd": "9.99",
    "interval": "month",
    "interval_count": 1,
    "player_card_id": 42,
    "metadata": {"guild_id": "42"},
    "consent": {
      "consent_at": "2026-09-06T14:01:50+00:00",
      "consent_ip": "203.0.113.7",
      "consent_user_agent": "Mozilla/5.0 ...",
      "disclosed_amount_usd": "9.99",
      "disclosed_interval": "month",
      "terms_version": "2026-09"
    }
  }'
Node, @invonetwork/web-sdk
const result = await invo.subscriptions.create({
  clientRequestId: "guild-42-member-7",      // generated once, persisted, replayed on retry
  playerEmail: "member@example.com",
  playerName: "Member Seven",
  itemId: "guild-42-membership",
  itemName: "Guild 42 membership",
  amountUsd: "9.99",
  interval: "month",
  intervalCount: 1,
  playerCardId: 42,                          // omit to let Invo pick the newest saved card
  metadata: { guild_id: "42" },
  consent: {
    consentAt: "2026-09-06T14:01:50+00:00",
    consentIp: memberIp,                     // the MEMBER's IP, as your server saw it
    consentUserAgent: memberUserAgent,
    disclosedAmountUsd: "9.99",
    disclosedInterval: "month",
    termsVersion: "2026-09",
  },
});

// result.idempotentReplay   false on the 201, true on a replayed 200
// result.subscription       the subscription object (see /docs/subscriptions-manage)
// result.card               the card backing it, or null
// result.firstCharge        branch on .status (next section)
Node, raw HTTP
const body = {
  client_request_id: "guild-42-member-7",
  player_email: "member@example.com",
  player_name: "Member Seven",
  item_id: "guild-42-membership",
  item_name: "Guild 42 membership",
  amount_usd: "9.99",
  interval: "month",
  interval_count: 1,
  player_card_id: 42,
  metadata: { guild_id: "42" },
  consent: {
    consent_at: "2026-09-06T14:01:50+00:00",
    consent_ip: memberIp,
    consent_user_agent: memberUserAgent,
    disclosed_amount_usd: "9.99",
    disclosed_interval: "month",
    terms_version: "2026-09",
  },
};

let { status, json } = await invo("POST", "/api/subscriptions/subscribe", body);
if (status >= 500 || status === 0) {
  // timeout or 5xx: REPLAY the same body before anything else
  ({ status, json } = await invo("POST", "/api/subscriptions/subscribe", body));
}

if (status === 201 || (status === 200 && json.idempotent_replay)) {
  handleFirstCharge(json.subscription, json.first_charge);   // next section
} else if (status === 409 && json.error_code === "ACTIVE_SUBSCRIPTION_EXISTS") {
  // the member already has a live subscription to this item: use it
  const existing = json.subscription_id;
} else if (status === 409 && json.error_code === "IDEMPOTENT_REPLAY_MISMATCH") {
  // same key, different terms: json.mismatched_fields says which
} else if (status === 403 && json.error_code === "GAME_NOT_LIVE") {
  // make the game live in the console
} else if (status === 503 && json.error === "flow_paused") {
  // retry later with the SAME client_request_id
}
Python, invonetwork
result = invo.subscriptions.create(
    client_request_id="guild-42-member-7",     # generated once, persisted, replayed on retry
    player_email="member@example.com",
    player_name="Member Seven",
    item_id="guild-42-membership",
    item_name="Guild 42 membership",
    amount_usd="9.99",
    interval="month",
    interval_count=1,
    player_card_id=42,                         # omit to let Invo pick the newest saved card
    metadata={"guild_id": "42"},
    consent={
        "consent_at": "2026-09-06T14:01:50+00:00",
        "consent_ip": member_ip,               # the MEMBER's IP, as your server saw it
        "consent_user_agent": member_user_agent,
        "disclosed_amount_usd": "9.99",
        "disclosed_interval": "month",
        "terms_version": "2026-09",
    },
)

# result.idempotent_replay   False on the 201, True on a replayed 200
# result.subscription        the subscription object
# result.card                the card backing it, or None
# result.first_charge        branch on .status (next section)
Python, raw HTTP
body = {
    "client_request_id": "guild-42-member-7",
    "player_email": "member@example.com",
    "player_name": "Member Seven",
    "item_id": "guild-42-membership",
    "item_name": "Guild 42 membership",
    "amount_usd": "9.99",
    "interval": "month",
    "interval_count": 1,
    "player_card_id": 42,
    "metadata": {"guild_id": "42"},
    "consent": {
        "consent_at": "2026-09-06T14:01:50+00:00",
        "consent_ip": member_ip,
        "consent_user_agent": member_user_agent,
        "disclosed_amount_usd": "9.99",
        "disclosed_interval": "month",
        "terms_version": "2026-09",
    },
}

try:
    status, res = invo("POST", "/api/subscriptions/subscribe", body)
    if status >= 500:
        status, res = invo("POST", "/api/subscriptions/subscribe", body)   # replay, same key
except requests.RequestException:
    status, res = invo("POST", "/api/subscriptions/subscribe", body)       # replay, same key

if status == 201 or (status == 200 and res.get("idempotent_replay")):
    handle_first_charge(res["subscription"], res["first_charge"])   # next section
elif status == 409 and res.get("error_code") == "ACTIVE_SUBSCRIPTION_EXISTS":
    existing = res["subscription_id"]      # the member's live subscription to this item
elif status == 409 and res.get("error_code") == "IDEMPOTENT_REPLAY_MISMATCH":
    mismatched = res["mismatched_fields"]
elif status == 503 and res.get("error") == "flow_paused":
    pass   # retry later with the SAME client_request_id

201 response

{
  "status": "success",
  "idempotent_replay": false,
  "subscription": {
    "subscription_id": "SUB_1757155200_A1B2C3D4",
    "game_id": "1234",
    "player_id": 98765,
    "client_request_id": "guild-42-member-7",
    "status": "active",
    "amount_usd": "9.99",
    "pending_amount_usd": null,
    "interval": "month",
    "interval_count": 1,
    "item_id": "guild-42-membership",
    "item_name": "Guild 42 membership",
    "current_period_start": "2026-10-06T14:02:11.482913+00:00",
    "current_period_end": "2026-11-06T14:37:44.482913+00:00",
    "period_seq": 2,
    "next_charge_at": "2026-11-06T14:37:44.482913+00:00",
    "cancel_at_period_end": false,
    "trial_end": null,
    "canceled_at": null,
    "ended_at": null,
    "wallet_only": false,
    "has_payment_method": true,
    "funding_rail": "card",
    "steam_agreement_status": null,
    "metadata": {"guild_id": "42"},
    "consent": {
      "consent_at": "2026-09-06T14:01:50+00:00",
      "consent_ip": "203.0.113.7",
      "consent_user_agent": "Mozilla/5.0 ...",
      "disclosed_amount_usd": "9.99",
      "disclosed_interval": "month",
      "terms_version": "2026-09"
    },
    "created_at": "2026-09-06T14:02:11.482913+00:00",
    "updated_at": "2026-09-06T14:02:13.104402+00:00",
    "revenue_share": {
      "recipient_player_id": 4242,
      "percent": "70.00",
      "settled_by_invo": false
    },
    "paid_through": "2026-10-06T14:37:44.482913+00:00",
    "amount_coins_estimate": "99.90"
  },
  "card": {
    "id": 42, "last_four": "4242", "brand": "visa",
    "exp_month": 12, "exp_year": 2030, "created_at": "2026-09-06T10:00:00"
  },
  "first_charge": {
    "status": "paid",
    "paid_period_seq": 1,
    "amount_usd": "9.99",
    "paid_through": "2026-10-06T14:37:44.482913+00:00",
    "failure_code": null,
    "next_retry_at": null,
    "confirmation_url": null,
    "expires_at": null,
    "message": "The first period was charged and the currency granted."
  }
}
  • subscription is the same object GET returns, plus amount_coins_estimate (create only): what one period is worth in coins on this rail. The full field list is on the manage page.
  • After a successful first charge subscription.period_seq is already 2: period 1 was just paid and the subscription now points at the next window. current_period_* is that next window; paid_through is the end of the window just paid. first_charge.paid_period_seq (1 here) names the period that was paid, and the subscription.renewed event for it carries period_seq: 1. Key your entitlement records on the paid period, never on subscription.period_seq.
  • card is the card backing the subscription (the one you named or the one Invo picked), or null when there is none. has_payment_method says the same thing on every read.
  • revenue_share is null when you did not supply one.
  • The consent block is all-null until you supply it.
  • first_charge always carries the eight keys status, amount_usd, paid_through, failure_code, next_retry_at, confirmation_url, expires_at and message, on every outcome; when status is paid it also carries paid_period_seq. Branch on first_charge.status, then read the fields you know exist.
  • first_charge.paid_through is the same value as subscription.paid_through, repeated so one object answers “what happened to the money”.

Reduced 201. If Invo created the subscription but could not render the full response, you get a 201 with a reduced body: subscription carries only subscription_id and status, first_charge is a null-filled skeleton, and a warning tells you to GET the subscription. The subscription exists; do not retry with a new key.

4. first_charge.status and what to do

statusWhat happenedPopulated fieldsYour move
paidMoney moved, currency granted.amount_usd (the amount actually charged for period 1), paid_through, paid_period_seq (1)Grant access until paid_through, recorded against paid_period_seq. Expect a subscription.renewed event with period_seq: 1.
requires_actionThe card issuer wants the cardholder to authenticate. Nothing has been charged. Subscription is awaiting_authentication.confirmation_url, expires_atSend the member to confirmation_url before expires_at. Do not grant paid access yet; paid_through is null. When they complete it you receive subscription.renewed. If the link lapses, Invo tries the card again on its own; the member need do nothing.
skipped_trialDeliberately not charged: the subscription is trialing.noneGrant trial access until subscription.trial_end. The first charge runs at trial end.
failedThe charge did not succeed. Subscription is past_due and in dunning.failure_code, next_retry_atTell the member to fix their card. Invo retries at next_retry_at. You also receive subscription.payment_failed (and subscription.past_due).
pendingNot resolved yet (an ambiguous processor answer or a paused flow).noneDo not grant paid access. Wait for subscription.renewed or subscription.payment_failed; Invo resolves it within about 30 minutes. Read GET /<id> if you need state sooner.

requires_action in detail

The link opens an Invo-hosted page at /subscription-auth?token=... on Invo’s checkout host; the member authenticates with their issuer there. Relay it by email or in-game message. Do not frame it, and do not log the URL beside identifiers you publish; it is a bearer link. It lives at most 72 hours and never past the next scheduled charge. The same link is also on the subscription.authentication_required event, which is the only way to get it again (a replay of /subscribe cannot rebuild it). A card that asks for authentication on more than two consecutive attempts for the same period is treated as declined.

Full detail on the challenge lifecycle is on the renewals page.

Node, handling every outcome
function handleFirstCharge(sub, fc) {
  switch (fc.status) {
    case "paid":
      // key the entitlement on the PAID period (fc.paid_period_seq === 1), not on
      // sub.period_seq, which already points at the next window (2)
      grantAccess(sub.subscription_id, fc.paid_period_seq, fc.paid_through);
      break;
    case "requires_action":
      // nothing charged; paid_through is null
      sendToMember(sub.player_id, "Confirm your payment: " + fc.confirmation_url, fc.expires_at);
      break;
    case "skipped_trial":
      grantTrialAccess(sub.subscription_id, sub.trial_end);
      break;
    case "failed":
      tellMemberToFixCard(sub.player_id, fc.failure_code, fc.next_retry_at);
      break;
    case "pending":
      // wait for subscription.renewed or subscription.payment_failed (about 30 minutes at most)
      break;
  }
}
Python, handling every outcome
def handle_first_charge(sub, fc):
    st = fc["status"]
    if st == "paid":
        # key the entitlement on the PAID period (fc["paid_period_seq"] == 1), not on
        # sub["period_seq"], which already points at the next window (2)
        grant_access(sub["subscription_id"], fc["paid_period_seq"], fc["paid_through"])
    elif st == "requires_action":
        # nothing charged; paid_through is None
        send_to_member(sub["player_id"], "Confirm your payment: " + fc["confirmation_url"],
                       expires_at=fc["expires_at"])
    elif st == "skipped_trial":
        grant_trial_access(sub["subscription_id"], sub["trial_end"])
    elif st == "failed":
        tell_member_to_fix_card(sub["player_id"], fc["failure_code"], fc["next_retry_at"])
    elif st == "pending":
        pass   # wait for subscription.renewed or subscription.payment_failed

5. Which card backs the subscription

  • You name one with player_card_id: it must belong to this player in this game and not be expired.
  • You name none and wallet_only is false (the default): Invo attaches the member’s newest unexpired saved card. If they have none the subscription is created card-less; its first empty-wallet renewal goes into dunning, and attaching a card later with /payment-method heals it.
  • wallet_only: true: no card is ever charged, even if one is saved later, until you flip the flag through /payment-method.

Invo re-verifies the card on every renewal: it must still belong to the member, still be unexpired, and still be the card the subscription points at. If the named card has gone (removed, expired, replaced) Invo falls back to the member’s newest saved card for that renewal and attaches it.

Trials

  • trial_days or trial_end creates the subscription in trialing with first_charge.status: "skipped_trial" and paid_through: null.
  • The trial window is period 1. At trial_end Invo opens period 2 and charges it; a successful charge moves the subscription to active and fires subscription.renewed with period_seq: 2.
  • Trial access is yours to grant from trial_end; paid access from paid_through after conversion.
  • Trials are not available on the Steam rail.
curl, a 7-day trial
curl -sS -X POST "$BASE/api/subscriptions/subscribe" \
  -H "X-Game-Secret-Key: $GAME_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "client_request_id": "guild-42-member-8",
    "player_email": "eight@example.com",
    "player_name": "Member Eight",
    "item_id": "guild-42-membership",
    "amount_usd": "9.99",
    "trial_days": 7
  }'
# 201, subscription.status "trialing", first_charge.status "skipped_trial", paid_through null
Node, @invonetwork/web-sdk
const trial = await invo.subscriptions.create({
  clientRequestId: "guild-42-member-8",
  playerEmail: "eight@example.com",
  playerName: "Member Eight",
  itemId: "guild-42-membership",
  amountUsd: "9.99",
  trialDays: 7,                 // or trialEnd: "2026-09-13T00:00:00+00:00"
});
// trial.firstCharge.status === "skipped_trial"; grant trial access until trial.subscription.trialEnd
Python, invonetwork
trial = invo.subscriptions.create(
    client_request_id="guild-42-member-8",
    player_email="eight@example.com",
    player_name="Member Eight",
    item_id="guild-42-membership",
    amount_usd="9.99",
    trial_days=7,                 # or trial_end="2026-09-13T00:00:00+00:00"
)
# trial.first_charge.status == "skipped_trial"; grant trial access until trial.subscription.trial_end

Wallet-only subscriptions

wallet_only: true is for memberships the member funds from balance they already hold (earned, received from another player, or bought when they chose). Each renewal spends the wallet and, when it is short, goes straight into dunning: subscription.payment_failed with failure_code: "insufficient_funds", then the ordinary 2 / 3 / 2 retry ladder. A member who tops up their wallet during the grace window is charged at the next retry. Convert it to a card-backed subscription later with /payment-method and wallet_only: false.

curl, wallet only
curl -sS -X POST "$BASE/api/subscriptions/subscribe" \
  -H "X-Game-Secret-Key: $GAME_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "client_request_id": "guild-42-member-9",
    "player_email": "nine@example.com",
    "player_name": "Member Nine",
    "item_id": "guild-42-membership",
    "amount_usd": "9.99",
    "wallet_only": true
  }'
# do NOT send player_card_id with wallet_only: true (409 WALLET_ONLY_SUBSCRIPTION)
Node, @invonetwork/web-sdk
const walletOnly = await invo.subscriptions.create({
  clientRequestId: "guild-42-member-9",
  playerEmail: "nine@example.com",
  playerName: "Member Nine",
  itemId: "guild-42-membership",
  amountUsd: "9.99",
  walletOnly: true,
});
Python, invonetwork
wallet_only = invo.subscriptions.create(
    client_request_id="guild-42-member-9",
    player_email="nine@example.com",
    player_name="Member Nine",
    item_id="guild-42-membership",
    amount_usd="9.99",
    wallet_only=True,
)

6. The consent object

Optional evidence that the member agreed to the recurring charge. Stored verbatim and returned on every read. Supply it: Invo is the merchant of record on the card rail, and a disputed charge is argued from this record.

FieldTypeRules
consent_atISO 8601Not more than 5 minutes in the future.
consent_ipstring, max 45The member’s IP as your server saw it (the request IP Invo sees is your server’s).
consent_user_agentstringTruncated to 500.
disclosed_amount_usddecimal stringThe price you showed the member. Should equal amount_usd.
disclosed_intervalmonth or yearThe interval you showed the member.
terms_versionstring, max 50Your terms version.

7. revenue_share (attribution only)

"revenue_share": {"recipient_player_email": "founder@example.com", "percent": "70"}
// or
"revenue_share": {"recipient_player_id": 4242, "percent": "70"}
  • The recipient must already be a player in your game and must not be the subscriber.
  • percent is 0 to 100, two decimals.
  • Invo records the share and reports, on every subscription.renewed, how much of that renewal is attributable to the recipient (revenue_share_attribution, base is the price net of Invo’s fee).
  • One share per subscription, set at create.

Invo pays nothing to the recipient. Every read and every event says settled_by_invo: false. You pay them yourself, out of your own revenue, on your own rails; this is the figure to do it from. The windowed, net-of-refunds version of that figure is on the reporting page.

Next

  • Webhooks: subscription.renewed for period 1 is the first event you will see.
  • Renewals: what happens a month from now, and what happens when the card fails.
  • Manage: cancel, reprice, change the card.
  • Sandbox: run the card recipe with the test tokens before you touch a real card.