Renewals, funding and dunning
What Invo does on every billing day: when it charges, how a period is funded from the wallet first and the rail second (worked examples on both rails), what happens when a charge fails, the retry ladder and the grace window, the card authentication pause, and the handful of deferrals that produce no event.
1. When Invo charges
- Billing is in advance: a period is charged at its start. The first period is charged inside
/subscribe(or at/steam/finalize); each later period is charged atnext_charge_at, which is the end of the period just paid. - At creation Invo adds up to one hour of random offset to the first period’s end, so a cohort of members who subscribed in the same minute renews spread across an hour a month later. The offset carries forward for the life of the subscription.
- The renewal engine runs every minute in both environments. A subscription is picked up within a minute or two of
next_charge_at, not at the exact second. - Invo charges at most one period per subscription per run. If Invo ever falls behind by whole periods (an outage on Invo’s side), the elapsed periods are forgiven, never back-billed; you see the gap as a jump in
period_seqon the nextsubscription.renewed.
Do not poll for renewals. Subscribe to subscription.renewed. It is the source of truth for entitlement and it carries the funding and split figures you cannot get from GET.
2. Funding order, card rail, worked examples
Price 9.99 USD per month, so a period is worth 99.90 coins. Shortfall is converted to USD rounded up to the cent, and never below the 1.00 USD minimum top-up.
| Wallet before | Shortfall | Card charged | Coins minted | Coins spent | Wallet after |
|---|---|---|---|---|---|
0.00 coins | 99.90 coins | 9.99 USD | 99.90 | 99.90 | 0.00 |
20.00 coins | 79.90 coins | 7.99 USD | 79.90 | 99.90 | 0.00 |
95.00 coins | 4.90 coins (0.49 USD, floored to the minimum) | 1.00 USD | 10.00 | 99.90 | 5.10 (dust, spent next period) |
120.00 coins | none | nothing; no card call | 0.00 | 99.90 | 20.10 |
The funding block on subscription.renewed reports balance_applied_coins (the pre-existing wallet portion), card_charged_usd, minted_coins and new_balance for every renewal.
The split follows the funding. The percentage fee applies to the whole period. The fixed 0.30 USD component applies only when the card was actually charged, and only to the card-funded portion. A period the wallet covered entirely carries the percentage alone. Read the split block on the event rather than recomputing it.
3. Funding order, Steam rail, worked examples
Price 9.99 USD per month, US member, so a period is worth 69 coins. Period 1 always charges the full price (that is what creates the agreement). From period 2 the wallet goes first, and Steam is charged the smallest amount that buys the missing coins, never above the price and never below the 1.00 USD minimum top-up.
| Period | Wallet before | Steam charged | Coins minted | Coins spent | Wallet after |
|---|---|---|---|---|---|
| 1 (always full price) | 20 coins | 9.99 USD | 69 | 69 | 20 |
| 2, empty wallet | 0 | 9.99 USD | 69 | 69 | 0 |
| 2, partial wallet | 20 | 7.00 USD (smallest charge that buys the missing 49) | 49 | 69 | 0 |
| 2, full wallet | 80 | nothing; no Steam call | 0 | 69 | 11 |
For a German member the same price is worth 58 coins and the partial top-up above would be 6.46 USD. The country is fixed at init, so every period of a subscription is worth the same coins. On Steam the split is computed on what the coins are worth (basis: "steam_net"), not on the price; the top-level amount_usd on the event is always the price.
4. When a charge fails
Failure schedule, per game, default retry after 2 days, then 3 days, then 2 days, then expire: three retries over seven days.
| Moment | Subscription | Events | Member access |
|---|---|---|---|
| Charge fails | past_due, next_charge_at = now + 2 days | subscription.payment_failed (first_failure: true, retries_remaining: 3, grace_period_end = now + 7 days), then subscription.past_due | Retained. |
| Retry 1 fails | past_due, next_charge_at = +3 days | subscription.payment_failed (first_failure: false, retries_remaining: 2) | Retained. |
| Retry 2 fails | past_due, next_charge_at = +2 days | subscription.payment_failed (retries_remaining: 1) | Retained. |
| Retry 3 fails | expired, ended_at set, next_charge_at null | subscription.payment_failed (retries_remaining: 0), then subscription.expired with final_period_end | Revoke at final_period_end (the last paid boundary; null if nothing was ever paid). |
| Any retry succeeds | active, period advanced | subscription.renewed | Continue. |
grace_period_end is computed from the live schedule at the moment of each failure, so it is always the date Invo will actually honour. Access is retained through the whole grace window; only subscription.expired revokes it. A member who fixes their card during the window (through /payment-method, or on Steam by topping up their wallet) is charged at the next retry; you can also force an immediate retry in sandbox (advance-clock with intervals: 0).
Failure codes
| Rail | failure_code | Meaning |
|---|---|---|
| card | card_declined | The issuer declined. |
| card | insufficient_funds | Wallet short and no chargeable card (card-less or wallet_only). |
| card | card_expired, card_detached, card_mismatch | The attached card is no longer usable and no fallback card was found. |
| card | processing_error, settlement_error | A processing or settlement failure on Invo’s side. Retried on the same ladder. |
| steam | steam_agreement_unavailable, steam_agreement_canceled, steam_account_locked | The agreement or the account cannot be charged. |
| steam | steam_<code> | A Steam refusal, with Steam’s code appended. |
| steam | steam_authorization_abandoned | The member never authorised within 24 hours. Appears on subscription.expired. |
| steam | steam_containment | Seven consecutive daily deferrals because the wallet holds value that cannot be spent in this game (section 6). Appears on subscription.expired only; no payment_failed precedes it. |
failure_message is a short human-readable string beside every code.
5. Card authentication (awaiting_authentication)
Some card issuers require the cardholder to authenticate a recurring charge. When that happens:
- Nothing is charged. No retry is consumed. Access is retained.
- The subscription moves to
awaiting_authenticationandnext_charge_atis pushed to the challenge deadline. - Invo mints a single-use confirmation link and sends
subscription.authentication_requiredcarryingconfirmation_urlandexpires_at. On the first charge the same link is also on the/subscribe201 (first_charge.confirmation_url). - You relay the link to the member (email, in-game message). The link opens an Invo-hosted page at
/subscription-auth?token=...on Invo’s checkout host; the member authenticates with their issuer there. Do not frame it, and do not log the URL beside identifiers you publish; it is a bearer link. - On success the renewal resumes:
subscription.renewedfires, the subscription isactive. The page tells a member who refreshes that the payment is confirmed. - The link lives at most 72 hours, and never past the next scheduled charge. If it lapses, the page says so, Invo tries the card again on its own, and ordinary dunning resumes. A card that asks for authentication on more than two consecutive attempts for the same period is treated as declined.
The card_amount_usd on the event is what the card is being asked for (the wallet shortfall), which differs from amount_due_usd (the period price) whenever the member holds a balance.
authentication_required is not a failure. Do not send the member a “payment failed” message; send them the link. Do not revoke access. The only way to reproduce the link if you lost it is to replay the webhook delivery.
6. Deferrals you will not be told about
A few renewal outcomes are deferrals rather than failures. No event is sent, no retry budget is consumed, and the member’s access is unaffected. You can see them on GET as an unchanged (or past_due) status with next_charge_at about 24 hours out:
- The price is above the platform’s per-charge ceiling of
500.00USD. Such a subscription is never charged; keep prices at or below500.00. - The member’s wallet holds value that cannot be spent in this game, and the shortfall cannot be topped up around it. The subscription reads
past_due. After seven consecutive daily deferrals it expires: you receivesubscription.expiredwithfailure_code: "steam_containment"andfinal_period_endset (nosubscription.payment_failedprecedes it, because no charge was attempted). Revoke atfinal_period_endas for any expiry. - On Steam, Invo cannot issue currency for your title right now (billing setup incomplete, rail suspended, credit unavailable), or the title’s Steam configuration is unusable. The deferral repeats daily until it is fixed on your side.
If a subscription’s next_charge_at keeps moving a day at a time with no event, one of the three above is the reason. Check the price, then your billing setup in the console.