Skip to main content
The App Settings page provides merchants with fine-grained control over subscription billing behavior, payment retry logic, and notification preferences. Settings are stored as Shopify metaobjects and persist across sessions.

Overview

App Settings allow merchants to customize:
  • Payment Failure Handling: Configure retry attempts and actions when payments fail
  • Inventory Failure Handling: Set behavior for out-of-stock scenarios
  • Notification Preferences: Control when and how staff are notified about subscription issues
Settings are stored using Shopify’s Metaobject API and are automatically created on first access.

Settings Configuration

Payment Failure Settings

Configure how the app handles failed payment attempts:
retryAttempts
number
default:"3"
Number of times to retry a failed payment (0-10)
daysBetweenRetryAttempts
number
default:"3"
Days to wait between retry attempts (1-14)
onFailure
enum
default:"skip"
Action to take after all retries are exhaustedOptions:
  • skip - Skip the current billing cycle and try again next cycle
  • pause - Pause the subscription until customer updates payment
  • cancel - Cancel the subscription automatically

Inventory Failure Settings

Configure behavior when products are out of stock during billing:
inventoryRetryAttempts
number
default:"3"
Number of times to retry billing when inventory is unavailable (0-10)
inventoryDaysBetweenRetryAttempts
number
default:"7"
Days to wait between inventory retry attempts (1-14)
inventoryOnFailure
enum
default:"skip"
Action to take when inventory is unavailable after all retriesOptions:
  • skip - Skip the current billing cycle
  • pause - Pause the subscription
  • cancel - Cancel the subscription
inventoryNotificationFrequency
enum
default:"immediately"
Frequency of staff notifications for inventory issuesOptions:
  • immediately - Send email immediately for each inventory failure
  • weekly - Send weekly digest of inventory failures
  • monthly - Send monthly digest of inventory failures

Implementation

Settings Model

The Settings model provides methods to load and update configuration:
app/models/Settings/Settings.server.ts
import { loadSettingsMetaobject, updateSettingsMetaobject } from '~/models/Settings/Settings.server';

// Load current settings
const settings = await loadSettingsMetaobject(admin.graphql);

// Update settings
await updateSettingsMetaobject(admin.graphql, {
  id: settings.id,
  retryAttempts: 5,
  daysBetweenRetryAttempts: 2,
  onFailure: 'pause',
  inventoryRetryAttempts: 3,
  inventoryDaysBetweenRetryAttempts: 7,
  inventoryOnFailure: 'skip',
  inventoryNotificationFrequency: 'weekly'
});

Settings Route

The settings page is implemented as a Remix route with form handling:
app/routes/app.settings._index/route.tsx
export async function loader({ request }: LoaderFunctionArgs) {
  const { admin } = await authenticate.admin(request);
  const settings = await loadSettingsMetaobject(admin.graphql);
  return json({ settings });
}

export async function action({ request }: ActionFunctionArgs) {
  const { admin } = await authenticate.admin(request);
  const formData = await request.formData();
  
  // Validate form data
  const validationResult = await validateFormData(
    getSettingsSchema(t),
    formData
  );
  
  if (validationResult.error) {
    return validationError(validationResult.error);
  }
  
  // Update settings
  await updateSettingsMetaobject(admin.graphql, validationResult.data);
  
  return json({
    toast: toast.success(t('toast.success'))
  });
}

UI Components

The settings page uses the BillingFailureSettings component:
app/routes/app.settings._index/components/BillingFailureSettings.tsx
export function BillingFailureSettings() {
  const { t } = useTranslation('app.settings');
  
  return (
    <Card>
      <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>
    </Card>
  );
}

Settings Schema

Settings are defined using Shopify’s metaobject definition:
app/models/Settings/SettingsDefinitions.ts
export const SETTINGS_METAOBJECT_DEFINITION = {
  definition: {
    name: 'Subscriptions App Settings',
    type: 'subscriptions_app_settings',
    fieldDefinitions: [
      {
        name: 'Retry Attempts',
        key: 'retry_attempts',
        type: 'number_integer'
      },
      {
        name: 'Days Between Retry Attempts',
        key: 'days_between_retry_attempts',
        type: 'number_integer'
      },
      {
        name: 'On Failure',
        key: 'on_failure',
        type: 'single_line_text_field'
      },
      // ... additional fields
    ]
  }
};

How Settings Affect Behavior

Dunning Management

Settings directly control the dunning process:
  1. Retry Attempts: Determines how many times DunningService will retry failed payments
  2. Days Between Retries: Sets the interval for the dunning job scheduler
  3. On Failure Action: Determines the contract status after exhausting retries
app/services/DunningService.ts
async startDunning(contractId: string, billingAttemptId: string) {
  const settings = await loadSettingsMetaobject(this.graphqlClient);
  
  // Use settings to determine retry behavior
  const maxRetries = settings.retryAttempts;
  const retryInterval = settings.daysBetweenRetryAttempts;
  const finalAction = settings.onFailure;
  
  // Schedule retries based on settings
  await this.scheduleRetries(contractId, maxRetries, retryInterval, finalAction);
}

Inventory Management

When products are out of stock during billing:
  1. Check inventoryRetryAttempts to determine retry count
  2. Use inventoryDaysBetweenRetryAttempts for scheduling
  3. Apply inventoryOnFailure action after retries exhausted
  4. Send staff notifications based on inventoryNotificationFrequency
app/jobs/email/EnqueueInventoryFailureEmailJob.ts
async execute() {
  const settings = await loadSettingsMetaobject(this.graphqlClient);
  
  if (settings.inventoryNotificationFrequency === 'immediately') {
    await this.sendImmediateNotification();
  } else {
    await this.addToDigest(settings.inventoryNotificationFrequency);
  }
}

Best Practices

Conservative Retry Settings

Start with 3-5 retry attempts to avoid overwhelming customers with retry emails

Appropriate Intervals

Use 2-3 day intervals between retries to give customers time to update payment methods

Final Action Strategy

Consider using “pause” instead of “cancel” to preserve customer relationships

Inventory Notifications

Use weekly or monthly digests for inventory notifications to avoid email fatigue

Validation

Settings are validated using Zod schemas:
app/routes/app.settings._index/validator.ts
export const getSettingsSchema = (t: TFunction) =>
  z.object({
    retryAttempts: z.coerce
      .number()
      .min(0, t('retryAttempts.error.min'))
      .max(10, t('retryAttempts.error.max')),
    daysBetweenRetryAttempts: z.coerce
      .number()
      .min(1, t('daysBetweenRetryAttempts.error.min'))
      .max(14, t('daysBetweenRetryAttempts.error.max')),
    onFailure: z.nativeEnum(OnFailureType),
    inventoryRetryAttempts: z.coerce.number().min(0).max(10),
    inventoryDaysBetweenRetryAttempts: z.coerce.number().min(1).max(14),
    inventoryOnFailure: z.nativeEnum(OnInventoryFailureType),
    inventoryNotificationFrequency: z.nativeEnum(
      InventoryNotificationFrequencyType
    )
  });

Payment Retries

Learn how dunning management uses these settings

Billing Schedules

Configure when subscriptions are billed

Dunning Management

Understand the retry process in detail

Email Jobs

See how notification settings affect email delivery

Testing

Test settings updates in your development environment:
test
import { loadSettingsMetaobject, updateSettingsMetaobject } from '~/models/Settings/Settings.server';

describe('Settings', () => {
  it('updates retry attempts', async () => {
    const settings = await loadSettingsMetaobject(graphqlClient);
    
    await updateSettingsMetaobject(graphqlClient, {
      ...settings,
      retryAttempts: 5
    });
    
    const updated = await loadSettingsMetaobject(graphqlClient);
    expect(updated.retryAttempts).toBe(5);
  });
});

Troubleshooting

Ensure metaobject definition is created:
await ensureSettingsMetaobjectDefinitionAndObjectExists(graphqlClient);
Check that values are within allowed ranges:
  • Retry attempts: 0-10
  • Days between retries: 1-14
Settings are loaded at job execution time. After updating settings, wait for the next billing cycle to see changes take effect.

Build docs developers (and LLMs) love