BillKit/Docs Console
Operations guide

Recover failed payments

What BillKit does when a renewal fails, what Mollie does, and the parts you have to build.

13 Sept 2026 · Intermediate · 10 min read

Who retries

Start here, because it determines everything else you build.

For an ordinary (licensed) subscription, Mollie owns the retries. The recurring schedule lives at Mollie, so Mollie is what re-attempts a failed charge, on its own ladder, and Mollie is what eventually cancels the subscription when the budget runs out. BillKit does not run a parallel retry loop, and there is no BillKit setting for retry days or intervals.

For a metered subscription there is no Mollie schedule, so BillKit does drive the retries itself: every 24 hours, up to 4 attempts, then the invoice is written off. See Metered billing.

What BillKit gives you on the licensed path is state and notification: the subscription goes past_due, events fire, and a dunning email goes out. Your job is to react to the state and decide what the customer can still do.

Do not build a retry loop of your own on top. Charging the mandate directly while Mollie’s schedule is also running is how a customer gets billed twice for one period.

The sequence

StepWhat happens
1A renewal charge fails at Mollie. BillKit sets status to past_due and emits subscription.past_due and payment.failed.
2The dunning worker picks the subscription up and emits dunning.email_required, then sends BillKit’s own notice to the customer. dunning_attempts_notified increments.
3Mollie retries. Each fresh failure produces another notification, throttled so one failure never produces two notices.
4aA retry succeeds, or the customer reauthorizes. The subscription returns to active, the dunning counters reset, and subscription.updated fires.
4bMollie exhausts its budget and cancels. BillKit emits subscription.canceled with a cancellation_reason of provider_max_retries or provider_mandate_invalid.

Read the dunning fields narrowly.

dunning_attempts_notified counts the notices BillKit sent, not the retries Mollie ran. They track each other closely, because a notice is throttled to one per fresh failure, but they are not the same number.

next_payment_attempt is when the provider is expected to try again — and null when it will not. Null is the signal, not an absence of data: the stored credential is dead, Mollie cancels rather than retries, and your email has to ask the customer to act instead of promising a date. The reasons that produce it are AC01 (invalid IBAN), AC04 (account closed), AC06 (account blocked), MD01 (no mandate), MD07 (debtor deceased), and the card codes that mean the credential itself is unusable — card_expired, inactive_card, invalid_card_number.

BillKit publishes no retry ceiling, on any surface. Mollie retries “up to 5 times, once a day, depending on the failure reason”, and the reasons above cancel on the first failure, so a fixed denominator like “attempt 2 of 5” is wrong for a large share of real failures — and rendering it to a customer promises attempts that will never happen while talking them out of the only action that saves the subscription. This is the same reason Stripe exposes invoice.attempt_count and a nullable invoice.next_payment_attempt and no maximum anywhere: the retry schedule is the processor’s, and an API should not commit to one it does not own.

Handle the events

Node.js
export async function handleEvent(event) {
switch (event.type) {
  case "subscription.past_due":
    // Keep access. Show a banner and a link to the portal.
    await flagBillingProblem(event.data.customer_id, {
      subscriptionId: event.data.id,
      attempt: event.data.dunning_attempts_notified,
      nextAttempt: event.data.next_payment_attempt,
    });
    break;

  case "subscription.updated":
    // Fires on recovery too. Re-read status rather than assuming.
    if (event.data.status === "active") {
      await clearBillingProblem(event.data.customer_id);
    }
    break;

  case "subscription.canceled":
    await revokeAccess(event.data.customer_id, event.data.cancellation_reason);
    break;
}
}

subscription.updated is the recovery signal, not a dedicated event. Read status off the payload rather than inferring intent from the event name.

Emails

BillKit sends the dunning notice itself, so a new account has working dunning without writing anything. The email names the plan, the amount, why the charge failed in plain language (“Your card had insufficient funds”, “Your card has expired”), and links to the portal page where the customer can fix it.

Branding comes from POST /v1/tenant/portal_branding: business name, support email, logo, colour tokens. Set it before your first live failure, because the default is unbranded.

If you would rather send your own, handle dunning.email_required instead. It carries everything the template uses:

dunning.email_required · data
{
"object": "dunning_notification",
"subscription_id": "sub_Pv9Kc3nX8mQ2rTyLbd",
"customer_id": "cus_Ja7Ye2vMq9xN4TdWpr",
"customer_email": "ada@example.com",
"failure_reason": "insufficient_funds",
"failed_payment_id": "pay_Zt6Wq1yBn4Xm9RcKvo",
"failed_at": "2026-09-13T08:14:02+00:00",
"amount_cents": 2900,
"currency": "EUR",
"payment_method": "creditcard",
"attempt": 2,
"next_payment_attempt": "2026-09-23T09:14:00+00:00"
}

The event fires whether or not BillKit’s own email went out, so if you send your own as well the customer receives two. Decide which one you want before you wire the handler.

The recovery flow

The fix for almost every failure is a new mandate, and there are two ways to get one.

Point the customer at the portal. Mint a session and redirect them. This is what the dunning email does, and it needs no UI from you.

shell
curl https://api.billkit.eu/v1/billing_portal/sessions \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
  "subscription_id": "sub_Pv9Kc3nX8mQ2rTyLbd",
  "return_url": "https://example.com/account"
}'

Or drive it yourself with POST /v1/subscriptions/{id}/reauthorize_payment_method, which returns a Mollie checkout URL for a small verification charge.

shell
curl https://api.billkit.eu/v1/subscriptions/sub_Pv9Kc3nX8mQ2rTyLbd/reauthorize_payment_method \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"return_url": "https://example.com/account"}'
Response · 200 OK
{
"object": "reauthorization",
"subscription_id": "sub_Pv9Kc3nX8mQ2rTyLbd",
"operation_id": "3f9a1c8e-2b74-4d51-9c0a-6e8b2f4d7a13",
"checkout_url": "https://www.mollie.com/checkout/select-method/...",
"verification_amount_cents": 100,
"verification_currency": "EUR"
}

When that payment settles, the new mandate replaces the old one, past_due returns to active, the dunning counters reset, and subscription.payment_method_updated fires.

The verification charge is not refunded. Do not write copy that promises it will be. It is small and it is real; say so plainly, or absorb it as a cost of recovery.

One thing recovery does not do: it does not immediately re-attempt the failed renewal. The new mandate is picked up on the next scheduled attempt. Do not tell the customer their payment has gone through the moment they add a card.

SEPA takes longer to fail

SEPA Direct Debit settles over days, not seconds, so a debit reported as failed can still come good. BillKit holds the dunning notice for 5 days on directdebit before sending anything. The subscription is still marked past_due immediately, so your entitlement logic sees it, but the customer is not alarmed about a payment that may yet clear.

Card failures notify on the next worker pass, with no hold.

Deciding what to restrict

past_due is inside the entitlement set alongside active and trialing, and that is the recommendation, not just the default. The customer has paid for the period they are in. Cutting access the hour a card expires turns a solvable payment problem into a cancellation, and the person who can fix it is usually not the person who hits the wall.

Degrade instead of cutting off. Read-only, export-only, or a persistent banner with a portal link all keep the account recoverable. Save the hard cutoff for canceled, which is the point at which Mollie has actually given up.

Testing it

Choosing failed at Mollie’s test-mode status selector exercises the failure half of the pipeline end to end: a real payment.failed, and the checkout session flipped to expired.

The renewal path is harder to reach, because a renewal failure needs Mollie to actually attempt and fail a scheduled charge. Once a subscription is past_due, attach its customer to a test clock and advance it: the dunning worker runs against that clock’s customers under the advanced time, so you can watch the notification cadence without waiting real days for it.