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
POST /api/checkout/card-setup-sessionsmints a link to the Invo-hosted card page; the member saves their card there. (Alternatives:/setup-intentwith your own card form, or a purchase withsave_card: true.)GET /api/currency-purchases/player-cardsgives you the card’sid.POST /api/subscriptions/subscribecreates the subscription and charges period 1 before it responds.- Branch on
first_charge.status. Then wait forsubscription.renewed.
Prerequisites (keys, base URLs, the title being live, a webhook target) are on the overview. Both card-capture endpoints need the member to already exist; see “The member must exist first” 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.
The member must exist first
Saving a card is not an identity event. The hosted card page and /setup-intent both identify an existing member by player_email and refuse an unknown one with 404 PLAYER_NOT_FOUND; neither creates players. A member comes to exist in your title through any call that carries player_name: /subscribe itself, a currency purchase, a send or a transfer.
- Member already known to your title (an earlier purchase, send, transfer or subscription): card page first, then
/subscribe. The first charge lands inline. - Brand-new member: the card page cannot go first, and there is no call that creates a member without either a purchase or a subscription. Two orders work:
- Recommended when the member must pay today: a currency purchase first.
POST /api/currency-purchases/purchase-currency(or the currency hosted checkout) creates the member and, with a new card andsave_card: true, saves that card with the off-session consent in the same call (option B). Then/subscribe: Invo attaches the newest saved card and the first charge lands inline. /subscribefirst, card page second./subscribecreates the member. Withtrial_daysnothing 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-methodcall. Without a trial the first charge fails for lack of a card (first_charge.status: "failed",failure_code: "no_payment_method", the event’soutcomeisinsufficient_funds), the subscription enters dunning, and the card saved afterwards is adopted at the next retry, two days later by default. Fine for a trial; not for money today.
- Recommended when the member must pay today: a currency purchase first.
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)
| Field | Type | Required | Notes |
|---|---|---|---|
player_email | string | yes | Must already exist in this title; this endpoint does not create players. Lower-cased. |
success_url | URL | no | Where 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_url | URL | no | Carried on the session; not used by the page today. |
metadata | object | no | Stored 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"}| HTTP | error_code or body | Meaning | What to do |
|---|---|---|---|
| 400 | INVALID_PLAYER_EMAIL | player_email missing or not an email. | Send the member’s email. |
| 400 | INVALID_INPUT | success_url or cancel_url uses a script scheme. | Use https or an app deep link. |
| 404 | PLAYER_NOT_FOUND (body also carries "status": "error") | The member does not exist in this title. This flow never creates players. | Create the member first (see the box above). |
| 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 header | More 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_urlis 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/sandboxprefix to it; the API call carries that prefix, the page link does not.- The
session_idis 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_urlagain. 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.
# 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",
"success_url": "https://yourtitle.com/membership/saved",
"cancel_url": "https://yourtitle.com/membership",
"metadata": {"member_id": "7"}
}'
# 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, ...}]}// 1. mint the link
const session = await invo.cards.createSetupSession({
playerEmail: "member@example.com",
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", ... }// 1. mint the link
const { status, json } = await invo("POST", "/api/checkout/card-setup-sessions", {
player_email: "member@example.com",
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") {
// the member does not exist in this title yet: create them first
}
// 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# 1. mint the link
session = invo.cards.create_setup_session(
player_email="member@example.com",
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# 1. mint the link
status, body = invo("POST", "/api/checkout/card-setup-sessions", {
"player_email": "member@example.com",
"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 # the member does not exist in this title yet: create them first
# 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 NoneIn 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. - 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, notclient_request_id(which is accepted as an alias). - It charges nothing. A saved card is not a subscription; you still call
/subscribe.
| Field | Type | Required | Notes |
|---|---|---|---|
player_email | string | yes | Must already exist in this title. This endpoint does not create players (404 PLAYER_NOT_FOUND). |
setup_reference | string, 1 to 200 chars of A-Z a-z 0-9 . _ : - | yes | Idempotency 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_id | string | no | A 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.
| HTTP | error_code | What to do |
|---|---|---|
| 400 | MISSING_SETUP_REFERENCE, INVALID_SETUP_REFERENCE | Send a setup_reference within the character rules. |
| 404 | PLAYER_NOT_FOUND | Create the player first (any endpoint that creates players, for example a purchase). |
| 409 | SETUP_REFERENCE_REUSED | Use a new reference for a new setup. |
| 400 | CARD_DECLINED, SETUP_FAILED, INVALID_PAYMENT_METHOD | Ask the member for another card. |
| 400 | RAW_CARD_NOT_SUPPORTED | Send a tokenised card, never raw card numbers. |
| 500 | CARD_PERSIST_FAILED | The card was authorised but not recorded; retry /setup-intent/confirm with the same setup_intent_id. |
| 500 | SETUP_CONFIRMATION_FAILED | Retry confirm with the same setup_intent_id. |
| 503 | flow_paused | Card setup is paused for maintenance. Retry later with the same reference. |
# 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>"}'// 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, falseconst { 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;
}
}# 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 Falsestatus, 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_FAILEDOption 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. 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 -sS "$BASE/api/currency-purchases/player-cards?player_email=member%40example.com" \
-H "X-Game-Secret-Key: $GAME_SECRET"const { cards } = await invo.cards.list("member@example.com");
const newest = cards[0]; // { id: 42, lastFour: "4242", brand: "visa", expMonth: 12, expYear: 2030 }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 cardcards = invo.cards.list("member@example.com").cards
newest = cards[0] if cards else None_, 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
| Field | Type | Req. | Limits and rules | Error on violation |
|---|---|---|---|---|
client_request_id | string | yes | Unique 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_email | string | yes | Valid email, max 255. Lower-cased. | PLAYER_EMAIL_INVALID, PLAYER_EMAIL_TOO_LONG |
player_name | string | yes | Max 255. Used only if the player is created. | PLAYER_NAME_REQUIRED, PLAYER_NAME_TOO_LONG |
player_phone | string | no | Max 30. | PLAYER_PHONE_TOO_LONG |
item_id | string | yes | Your 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_name | string | no | Max 255. Echoed on events. | ITEM_NAME_TOO_LONG |
amount_usd | decimal string | yes | 0.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 |
interval | string | no | month (default) or year. | INTERVAL_INVALID |
interval_count | integer | no | 1 (default) to 36. 3 with month bills quarterly. | INTERVAL_COUNT_INVALID, INTERVAL_COUNT_OUT_OF_RANGE |
trial_days | integer | no | 1 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_end | ISO 8601 | no | Explicit 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_only | boolean | no | Default 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_id | integer | no | The 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 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) |
metadata | JSON object | no | Up 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 |
consent | object | no | Evidence 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_share | object | no | Attribution 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
| HTTP | error_code | Meaning | What to do |
|---|---|---|---|
| 403 | GAME_NOT_LIVE | The title is not live. | Make the title live in the console. |
| 503 | body status: "error", error: "flow_paused" | Subscription changes are paused for maintenance. | Retry later with the same client_request_id. |
| 400 | CURRENCY_NOT_CONFIGURED | The title has no currency configured. | Configure the title’s currency in the console. |
| 409 | ACTIVE_SUBSCRIPTION_EXISTS | The 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. |
| 409 | CONCURRENT_REQUEST | Two creates for a brand-new player collided. | Retry with the same client_request_id. |
| 409 | CLIENT_REQUEST_ID_CONFLICT | This 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. |
| 409 | IDEMPOTENT_REPLAY_MISMATCH | Same 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. |
| 409 | REVENUE_SHARE_EXISTS | A revenue share already exists for this subscription. | Nothing; the share is set once at create. |
| 409 | PHONE_SHARE_APPROVAL_REQUIRED | The supplied phone belongs to another identity. | Run the phone-share approval flow the body describes, then retry. |
| 400 | DATA_CONFLICT, INVALID_FIELD_VALUE | A value the database refused. | Fix the field and retry with a new key. |
| 500 | INTERNAL_ERROR | Unexpected 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 -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"
}
}'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)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
}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)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_id201 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",
"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-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."
}
}subscriptionis the same objectGETreturns, plusamount_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_seqis already2: period 1 was just paid and the subscription now points at the next window.current_period_*is that next window;paid_throughis the end of the window just paid.first_charge.paid_period_seq(1here) names the period that was paid, and thesubscription.renewedevent for it carriesperiod_seq: 1. Key your entitlement records on the paid period, never onsubscription.period_seq. cardis the card backing the subscription (the one you named or the one Invo picked), ornullwhen there is none.has_payment_methodsays the same thing on every read.revenue_shareisnullwhen you did not supply one.- The
consentblock carries four fields (consent_at,disclosed_amount_usd,disclosed_interval,terms_version), all null until you supply them.consent_ipandconsent_user_agentare stored for the dispute record and never returned on any read. first_chargealways carries all nine keysstatus,amount_usd,paid_through,paid_period_seq,failure_code,next_retry_at,confirmation_url,expires_atandmessage, on every outcome, most of themnullmost of the time (paid_period_seqisnullon every non-paidoutcome). Branch onfirst_charge.status, then read the fields you know exist.first_charge.paid_throughis the same value assubscription.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
status | What happened | Populated fields | Your move |
|---|---|---|---|
paid | Money 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_action | The card issuer wants the cardholder to authenticate. Nothing has been charged. Subscription is awaiting_authentication. | confirmation_url, expires_at | Send 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_trial | Deliberately not charged: the subscription is trialing. | none | Grant trial access until subscription.trial_end. The first charge runs at trial end. |
failed | The charge did not succeed. Subscription is past_due and in dunning. | failure_code, next_retry_at | Tell the member to fix their card. Invo retries at next_retry_at. You also receive subscription.payment_failed (and subscription.past_due). |
pending | Not resolved yet (an ambiguous processor answer or a paused flow). | none | Do 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.
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;
}
}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_failed5. Which card backs the subscription
- 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_onlyisfalse(the default): Invo attaches the member’s newest unexpired saved card. If they have none the subscription is created card-less (card: null,has_payment_method: false) and its first empty-wallet charge fails withfailure_code: "no_payment_method"(eventoutcome: "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. 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_daysortrial_endcreates the subscription intrialingwithfirst_charge.status: "skipped_trial"andpaid_through: null.- The trial window is period 1. At
trial_endInvo opens period 2 and charges it; a successful charge moves the subscription toactiveand firessubscription.renewedwithperiod_seq: 2. - Trial access is yours to grant from
trial_end; paid access frompaid_throughafter conversion. - Trials are not available on the Steam rail.
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 nullconst 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.trialEndtrial = 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_endWallet-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 outcome: "insufficient_funds" and failure_code: "wallet_only", 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 -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)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,
});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. Four of the six fields come back on every read (consent_at, disclosed_amount_usd, disclosed_interval, terms_version); consent_ip and consent_user_agent are kept for the dispute record and are not returned to you. Supply all six: Invo is the merchant of record on the card rail, and a disputed charge is argued from this record.
| Field | Type | Rules |
|---|---|---|
consent_at | ISO 8601 | Not more than 5 minutes in the future. |
consent_ip | string, max 45 | The member’s IP as your server saw it (the request IP Invo sees is your server’s). |
consent_user_agent | string | Truncated to 500. |
disclosed_amount_usd | decimal string | The price you showed the member. Should equal amount_usd. |
disclosed_interval | month or year | The interval you showed the member. |
terms_version | string, max 50 | Your 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 title and must not be the subscriber.
percentis0to100, 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.