Reading and changing a subscription

Get one subscription, list a member’s subscriptions, and the three ways to change one: cancel, change the price, change the card. All of these work on a testing game as well as a live one. The clients and raw HTTP helpers the samples use are defined on the overview.

1. GET /api/subscriptions/<subscription_id>

Query: include_player (boolean, default false) adds a player block.

GET $BASE/api/subscriptions/SUB_1757155200_A1B2C3D4?include_player=false
X-Game-Secret-Key: <game secret>

200
{"status": "success", "subscription": { ...the subscription object (section 3)... }}

404 SUBSCRIPTION_NOT_FOUND for an id that does not belong to your game. 400 INVALID_FIELD_VALUE for a malformed id.

curl
curl -sS "$BASE/api/subscriptions/SUB_1757155200_A1B2C3D4" \
  -H "X-Game-Secret-Key: $GAME_SECRET"
Node, @invonetwork/web-sdk
const sub = await invo.subscriptions.get("SUB_1757155200_A1B2C3D4");
// sub.status, sub.paidThrough (entitlement), sub.currentPeriodEnd (billing window, NOT entitlement)
Node, raw HTTP
const { status, json } = await invo("GET", "/api/subscriptions/SUB_1757155200_A1B2C3D4");
if (status === 200) {
  const sub = json.subscription;
  const entitledUntil = sub.paid_through;      // null until something has been paid
}
Python, invonetwork
sub = invo.subscriptions.get("SUB_1757155200_A1B2C3D4")
# sub.status, sub.paid_through (entitlement), sub.current_period_end (billing window, NOT entitlement)
Python, raw HTTP
status, res = invo("GET", "/api/subscriptions/SUB_1757155200_A1B2C3D4")
if status == 200:
    sub = res["subscription"]
    entitled_until = sub["paid_through"]        # None until something has been paid

2. GET /api/subscriptions/player

ParamRules
player_emailrequired (400 PLAYER_EMAIL_REQUIRED, PLAYER_EMAIL_TOO_LONG)
statusoptional comma list of statuses; live expands to the four live statuses and may be mixed in (status=live,canceled). Unknown values: 400 STATUS_INVALID.
include_playeroptional boolean
limit1 to 100, default 50 (400 PAGINATION_INVALID)
offset0 to 100000, default 0 (400 PAGINATION_INVALID)
GET $BASE/api/subscriptions/player?player_email=member@example.com&status=live&limit=50&offset=0
X-Game-Secret-Key: <game secret>

200
{
  "status": "success",
  "player_email": "member@example.com",
  "subscriptions": [ { ...subscription object... } ],
  "pagination": {"total_count": 3, "limit": 50, "offset": 0, "has_more": false}
}

Ordered newest first with a stable tiebreaker, so paging never skips or repeats a row. A player who does not exist yet returns an empty list and total_count: 0, not a 404.

curl
curl -sS "$BASE/api/subscriptions/player?player_email=member%40example.com&status=live" \
  -H "X-Game-Secret-Key: $GAME_SECRET"
Node, @invonetwork/web-sdk
const page = await invo.subscriptions.listForPlayer("member@example.com", {
  status: ["live"],           // or ["live", "canceled"], or omit for all
  limit: 50,
  offset: 0,
});
// page.subscriptions, page.pagination.hasMore
Node, raw HTTP
const qs = new URLSearchParams({ player_email: "member@example.com", status: "live", limit: "50", offset: "0" });
const { json } = await invo("GET", "/api/subscriptions/player?" + qs);
const live = json.subscriptions;                 // [] for a player who does not exist yet
Python, invonetwork
page = invo.subscriptions.list_for_player("member@example.com", status=["live"], limit=50, offset=0)
# page.subscriptions, page.pagination.has_more
Python, raw HTTP
_, res = invo("GET", "/api/subscriptions/player",
              params={"player_email": "member@example.com", "status": "live", "limit": 50, "offset": 0})
live = res["subscriptions"]                      # [] for a player who does not exist yet

3. The subscription object

FieldTypeNotes
subscription_idstring SUB_<epoch>_<8 chars>Not a secret; scope every call by your own key.
game_idstringYour game id, stringified.
player_idinteger
client_request_idstringYour idempotency key.
statusstringSee statuses.
amount_usddecimal stringThe current price.
pending_amount_usddecimal string or nullA staged price change not yet applied.
interval, interval_count
item_id, item_name
current_period_start, current_period_endISO 8601The billing window. Not entitlement.
period_seqinteger1-based counter of billing periods. Advances on every successful charge.
next_charge_atISO 8601 or nullWhen Invo next touches the subscription. Also the retry time in dunning and the challenge deadline in awaiting_authentication. null on terminal rows and on pending_steam_authorization.
cancel_at_period_endboolean
trial_endISO 8601 or null
canceled_at, ended_atISO 8601 or null
wallet_onlyboolean
has_payment_methodbooleanA card is attached. Never the card’s identifiers.
funding_railcard or steam
steam_agreement_statuspending, active, canceled or nullNull on card subscriptions.
metadataobject or nullYours, echoed. Invo writes cancellation reasons under the reserved _invo key.
consentobjectSix fields, all null until supplied.
created_at, updated_atISO 8601
revenue_shareobject or nullrecipient_player_id, percent, settled_by_invo: false.
paid_throughISO 8601 or nullThe entitlement boundary.

The create response adds amount_coins_estimate, which is not on reads. A full example of the object is on the card road.

4. POST /api/subscriptions/<subscription_id>/cancel

Body (optional): {"at_period_end": true, "reason": "..."}. at_period_end defaults to true; an empty string also means the default. reason is stored (max 500 chars) and carried on the event.

ModeEffectResponse fields
at_period_end: true and the member has paid for a period that has not endedSubscription stays live with cancel_at_period_end: true and is retired at paid_through without another charge.cancel_at_period_end: true, effective_at = paid_through, access_until = paid_through
at_period_end: true but nothing has been paid, or the paid window has already elapsedDowngraded to immediate. There is no paid access to preserve.terminated_immediately: true, downgrade_reason: "NO_PAID_PERIOD", message
at_period_end: falseEnded now.cancel_at_period_end: false, effective_at = now, access_until = paid_through if it is in the future, else now
200
{
  "status": "success",
  "already_canceled": false,
  "cancel_at_period_end": true,
  "effective_at": "2026-10-06T14:37:44.482913+00:00",
  "current_period_end": "2026-10-06T14:37:44.482913+00:00",
  "access_until": "2026-10-06T14:37:44.482913+00:00",
  "paid_through": "2026-10-06T14:37:44.482913+00:00",
  "subscription": { ... }
}
  • On a Steam subscription the body also carries steam_agreement_status and steam_agreement_canceled (whether the agreement was ended at Steam in this call; if not, Invo retries on its own and no further charge is ever made either way).
  • Cancelling a subscription that is already canceled or expired returns 200 with already_canceled: true, final_status, and the same date fields, so a batch job needs no special case.
  • One subscription.canceled event fires at request time in both modes; the at-period-end retirement does not fire a second one.
  • Cancel is never blocked by a maintenance pause.
  • 404 SUBSCRIPTION_NOT_FOUND for an id that is not yours.

Read terminated_immediately and access_until on every cancel response. An at-period-end cancel on an unpaid subscription is immediate, and the field is how you find out. Revoke at access_until, which is also on the subscription.canceled event.

curl
curl -sS -X POST "$BASE/api/subscriptions/SUB_1757155200_A1B2C3D4/cancel" \
  -H "X-Game-Secret-Key: $GAME_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"at_period_end": true, "reason": "member request"}'
Node, @invonetwork/web-sdk
const res = await invo.subscriptions.cancel("SUB_1757155200_A1B2C3D4", {
  atPeriodEnd: true,                // false ends it now
  reason: "member request",
});
revokeAt(res.subscription.subscriptionId, res.accessUntil);
if (res.terminatedImmediately) {
  // nothing had been paid, so at-period-end was downgraded to immediate (res.downgradeReason)
}
Node, raw HTTP
const { status, json } = await invo("POST", "/api/subscriptions/SUB_1757155200_A1B2C3D4/cancel", {
  at_period_end: true,
  reason: "member request",
});
if (status === 200) {
  revokeAt(json.subscription.subscription_id, json.access_until);
  if (json.already_canceled) { /* json.final_status; nothing changed */ }
  if (json.terminated_immediately) { /* json.downgrade_reason === "NO_PAID_PERIOD" */ }
}
Python, invonetwork
res = invo.subscriptions.cancel("SUB_1757155200_A1B2C3D4", at_period_end=True, reason="member request")
revoke_at(res.subscription.subscription_id, res.access_until)
if res.terminated_immediately:
    pass   # nothing had been paid; at-period-end was downgraded (res.downgrade_reason)
Python, raw HTTP
status, res = invo("POST", "/api/subscriptions/SUB_1757155200_A1B2C3D4/cancel",
                   {"at_period_end": True, "reason": "member request"})
if status == 200:
    revoke_at(res["subscription"]["subscription_id"], res["access_until"])
    if res.get("already_canceled"):
        pass   # res["final_status"]; nothing changed
    if res.get("terminated_immediately"):
        pass   # res["downgrade_reason"] == "NO_PAID_PERIOD"

5. POST /api/subscriptions/<subscription_id>/amount

Body: {"amount_usd": "14.99"}. Validated like amount_usd on create.

  • The new price is staged and applied on the next successful renewal, which first bills the current window at the old price. No proration.
  • Sending the current price clears a staged change (change_queued: false).
  • On Steam an increase is refused (409 STEAM_REAUTHORIZATION_REQUIRED, with current_amount_usd and requested_amount_usd); a decrease stages.
  • An increase to more than double the price the member last agreed to, or to a price above 500.00 USD, is staged but never applied: pending_amount_usd stays set and the old price keeps billing. Raise prices in steps.
  • 409 SUBSCRIPTION_TERMINAL on a canceled or expired subscription. 503 while paused.
200
{
  "status": "success",
  "old_amount_usd": "9.99",
  "new_amount_usd": "14.99",
  "pending_amount_usd": "14.99",
  "change_queued": true,
  "applies_from": "2026-11-06T14:37:44.482913+00:00",
  "applies_from_period_seq": 3,
  "paid_through": "2026-10-06T14:37:44.482913+00:00",
  "current_period_end": "2026-11-06T14:37:44.482913+00:00",
  "prorated": false,
  "subscription": { ... }
}

applies_from is null until something has been paid; applies_from_period_seq is always meaningful. There is no subscription.amount_changed event; the price a period was billed at appears on that period’s subscription.renewed.

curl
curl -sS -X POST "$BASE/api/subscriptions/SUB_1757155200_A1B2C3D4/amount" \
  -H "X-Game-Secret-Key: $GAME_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"amount_usd": "14.99"}'
Node, @invonetwork/web-sdk
const res = await invo.subscriptions.changeAmount("SUB_1757155200_A1B2C3D4", "14.99");
// res.changeQueued, res.appliesFromPeriodSeq; the current window still bills at res.oldAmountUsd
Node, raw HTTP
const { status, json } = await invo("POST", "/api/subscriptions/SUB_1757155200_A1B2C3D4/amount",
  { amount_usd: "14.99" });
if (status === 409 && json.error_code === "STEAM_REAUTHORIZATION_REQUIRED") {
  // Steam: start a new subscription at json.requested_amount_usd, then cancel this one
}
Python, invonetwork
res = invo.subscriptions.change_amount("SUB_1757155200_A1B2C3D4", "14.99")
# res.change_queued, res.applies_from_period_seq; the current window still bills at res.old_amount_usd
Python, raw HTTP
status, res = invo("POST", "/api/subscriptions/SUB_1757155200_A1B2C3D4/amount", {"amount_usd": "14.99"})
if status == 409 and res.get("error_code") == "STEAM_REAUTHORIZATION_REQUIRED":
    pass   # Steam: start a new subscription at res["requested_amount_usd"], then cancel this one

6. POST /api/subscriptions/<subscription_id>/payment-method (card only)

Body: {"player_card_id": 42, "wallet_only": false}. card_id is an alias.

  • The card must belong to this player in this game and not be expired (404 PAYMENT_METHOD_NOT_FOUND, 400 PAYMENT_METHOD_EXPIRED, 400 PAYMENT_METHOD_INVALID).
  • On a wallet_only subscription you must pass wallet_only: false to convert it; otherwise 409 WALLET_ONLY_SUBSCRIPTION. Passing wallet_only: true together with a card is 400 WALLET_ONLY_CONFLICT.
  • 409 NOT_A_CARD_SUBSCRIPTION on Steam. 409 SUBSCRIPTION_TERMINAL on a terminal row. 409 PAYMENT_METHOD_UNAVAILABLE if the card was removed concurrently. 400 PAYMENT_METHOD_REQUIRED when the id is missing. 503 while paused.
  • The new card is used from the next charge, including a pending retry in dunning.
200
{"status": "success", "replaced": true, "wallet_only": false,
 "card": {"id": 42, "last_four": "4242", "brand": "visa", "exp_month": 12, "exp_year": 2030, "created_at": "..."},
 "subscription": { ... }}
curl
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}'
Node, @invonetwork/web-sdk
// attach (or replace) the card; also converts a wallet-only subscription
const res = await invo.subscriptions.setPaymentMethod("SUB_1757155200_A1B2C3D4", { playerCardId: 42 });

// or make an existing card subscription wallet-only from the next charge
await invo.subscriptions.setPaymentMethod("SUB_1757155200_A1B2C3D4", { walletOnly: true });
Node, raw HTTP
const { status, json } = await invo("POST", "/api/subscriptions/SUB_1757155200_A1B2C3D4/payment-method",
  { player_card_id: 42, wallet_only: false });
if (status === 200) { /* json.replaced, json.card */ }
else if (json.error_code === "NOT_A_CARD_SUBSCRIPTION") { /* Steam subscription */ }
Python, invonetwork
res = invo.subscriptions.set_payment_method("SUB_1757155200_A1B2C3D4", player_card_id=42)
# or: invo.subscriptions.set_payment_method("SUB_...", wallet_only=True)
Python, raw HTTP
status, res = invo("POST", "/api/subscriptions/SUB_1757155200_A1B2C3D4/payment-method",
                   {"player_card_id": 42, "wallet_only": False})
if status == 200:
    pass   # res["replaced"], res["card"]
elif res.get("error_code") == "NOT_A_CARD_SUBSCRIPTION":
    pass   # Steam subscription

Refunds are on their own page: Refunds. Every code these routes can return is collected on the error reference.