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/checkout/card-setup-sessions mints a link to the Invo-hosted card page; the member saves their card there. (Alternatives: /setup-intent with your own card form, or a purchase with save_card: true.)
  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.

Offering a trial? A free trial charges nothing until it ends. A paid trial (for example $1 for 7 days, then $29 a month) charges the trial price inside step 3, so the member must already have a saved card. Both are in section 5.

Prerequisites (keys, base URLs, the title being live, a webhook target) are on the overview. The hosted card page creates a brand-new member when you send player_name; /setup-intent needs the member to already exist. See “New and existing members” below.

1. Capturing a card

A card subscription needs a card saved for later, off-session use. There are three ways to save one. The subscription endpoints never receive card data; your PCI scope does not change. The recommended way, and the only one that ties nothing in your code to how Invo processes cards, is the Invo-hosted card page: two server-to-server calls and one redirect, no browser card code at all.

Card only: save a card before /subscribe

Since 2026-09-17 a card subscription is paid only by card: the card is charged the full price every period and the member’s coin wallet never pays for it. A member who holds coins but has no saved card is still subscribed (201), but the first charge fails with failure_code: "no_payment_method" and the subscription enters dunning. Make sure the member has a saved card first.

New and existing members

Every card belongs to a member of your title, identified by player_email. The hosted card page can create that member for you: send player_name (and, optionally, player_phone) and a member your title has never seen is created exactly as /subscribe would create them, then the link is minted. Without player_name an unknown member is refused with 404 PLAYER_NOT_FOUND and nothing is created. A member who already exists is used as is: never created twice, and never renamed by this call. /setup-intent (option A) still never creates members.

  • Member already known to your title (an earlier purchase, send, transfer or subscription): card page first (only player_email is needed), then /subscribe. The first charge lands inline.
  • Brand-new member (a first-time signup): card page first with player_name, then /subscribe. The member is created with their card, and the first charge lands inline. This is the order a paid trial for a new signup needs (section 5). Two other orders still work:
    1. A currency purchase first. POST /api/currency-purchases/purchase-currency (or the currency hosted checkout) creates the member and, with a new card and save_card: true, saves that card with the off-session consent in the same call (option B). Then /subscribe.
    2. /subscribe first, card page second. With trial_days nothing is charged until the trial ends, the member saves a card on the hosted page during the trial, and Invo adopts it at conversion with no /payment-method call. Without a trial the first charge fails for lack of a card (first_charge.status: "failed", failure_code: "no_payment_method", the event’s outcome is insufficient_funds), the subscription enters dunning, and the card saved afterwards is adopted at the next retry, two days later by default. Not possible for a paid trial, which is refused with 400 PAYMENT_METHOD_REQUIRED when the member has no saved card.

Option 0 (recommended): the Invo-hosted card page

Your server mints a short-lived card-setup session, you send the member to the URL it returns, Invo renders the card form and saves the card, and you read the card’s id back from /player-cards. Nothing in your integration references a card processor or loads its script, so Invo can change processor without you changing anything.

Step 1. POST /api/checkout/card-setup-sessions (your server, with X-Game-Secret-Key)

FieldTypeRequiredNotes
player_emailstringyesThe member. Lower-cased. A member your title does not know yet is 404 PLAYER_NOT_FOUND unless you send player_name.
player_namestring, max 255noCreates the member when they are new to this title, exactly as /subscribe does. Used only then: a member who already exists is never renamed by this call. Omit it and an unknown member is refused as before.
player_phonestring, max 30noUsed only when player_name creates a new member; ignored for an existing one. Send it in international format (+15555550100). A phone that already belongs to another member can answer 409 PHONE_SHARE_APPROVAL_REQUIRED, as on /subscribe.
success_urlURLnoWhere the page sends the member after the card is saved. Absent: the page shows “Card saved. You can close this window.” and stops. https and app deep-link schemes are accepted; script schemes are refused.
cancel_urlURLnoCarried on the session; not used by the page today.
metadataobjectnoStored on the session and not returned anywhere today (no call reads a session back; the page redirects to success_url verbatim with nothing appended). Put your own correlation id in success_url instead. Not validated.
201
{"session_id": "<opaque>",
 "card_setup_url": "https://invo.network/card-setup?session=<token>",
 "expires_at": "2026-09-12T00:29:08+00:00"}
HTTPerror_code or bodyMeaningWhat to do
400INVALID_PLAYER_EMAILplayer_email missing or not an email.Send the member’s email.
400INVALID_INPUTsuccess_url or cancel_url uses a script scheme; or, when the member is new, player_name is not text, is over 255 characters or has no visible characters, or player_phone is not text or is over 30 characters.Use https or an app deep link; send a real name and phone.
404PLAYER_NOT_FOUND (body also carries "status": "error")The member does not exist in this title and no player_name was sent. Nothing was created.Send player_name to create the member with their card.
409PHONE_SHARE_APPROVAL_REQUIREDCreating the new member needs the approval of whoever already holds player_phone. Same body as the phone-share 409 on /subscribe. Nothing was created and no link was minted.Handle it as on /subscribe, then call again.
503PLAYER_CREATE_FAILEDThe new member could not be created. Nothing was created and no link was minted.Call again.
401{"message": "..."}Header missing, or the key is unknown, disabled or out of its rotation grace.Send the current key for this environment.
403{"message": "Game '<name>' is not active"}The title is neither live nor testing. (A testing title may save cards.)Contact Invo.
503{"status": "error", "error_code": "<configuration code>", "message": "Card setup is not available right now."}Card setup is not available in this environment. The error_code names an Invo-side configuration condition; do not branch on it.Retry later; contact Invo if it persists.
429{"error": "rate_limit_exceeded", "message": "...", "retry_after": <seconds>, "limit_type": "rate_limit"} plus a Retry-After headerMore than 2000 mints per minute on one key, or more than 120 per minute from one IP.Honour Retry-After.
  • The session lives 10 minutes (expires_at). Entering a card is one sitting: mint the link when the member is ready, not in advance. An expired link shows “This card link has expired. Ask for a new one.”; mint another.
  • card_setup_url is absolute and lives on the host root of the environment you called (https://invo.network/card-setup?session=... in production, https://sandbox.invo.network/card-setup?session=... in sandbox). Use it as given. Do not rebuild it from your base URL, and do not add the sandbox /sandbox prefix to it; the API call carries that prefix, the page link does not.
  • The session_id is yours to log; the token inside the URL is a bearer credential for this one card entry. Do not log the URL beside identifiers you publish.

Step 2. Send the member to card_setup_url

Redirect, or open it as a top-level window or tab. Do not frame it. What the member sees:

  • A page titled “Save your card”, showing their email, saying in words that they will not be charged now and that the card is being saved for the subscription’s renewals. The first charge happens when you call /subscribe, not on this page.
  • The card fields, a “Save card” button, and any authentication their issuer asks for.
  • On success, a redirect to your success_url, or “Card saved. You can close this window.” when you gave none.
  • Reloads are safe. The link may be reloaded freely before the card is saved. A reload after the issuer has authorised the card opens directly on a “Finish saving card” step rather than asking for the card again.
  • If saving fails after the issuer authorised the card, the page says “We could not save your card. Please try again in a moment.” and the same link keeps working; nothing is recorded until the save succeeds. One exception: if Invo could not hand the link back for another attempt the message is “This link can no longer be used. Ask for a new card link.”, and you mint a new one.
  • Once the card is saved the link is spent. Within the 10-minute token life, reopening it shows the page on its “Finish saving card” step; when the member clicks, Invo returns the same saved card (nothing is saved twice) and redirects to success_url again. A duplicated tab finishing a second time gets the same answer. After the 10 minutes the link reads “This card link has expired.” A lost response cannot strand the member either way.
  • Terminal: if what the member entered is not a card (a bank account, for instance) the page stops with “Only a card can be saved for a subscription. Ask for a new link and enter a card.” Mint a new link.

Step 3. Read the card from GET /api/currency-purchases/player-cards

A card saved this way appears in the list immediately (the list cache is cleared), carries the off-session consent a subscription needs, and is indistinguishable from one saved through option A. Read its id: the member’s next /subscribe picks their newest card up automatically (section 5), or you name it with player_card_id. There is no partner-visible event for a saved card; the list read, on the member’s return to success_url, is the signal.

curl
# 1. your server: mint the link
curl -sS -X POST "$BASE/api/checkout/card-setup-sessions" \
  -H "X-Game-Secret-Key: $GAME_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "player_email": "member@example.com",
    "player_name": "Member Seven",
    "success_url": "https://yourtitle.com/membership/saved",
    "cancel_url":  "https://yourtitle.com/membership",
    "metadata": {"member_id": "7"}
  }'
# player_name creates the member if they are new; an existing member is used as is
# 201 {"session_id": "...", "card_setup_url": "https://invo.network/card-setup?session=...",
#      "expires_at": "2026-09-12T00:29:08+00:00"}

# 2. send the member to card_setup_url; they come back to success_url

# 3. your server: read the saved card
curl -sS "$BASE/api/currency-purchases/player-cards?player_email=member%40example.com" \
  -H "X-Game-Secret-Key: $GAME_SECRET"
# 200 {"cards": [{"id": 42, "last_four": "4242", "brand": "visa", "exp_month": 12, "exp_year": 2030, ...}]}
Node, @invonetwork/web-sdk (3.9.0 or later; playerName needs 3.15.0)
// 1. mint the link
const session = await invo.cards.createSetupSession({
  playerEmail: "member@example.com",
  playerName: "Member Seven",                             // optional; creates the member if they are new
  successUrl: "https://yourtitle.com/membership/saved",   // optional
  cancelUrl: "https://yourtitle.com/membership",          // optional; carried, not used by the page today
  metadata: { member_id: "7" },                           // optional; stored, never returned: correlate via successUrl
});
// session.sessionId, session.cardSetupUrl, session.expiresAt (10 minutes out); metadata is not returned anywhere

// 2. send the member there (redirect or top-level window; never an iframe)
redirectMember(session.cardSetupUrl);

// 3. when they land on successUrl, read the card
const { cards } = await invo.cards.list("member@example.com");
const card = cards[0];            // newest first: { id: 42, lastFour: "4242", brand: "visa", ... }
Node, raw HTTP
// 1. mint the link
const { status, json } = await invo("POST", "/api/checkout/card-setup-sessions", {
  player_email: "member@example.com",
  player_name: "Member Seven",                   // creates the member if they are new
  success_url: "https://yourtitle.com/membership/saved",
  cancel_url: "https://yourtitle.com/membership",
  metadata: { member_id: "7" },
});
if (status === 201) {
  redirectMember(json.card_setup_url);           // use it as given; expires at json.expires_at
} else if (status === 404 && json.error_code === "PLAYER_NOT_FOUND") {
  // unknown member and no player_name was sent: send player_name to create them
} else if (status === 409 && json.error_code === "PHONE_SHARE_APPROVAL_REQUIRED") {
  // the phone belongs to another member: handle it as on /subscribe, then call again
} else if (status === 503 && json.error_code === "PLAYER_CREATE_FAILED") {
  // the member could not be created; nothing was created: call again
}

// 3. on their return, read the card
const cards = (await invo("GET",
  "/api/currency-purchases/player-cards?player_email=" + encodeURIComponent("member@example.com"))).json.cards;
const card = cards[0];                           // undefined when nothing was saved
Python, invonetwork (3.8.0 or later; player_name needs 3.14.0)
# 1. mint the link
session = invo.cards.create_setup_session(
    player_email="member@example.com",
    player_name="Member Seven",                             # optional; creates the member if they are new
    success_url="https://yourtitle.com/membership/saved",   # optional
    cancel_url="https://yourtitle.com/membership",          # optional; carried, not used by the page today
    metadata={"member_id": "7"},                            # optional; stored, never returned: correlate via success_url
)
# session.session_id, session.card_setup_url, session.expires_at (10 minutes out); metadata is not returned anywhere

# 2. send the member there (redirect or top-level window; never an iframe)
redirect_member(session.card_setup_url)

# 3. when they land on success_url, read the card
cards = invo.cards.list("member@example.com").cards
card = cards[0] if cards else None               # newest first
Python, raw HTTP
# 1. mint the link
status, body = invo("POST", "/api/checkout/card-setup-sessions", {
    "player_email": "member@example.com",
    "player_name": "Member Seven",               # creates the member if they are new
    "success_url": "https://yourtitle.com/membership/saved",
    "cancel_url": "https://yourtitle.com/membership",
    "metadata": {"member_id": "7"},
})
if status == 201:
    redirect_member(body["card_setup_url"])      # use it as given; expires at body["expires_at"]
elif status == 404 and body.get("error_code") == "PLAYER_NOT_FOUND":
    pass   # unknown member and no player_name was sent: send player_name to create them
elif status == 409 and body.get("error_code") == "PHONE_SHARE_APPROVAL_REQUIRED":
    pass   # the phone belongs to another member: handle it as on /subscribe, then call again
elif status == 503 and body.get("error_code") == "PLAYER_CREATE_FAILED":
    pass   # the member could not be created; nothing was created: call again

# 3. on their return, read the card
_, cards_body = invo("GET", "/api/currency-purchases/player-cards",
                     params={"player_email": "member@example.com"})
card = cards_body["cards"][0] if cards_body["cards"] else None

In sandbox the page is served by the sandbox host and takes the test-mode card numbers listed on the sandbox recipe. The mint call carries the sandbox prefix like every other API call (https://sandbox.invo.network/sandbox/api/checkout/card-setup-sessions); without it you get a 404 that reads exactly like the feature not existing.

Option A: POST /api/currency-purchases/setup-intent (your own card form; bound to the current processor)

Use this only if you already run a card form in your own client against Invo’s current card processor. It hands back a client_secret that only that processor’s client library can consume, so this path is bound to the processor of the day and will need rework when Invo changes processor. Option 0 does not. If you are starting fresh, use option 0.

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 title or you get 404 PLAYER_NOT_FOUND. Create them with their first purchase, send or subscription, or use the hosted card page (option 0) with player_name, which creates the member with their card.
  • It takes no player_name (unlike the hosted card page), 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 title. 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-13T01:15:49.946235+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 your secret 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         -> "succeeded" when the card is already saved; otherwise
//                         "requires_action" | "requires_confirmation" | "requires_payment_method"

// 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          -> "succeeded" when the card is already saved; otherwise
#                          "requires_action" | "requires_confirmation" | "requires_payment_method"

# 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, 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 0, A or B.

Which hosted pages save a card, and which do not

  • The hosted card page (option 0) saves a card and charges nothing. It is the page built for this.
  • The platform-commerce checkout page (item sales) never saves a card for recurring billing.
  • The currency-purchase hosted checkout saves a card only when the member enters a new card and ticks the “save this card” box on Invo’s page (the page, not you, sends save_card: true); a card picked from their saved list there is not re-saved. It is the member’s choice, so do not rely on it as your capture path; use option 0 and read the list.

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-13T01:15:49.946235+00:00"}]}

Only unexpired cards are listed, newest first. A paid trial needs one of these before /subscribe; for a brand-new member, save it through the hosted card page with player_name (section 5). An unknown player returns {"cards": []}. The list is served from a short cache (about 60 seconds); a card saved through the hosted card page or /setup-intent appears immediately because those paths clear 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").cards
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 free trial; a paid trial’s period 1 is charged like any first period). 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 title. 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 the per-charge ceiling of 25000.00 are accepted but never charged (see deferrals). The ceiling is per charge, not per month: a $500 per month plan sold as a 12-month plan is a single $6,000 charge and is fine.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
trial_amount_usddecimal stringnoMakes the trial a paid trial (section 5). Only with trial_days or trial_end. Card subscriptions only: never with wallet_only: true, never on Steam. At least 0.50 (the card minimum) and less than amount_usd. Charged at creation as period 1, so the member must already have a saved card (named with player_card_id, or picked automatically as in section 5); without one the create is refused and nothing is created. Omit it for a free trial.TRIAL_AMOUNT_REQUIRES_TRIAL, TRIAL_AMOUNT_NOT_SUPPORTED_FOR_FUNDING, TRIAL_AMOUNT_INVALID, PAYMENT_METHOD_REQUIRED
wallet_onlybooleannoDeprecated. Default false. Still accepted and returned, but since 2026-09-17 a card subscription is paid only by card, so true means the subscription can never be paid: every charge fails with failure_code: "wallet_only" and the subscription goes through dunning to expired. Do not send true on new integrations. 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 title, 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 still created, without a card, and its first charge fails with failure_code: "no_payment_method" whatever the member’s balance (see section 5).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, CONSENT_DISCLOSED_TRIAL_AMOUNT_INVALID, CONSENT_DISCLOSED_TRIAL_DAYS_INVALID, CONSENT_DISCLOSED_TRIAL_END_INVALID
revenue_shareobjectnoAttribution to a second player in your title. 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 title is not live.Make the title live in the console.
503body status: "error", error: "flow_paused"Subscription changes are paused for maintenance.Retry later with the same client_request_id.
400PAYMENT_METHOD_REQUIREDA paid trial was requested and the member has no usable saved card (none named, and no unexpired saved card, or a member Invo has never seen). Nothing was created.Save the member’s card first (section 1), then subscribe; you may name it with player_card_id.
400CURRENCY_NOT_CONFIGUREDThe title has no currency configured.Configure the title’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, trial_amount_usd). For trial_amount_usd an omitted value counts: a replay without it against a paid trial, or with it against a subscription that has none, is a mismatch.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,
    "trial_amount_usd": 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",
      "disclosed_amount_usd": "9.99",
      "disclosed_interval": "month",
      "terms_version": "2026-09",
      "disclosed_trial_amount_usd": null,
      "disclosed_trial_days": null,
      "disclosed_trial_end": null
    },
    "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",
    "trial_amount_coins_estimate": null
  },
  "card": {
    "id": 42, "last_four": "4242", "brand": "visa",
    "exp_month": 12, "exp_year": 2030, "created_at": "2026-09-13T01:15:49.946235+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 two create-only estimates: amount_coins_estimate, what one regular period is worth in coins on this rail, and trial_amount_coins_estimate, what the trial period of a paid trial is worth (null without a paid trial). 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 carries seven fields (consent_at, disclosed_amount_usd, disclosed_interval, terms_version, and the three disclosed trial fields), each null until you supply it. consent_ip and consent_user_agent are stored for the dispute record and never returned on any read.
  • first_charge always carries all nine keys status, amount_usd, paid_through, paid_period_seq, failure_code, next_retry_at, confirmation_url, expires_at and message, on every outcome, most of them null most of the time (paid_period_seq is null on every non-paid outcome). 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. The card was charged the full price, or the trial price on a paid trial.amount_usd (the amount actually charged for period 1; the trial price on a paid trial), paid_through, paid_period_seqGrant access until paid_through, recorded against paid_period_seq. Expect a subscription.renewed event with period_seq equal to paid_period_seq (1 on a create; 2 if a replay arrives after a free trial converted). On a paid trial the subscription still reads trialing and the event carries is_trial: true.
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 on a free trial. Never returned for a paid trial.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 no_payment_method means the member has no saved card (coins do not pay); wallet_negative means the member’s coin balance is below zero and nothing was charged. On a paid trial whose charge was refused the subscription is instead expired and next_retry_at is null (section 5).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). On a refused paid trial you receive subscription.payment_failed then subscription.expired; save a working card and subscribe again with a new client_request_id.
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 an in-app 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":                       // free trial only
      grantTrialAccess(sub.subscription_id, sub.trial_end);
      break;
    case "failed":
      if (sub.status === "expired") {
        // a paid trial whose charge was refused: nothing will be retried.
        // Get a working card saved, then subscribe again with a NEW client_request_id.
        askMemberForAnotherCard(sub.player_id, fc.failure_code);
      } else {
        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":                 # free trial only
        grant_trial_access(sub["subscription_id"], sub["trial_end"])
    elif st == "failed":
        if sub["status"] == "expired":
            # a paid trial whose charge was refused: nothing will be retried.
            # Get a working card saved, then subscribe again with a NEW client_request_id.
            ask_member_for_another_card(sub["player_id"], fc["failure_code"])
        else:
            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, and trials

  • You name one with player_card_id: it must belong to this player in this title 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 still created card-less (card: null, has_payment_method: false), but the card is the only way a card subscription is paid, so its first charge fails at once, whatever the wallet holds, with failure_code: "no_payment_method" (event outcome: "insufficient_funds") and goes into dunning. A card the member saves afterwards (through the hosted card page, for instance) is adopted automatically at the next charge attempt: Invo picks the newest saved card, attaches it, and the read then names it. You can also attach one explicitly with /payment-method, which additionally lets you choose which card.
  • Paid trials are the exception to the card-less create. With trial_amount_usd and no usable card (none named and no unexpired saved card, or a member Invo has never seen), /subscribe refuses with 400 PAYMENT_METHOD_REQUIRED and creates nothing. Save the member’s card first, then subscribe.
  • wallet_only: true (deprecated): no card is ever charged, even if one is saved later, until you flip the flag through /payment-method. Since 2026-09-17 that means no charge can ever succeed: each fails with failure_code: "wallet_only".

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: free or paid

A trial is period 1 of the subscription. You choose whether the member pays for it. Either way the trial ends at trial_end, Invo charges the regular price amount_usd as period 2, and billing carries on as normal from there. Trials are not available on the Steam rail.

Free trialPaid trial
You sendtrial_days or trial_endtrial_days or trial_end, plus trial_amount_usd
Saved card needed at createNo (but have one by trial_end)Yes, or 400 PAYMENT_METHOD_REQUIRED
Charged at createNothingtrial_amount_usd, as period 1
first_charge.statusskipped_trialpaid, requires_action, failed or pending, like any first charge
paid_through after createnulltrial_end (once the trial charge is paid)
subscription.renewed for period 1None; the first event is period 2Yes, with is_trial: true and the trial price
If the trial’s own charge is declinedNot applicableThe subscription ends at once as expired
At trial_endamount_usd charged as period 2amount_usd charged as period 2

Free 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 the card the full price; a successful charge moves the subscription to active and fires subscription.renewed with period_seq: 2. A trialing subscription with no card fails its first paid charge like any other card subscription with no card.
  • Trial access is yours to grant until trial_end; paid access from paid_through after conversion.
curl, a 7-day free 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

Paid trials (trial_amount_usd)

Send trial_amount_usd with trial_days or trial_end to charge a small amount for the trial window and then the regular price: for example 1.00 USD for 7 days, then 29.00 USD a month, as one subscription on one card. The rules:

  • Card subscriptions only. Only with trial_days or trial_end (TRIAL_AMOUNT_REQUIRES_TRIAL); never with wallet_only: true and never on Steam (TRIAL_AMOUNT_NOT_SUPPORTED_FOR_FUNDING); at least 0.50, the card minimum, and less than amount_usd (TRIAL_AMOUNT_INVALID); and high enough that the trial charge leaves you something after Invo’s card fee (TRIAL_AMOUNT_BELOW_FEE_FLOOR, whose message names the smallest price accepted).
  • Save the card first. The trial price is charged inside the create request, so the member must already have a usable saved card when you call /subscribe (named with player_card_id, or their newest unexpired card, picked automatically). Without one the create is refused with 400 PAYMENT_METHOD_REQUIRED and nothing is created. Free trials and subscriptions without a trial are unchanged: they are still created card-less. For a brand-new signup, see “Paid trial for a new member” below.
  • Period 1 is the trial window (creation to trial_end) and is charged trial_amount_usd at creation. first_charge reports it exactly as any first charge is reported, with amount_usd equal to the trial price. skipped_trial is never returned for a paid trial.
  • On success the 201 reads status: "trialing", first_charge.status: "paid", first_charge.amount_usd equal to the trial price, first_charge.paid_period_seq: 1, and paid_through equal to trial_end. subscription.amount_usd is the regular price and subscription.trial_amount_usd the trial price. You receive subscription.renewed for period 1 with is_trial: true and amount_usd equal to the trial price.
  • At trial_end Invo charges amount_usd as period 2: subscription.renewed with period_seq: 2 and is_trial: false, and the subscription moves to active. Dunning and card authentication apply to period 2 and later exactly as to any renewal. The step from the trial price to the regular price is not a price change: nothing is staged in pending_amount_usd, and the rule that refuses a staged price more than double the agreed one does not apply to it.
  • If the trial charge is refused (the card is declined, or at the moment of the charge there is no chargeable card, for example because the saved card was removed or has expired), the subscription does not go live. It ends at once as expired. The 201 carries first_charge.status: "failed" with the failure_code, next_retry_at: null and paid_through: null, and you receive subscription.payment_failed (retries_remaining: 0) then subscription.expired with expire_cause: "trial_payment_failed". The item is free again: subscribe the member again with a new client_request_id once they have a working card. A replay of the original key reports the same refused charge and charges nothing.
  • Only a refusal of the card ends a paid trial. A temporary error that says nothing about the card (a processing problem on Invo’s side or the card processor’s, or a member coin balance below zero, which is refused before the card is presented) does not end it. The subscription goes to past_due, first_charge.status is failed with a next_retry_at, you receive subscription.payment_failed and subscription.past_due, and the trial charge is retried on the normal schedule. A retry that is then declined ends the trial as above.
  • Card authentication pauses the trial charge exactly as it pauses any first charge: first_charge.status: "requires_action" with a confirmation_url, and the subscription reads awaiting_authentication. When the member completes it before trial_end, the subscription returns to trialing.
  • A trial charge that settles late is followed at once by the full price. If the trial charge only succeeds after trial_end (the member authenticated late, or a retry succeeded after the window closed), the member is charged the trial price for period 1 and then, immediately after, the full price for period 2: two charges back to back. The trial window is not moved or extended. To avoid that, cancel a subscription that is still awaiting_authentication or past_due at trial_end.
  • Events about the trial period say so. subscription.renewed carries is_trial on every delivery: true only for period 1 of a paid trial, false otherwise. subscription.payment_failed, subscription.past_due, subscription.authentication_required and subscription.expired for period 1 of a paid trial carry is_trial: true and trial_amount_usd (their amount_due_usd is the trial price, while the common block’s amount_usd is the regular price). Those two fields are absent on every other event of those types.
  • Consent. Send the trial terms you showed the member: disclosed_trial_amount_usd and either disclosed_trial_days or disclosed_trial_end (section 6).
  • Idempotency. trial_amount_usd is one of the terms a replay is checked against: the same key with a different trial price, or with the trial price added or removed, is 409 IDEMPOTENT_REPLAY_MISMATCH.
  • Fees and refunds. The trial period is an ordinary paid card period. The subscription fee applies to the trial price like any card charge, including the fixed 0.30 USD, so on a 1.00 trial on the Open tier Invo’s fee is 0.35 and yours is 0.65. It can be refunded through /refund with period_seq: 1, up to the trial price, and you bear the whole refunded amount, Invo’s card fee included, as on any card refund. A full refund of it ends the subscription, as a full refund of any period does. See Refunds.

Paid trial for a new member

A first-time signup has no member record and no card yet, and a paid trial needs the card before /subscribe. The hosted card page solves both in one step: send player_name with the mint and Invo creates the member, then the member saves their card.

  1. POST /api/checkout/card-setup-sessions with player_email and player_name (and player_phone if you have it). Invo creates the member and returns card_setup_url.
  2. Send the member to card_setup_url. They save their card and return to your success_url. Nothing is charged on the page.
  3. Read the card from GET /api/currency-purchases/player-cards (cards.list in the SDKs) and take its id.
  4. POST /api/subscriptions/subscribe with player_card_id set to that id, trial_days or trial_end, and trial_amount_usd. The trial price is charged inside the call.
Node, @invonetwork/web-sdk (3.15.0 or later)
// 1. create the member and mint the card link in one call
const session = await invo.cards.createSetupSession({
  playerEmail: "new@example.com",
  playerName: "New Member",
  successUrl: "https://yourtitle.com/membership/card-saved?member=new",
});

// 2. send the member to session.cardSetupUrl (top-level); they come back to successUrl

// 3. read the card, then 4. subscribe with the paid trial
const { cards } = await invo.cards.list("new@example.com");
const r = await invo.subscriptions.create({
  clientRequestId: "new-member-pro",
  playerEmail: "new@example.com",
  playerName: "New Member",
  itemId: "pro-membership",
  amountUsd: "29.00",
  trialDays: 7,
  trialAmountUsd: "1.00",
  playerCardId: cards[0].id,
  consent: {
    consentAt: new Date().toISOString(),
    consentIp: memberIp,
    disclosedAmountUsd: "29.00",
    disclosedInterval: "month",
    disclosedTrialAmountUsd: "1.00",
    disclosedTrialDays: 7,
  },
});
Python, invonetwork (3.14.0 or later)
# 1. create the member and mint the card link in one call
session = invo.cards.create_setup_session(
    player_email="new@example.com",
    player_name="New Member",
    success_url="https://yourtitle.com/membership/card-saved?member=new",
)

# 2. send the member to session.card_setup_url (top-level); they come back to success_url

# 3. read the card, then 4. subscribe with the paid trial
cards = invo.cards.list("new@example.com").cards
r = invo.subscriptions.create(
    client_request_id="new-member-pro",
    player_email="new@example.com",
    player_name="New Member",
    item_id="pro-membership",
    amount_usd="29.00",
    trial_days=7,
    trial_amount_usd="1.00",
    player_card_id=cards[0].id,
    consent={
        "consent_at": "2026-09-24T14:01:50+00:00",
        "consent_ip": member_ip,
        "disclosed_amount_usd": "29.00",
        "disclosed_interval": "month",
        "disclosed_trial_amount_usd": "1.00",
        "disclosed_trial_days": 7,
    },
)

Example: $1.00 for 7 days, then $29.00 a month

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": "member-77-pro-2026-09",
    "player_email": "member77@example.com",
    "player_name": "Member 77",
    "item_id": "pro",
    "item_name": "Pro",
    "amount_usd": "29.00",
    "interval": "month",
    "trial_days": 7,
    "trial_amount_usd": "1.00",
    "player_card_id": 42,
    "consent": {
      "consent_at": "2026-09-23T10:00:00+00:00",
      "consent_ip": "203.0.113.7",
      "consent_user_agent": "Mozilla/5.0 ...",
      "disclosed_amount_usd": "29.00",
      "disclosed_interval": "month",
      "disclosed_trial_amount_usd": "1.00",
      "disclosed_trial_days": 7,
      "terms_version": "2026-09"
    }
  }'
Node, raw HTTP
const body = {
  client_request_id: "member-77-pro-2026-09",     // generated once, persisted, replayed on retry
  player_email: "member77@example.com",
  player_name: "Member 77",
  item_id: "pro",
  item_name: "Pro",
  amount_usd: "29.00",                            // the regular price, charged from period 2
  interval: "month",
  trial_days: 7,
  trial_amount_usd: "1.00",                       // charged now, as period 1
  player_card_id: 42,                             // a saved card is required for a paid trial
  consent: {
    consent_at: "2026-09-23T10:00:00+00:00",
    consent_ip: memberIp,
    consent_user_agent: memberUserAgent,
    disclosed_amount_usd: "29.00",
    disclosed_interval: "month",
    disclosed_trial_amount_usd: "1.00",
    disclosed_trial_days: 7,
    terms_version: "2026-09",
  },
};

const { status, json } = await invo("POST", "/api/subscriptions/subscribe", body);
if (status === 400 && json.error_code === "PAYMENT_METHOD_REQUIRED") {
  // no saved card: send the member to the hosted card page (section 1), then call again
} else if (status === 201 || (status === 200 && json.idempotent_replay)) {
  const fc = json.first_charge;
  if (fc.status === "paid") {
    grantTrialAccess(json.subscription.subscription_id, fc.paid_through);   // paid_through === trial_end
  } else if (fc.status === "failed" && json.subscription.status === "expired") {
    // the card refused the trial charge; nothing is retried. New card, NEW client_request_id.
  } else if (fc.status === "failed") {
    // a temporary error: past_due, retried at fc.next_retry_at
  } else if (fc.status === "requires_action") {
    sendToMember(fc.confirmation_url, fc.expires_at);
  }
}
Python, raw HTTP
body = {
    "client_request_id": "member-77-pro-2026-09",   # generated once, persisted, replayed on retry
    "player_email": "member77@example.com",
    "player_name": "Member 77",
    "item_id": "pro",
    "item_name": "Pro",
    "amount_usd": "29.00",                          # the regular price, charged from period 2
    "interval": "month",
    "trial_days": 7,
    "trial_amount_usd": "1.00",                     # charged now, as period 1
    "player_card_id": 42,                           # a saved card is required for a paid trial
    "consent": {
        "consent_at": "2026-09-23T10:00:00+00:00",
        "consent_ip": member_ip,
        "consent_user_agent": member_user_agent,
        "disclosed_amount_usd": "29.00",
        "disclosed_interval": "month",
        "disclosed_trial_amount_usd": "1.00",
        "disclosed_trial_days": 7,
        "terms_version": "2026-09",
    },
}

status, res = invo("POST", "/api/subscriptions/subscribe", body)
if status == 400 and res.get("error_code") == "PAYMENT_METHOD_REQUIRED":
    pass   # no saved card: send the member to the hosted card page (section 1), then call again
elif status == 201 or (status == 200 and res.get("idempotent_replay")):
    fc = res["first_charge"]
    if fc["status"] == "paid":
        grant_trial_access(res["subscription"]["subscription_id"], fc["paid_through"])  # == trial_end
    elif fc["status"] == "failed" and res["subscription"]["status"] == "expired":
        pass   # the card refused the trial charge; nothing is retried. New card, NEW client_request_id.
    elif fc["status"] == "failed":
        pass   # a temporary error: past_due, retried at fc["next_retry_at"]
    elif fc["status"] == "requires_action":
        send_to_member(fc["confirmation_url"], fc["expires_at"])

201 when the trial charge is paid

{
  "status": "success",
  "idempotent_replay": false,
  "subscription": {
    "subscription_id": "SUB_1758621605_7F3K9Q2M",
    "game_id": "1234",
    "player_id": 98777,
    "client_request_id": "member-77-pro-2026-09",
    "status": "trialing",
    "amount_usd": "29.00",
    "pending_amount_usd": null,
    "interval": "month",
    "interval_count": 1,
    "item_id": "pro",
    "item_name": "Pro",
    "current_period_start": "2026-09-30T10:23:41.204511+00:00",
    "current_period_end": "2026-10-30T10:23:41.204511+00:00",
    "period_seq": 2,
    "next_charge_at": "2026-09-30T10:23:41.204511+00:00",
    "cancel_at_period_end": false,
    "trial_end": "2026-09-30T10:23:41.204511+00:00",
    "trial_amount_usd": "1.00",
    "canceled_at": null,
    "ended_at": null,
    "wallet_only": false,
    "has_payment_method": true,
    "funding_rail": "card",
    "steam_agreement_status": null,
    "metadata": null,
    "consent": {
      "consent_at": "2026-09-23T10:00:00+00:00",
      "disclosed_amount_usd": "29.00",
      "disclosed_interval": "month",
      "terms_version": "2026-09",
      "disclosed_trial_amount_usd": "1.00",
      "disclosed_trial_days": 7,
      "disclosed_trial_end": null
    },
    "created_at": "2026-09-23T10:00:05.204511+00:00",
    "updated_at": "2026-09-23T10:00:07.881250+00:00",
    "revenue_share": null,
    "paid_through": "2026-09-30T10:23:41.204511+00:00",
    "amount_coins_estimate": "290.00",
    "trial_amount_coins_estimate": "10.00"
  },
  "card": {
    "id": 42, "last_four": "4242", "brand": "visa",
    "exp_month": 12, "exp_year": 2030, "created_at": "2026-09-20T18:02:11.004211+00:00"
  },
  "first_charge": {
    "status": "paid",
    "amount_usd": "1.00",
    "paid_through": "2026-09-30T10:23:41.204511+00:00",
    "paid_period_seq": 1,
    "failure_code": null,
    "next_retry_at": null,
    "confirmation_url": null,
    "expires_at": null,
    "message": "The first period was charged and the currency granted."
  }
}
  • trial_end is seven days out plus up to an hour of spread (from trial_days; send trial_end instead to fix it to the second). It is also paid_through and next_charge_at: the regular price is charged then.
  • period_seq is already 2, as after any paid first charge; first_charge.paid_period_seq: 1 is the trial period.
  • amount_coins_estimate (290.00) is what one regular period is worth; trial_amount_coins_estimate (10.00) is what the trial period is worth.
  • The disclosed trial terms are stored and returned in consent. If they disagree with the trial actually created, Invo records the disagreement; it does not refuse the create.

The events that follow

// right away: subscription.renewed for the trial period
{ "subscription_id": "SUB_1758621605_7F3K9Q2M", "item_id": "pro",
  "period_seq": 1, "is_trial": true,
  "amount_usd": "1.00", "amount_coins": "10.00",
  "period_start": "2026-09-23T10:00:05+00:00", "period_end": "2026-09-30T10:23:41+00:00",
  "funding": { "card_charged_usd": "1.00", "minted_coins": "10.00", ... },
  "split": { "total_usd": "1.00", "basis": "price",
             "invo_fee_usd": "0.35", "partner_revenue_usd": "0.65", ... },
  ... }

// at trial_end: subscription.renewed for the first regular period
{ "subscription_id": "SUB_1758621605_7F3K9Q2M", "item_id": "pro",
  "period_seq": 2, "is_trial": false,
  "amount_usd": "29.00", "amount_coins": "290.00",
  "period_start": "2026-09-30T10:23:41+00:00", "period_end": "2026-10-30T10:23:41+00:00",
  "split": { "total_usd": "29.00", "basis": "price",
             "invo_fee_usd": "1.61", "partner_revenue_usd": "27.39", ... },
  ... }
// the subscription is now "active"; later periods renew at 29.00 as normal

The split figures are the Open tier rate on a US card, 4.5 percent plus 0.30 USD; read the split block rather than recomputing it. Full payloads are on the webhooks page.

201 when the card refuses the trial charge

{
  "status": "success",
  "idempotent_replay": false,
  "subscription": {
    "subscription_id": "SUB_1758621605_7F3K9Q2M",
    "status": "expired",
    "amount_usd": "29.00",
    "trial_amount_usd": "1.00",
    "period_seq": 1,
    "next_charge_at": null,
    "ended_at": "2026-09-23T10:00:07.512004+00:00",
    "paid_through": null,
    ...
  },
  "card": { "id": 42, ... },
  "first_charge": {
    "status": "failed",
    "amount_usd": "1.00",
    "paid_through": null,
    "paid_period_seq": null,
    "failure_code": "card_declined",
    "next_retry_at": null,
    "confirmation_url": null,
    "expires_at": null,
    "message": "The trial charge did not succeed, so the subscription did not start and will not be retried. Subscribe again with a new client_request_id."
  }
}
// then: subscription.payment_failed (retries_remaining 0, is_trial true, trial_amount_usd "1.00")
// then: subscription.expired (expire_cause "trial_payment_failed", is_trial true,
//       trial_amount_usd "1.00", period_seq 1, final_period_end null)

Wallet-only subscriptions (deprecated)

wallet_only is deprecated and a wallet-only card subscription can never be paid. Since 2026-09-17 a card subscription is paid only by card, and wallet_only: true still means “never charge a card”. So every charge fails with outcome: "insufficient_funds" and failure_code: "wallet_only", the ordinary 2 / 3 / 2 retry ladder runs, and the subscription lapses with subscription.expired. The flag is still accepted and returned, so an existing integration does not break, but do not send it. If you offered a “pay with balance only” option, remove it. To keep an existing wallet-only member, attach a card with /payment-method and wallet_only: false inside the grace window.

curl, converting an existing wallet-only subscription to a card
curl -sS -X POST "$BASE/api/subscriptions/SUB_1757155200_A1B2C3D4/payment-method" \
  -H "X-Game-Secret-Key: $GAME_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"player_card_id": 42, "wallet_only": false}'
# the card is used from the next charge, including a pending retry in dunning
Node, @invonetwork/web-sdk
await invo.subscriptions.setPaymentMethod("SUB_1757155200_A1B2C3D4", {
  playerCardId: 42,
  walletOnly: false,
});
Python, invonetwork
invo.subscriptions.set_payment_method(
    "SUB_1757155200_A1B2C3D4",
    player_card_id=42,
    wallet_only=False,
)

6. The consent object

Optional evidence that the member agreed to the recurring charge. Stored verbatim. Seven of the nine fields come back on every read (consent_at, disclosed_amount_usd, disclosed_interval, terms_version and the three disclosed trial fields); consent_ip and consent_user_agent are kept for the dispute record and are not returned to you. Supply them all on every create: when a member disputes a recurring charge with their card issuer, the dispute is argued from this record, and a record showing the price, the interval and any trial terms the member was shown is the strongest answer to “I never agreed to this”.

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 subscription price you showed the member, not a charge total. Should equal amount_usd.
disclosed_intervalmonth or yearThe interval you showed the member.
terms_versionstring, max 50Your terms version.
disclosed_trial_amount_usddecimal stringPaid trial: the trial price you showed the member. Should equal trial_amount_usd.
disclosed_trial_daysinteger, 1 to 365Paid trial: the trial length you showed the member, in days. Send this or disclosed_trial_end.
disclosed_trial_endISO 8601Paid trial: the trial end you showed the member.

Consent is not enforced: a disclosed price or trial that disagrees with what was created is recorded by Invo, and the create still succeeds. Get it right anyway; the disagreement is exactly what a dispute turns on.

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 title 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 card numbers before you touch a real card.
  • Tiers: on the default Open tier you are the seller of your card subscriptions and responsible for any tax on them; on the Merchant of Record tier Invo is the seller.