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_hereNever 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
| Field | Type | Notes |
|---|---|---|
status | string | One 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_through | string|null | This is entitlement. The furthest period end actually paid for. null before the first successful charge. |
current_period_end | string | The billing boundary — a projection that exists from creation, before any money has moved. Never grant access off it. |
amount_usd | string | The price billed today. USD is canonical; there is no coin-denominated price. |
pending_amount_usd | string|null | A price change staged for the next period. null when nothing is queued. |
period_seq | int | 1-based billing cycle counter. Advances exactly once per successful renewal. |
next_charge_at | string|null | When the next charge is due. null only in a terminal state. |
cancel_at_period_end | bool | true means the agreement is scheduled to end and will not be charged again. |
wallet_only | bool | true means no card may ever back this subscription. Renewals are funded from balance or they fail. |
has_payment_method | bool | Whether a card is attached. Invo never returns processor identifiers — presence is what you act on. |
revenue_share | object|null | Your declared split recipient. settled_by_invo is always false — this is attribution data, not a remittance. |
metadata | object|null | Your JSON, echoed verbatim on every read and every lifecycle event. |
3. Retrieve a subscription
/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
| Field | Type | Req. | Notes |
|---|---|---|---|
include_player | bool | no | Adds player_email and player_name to the object. Default false. |
Response (200)
{
"status": "success",
"subscription": { ...the subscription object... }
}| HTTP | Code | Meaning |
|---|---|---|
| 404 | SUBSCRIPTION_NOT_FOUND | No such subscription in this game. |
| 400 | INVALID_FIELD_VALUE | Malformed subscription id. |
4. List a member's subscriptions
/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
| Field | Type | Req. | Notes |
|---|---|---|---|
player_email | string | yes | The member. ≤ 255 chars. |
status | string | no | Comma-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_player | bool | no | Adds player_email / player_name to each row. |
limit | int | no | Default 50, silently capped at 100. Must be ≥ 1. |
offset | int | no | Default 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.
| HTTP | Code | Meaning |
|---|---|---|
| 400 | PLAYER_EMAIL_REQUIRED | Missing player_email. |
| 400 | PLAYER_EMAIL_TOO_LONG | Over 255 characters. |
| 400 | STATUS_INVALID | Unknown status value(s); the offending ones are named in the message. |
| 400 | PAGINATION_INVALID | limit/offset not integers, or out of range. |
5. Change the price
/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
| Field | Type | Req. | Notes |
|---|---|---|---|
amount_usd | decimal | yes | The new recurring price. 0.01 – 999999.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 2× 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.
| HTTP | Code | Meaning |
|---|---|---|
| 400 | AMOUNT_REQUIRED | amount_usd missing or empty. |
| 400 | AMOUNT_INVALID | Not a finite decimal. |
| 400 | AMOUNT_TOO_SMALL / AMOUNT_TOO_LARGE | Outside 0.01 – 999999.99. |
| 404 | SUBSCRIPTION_NOT_FOUND | Unknown id in this game. |
| 409 | SUBSCRIPTION_TERMINAL | Already canceled or expired — it cannot be repriced. |
| 503 | flow_paused | Subscription changes are temporarily paused for maintenance. Retry shortly. |
6. Attach or replace the card
/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
| Field | Type | Req. | Notes |
|---|---|---|---|
player_card_id | int | yes | An Invo saved-card id. Fractional or boolean values are rejected rather than truncated. |
wallet_only | bool | no | Pass 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... }
}| HTTP | Code | Meaning |
|---|---|---|
| 400 | PAYMENT_METHOD_REQUIRED | player_card_id missing. |
| 400 | PAYMENT_METHOD_INVALID | player_card_id is not an integer. |
| 400 | PAYMENT_METHOD_EXPIRED | That card has already expired and cannot back a recurring charge. |
| 400 | WALLET_ONLY_CONFLICT | wallet_only: true combined with attaching a card. |
| 404 | PAYMENT_METHOD_NOT_FOUND | No saved card with that id for this member. Deliberately the same answer as "not their card". |
| 409 | WALLET_ONLY_SUBSCRIPTION | The subscription is wallet-only; pass wallet_only: false to convert it. |
| 409 | PAYMENT_METHOD_UNAVAILABLE | The card was removed while you were attaching it. Retry with a current card. |
| 409 | SUBSCRIPTION_TERMINAL | Already canceled or expired. |
| 503 | flow_paused | Temporarily paused for maintenance. |
7. Cancel a subscription
/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
| Field | Type | Req. | Notes |
|---|---|---|---|
at_period_end | bool | no | Defaults to true — the safe answer preserves paid access. An empty or unparseable value also takes the default. |
reason | string | no | ≤ 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
| Thing | What happens on cancel |
|---|---|
| Entitlement / access | Runs to access_until (the paid-through boundary), then you revoke it. Never derive this from current_period_end. |
| Currency already granted | Untouched. 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 period | Not refunded and not prorated. If you want the last cycle back, call the refund endpoint. |
| Future charges | None. A cancelled agreement is never billed again in either mode. |
| The one-per-item slot | Freed 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
/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
| Field | Type | Req. | Notes |
|---|---|---|---|
client_request_id | string | yes | The idempotency anchor. ≤ 255 chars. A repeat returns the stored receipt and moves nothing. Must not begin with sub_. |
period_seq | int | no | Which cycle to refund. Defaults to the most recent billed period. |
amount_usd | decimal | no | Defaults to the full remaining refundable amount. Must be positive and must not exceed the remainder. |
reason | string | no | ≤ 500 chars. Echoed on the receipt and the webhook. |
Three funding shapes, handled for you
funding_shape | How the period was paid | Full refund returns |
|---|---|---|
wallet | Entirely from the member's existing balance. No card was ever presented. | Every coin re-credited to the wallet. No cash movement at all. |
card | The 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. |
mixed | Part 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
| HTTP | Code | Meaning |
|---|---|---|
| 400 | CLIENT_REQUEST_ID_REQUIRED | Missing or empty. |
| 400 | CLIENT_REQUEST_ID_INVALID / CLIENT_REQUEST_ID_TOO_LONG | Not a scalar string, or over 255 chars. |
| 400 | CLIENT_REQUEST_ID_RESERVED | Begins with sub_, which is reserved for Invo's own recurring-billing records. |
| 400 | INVALID_PERIOD_SEQ / INVALID_AMOUNT | Not an integer ≥ 1 / not a positive decimal. |
| 400 | AMOUNT_EXCEEDS_REMAINING | More than is left to refund. The response names remaining_refundable_usd. |
| 404 | SUBSCRIPTION_NOT_FOUND / PERIOD_NOT_FOUND | Unknown subscription in this game, or no such cycle on it. |
| 409 | NO_PAID_PERIOD | Nothing has ever been collected — the ordinary state of a subscription that has not renewed yet. |
| 409 | PERIOD_NOT_REFUNDABLE | That cycle was never collected (it is pending, failed or forgiven). |
| 409 | ALREADY_REFUNDED | Fully refunded already. Carries refunded_amount_usd and amount_usd. |
| 409 | MINT_ORDER_MISSING / NO_PAYMENT_REFERENCE | The card payment record behind this cycle is missing — it needs manual reconciliation, so nothing is guessed. |
| 502 | PROCESSOR_ERROR | The card refund could not be completed. Retry with the same client_request_id. |
| 502 | REFUND_LOOKUP_FAILED / REFUND_MISMATCH | A pre-existing refund could not be verified against this cycle. Flagged for reconciliation rather than assumed. |
| 503 | FLOW_PAUSED | Refunds are temporarily paused for maintenance. |
| 503 | service_unavailable | Refunds are briefly unavailable. Retry in a moment. |
10. Errors common to every endpoint
| HTTP | Code | Meaning |
|---|---|---|
| 400 | INVALID_BODY | The request body is not a JSON object. |
| 400 | INVALID_FIELD_VALUE | A field is malformed or too long for its column. |
| 401 | — | Missing or invalid X-Game-Secret-Key. |
| 404 | SUBSCRIPTION_NOT_FOUND | Unknown id in this game. Cross-tenant ids look identical to unknown ones. |
| 429 | rate_limit_exceeded | Rate limited. The body and the Retry-After header both carry retry_after in seconds. |
| 500 | INTERNAL_ERROR | Unexpected failure. Nothing was committed; retry is safe. |
11. Worked examples
curlRead, list, cancel, refund
# 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
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
- Subscriptions Overview — the model, the statuses, and what Invo owns versus what you own.
- Create a Subscription — the subscribe call, trials, consent capture and idempotency.
- Renewals, Funding & Dunning — what happens every billing period, and why a renewal can pause for cardholder authentication.
- Subscription Webhooks — every lifecycle event payload, plus delivery replay.
- Subscription Reporting — settled-renewal attribution for paying your own recipients.