BillKit/DocsConsole
Operations guide

Recover failed payments

What BillKit does when a renewal fails, how it retries, and the parts you have to build.

25 Sept 2026·Intermediate·10 min read

Who retries

Start here, because it determines everything else you build.

BillKit owns the renewal schedule and the retries. When a period ends, BillKit charges the stored payment method itself, as a merchant-initiated payment on the customer’s Mollie mandate. When that charge fails, BillKit decides whether and when to try again, from the payment method and the reason the charge was declined, and BillKit ends the subscription when the retries run out. There is no BillKit setting for retry days or intervals: the schedule below is fixed.

Metered subscriptions have their own collection schedule, because their invoice is written before it is charged. See Metered billing.

What you get is state and notification: the subscription goes past_due, events fire, a dunning email goes out, and next_payment_attempt says exactly when the next retry runs. 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 yourself while BillKit’s retries are also running is how a customer gets billed twice for one period. To collect the period sooner, send the customer to pay it on-session (see the recovery flow).

The sequence

StepWhat happens
1A renewal charge fails. BillKit sets status to past_due, sets next_payment_attempt to the next retry (or null, see below), and emits subscription.past_due, subscription.updated and payment.failed. The customer keeps access.
2Within about 30 minutes (5 days for SEPA Direct Debit, see below), BillKit emits dunning.email_required and sends its own notice to the customer. dunning_attempts_notified increments.
3BillKit retries on the schedule for the payment method. Every retry charges the same amount, fixed when the period was first charged. Each failed attempt produces exactly one notification, and attempt counts them.
4aA retry succeeds, or the customer pays the period on-session. The subscription returns to active, the period moves on, the dunning counters reset, and subscription.updated fires.
4bThe retries run out, or a stored method that cannot be charged again is not replaced in time. BillKit cancels the subscription immediately and emits subscription.canceled with a cancellation_reason of payment_failed.

The retry schedule depends on the payment method:

MethodRetries
Card and other instant methods1, 3, 5 and 7 days after the first failed charge: five attempts in about eight days.
SEPA Direct DebitAt most three attempts, each a week after the previous one failed, within 30 days of the first failure.

Some declines are not retried at all, because retrying cannot succeed or the customer’s bank has said not to:

  • The stored credential is dead: AC01 (invalid IBAN), AC04 (account closed), AC06 (account blocked), MD01 (no mandate), MD07 (debtor deceased), AG01, SL01, and the card codes that mean the card itself is unusable: card_expired, inactive_card, invalid_card_number, invalid_card_type.
  • The debit was refused on purpose: MS02 and MD06 (the account holder refused or disputed it) and possible_fraud.
  • The bank wants the customer present: an authentication_* decline. A charge the customer is not present for cannot pass Strong Customer Authentication, so only an on-session payment can.
  • Mollie refused the charge on the mandate, or there is no usable mandate: BillKit records a failed payment with failure_reason mandate_refused, since no bank ever answered. A subscription provisioned without a stored mandate is not refused for that alone: at its first renewal BillKit binds the customer’s newest usable Mollie mandate and charges it. Only a customer with none is refused.

In those cases next_payment_attempt is null, and the subscription stays past_due while the customer is asked to pay with a new method: up to 14 days from the first failure for a card, 30 for SEPA Direct Debit. If they do not, it is canceled with payment_failed. Any other reason, and a failure with no reason at all, is retried.

A chargeback on a renewal whose reason is in the first two groups has the same effect on the stored method. The charged-back period is not restated and nothing is debited again, but BillKit makes no further charge on that mandate: when the next period falls due, the subscription goes past_due straight away with no attempt at the bank, and the customer is asked for a new method as above.

Read the dunning fields narrowly.

dunning_attempts_notified counts the notices BillKit sent, not the charges it attempted. 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 the exact time BillKit will charge again, and it is null when it will not. Null is the signal, not an absence of data: your email has to ask the customer to act instead of promising a date.

BillKit publishes no retry ceiling on the subscription. How many attempts remain depends on the method and on the decline, and several declines stop retrying at once. A fixed denominator like “attempt 2 of 5” is therefore 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 one action that saves the subscription. Branch on next_payment_attempt being null instead.

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;
}
}

There is no dedicated recovery event. Read status off the payload rather than inferring intent from the event name, and handle subscription.payment_method_updated the same way if you offer reauthorization.

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. Verify a domain too and the notice arrives from your own address rather than ours, which is worth doing for a message whose whole job is to be believed. See Your own domain.

With no support email set, the footer carries no address and the email does not invite a reply, because a reply would reach nobody who can help; the unpaid-invoice notice tells the customer to contact you instead. The metered notice says the subscription will be canceled if collection keeps failing, and names no date, because that cancellation runs on the collection schedule in Metered billing.

To send your own notice as well, handle dunning.email_required; BillKit’s email still goes out, so yours would be a second one. 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": 1789287242,
"amount_cents": 2900,
"currency": "EUR",
"payment_method": "creditcard",
"attempt": 2,
"next_payment_attempt": 1790154840
}

The event fires whether or not BillKit’s own email went out, and BillKit’s email cannot be switched off, so if you send your own as well the customer receives two. Use the event for your own records and in-app prompts, or accept the duplicate.

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. See Hand customers a self-service portal.

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.

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"
}

For a past_due subscription, the payment behind that URL is the unpaid renewal itself, charged with the customer present: verification_amount_cents is the same amount every retry charges. When it is paid, the new mandate replaces the old one, the period moves on, the subscription is active again, and subscription.payment_method_updated and payment.succeeded fire. No retry is needed. Calling the route again while that payment page is still open returns the same checkout_url. This is also the only way through an authentication_* decline.

For a subscription that is not past due (a card about to expire, say), the payment is a small verification charge instead. verification_amount_cents is then the smallest amount every method on your Mollie account accepts, which can be zero on a card-only account, and the new method is charged from the next renewal on. The verification charge is a payment of type reauthorization, and is left out of GET /v1/payments.

A non-zero verification charge is not refunded. Do not write copy that promises it will be. Say so plainly, or absorb it as a cost of recovery.

Until the customer completes that payment, nothing changes. While a past-due payment page is open at Mollie, no scheduled retry runs beside it; if the customer leaves without paying, the retries carry on against the old method once Mollie closes the page. Do not tell the customer their payment has gone through the moment they open the page.

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 within about 30 minutes, 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 BillKit has stopped retrying.

Testing it

Choosing failed at Mollie’s test-mode status selector on a hosted checkout’s first payment shows the start of the pipeline: the checkout session moves to expired and checkout.session.expired fires. (An embedded session stays open for a retry instead.) A first payment that fails does emit payment.failed, but with no subscription_id, because there is no subscription yet. Nothing goes past_due and no dunning runs.

To exercise the renewal path without waiting a month, create the customer attached to a test clock (test_clock_id on POST /v1/customers; it cannot be added later). Advancing the clock past current_period_end makes BillKit charge the renewal, and further advances run the retries and the dunning notices at the advanced time. Mollie decides the outcome of each charge as it does for any test payment. Clock-attached customers are only charged and notified when you advance their clock.