Skip to main content
The Shopify Subscriptions Reference App includes a sophisticated payment retry system (dunning) that automatically handles failed billing attempts, maximizing subscription revenue recovery.

Overview

When a subscription billing attempt fails, the dunning system:
  • Automatically retries failed payments
  • Sends notifications to customers and merchants
  • Configures retry intervals and attempt limits
  • Takes action after final failure (pause, cancel, or skip)
Dunning management is critical for subscription businesses to reduce involuntary churn from failed payments.

How Payment Retries Work

1

Billing Failure Detected

A subscription billing attempt fails and triggers the dunning workflow
2

Dunning Job Initiated

The DunningStartJob is enqueued to handle the failure
3

Retry Logic Applied

The system determines the appropriate retry strategy based on settings
4

Retry Attempts Executed

Payment retries are scheduled according to configured intervals
5

Final Action Taken

After exhausting retries, the system pauses, cancels, or skips the subscription

Dunning Service Architecture

The dunning system uses a service-oriented architecture with specialized handlers in app/services/DunningService.ts:34-121:
export class DunningService {
  static BILLING_CYCLE_BILLED_STATUS = 'BILLED';
  static TERMINAL_STATUS = ['EXPIRED', 'CANCELLED'];

  async run(): Promise<DunningServiceResult> {
    const {shopDomain, contract, billingCycle, failureReason} = this;

    // Check if billing attempt is ready
    if (this.billingAttemptNotReady) {
      return 'BILLING_ATTEMPT_NOT_READY';
    }

    // Create or find existing dunning tracker
    const dunningTracker = await findOrCreateBy({
      shop: shopDomain,
      contractId: contract.id,
      billingCycleIndex: billingCycle.cycleIndex,
      failureReason: failureReason,
    });

    // Handle different dunning scenarios
    switch (true) {
      case this.finalAttempt:
        await markCompleted(dunningTracker);
        await new FinalAttemptDunningService({...}).run();
        return 'FINAL_ATTEMPT_DUNNING';
      
      case this.penultimateAttempt:
        await new PenultimateAttemptDunningService({...}).run();
        return 'PENULTIMATE_ATTEMPT_DUNNING';
      
      default:
        await new RetryDunningService({...}).run();
        return 'RETRY_DUNNING';
    }
  }
}

Dunning Job Implementation

The DunningStartJob orchestrates the retry process in app/jobs/dunning/DunningStartJob.ts:17-49:
export class DunningStartJob extends Job<
  Jobs.Parameters<Webhooks.SubscriptionBillingAttemptFailure>
> {
  public queue: string = 'webhooks';

  async perform(): Promise<void> {
    const {shop, payload} = this.parameters;
    const {admin_graphql_api_id: billingAttemptId, error_code: failureReason} =
      payload;

    // Handle inventory-specific errors differently
    if (
      errorCode === SubscriptionBillingAttemptErrorCode.InsufficientInventory ||
      errorCode === SubscriptionBillingAttemptErrorCode.InventoryAllocationsNotFound
    ) {
      const inventoryService = await buildInventoryService({
        shopDomain: shop,
        billingAttemptId,
        failureReason,
      });
      result = await inventoryService.run();
    } else {
      const dunningService = await buildDunningService({
        shopDomain: shop,
        billingAttemptId,
        failureReason,
      });
      result = await dunningService.run();
    }
  }
}
Inventory-related failures use a separate retry service with different notification logic since they require merchant action rather than customer payment updates.

Configuration Settings

Merchants configure dunning behavior in the Settings page. The settings are stored in a Shopify metaobject.

Payment Failure Settings

retryAttempts
number
required
Number of retry attempts before final action (0-10)Default: 3 attempts
daysBetweenRetryAttempts
number
required
Days to wait between each retry attempt (1-14)Default: 3 days
onFailure
enum
required
Action to take after exhausting all retries
  • skip - Skip the current billing cycle
  • pause - Pause the subscription
  • cancel - Cancel the subscription

Inventory Failure Settings

inventoryRetryAttempts
number
required
Retry attempts for inventory-related failures (0-10)
inventoryDaysBetweenRetryAttempts
number
required
Days between inventory failure retries (1-14)
inventoryOnFailure
enum
required
Action after final inventory failure: skip, pause, or cancel
inventoryNotificationFrequency
enum
required
How often to notify merchants about inventory issues
  • immediately - Send notification for each failure
  • weekly - Send weekly digest
  • monthly - Send monthly digest

Settings UI

The billing failure settings interface from app/routes/app.settings._index/components/BillingFailureSettings.tsx:50-138:
export function BillingFailureSettings() {
  const onFailureOptions = [
    {label: t('onFailure.options.skip'), value: OnFailureType.skip},
    {label: t('onFailure.options.pause'), value: OnFailureType.pause},
    {label: t('onFailure.options.cancel'), value: OnFailureType.cancel},
  ];

  return (
    <Card>
      <BlockStack gap="200">
        <Text as="h2" variant="headingSm">
          {t('paymentFailureTitle')}
        </Text>
        <FormLayout>
          <FormLayout.Group>
            <TextField
              label={t('retryAttempts.label')}
              name="retryAttempts"
              type="number"
              min={0}
              max={10}
            />
            <TextField
              label={t('daysBetweenRetryAttempts.label')}
              name="daysBetweenRetryAttempts"
              type="number"
              min={1}
              max={14}
            />
          </FormLayout.Group>
          <Select
            label={t('onFailure.label')}
            name="onFailure"
            options={onFailureOptions}
          />
        </FormLayout>
      </BlockStack>
    </Card>
  );
}

Retry Flow

Dunning Tracker

The system tracks each dunning process in the database to prevent duplicate retries:
const dunningTracker = await findOrCreateBy({
  shop: shopDomain,
  contractId: contract.id,
  billingCycleIndex: billingCycle.cycleIndex,
  failureReason: failureReason,
});

Tracker Fields

  • shop - Shop domain
  • contractId - Subscription contract GID
  • billingCycleIndex - Which billing cycle failed
  • failureReason - Payment failure error code
  • completed - Whether dunning process is complete
  • attemptCount - Number of retry attempts made
The dunning tracker ensures idempotency, preventing the same failure from triggering multiple retry sequences.

Retry Strategies

Standard Retry

Used for most payment failures:
  • Schedules retry based on daysBetweenRetryAttempts
  • Sends customer notification about retry
  • Updates dunning tracker with attempt count

Penultimate Attempt

The second-to-last retry attempt:
  • Sends warning notification to customer
  • Informs customer of upcoming final action
  • Provides payment update instructions

Final Attempt

The last retry before taking final action:
  • Executes configured onFailure action
  • Sends final notification to customer
  • Sends merchant notification about the failure
  • Marks dunning tracker as completed

Inventory-Specific Handling

Inventory failures require different handling:

Merchant Notifications

Merchants receive alerts about out-of-stock items

Configurable Frequency

Notification frequency can be immediate, weekly, or monthly

Separate Retry Logic

Independent retry attempts and intervals from payment failures

Restock Detection

Automatically retries when inventory becomes available

Webhook Integration

The dunning system responds to Shopify webhooks:

Subscription Billing Attempt Failure

Triggered when a billing attempt fails:
// Webhook payload structure
{
  admin_graphql_api_id: "gid://shopify/SubscriptionBillingAttempt/123",
  error_code: "payment_method_declined",
  error_message: "Card declined",
  // ... other fields
}

Subscription Billing Attempt Success

Triggered when a retry succeeds:
// Stops dunning process
await DunningStopJob.enqueue({
  shop: shopDomain,
  contractId,
  billingCycleIndex,
});

Best Practices

1

Configure Reasonable Retry Counts

3-5 retry attempts balances recovery rate with customer experience
2

Space Retries Appropriately

3-7 days between attempts gives customers time to update payment info
3

Choose Final Actions Carefully

Pausing is often better than canceling as it’s reversible
4

Monitor Dunning Metrics

Track retry success rates to optimize your configuration
5

Communicate Clearly

Ensure notification emails explain the issue and provide resolution steps
Too many retry attempts can frustrate customers. Too few attempts leave revenue on the table. Test different configurations to find the optimal balance.

Common Failure Reasons

The customer’s payment method was declined. This is the most common failure type. Retries often succeed if the customer updates their payment info or has sufficient funds later.
The subscribed products are out of stock. Requires merchant to restock inventory. Uses separate inventory dunning logic.
The customer’s card has expired. Requires customer to update payment method. Send payment update email immediately.
The payment method is invalid or no longer exists. Requires customer action to add a new payment method.

Notifications

The dunning system sends notifications to both customers and merchants:

Customer Notifications

  • Retry attempt notification
  • Penultimate warning notification
  • Final failure notification
  • Payment update instructions

Merchant Notifications

  • Final attempt failure summary
  • Inventory shortage alerts (configurable frequency)
  • Bulk failure reports
Customize notification templates to match your brand voice and provide clear calls-to-action for resolving payment issues.

Build docs developers (and LLMs) love