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 -sS "$BASE/api/subscriptions/SUB_1757155200_A1B2C3D4" \
-H "X-Game-Secret-Key: $GAME_SECRET"const sub = await invo.subscriptions.get("SUB_1757155200_A1B2C3D4");
// sub.status, sub.paidThrough (entitlement), sub.currentPeriodEnd (billing window, NOT entitlement)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
}sub = invo.subscriptions.get("SUB_1757155200_A1B2C3D4")
# sub.status, sub.paid_through (entitlement), sub.current_period_end (billing window, NOT entitlement)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
| Param | Rules |
|---|---|
player_email | required (400 PLAYER_EMAIL_REQUIRED, PLAYER_EMAIL_TOO_LONG) |
status | optional 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_player | optional boolean |
limit | 1 to 100, default 50 (400 PAGINATION_INVALID) |
offset | 0 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 -sS "$BASE/api/subscriptions/player?player_email=member%40example.com&status=live" \
-H "X-Game-Secret-Key: $GAME_SECRET"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.hasMoreconst 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 yetpage = invo.subscriptions.list_for_player("member@example.com", status=["live"], limit=50, offset=0)
# page.subscriptions, page.pagination.has_more_, 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 yet3. The subscription object
| Field | Type | Notes |
|---|---|---|
subscription_id | string SUB_<epoch>_<8 chars> | Not a secret; scope every call by your own key. |
game_id | string | Your game id, stringified. |
player_id | integer | |
client_request_id | string | Your idempotency key. |
status | string | See statuses. |
amount_usd | decimal string | The current price. |
pending_amount_usd | decimal string or null | A staged price change not yet applied. |
interval, interval_count | ||
item_id, item_name | ||
current_period_start, current_period_end | ISO 8601 | The billing window. Not entitlement. |
period_seq | integer | 1-based counter of billing periods. Advances on every successful charge. |
next_charge_at | ISO 8601 or null | When 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_end | boolean | |
trial_end | ISO 8601 or null | |
canceled_at, ended_at | ISO 8601 or null | |
wallet_only | boolean | |
has_payment_method | boolean | A card is attached. Never the card’s identifiers. |
funding_rail | card or steam | |
steam_agreement_status | pending, active, canceled or null | Null on card subscriptions. |
metadata | object or null | Yours, echoed. Invo writes cancellation reasons under the reserved _invo key. |
consent | object | Six fields, all null until supplied. |
created_at, updated_at | ISO 8601 | |
revenue_share | object or null | recipient_player_id, percent, settled_by_invo: false. |
paid_through | ISO 8601 or null | The 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.
| Mode | Effect | Response fields |
|---|---|---|
at_period_end: true and the member has paid for a period that has not ended | Subscription 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 elapsed | Downgraded to immediate. There is no paid access to preserve. | terminated_immediately: true, downgrade_reason: "NO_PAID_PERIOD", message |
at_period_end: false | Ended 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_statusandsteam_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
canceledorexpiredreturns 200 withalready_canceled: true,final_status, and the same date fields, so a batch job needs no special case. - One
subscription.canceledevent 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_FOUNDfor 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 -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"}'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)
}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" */ }
}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)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, withcurrent_amount_usdandrequested_amount_usd); a decrease stages. - An increase to more than double the price the member last agreed to, or to a price above
500.00USD, is staged but never applied:pending_amount_usdstays set and the old price keeps billing. Raise prices in steps. 409 SUBSCRIPTION_TERMINALon a canceled or expired subscription.503while 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 -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"}'const res = await invo.subscriptions.changeAmount("SUB_1757155200_A1B2C3D4", "14.99");
// res.changeQueued, res.appliesFromPeriodSeq; the current window still bills at res.oldAmountUsdconst { 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
}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_usdstatus, 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_onlysubscription you must passwallet_only: falseto convert it; otherwise409 WALLET_ONLY_SUBSCRIPTION. Passingwallet_only: truetogether with a card is400 WALLET_ONLY_CONFLICT. 409 NOT_A_CARD_SUBSCRIPTIONon Steam.409 SUBSCRIPTION_TERMINALon a terminal row.409 PAYMENT_METHOD_UNAVAILABLEif the card was removed concurrently.400 PAYMENT_METHOD_REQUIREDwhen the id is missing.503while 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 -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}'// 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 });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 */ }res = invo.subscriptions.set_payment_method("SUB_1757155200_A1B2C3D4", player_card_id=42)
# or: invo.subscriptions.set_payment_method("SUB_...", wallet_only=True)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 subscriptionRefunds are on their own page: Refunds. Every code these routes can return is collected on the error reference.