Manage a Subscription

Everything that happens to a subscription after it exists: read one, list a member's subscriptions, change the price, replace the card behind it, cancel it, and refund a billed period. Invo owns the billing clock — these endpoints change what the clock will do next, they never charge anything themselves.

New here? Start with Subscriptions Overview and Create a Subscription. For what happens on billing day — funding, retries, step-up authentication — see Renewals, Funding & Dunning.

1. Authentication

Server-to-server only

Every endpoint on this page is called from your server with your game secret key. There is no session, no consent step and no client confirmation.

X-Game-Secret-Key: your_secret_key_here

Never ship the secret key in a browser or game client. All reads and writes are scoped to the authenticated game — a subscription id belonging to another tenant returns 404, never the row.

Base path: /api/subscriptions. Sandbox paths carry the /sandbox prefix. All timestamps are ISO 8601 with an explicit timezone offset, and all money values are strings so a JSON parser cannot silently turn them into floats.

2. The subscription object

Every endpoint below returns this object under a subscription key, so you can write one decoder and reuse it.

{
  "subscription_id": "SUB_1786012800_A1B2C3D4",
  "game_id": "1024",
  "player_id": 5512,
  "client_request_id": "member-9931-premium",
  "status": "active",
  "amount_usd": "9.99",
  "pending_amount_usd": null,
  "interval": "month",
  "interval_count": 1,
  "item_id": "premium_membership",
  "item_name": "Premium Membership",
  "current_period_start": "2026-08-13T09:14:22+00:00",
  "current_period_end": "2026-09-13T09:14:22+00:00",
  "period_seq": 3,
  "next_charge_at": "2026-09-13T09:41:05+00:00",
  "cancel_at_period_end": false,
  "trial_end": null,
  "canceled_at": null,
  "ended_at": null,
  "wallet_only": false,
  "has_payment_method": true,
  "metadata": { "tier": "gold" },
  "consent": {
    "consent_at": "2026-06-13T09:14:02+00:00",
    "consent_ip": "203.0.113.24",
    "consent_user_agent": "Mozilla/5.0 ...",
    "disclosed_amount_usd": "9.99",
    "disclosed_interval": "month",
    "terms_version": "2026-05-01"
  },
  "revenue_share": {
    "recipient_player_id": 4471,
    "percent": "10.00",
    "settled_by_invo": false
  },
  "paid_through": "2026-09-13T09:14:22+00:00",
  "created_at": "2026-06-13T09:14:22+00:00",
  "updated_at": "2026-08-13T09:14:22+00:00"
}

Fields worth reading carefully

FieldTypeNotes
statusstringOne of trialing, active, past_due, awaiting_authentication, canceled, expired. The first four are live — they still occupy the member's one-subscription-per-item slot.
paid_throughstring|nullThis is entitlement. The furthest period end actually paid for. null before the first successful charge.
current_period_endstringThe billing boundary — a projection that exists from creation, before any money has moved. Never grant access off it.
amount_usdstringThe price billed today. USD is canonical; there is no coin-denominated price.
pending_amount_usdstring|nullA price change staged for the next period. null when nothing is queued.
period_seqint1-based billing cycle counter. Advances exactly once per successful renewal.
next_charge_atstring|nullWhen the next charge is due. null only in a terminal state.
cancel_at_period_endbooltrue means the agreement is scheduled to end and will not be charged again.
wallet_onlybooltrue means no card may ever back this subscription. Renewals are funded from balance or they fail.
has_payment_methodboolWhether a card is attached. Invo never returns processor identifiers — presence is what you act on.
revenue_shareobject|nullYour declared split recipient. settled_by_invo is always false — this is attribution data, not a remittance.
metadataobject|nullYour JSON, echoed verbatim on every read and every lifecycle event.

3. Retrieve a subscription

GET

/api/subscriptions/{subscription_id}

Reads one subscription. The lookup is scoped by tenant as well as by id, so a subscription id from another game is a 404 — the id is not a capability on its own.

Query parameters

FieldTypeReq.Notes
include_playerboolnoAdds player_email and player_name to the object. Default false.

Response (200)

{
  "status": "success",
  "subscription": { ...the subscription object... }
}
HTTPCodeMeaning
404SUBSCRIPTION_NOT_FOUNDNo such subscription in this game.
400INVALID_FIELD_VALUEMalformed subscription id.

4. List a member's subscriptions

GET

/api/subscriptions/player

All subscriptions for one member in your game, newest first, with filtering and pagination. A member you have never seen returns an empty list with 200 — not a 404 — because "no subscriptions" and "no such member" are the same answer to you.

Query parameters

FieldTypeReq.Notes
player_emailstringyesThe member. ≤ 255 chars.
statusstringnoComma-separated status list. The token live expands to trialing,active,past_due,awaiting_authentication and may be mixed with explicit values (status=live,expired).
include_playerboolnoAdds player_email / player_name to each row.
limitintnoDefault 50, silently capped at 100. Must be ≥ 1.
offsetintnoDefault 0, maximum 100000.

Response (200)

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

Ordering is stable. Rows come back newest-created first, tie-broken by subscription id, so paging cannot return the same row twice or skip one when several subscriptions were created in the same instant.

HTTPCodeMeaning
400PLAYER_EMAIL_REQUIREDMissing player_email.
400PLAYER_EMAIL_TOO_LONGOver 255 characters.
400STATUS_INVALIDUnknown status value(s); the offending ones are named in the message.
400PAGINATION_INVALIDlimit/offset not integers, or out of range.

5. Change the price

POST

/api/subscriptions/{subscription_id}/amount

Stages a new recurring price. It never touches the price billing today. The change is written to pending_amount_usd and promoted into amount_usd in the same transaction that closes the current period — so there is no window in which the new price can be billed against the cycle the member is already in. There is no proration.

Request body

FieldTypeReq.Notes
amount_usddecimalyesThe new recurring price. 0.01999999.99, rounded to 2 dp. Sending the current price clears any queued change.

Response (200)

{
  "status": "success",
  "old_amount_usd": "9.99",
  "new_amount_usd": "12.99",
  "pending_amount_usd": "12.99",
  "change_queued": true,
  "applies_from": "2026-10-13T09:14:22+00:00",
  "applies_from_period_seq": 4,
  "paid_through": "2026-09-13T09:14:22+00:00",
  "current_period_end": "2026-09-13T09:14:22+00:00",
  "prorated": false,
  "subscription": { ...the subscription object... }
}

applies_from is derived from the paid clock, so it is null until something has actually been collected — before the first successful charge only applies_from_period_seq is meaningful.

Two guardrails apply at renewal, not here

A renewal above $500 is refused rather than charged, and a staged increase may not exceed the price the member was last disclosed. A refused promotion is not silent: the staged value stays visible in pending_amount_usd and the old price keeps billing. Both refuse rather than clamp — a refused renewal is recoverable, a wrong charge is not.

HTTPCodeMeaning
400AMOUNT_REQUIREDamount_usd missing or empty.
400AMOUNT_INVALIDNot a finite decimal.
400AMOUNT_TOO_SMALL / AMOUNT_TOO_LARGEOutside 0.01999999.99.
404SUBSCRIPTION_NOT_FOUNDUnknown id in this game.
409SUBSCRIPTION_TERMINALAlready canceled or expired — it cannot be repriced.
503flow_pausedSubscription changes are temporarily paused for maintenance. Retry shortly.

6. Attach or replace the card

POST

/api/subscriptions/{subscription_id}/payment-method

Points the subscription at one of the member's saved cards. You pass an Invo card id, never card data and never a processor identifier — the card itself is captured on an Invo-hosted page, so your PCI scope is unchanged. The card must belong to this member and this game and must not be expired; that check is the control that stops one member's subscription being billed to another's card.

Request body

FieldTypeReq.Notes
player_card_idintyesAn Invo saved-card id. Fractional or boolean values are rejected rather than truncated.
wallet_onlyboolnoPass false to convert a wallet-only subscription to card-backed. Passing true while attaching a card is a contradiction and is rejected.

Response (200)

{
  "status": "success",
  "replaced": true,
  "wallet_only": false,
  "card": {
    "id": 42,
    "brand": "visa",
    "last_four": "4242",
    "exp_month": 11,
    "exp_year": 2029,
    "created_at": "2026-06-13T09:12:00+00:00"
  },
  "subscription": { ...the subscription object... }
}
HTTPCodeMeaning
400PAYMENT_METHOD_REQUIREDplayer_card_id missing.
400PAYMENT_METHOD_INVALIDplayer_card_id is not an integer.
400PAYMENT_METHOD_EXPIREDThat card has already expired and cannot back a recurring charge.
400WALLET_ONLY_CONFLICTwallet_only: true combined with attaching a card.
404PAYMENT_METHOD_NOT_FOUNDNo saved card with that id for this member. Deliberately the same answer as "not their card".
409WALLET_ONLY_SUBSCRIPTIONThe subscription is wallet-only; pass wallet_only: false to convert it.
409PAYMENT_METHOD_UNAVAILABLEThe card was removed while you were attaching it. Retry with a current card.
409SUBSCRIPTION_TERMINALAlready canceled or expired.
503flow_pausedTemporarily paused for maintenance.

7. Cancel a subscription

POST

/api/subscriptions/{subscription_id}/cancel

Ends the agreement, either at the end of the paid period (default) or immediately. Cancel is an exit path and is deliberately not gated by the maintenance pause — a member trying to leave is never trapped by an incident on our side.

Request body

FieldTypeReq.Notes
at_period_endboolnoDefaults to true — the safe answer preserves paid access. An empty or unparseable value also takes the default.
reasonstringno≤ 500 chars. Recorded on the audit trail and echoed on the webhook.

The body is optional — POST with no body cancels at period end.

Response (200)

{
  "status": "success",
  "already_canceled": false,
  "cancel_at_period_end": true,
  "effective_at": "2026-09-13T09:14:22+00:00",
  "current_period_end": "2026-09-13T09:14:22+00:00",
  "access_until": "2026-09-13T09:14:22+00:00",
  "paid_through": "2026-09-13T09:14:22+00:00",
  "subscription": { ...the subscription object... }
}

Response when the flag was downgraded (200)

{
  "status": "success",
  "already_canceled": false,
  "cancel_at_period_end": false,
  "terminated_immediately": true,
  "downgrade_reason": "NO_PAID_PERIOD",
  "message": "No period has been paid for, so there was no remaining access to preserve; the subscription was canceled immediately.",
  "effective_at": "2026-08-13T11:02:44+00:00",
  "access_until": "2026-08-13T11:02:44+00:00",
  "paid_through": null,
  "subscription": { ...status is now "canceled"... }
}

Response when it was already over (200)

{
  "status": "success",
  "already_canceled": true,
  "final_status": "expired",
  "cancel_at_period_end": false,
  "effective_at": "2026-07-30T04:10:00+00:00",
  "access_until": "2026-07-13T09:14:22+00:00",
  "paid_through": "2026-07-13T09:14:22+00:00",
  "subscription": { ... }
}

Cancelling something already canceled or expired returns 200 with already_canceled: true, not an error — in both cases the thing you asked for is already true, and a batch reconciler should not have to special-case a distinction that means nothing to it.

8. Cancellation semantics

At period end (default)

The subscription stays live with its status and next charge date intact — that is what keeps it schedulable — and only cancel_at_period_end flips to true. When the boundary arrives the billing engine retires it instead of charging it.

The member keeps everything they paid for, until access_until.

Immediate

at_period_end: false. Status becomes canceled, canceled_at and ended_at are stamped, and next_charge_at is cleared — the agreement leaves the billing schedule entirely and can never be charged again.

access_until is still the paid-through boundary if it is in the future, otherwise now.

An at-period-end cancel can be downgraded — read the response

If nothing has ever been paid, or the paid window has already elapsed, at_period_end: true is downgraded to an immediate cancellation and the response carries terminated_immediately: true with downgrade_reason: NO_PAID_PERIOD and access_until = now.

There is no paid period to preserve, and honouring the flag off current_period_end — a projection that exists from the moment of creation — would hand out a free cycle to anyone who subscribes and cancels a minute later. Read terminated_immediately rather than assuming the flag was honoured.

What the member keeps

ThingWhat happens on cancel
Entitlement / accessRuns to access_until (the paid-through boundary), then you revoke it. Never derive this from current_period_end.
Currency already grantedUntouched. Cancelling never claws back balance credited by earlier renewals — it stays in the member's wallet and remains spendable. Reversing a specific cycle is a refund, not a cancel.
Money for the current periodNot refunded and not prorated. If you want the last cycle back, call the refund endpoint.
Future chargesNone. A cancelled agreement is never billed again in either mode.
The one-per-item slotFreed once the status is terminal, so the member can subscribe to the same item_id again with a fresh client_request_id.

How it appears in webhooks

One event, at request time, in both modes. An at-period-end cancel is a single subscription.canceled with a future effective date — not a promise followed by a second event when the boundary arrives. The later retirement fulfils this event rather than being a new one. subscription.expired is a different thing entirely: that is dunning, not cancellation.

{
  "event": "subscription.canceled",
  "data": {
    "subscription_id": "SUB_1786012800_A1B2C3D4",
    "item_id": "premium_membership",
    "item_name": "Premium Membership",
    "player_email": "member@example.com",
    "identity_id": "...",
    "status": "active",
    "amount_usd": "9.99",
    "interval": "month",
    "interval_count": 1,
    "period_seq": 3,
    "current_period_start": "2026-08-13T09:14:22+00:00",
    "current_period_end": "2026-09-13T09:14:22+00:00",
    "next_charge_at": "2026-09-13T09:41:05+00:00",
    "cancel_at_period_end": true,
    "effective_at": "2026-09-13T09:14:22+00:00",
    "canceled_at": null,
    "ended_at": null,
    "access_until": "2026-09-13T09:14:22+00:00",
    "paid_through": "2026-09-13T09:14:22+00:00",
    "reason": "member request",
    "metadata": { "tier": "gold" }
  }
}

Revoke at effective_at, not on receipt. On an immediate cancel effective_at is now, status reads canceled and canceled_at / ended_at are populated. Full payload reference: Subscription Webhooks.

9. Refund a billed period

POST

/api/subscriptions/{subscription_id}/refund

Refunds one billed cycle, in full or in part. Because a renewal can be funded three different ways, Invo works out where the money should go — you do not have to.

Request body

FieldTypeReq.Notes
client_request_idstringyesThe idempotency anchor. ≤ 255 chars. A repeat returns the stored receipt and moves nothing. Must not begin with sub_.
period_seqintnoWhich cycle to refund. Defaults to the most recent billed period.
amount_usddecimalnoDefaults to the full remaining refundable amount. Must be positive and must not exceed the remainder.
reasonstringno≤ 500 chars. Echoed on the receipt and the webhook.

Three funding shapes, handled for you

funding_shapeHow the period was paidFull refund returns
walletEntirely from the member's existing balance. No card was ever presented.Every coin re-credited to the wallet. No cash movement at all.
cardThe wallet was empty; the card funded the whole cycle and the currency was spent the same instant.Cash back to the card. The member holds no currency from that cycle, so nothing is re-credited.
mixedPart balance, part card top-up — the common case.The pre-existing wallet portion in currency, the card portion as cash.

Partial refunds settle in currency

A partial refund credits the member's wallet and touches no card. The card funded a shortfall, not a pro-rata share, so "40% of the card leg" would be a fiction — and currency is what the member can immediately re-spend.

Partials compose correctly with a later completing refund: take $3 in currency, then refund the rest of a card-funded $9.99 cycle, and the member ends up with $9.99 back on the card and the $3 of currency removed again. balance_delta_coins is signed — a negative value is legal and expected on that path.

Response (200)

{
  "status": "refunded",
  "idempotent_replay": false,
  "subscription_id": "SUB_1786012800_A1B2C3D4",
  "period_seq": 3,
  "client_request_id": "refund-9931-aug",
  "refund_key": "subrefund:SUB_1786012800_A1B2C3D4:3:...",
  "transaction_id": "TXN_...",
  "refunded_amount_usd": "9.99",
  "total_refunded_amount_usd": "9.99",
  "remaining_refundable_usd": "0.00",
  "is_full_refund": true,
  "funding_shape": "mixed",
  "balance_delta_coins": "60.00",
  "card_refunded_usd": "3.99",
  "invo_fee_retained": true,
  "partner_revenue_reversed_usd": "9.65",
  "partner_revenue_reversal_mode": "full",
  "new_balance": "60.00",
  "revenue_share_attribution": {
    "recipient_player_id": 4471,
    "percent": "10.00",
    "original_attributed_amount_usd": "0.97",
    "refunded_attributed_amount_usd": "0.97",
    "net_attributed_amount_usd": "0.00",
    "settled_by_invo": false
  },
  "reason": "member request",
  "refunded_at": "2026-08-13T11:20:31+00:00"
}

The receipt also carries the processor-side refund reference for the card leg, plus a flag noting the rare case where a previous attempt moved the money and this call only recorded it. Both are reconciliation aids, not something to branch on.

What is reversed, and what is not.

Invo retains its fee (invo_fee_retained: true) — the platform bears that cost, exactly as on item purchases.

Your revenue is reversed, in full on a first full refund (partner_revenue_reversal_mode: "full") or pro rata on a partial ("prorata"). Successive partials sum exactly to your full revenue for the cycle.

Attribution is corrected but nothing moves — the recomputed net_attributed_amount_usd is what you should pay your recipient for that renewal, and settled_by_invo stays false.

A full refund also flips the period out of the paid set, so it stops counting toward paid_through. Fires subscription.refunded — reverse the entitlement for that cycle when you receive it.

Errors

HTTPCodeMeaning
400CLIENT_REQUEST_ID_REQUIREDMissing or empty.
400CLIENT_REQUEST_ID_INVALID / CLIENT_REQUEST_ID_TOO_LONGNot a scalar string, or over 255 chars.
400CLIENT_REQUEST_ID_RESERVEDBegins with sub_, which is reserved for Invo's own recurring-billing records.
400INVALID_PERIOD_SEQ / INVALID_AMOUNTNot an integer ≥ 1 / not a positive decimal.
400AMOUNT_EXCEEDS_REMAININGMore than is left to refund. The response names remaining_refundable_usd.
404SUBSCRIPTION_NOT_FOUND / PERIOD_NOT_FOUNDUnknown subscription in this game, or no such cycle on it.
409NO_PAID_PERIODNothing has ever been collected — the ordinary state of a subscription that has not renewed yet.
409PERIOD_NOT_REFUNDABLEThat cycle was never collected (it is pending, failed or forgiven).
409ALREADY_REFUNDEDFully refunded already. Carries refunded_amount_usd and amount_usd.
409MINT_ORDER_MISSING / NO_PAYMENT_REFERENCEThe card payment record behind this cycle is missing — it needs manual reconciliation, so nothing is guessed.
502PROCESSOR_ERRORThe card refund could not be completed. Retry with the same client_request_id.
502REFUND_LOOKUP_FAILED / REFUND_MISMATCHA pre-existing refund could not be verified against this cycle. Flagged for reconciliation rather than assumed.
503FLOW_PAUSEDRefunds are temporarily paused for maintenance.
503service_unavailableRefunds are briefly unavailable. Retry in a moment.

10. Errors common to every endpoint

HTTPCodeMeaning
400INVALID_BODYThe request body is not a JSON object.
400INVALID_FIELD_VALUEA field is malformed or too long for its column.
401Missing or invalid X-Game-Secret-Key.
404SUBSCRIPTION_NOT_FOUNDUnknown id in this game. Cross-tenant ids look identical to unknown ones.
429rate_limit_exceededRate limited. The body and the Retry-After header both carry retry_after in seconds.
500INTERNAL_ERRORUnexpected failure. Nothing was committed; retry is safe.

11. Worked examples

curlRead, list, cancel, refund

Lifecycle calls
# Retrieve one subscription (with the member's email/name)
curl -s "https://invo.network/api/subscriptions/SUB_1786012800_A1B2C3D4?include_player=true" \
  -H "X-Game-Secret-Key: $INVO_SECRET"

# List every LIVE subscription for a member
curl -s "https://invo.network/api/subscriptions/player?player_email=member%40example.com&status=live&limit=25" \
  -H "X-Game-Secret-Key: $INVO_SECRET"

# Stage a price change for the next cycle (no proration)
curl -s -X POST "https://invo.network/api/subscriptions/SUB_1786012800_A1B2C3D4/amount" \
  -H "X-Game-Secret-Key: $INVO_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"amount_usd": "12.99"}'

# Cancel at the end of the paid period (the default)
curl -s -X POST "https://invo.network/api/subscriptions/SUB_1786012800_A1B2C3D4/cancel" \
  -H "X-Game-Secret-Key: $INVO_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"at_period_end": true, "reason": "member request"}'

# Refund the most recent billed cycle, in full
curl -s -X POST "https://invo.network/api/subscriptions/SUB_1786012800_A1B2C3D4/refund" \
  -H "X-Game-Secret-Key: $INVO_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"client_request_id": "refund-9931-aug", "reason": "member request"}'

Node.jsA cancel flow that reads the response properly

cancelMembership.js
const BASE = 'https://invo.network/api/subscriptions';
const headers = {
  'X-Game-Secret-Key': process.env.INVO_SECRET,
  'Content-Type': 'application/json',
};

async function invo(path, init) {
  const res = await fetch(BASE + '/' + path, { headers, ...init });
  const body = await res.json();
  if (!res.ok) {
    const err = new Error(body.message || 'Invo request failed');
    err.status = res.status;
    err.code = body.error_code;
    throw err;
  }
  return body;
}

async function cancelMembership(subscriptionId, reason) {
  const out = await invo(subscriptionId + '/cancel', {
    method: 'POST',
    body: JSON.stringify({ at_period_end: true, reason }),
  });

  if (out.already_canceled) {
    // Nothing to do — it was already over. final_status tells you which way.
    return { revokeAt: out.access_until, note: out.final_status };
  }

  if (out.terminated_immediately) {
    // The flag was DOWNGRADED: nothing had been paid for, so there was no
    // access to preserve. downgrade_reason === 'NO_PAID_PERIOD'.
    return { revokeAt: out.effective_at, note: out.downgrade_reason };
  }

  // Scheduled. Keep the member in until access_until; the billing engine
  // retires the agreement at the boundary and will not charge again.
  return { revokeAt: out.access_until, note: 'scheduled' };
}

async function refundLastCycle(subscriptionId, idempotencyKey) {
  try {
    return await invo(subscriptionId + '/refund', {
      method: 'POST',
      body: JSON.stringify({ client_request_id: idempotencyKey }),
    });
  } catch (err) {
    // A replay is not an error: the same client_request_id returns the stored
    // receipt with idempotent_replay: true and moves no money.
    if (err.code === 'NO_PAID_PERIOD' || err.code === 'ALREADY_REFUNDED') {
      return null; // nothing collectable to give back
    }
    throw err;
  }
}

module.exports = { cancelMembership, refundLastCycle };

12. Where to next