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.

  1. 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.
  2. Re-check the agreement. Status, cancel_at_period_end and 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.
  3. 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.
  4. Charge the shortfall only, if there is one and the subscription is card-backed. Nothing is charged when the wallet already covers it.
  5. 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, and period_seq advances 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: true keeps 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 forgiven and 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 the disclosed price is refused — the old price keeps billing and pending_amount_usd stays visible.
  • Entitlement comes from paid cycles. paid_through is the truth; current_period_end is 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.

StepMovementWallet after
Price of the cycle$9.99 → 99.90 currency60.00
Shortfall99.90 − 60.00 = 39.90 currency → $3.9960.00
Card charged$3.99, minting 39.90 currency99.90
Renewal debit99.90 spent on the membership0.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_usd is "0.00" and minted_coins is "0.00".
  • Only subscription.renewed fires — no purchase.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.

WhenWhat happensStatusEvent
Day 0First failure. Access is retained.past_duepayment_failed (first_failure: true) + past_due
Day 2Retry 1 of 3.past_duepayment_failed
Day 5Retry 2 of 3.past_duepayment_failed
Day 7Retry 3 of 3 — the last one. This is grace_period_end.past_dueexpiredfinal payment_failed + expired

The things integrators get wrong

  • The member keeps access for the whole grace window. payment_failed and past_due both carry access_retained: true. Only subscription.expired revokes entitlement.
  • payment_failed fires on the FIRST failure, deliberately — while the member can still fix it. It fires on every subsequent failure too; use first_failure to decide whether to send a gentle nudge or a final warning.
  • past_due fires once per dunning run, on entry into arrears. payment_failed is 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" }
  }
}
QuestionAnswer
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_at72 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

webhooks/subscriptions.js
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 outcomeStatus becomesEvent(s)What you do
Paid entirely from balanceactivesubscription.renewedExtend access to current_period_end, write your ledger entry, send a receipt.
Paid, card topped up the shortfallactivesubscription.renewed + purchase.completedSame — and make sure the purchase handler does not grant twice. Correlate on the order id.
Balance short, no card / card unusablepast_duepayment_failed (+ past_due on entry)Keep access. Prompt a top-up or a card update against grace_period_end.
Card declinedpast_duepayment_failed (+ past_due on entry)Keep access. Ask the member to update their card. Do not retry yourself.
Cardholder authentication requiredawaiting_authenticationauthentication_requiredSurface confirmation_url immediately. Keep access. Do not treat as a failure.
Retry budget exhaustedexpiredfinal payment_failed + expiredRevoke at final_period_end, decrement your counters, offer re-subscribe.
Cancelled agreement reaches its boundarycancelednone — subscription.canceled already fired at request timeRevoke at the effective_at you were given earlier. Expect no second event.
Trial convertsactivesubscription.renewedNothing special — the trial window itself is never charged.
A settled cycle is refundedunchangedsubscription.refundedReverse 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_end and retries_remaining, escalating as the deadline nears.
  • Entitlement from paid cycles. Grant on subscription.renewed, revoke on expired / canceled / refunded.
  • A card-capture prompt when has_payment_method is 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 your payment_failed handler 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. Use paid_through.
  • Double-granting on purchase.completed for 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_url alongside identifiers you publish. It is a bearer capability.

9. Related pages