LUCENTCOMMERCEGET A FREE STORE AUDITFREE AUDIT

SUBSCRIPTIONS · RETENTION · OPERATIONS · 9 JULY 2026 · 7 MIN READ

Subscription billing edge cases that lose customers

A failed renewal is not one event. Shopify reports more than sixty distinct reasons, and the right response to a declined card is nothing like the right response to an out-of-stock line.

A wholesale order form beside its negotiated price list

Read the error code before you retry. Shopify’s SubscriptionBillingAttemptErrorCode enum reports more than sixty distinct failure reasons, and they fall into three groups that need three different responses: transient failures worth retrying unchanged, customer-action failures where retrying is pointless until the customer updates something, and merchant-side failures that are your operational problem and not a payment problem at all. A retry schedule that treats all three the same wastes attempts on the ones it cannot fix and annoys the customers it can.

IN SHORT

  • Shopify’s `SubscriptionBillingAttemptErrorCode` enum distinguishes more than sixty failure reasons, including `INSUFFICIENT_FUNDS`, `EXPIRED_CARD`, `AUTHENTICATION_REQUIRED`, `INSUFFICIENT_INVENTORY` and `PAYMENT_PROVIDER_IS_NOT_ENABLED`.
  • Retrying is only sensible for codes that can change on their own — Shopify documents `TRANSIENT_ERROR` as “Transient error, try again later”, and that is the clearest example.
  • `EXPIRED_CARD`, `INVALID_PAYMENT_METHOD` and `PAYMENT_METHOD_NOT_FOUND` will fail identically on every retry until the customer supplies a new payment method.
  • `customerPaymentMethodSendUpdateEmail` “Sends an email to a customer containing a secure link to update a specific vaulted payment method”, and only its `from` and `bcc` fields are customisable.
  • `INSUFFICIENT_INVENTORY` and `INVENTORY_ALLOCATIONS_NOT_FOUND` are billing failures caused by your operations, not by the customer’s bank.
  • `subscriptionBillingAttemptCreate` takes an `idempotencyKey` so a retried request cannot charge the customer twice.
  • The `SUBSCRIPTION_BILLING_ATTEMPTS_FAILURE` webhook is what you build the recovery flow on; `SUBSCRIPTION_BILLING_ATTEMPTS_CHALLENGED` is a separate topic for 3D Secure challenges.

Three kinds of failure, not one

The single most common design error in subscription recovery is a retry schedule with no branching: attempt, wait three days, attempt, wait three days, attempt, cancel. It is easy to build and it is wrong, because it applies a strategy that only works for one class of failure to all of them.

Shopify’s billing attempt error codes make the classes obvious if you read them. Grouped by what could possibly change between now and a retry:

  • Transient, retry unchanged. TRANSIENT_ERROR (“Transient error, try again later”), PROCESSING_ERROR, PAYMENT_PROVIDER_ERROR, and arguably INSUFFICIENT_FUNDS — the only one of these where the passage of time genuinely fixes things, because payday is a real event.
  • Needs the customer. EXPIRED_CARD, EXPIRED_PAYMENT_METHOD, INVALID_PAYMENT_METHOD (“Payment method is invalid. Please update or create a new payment method.”), PAYMENT_METHOD_NOT_FOUND, INCORRECT_ADDRESS, INCORRECT_ZIP, CALL_ISSUER (“The card issuer requires the cardholder to call them”), AUTHENTICATION_REQUIRED and OFF_SESSION_REJECTED (“The off-session payment was rejected and requires customer action”).
  • Your problem. INSUFFICIENT_INVENTORY (“Not enough inventory found”), INVENTORY_ALLOCATIONS_NOT_FOUND (“No inventory location found or enabled”), PAYMENT_PROVIDER_IS_NOT_ENABLED, MERCHANT_ACCOUNT_ERROR, TEST_MODE (“Gateway is in test mode and attempted to bill a live payment method”) and PAYMENT_METHOD_NOT_SPECIFIED.

Why retrying the wrong class is worse than doing nothing

Retrying an EXPIRED_CARD four times produces four identical declines, four entries on the customer’s bank statement that may show as attempted transactions, and four opportunities for your dunning emails to say something that is not true. It also delays the only action that could work — asking the customer for a new card — by however long the retry schedule runs. The customer who would have updated their card on day one is, on day twelve, someone who has had three emails about a payment problem and has started thinking about whether they still want the subscription.

The merchant-side codes are worse, because a retry loop can send a dunning email for a failure the customer had nothing to do with. INSUFFICIENT_INVENTORY means you sold a renewal you could not fulfil; TEST_MODE means somebody left a gateway misconfigured. Neither should generate a message asking the customer to check their payment details, and both should page somebody internally. If your subscription platform cannot distinguish these, that is worth knowing before you find out through a support ticket.

Card issuer behaviour is the reason to be conservative about volume as well as targeting. RETRY_DECLINED (“The payment retry was declined”) exists as a distinct code, and DO_NOT_HONOR and FRAUD_SUSPECTED are decisions by the issuer rather than statements about the balance. Hammering those does not change the answer.

The out-of-stock renewal

This is the edge case that loses customers quietly, because it does not look like a payment problem to anybody. A subscription renews, the item is out of stock, the billing attempt fails with INSUFFICIENT_INVENTORY, and — depending on how the recovery flow is written — the customer either gets a payment failure email about a card that is perfectly fine, or gets nothing at all and simply does not receive their delivery.

The fix is a decision, not code: for each subscription product, decide in advance whether a renewal may be billed and backordered, swapped for a substitute, or skipped with the cycle pushed forward. Then make sure the operational reality matches — INVENTORY_ALLOCATIONS_NOT_FOUND (“No inventory location found or enabled”) usually means a location was disabled or a product was never stocked at the location the subscription draws from, which is a data problem that will recur every cycle until somebody fixes the source.

This is also where the subscription-vs-catalogue conflict shows up. If your subscription items share stock with one-off sales, a good week of one-off trading eats the inventory your renewals were counting on. Reserving stock for subscriptions is unglamorous and it is the difference between predictable recurring revenue and a monthly scramble.

Authentication, and the renewal nobody is present for

Subscription renewals are off-session by definition — the customer is not at their keyboard. When an issuer decides it wants the cardholder, the attempt cannot simply succeed. Shopify has a dedicated webhook topic for this: SUBSCRIPTION_BILLING_ATTEMPTS_CHALLENGED, documented as occurring “when the financial instutition challenges the subscripttion billing attempt charge as per 3D Secure” (the typos are Shopify’s). There are matching error codes — AUTHENTICATION_REQUIRED (“Additional authentication is required to complete the payment”), EXPIRED_BUYER_ACTION (“The required buyer action has expired”) and OFF_SESSION_REJECTED.

The practical consequence is that a challenged renewal has a deadline. The customer has to act, and the action expires. A recovery flow that treats a challenge like a decline — queue it, retry in three days — will hit EXPIRED_BUYER_ACTION and start again. Challenges need to go to the front of the queue with a message that says what the customer must do, not a generic “your payment failed”.

The email, and the link inside it

Shopify provides customerPaymentMethodSendUpdateEmail, which “Sends an email to a customer containing a secure link to update a specific vaulted payment method”. The documented use case is exactly this one: “when a customer’s credit card is expiring or has been declined, and they need to provide updated payment details for ongoing subscriptions”. Its email input accepts only from and bcc, so the template is Shopify’s, not yours.

That constraint is a feature more often than it is a problem. The alternative — a bespoke update flow on your storefront — means you are now responsible for a page that handles payment credentials, and for a link that has to be secure and expire sensibly. If your requirement is genuinely just “tell the customer their card needs updating and give them somewhere to do it”, use the mutation and spend the effort on when you send it instead of on what it looks like.

When you send it matters more than the copy in any case. An expiring-card email that arrives before the failure costs you nothing and prevents the whole sequence; one that arrives after three failed attempts is asking the customer to rescue a relationship they have already been told is in trouble.

Do not charge anyone twice

Every recovery system retries, and every retry is an opportunity to bill someone twice — for example when a request times out on your side after Shopify has accepted it. subscriptionBillingAttemptCreate takes an idempotencyKey for exactly this, ensuring the billing attempt executes only once so that a retried request cannot produce a duplicate charge. Generate the key from the contract and the billing cycle, not from a random value per attempt, or you have an idempotency key that makes every retry unique and protects nothing.

It also helps to remember the mutation is asynchronous: the payload returns a subscriptionBillingAttempt with a ready field that is false initially, and processing continues after the mutation returns. So the response is not the outcome. The outcome arrives on the SUBSCRIPTION_BILLING_ATTEMPTS_SUCCESS and SUBSCRIPTION_BILLING_ATTEMPTS_FAILURE webhook topics, both of which Shopify documents as requiring the read_own_subscription_contracts scope. Anything that reads the mutation response as a result — a dashboard, a support tool, a log line someone will trust at 2am — is reading it wrong.

What we would build, and what we would not

If you run subscriptions on a platform, most of this already exists and the work is configuration and honesty: find out which error codes it branches on, what it emails for each, and whether merchant-side failures reach a human. Rebuilding a dunning engine that your platform already ships is the expensive way to discover it was adequate.

Build custom when the branching your business needs is not expressible in the platform — inventory-aware renewals, B2B terms where a failed charge should not suspend an account, or per-market retry rules. And measure the split before you invest in any of it. Everyone quotes a figure for how much subscription churn is involuntary; we have not found one with a methodology worth repeating, and your own failure codes over three months are a better number than anybody’s benchmark.

Questions this raises

How do you handle a failed subscription payment on Shopify?

Branch on the error code. `SubscriptionBillingAttemptErrorCode` reports more than sixty reasons: retry the transient ones such as `TRANSIENT_ERROR` and `PROCESSING_ERROR`, ask the customer to act on `EXPIRED_CARD`, `INVALID_PAYMENT_METHOD` and `AUTHENTICATION_REQUIRED`, and route merchant-side ones such as `INSUFFICIENT_INVENTORY` or `PAYMENT_PROVIDER_IS_NOT_ENABLED` to your own team rather than to the customer.

How many times should you retry a failed renewal?

It depends entirely on the code, which is why a single global answer is the wrong shape. Codes that cannot change without the customer doing something should not be retried at all beyond one attempt. For genuinely transient failures, retry — but note that Shopify has a distinct `RETRY_DECLINED` code, so the issuer is capable of treating your retries as their own signal.

Why does a subscription fail to bill when the customer’s card is fine?

Usually inventory. `INSUFFICIENT_INVENTORY` (“Not enough inventory found”) and `INVENTORY_ALLOCATIONS_NOT_FOUND` (“No inventory location found or enabled”) are both billing attempt failures with no payment cause. Other non-payment causes include `PAYMENT_PROVIDER_IS_NOT_ENABLED`, `MERCHANT_ACCOUNT_ERROR` and `TEST_MODE`.

What is a challenged billing attempt?

A renewal the customer’s bank wants authenticated under 3D Secure. Shopify emits it on the `SUBSCRIPTION_BILLING_ATTEMPTS_CHALLENGED` webhook topic, separate from failure, and the related codes include `AUTHENTICATION_REQUIRED` and `EXPIRED_BUYER_ACTION`. Because the required buyer action can expire, challenges need prompt, specific messaging rather than a place in the normal retry queue.

Can you send your own payment update email instead of Shopify’s?

You can, but consider whether you need to. `customerPaymentMethodSendUpdateEmail` sends a secure link to update a vaulted payment method and accepts only `from` and `bcc` as customisation. A bespoke alternative means owning a credential-handling page and a secure expiring link. Unless the branded template is worth that, use the mutation and put the effort into the timing.

How do you avoid double-charging during recovery?

Pass an `idempotencyKey` to `subscriptionBillingAttemptCreate`, derived from the contract and the billing cycle rather than generated fresh per request. And treat the mutation as asynchronous — the returned attempt has a `ready` field that starts false, so the real outcome arrives on the success and failure webhooks, not in the response.

NEXT STEP

Free store audit

A senior Shopify engineer reviews your storefront, theme performance and checkout, then sends a prioritised list of fixes.