Renewals, Funding & Dunning
What actually happens every billing period. Invo owns the clock, the funding decision, the retries and the recovery; you react to webhooks. This page is the whole of that behaviour — including the one part that surprises every integrator: a renewal can pause, mid-cycle, waiting for the cardholder to authenticate with their bank.
You do not schedule anything. There is no "charge now" endpoint and nothing for you to call on a timer. Creating the subscription (see Create a Subscription) is the last write you make until you want to change or end it (see Manage a Subscription).
1. What a renewal is, end to end
Subscriptions bill in advance: the charge for a cycle happens at the start of it. When next_charge_at comes due, Invo runs this sequence for that one subscription.
- Open the cycle. A period record — the invoice equivalent — is opened for this
period_seq, and an attempt record is written before anything is charged. - Re-check the agreement. Status,
cancel_at_period_endand card eligibility are all re-read at this moment, not trusted from when the cycle was scheduled. A member who cancelled ten minutes ago is not charged. - Read the balance and compute the shortfall. The price is converted to currency at the platform rate and compared against what the member already holds.
- Charge the shortfall only, if there is one and the subscription is card-backed. Nothing is charged when the wallet already covers it.
- Settle in one transaction: mint the topped-up currency, debit the wallet for the full price, split the fee, advance the clock, mark the cycle paid, and emit
subscription.renewed.
What you are guaranteed
Invariants you can build on
- At most one cycle is charged per subscription per run. There is exactly one invoice per
period_seq, andperiod_seqadvances exactly once per successful renewal. - A retry cannot double-charge. Every attempt carries its own idempotency identity end to end, so a worker that dies mid-charge resumes rather than re-charges.
- A cancelled subscription is retired, never billed.
cancel_at_period_end: truekeeps the agreement schedulable precisely so it can be retired at the boundary. - A wallet-only subscription never touches a card, even if a card is somehow attached to it.
- The price billed is the price snapshotted on the cycle, so a price change staged mid-cycle can never hit the cycle the member is already in.
- Missed cycles are forgiven, not back-billed. If billing was interrupted, cycles that fully elapsed are recorded as
forgivenand only the cycle containing now is charged. A member who went unbilled for three months does not get charged three times on recovery. - Hard ceilings refuse rather than clamp. A renewal above $500 is refused, not charged, and a staged increase above 2× the disclosed price is refused — the old price keeps billing and
pending_amount_usdstays visible. - Entitlement comes from paid cycles.
paid_throughis the truth;current_period_endis a projection.
On timing. next_charge_at carries a small spread (up to an hour) so a whole signup cohort does not renew on the same instant. Treat it as "around then", not to the second, and let the webhook — not a clock on your side — tell you a renewal happened.
2. Dual funding: balance first, card for the shortfall
A renewal is settled from the member's Invo balance. The card is a backstop, not the payment method: it is charged only for the part the wallet cannot cover, and the currency it buys is spent in the same transaction. The member's ledger reads currency in, currency out — you never gain a card relationship and your PCI scope is unchanged.
Leg 1 — Balance
Everything the member already holds is applied first, up to the full price. Reported as balance_applied_coins on the renewal event. If this covers the price, the renewal is complete and no card is presented at all.
Leg 2 — Card top-up
Only the shortfall is charged, rounded up to the cent and floored at a $1.00 minimum top-up. Reported as card_charged_usd, with the currency it produced as minted_coins. Invo remains merchant of record.
A worked example
A $9.99/month membership. The platform rate is a flat 10 units of currency per USD, so the cycle costs 99.90 in currency. On renewal day the member holds 60.00.
| Step | Movement | Wallet after |
|---|---|---|
| Price of the cycle | $9.99 → 99.90 currency | 60.00 |
| Shortfall | 99.90 − 60.00 = 39.90 currency → $3.99 | 60.00 |
| Card charged | $3.99, minting 39.90 currency | 99.90 |
| Renewal debit | 99.90 spent on the membership | 0.00 |
The cycle records balance_applied_coins: 60.00 and card_charged_usd: 3.99 — the two numbers that let you reconcile exactly how a renewal was funded, months later.
If the shortfall is tiny — say $0.40 — the card is charged the $1.00 minimum instead, and the extra $0.60 of currency simply stays in the member's wallet. It is not lost; it reduces the next cycle's shortfall.
What the success event carries
{
"event": "subscription.renewed",
"data": {
"subscription_id": "SUB_1786012800_A1B2C3D4",
"item_id": "premium_membership",
"period_seq": 3,
"transaction_id": "TXN_...",
"order_id": "ORD_...",
"mint_order_id": "ORD_...",
"player_email": "member@example.com",
"identity_id": "...",
"amount_usd": "9.99",
"amount_coins": "99.90",
"period_start": "2026-08-13T09:14:22+00:00",
"period_end": "2026-09-13T09:14:22+00:00",
"current_period_start": "2026-09-13T09:14:22+00:00",
"current_period_end": "2026-10-13T09:14:22+00:00",
"next_charge_at": "2026-09-13T09:41:05+00:00",
"funding": {
"balance_applied_coins": "60.00",
"card_charged_usd": "3.99",
"minted_coins": "39.90",
"new_balance": "0.00"
},
"split": {
"total_usd": "9.99",
"invo_fee_usd": "0.34",
"partner_revenue_usd": "9.65",
"invo_fee_coins": "3.40",
"partner_revenue_coins": "96.50",
"invo_fee_percent": "3.5"
},
"revenue_share_attribution": { "settled_by_invo": false },
"metadata": { "tier": "gold" }
}
}A card-funded renewal fires a second, honest event
The top-up leg really does mint currency, so it also emits purchase.completed — the same event every other funding rail fires when currency is credited. If your purchase.completed handler grants something, it will now grant on renewals too. Correlate on the order id: the renewal's mint_order_id is the order_id on that purchase event. A wallet-funded renewal emits only subscription.renewed.
3. When the balance covers the whole price
No card is touched. No payment is attempted, no processor is contacted, and no authentication can be required — there is nothing to authenticate. The renewal is a ledger movement and settles immediately.
card_charged_usdis"0.00"andminted_coinsis"0.00".- Only
subscription.renewedfires — nopurchase.completed. - A later refund of that cycle re-credits currency and moves no cash (
funding_shape: "wallet").
Wallet-only subscriptions always behave this way. If you set wallet_only: true at subscribe time, a renewal that the balance cannot cover does not fall back to a card — it fails and enters dunning like any other unfunded renewal. That is the deliberate trade for never involving a card.
4. Failed payments and the dunning ladder
The default ladder is 3 retries over 7 days, then expire, spaced +2, +3, +2 days. It is configurable per tenant — ask us if you want a different shape — and the deadline Invo publishes to you is always computed from the ladder actually in force, never from a frozen copy.
| When | What happens | Status | Event |
|---|---|---|---|
| Day 0 | First failure. Access is retained. | past_due | payment_failed (first_failure: true) + past_due |
| Day 2 | Retry 1 of 3. | past_due | payment_failed |
| Day 5 | Retry 2 of 3. | past_due | payment_failed |
| Day 7 | Retry 3 of 3 — the last one. This is grace_period_end. | past_due → expired | final payment_failed + expired |
The things integrators get wrong
- The member keeps access for the whole grace window.
payment_failedandpast_dueboth carryaccess_retained: true. Onlysubscription.expiredrevokes entitlement. payment_failedfires on the FIRST failure, deliberately — while the member can still fix it. It fires on every subsequent failure too; usefirst_failureto decide whether to send a gentle nudge or a final warning.past_duefires once per dunning run, on entry into arrears.payment_failedis per attempt. Subscribe to whichever granularity you want and ignore the other.- The cycle stays open while retries remain. It is only recorded as failed when the budget is exhausted — the statement that this cycle was never collected.
- Not every failure consumes a retry. A pending authentication challenge does not, so an attempt number and a retry number are different things. Read
retries_remaining, never count events.
The failure payload
{
"event": "subscription.payment_failed",
"data": {
"subscription_id": "SUB_1786012800_A1B2C3D4",
"item_id": "premium_membership",
"player_email": "member@example.com",
"status": "past_due",
"period_seq": 4,
"attempt_no": 1,
"period_start": "2026-09-13T09:14:22+00:00",
"period_end": "2026-10-13T09:14:22+00:00",
"amount_due_usd": "9.99",
"outcome": "card_declined",
"reason": "card_declined",
"failure_code": "card_declined",
"failure_message": "Your card was declined.",
"first_failure": true,
"retry_at": "2026-09-15T09:41:05+00:00",
"retries_remaining": 3,
"grace_period_end": "2026-09-20T09:41:05+00:00",
"access_retained": true,
"metadata": { "tier": "gold" }
}
}outcome is one of insufficient_funds, card_declined, authentication_required or error. amount_due_usd is the cycle's price, not the subscription's — a price change staged for the next cycle never appears on a failure for this one. Email against grace_period_end: it is a deadline Invo actually honours.
The terminal outcome
When the last retry fails, the subscription becomes expired: it leaves the billing schedule permanently, the cycle is recorded as failed, and the member's one-subscription-per-item slot is freed. There are no further attempts and no automatic resurrection — the member must subscribe again, with a fresh client_request_id.
{
"event": "subscription.expired",
"data": {
"subscription_id": "SUB_1786012800_A1B2C3D4",
"status": "expired",
"period_seq": 4,
"failed_period_start": "2026-09-13T09:14:22+00:00",
"failed_period_end": "2026-10-13T09:14:22+00:00",
"final_attempt_no": 4,
"attempts_used": 4,
"failure_code": "card_declined",
"failure_message": "Your card was declined.",
"ended_at": "2026-09-20T09:42:10+00:00",
"final_period_end": "2026-09-13T09:14:22+00:00",
"access_retained": false
}
}final_period_end is the last instant the member actually paid for — the paid-through boundary, not the billing projection. It is null if nothing was ever collected. Revoke on this event.
5. Step-up authentication (SCA / 3-D Secure)
This is the behaviour that surprises people. Build for it.
Some renewals — mostly UK and EU cardholders — cannot complete without the cardholder re-authenticating with their bank. This is regulatory. It is not a decline, it is not a soft failure, and retrying cannot solve it. The only thing that resolves it is the member completing a challenge.
Invo hosts that challenge page and hands you a link. You must surface that link to the member. If you do not, they have no way to complete it, and a member who fully intended to pay eventually gets expired.
When a renewal needs authentication, the subscription moves to its own status — awaiting_authentication — and Invo emits subscription.authentication_required. This is a distinct state on purpose: it is not past_due, the dunning clock is paused, and no retry has been consumed.
The event
{
"event": "subscription.authentication_required",
"data": {
"subscription_id": "SUB_1786012800_A1B2C3D4",
"item_id": "premium_membership",
"player_email": "member@example.com",
"status": "awaiting_authentication",
"period_seq": 4,
"attempt_no": 1,
"period_start": "2026-09-13T09:14:22+00:00",
"period_end": "2026-10-13T09:14:22+00:00",
"amount_due_usd": "9.99",
"card_amount_usd": "3.99",
"confirmation_url": "https://.../subscription-auth?token=...",
"expires_at": "2026-09-16T09:41:05+00:00",
"reason": "authentication_required",
"access_retained": true,
"retry_consumed": false,
"metadata": { "tier": "gold" }
}
}| Question | Answer |
|---|---|
| What status does it carry? | awaiting_authentication. Still a live subscription; still occupies the member's slot. |
| Does the member lose access? | No. access_retained: true. Do not degrade or cancel anything. |
| Does it burn a retry? | No. retry_consumed: false. The retry budget is untouched. |
| What is the member sent? | Nothing, by us. You deliver confirmation_url — email, in-app message, both. That is the required piece of UI. |
| How much is being authenticated? | card_amount_usd — the wallet shortfall, not the membership price. They differ whenever the member holds a balance, and quoting the wrong one in your email confuses people. |
| How long do they have? | Until expires_at — 72 hours by default. Send the link promptly; a link delivered on day three is a link nobody can use. |
| How many times can this happen? | At most two challenges per cycle. Past that ceiling the outcome is treated as a decline and ordinary dunning resumes. |
| Can I build the link myself? | No. It carries its own high-entropy token, is scoped to one payment, and stops working the moment the renewal settles. Always use the URL from the event. |
If the member never completes it
Nothing is stranded and nothing loops. At expires_at the challenge lapses and the subscription returns to ordinary dunning — the pending payment is not re-attempted while the challenge is live, and the member is not expired while it is open.
A second attempt may raise a second challenge. After the ceiling (two per cycle) the behaviour converts to a decline: it consumes retry budget, fires payment_failed, and walks the normal ladder to expired. So an unattended challenge does eventually cost the member their subscription — which is exactly why surfacing the link is not optional.
The link cannot double-charge. Confirming it completes the payment that already exists rather than creating a new one, so a double-click, a refresh, a duplicated email and a concurrent retry are all incapable of taking money twice.
Node.jsHandling the event correctly
app.post('/webhooks/invo', async (req, res) => {
const { event, data } = req.body;
switch (event) {
case 'subscription.renewed':
// Idempotent on (subscription_id, period_seq) — deliveries can repeat.
await extendAccess(data.subscription_id, data.current_period_end);
await recordInvoice(data); // data.funding + data.split reconcile the cycle
break;
case 'subscription.authentication_required':
// NOT a failure. Do not degrade, do not email "your payment failed".
await emailMember(data.player_email, {
subject: 'Confirm your payment with your bank',
amountUsd: data.card_amount_usd, // the shortfall, not the price
link: data.confirmation_url, // never construct this yourself
expiresAt: data.expires_at, // ~72h
});
break;
case 'subscription.payment_failed':
// Access is RETAINED. Nudge, escalating as the deadline approaches.
await emailMember(data.player_email, {
subject: data.first_failure
? 'We could not renew your membership'
: 'Last chance to keep your membership',
deadline: data.grace_period_end,
retriesLeft: data.retries_remaining,
});
break;
case 'subscription.past_due':
await softDegrade(data.subscription_id); // keep the row, flag the account
break;
case 'subscription.expired':
await revokeAccess(data.subscription_id, data.final_period_end);
break;
case 'subscription.canceled':
await scheduleRevocation(data.subscription_id, data.effective_at);
break;
case 'subscription.refunded':
await reverseEntitlement(data.subscription_id, data.period_seq);
break;
}
res.sendStatus(200); // acknowledge fast; do the work asynchronously
});6. How a subscription recovers
One successful attempt clears everything
A paid renewal always lands in active, whatever the subscription was before — trialing (converted), past_due (recovered) or awaiting_authentication (challenge completed). In the same transaction the cycle is marked paid, period_seq advances, next_charge_at moves to the start of the next unpaid cycle, and any staged price change is promoted.
There is no separate "recovered" webhook. subscription.renewed arriving for a subscription you had marked past-due is the recovery signal — restore access on it and clear whatever soft degradation you applied.
Recovery happens on the schedule Invo already set, or immediately when the member completes an authentication challenge. Either way, the member topping up their balance or attaching a new card (see Manage a Subscription) is enough — no call is needed to trigger the retry.
7. Every outcome, at a glance
The whole contract on one screen. Full payload reference: Subscription Webhooks.
| Renewal outcome | Status becomes | Event(s) | What you do |
|---|---|---|---|
| Paid entirely from balance | active | subscription.renewed | Extend access to current_period_end, write your ledger entry, send a receipt. |
| Paid, card topped up the shortfall | active | subscription.renewed + purchase.completed | Same — and make sure the purchase handler does not grant twice. Correlate on the order id. |
| Balance short, no card / card unusable | past_due | payment_failed (+ past_due on entry) | Keep access. Prompt a top-up or a card update against grace_period_end. |
| Card declined | past_due | payment_failed (+ past_due on entry) | Keep access. Ask the member to update their card. Do not retry yourself. |
| Cardholder authentication required | awaiting_authentication | authentication_required | Surface confirmation_url immediately. Keep access. Do not treat as a failure. |
| Retry budget exhausted | expired | final payment_failed + expired | Revoke at final_period_end, decrement your counters, offer re-subscribe. |
| Cancelled agreement reaches its boundary | canceled | none — subscription.canceled already fired at request time | Revoke at the effective_at you were given earlier. Expect no second event. |
| Trial converts | active | subscription.renewed | Nothing special — the trial window itself is never charged. |
| A settled cycle is refunded | unchanged | subscription.refunded | Reverse the entitlement for that cycle and correct your recipient attribution. |
8. What to build, and what not to
Build this
- A step-up delivery path. Email plus an in-app banner carrying
confirmation_url. This is the one piece of UI the design genuinely requires from you. - An idempotent webhook handler, keyed on
(subscription_id, period_seq). Deliveries can repeat, and you can replay them yourself. - Dunning emails driven by
grace_period_endandretries_remaining, escalating as the deadline nears. - Entitlement from paid cycles. Grant on
subscription.renewed, revoke onexpired/canceled/refunded. - A card-capture prompt when
has_payment_methodis false on a non-wallet-only subscription — it will fail its next renewal otherwise. - Consent capture at signup. Invo is merchant of record, so the evidence you pass at subscribe time is what defends a disputed recurring charge.
Do not build this
- Your own retry logic. Invo owns the ladder. A second retry loop on your side means duplicate attempts against a card that already said no, which counts against retry-abuse metrics and helps nobody.
- A "charge now" poller. There is no such endpoint, and nothing you call moves the billing clock forward.
- Failure handling for
authentication_required. Wiring it to yourpayment_failedhandler starts a cancellation email at a member whose payment is fine. - Access derived from
current_period_end. It is a projection that exists before any money moves. Usepaid_through. - Double-granting on
purchase.completedfor card-funded renewals. Correlate on the order id or check the event source. - Silent auto re-subscribe after expiry. A new agreement needs new consent — re-subscribing someone who let their card lapse is how chargebacks start.
- Storing or logging
confirmation_urlalongside identifiers you publish. It is a bearer capability.
9. Related pages
- Subscriptions Overview — the model and the statuses.
- Create a Subscription — subscribe, trials,
wallet_only, consent. - Manage a Subscription — read, list, reprice, replace the card, cancel, refund.
- Subscription Webhooks — every payload, signature verification and delivery replay.
- Subscription Reporting — attribution over settled renewals.