Skip to main content

Overview

Dunning jobs manage the lifecycle of failed subscription payments, from initiating retry attempts to stopping the dunning process when payments succeed. These jobs integrate with Shopify’s subscription billing attempt webhooks to provide automated payment recovery.

Job Flow

  1. DunningStartJob - Triggered when a billing attempt fails
  2. DunningStopJob - Triggered when a billing attempt succeeds after previous failures

DunningStartJob

Initiates dunning process when a subscription billing attempt fails. Determines the appropriate recovery strategy based on the failure reason.

Parameters

shop
string
required
Shop domain where the billing failure occurred
payload.admin_graphql_api_id
string
required
GraphQL Admin API ID of the failed billing attempt
payload.error_code
string
required
Error code indicating the reason for billing failure

Configuration

queue
string
default:"webhooks"
Assigned to the webhooks queue for webhook-triggered jobs

Implementation

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;

    let result = '';
    const errorCode = failureReason as SubscriptionBillingAttemptErrorCodeType;

    if (
      errorCode === SubscriptionBillingAttemptErrorCode.InsufficientInventory ||
      errorCode ===
        SubscriptionBillingAttemptErrorCode.InventoryAllocationsNotFound
    ) {
      const inventoryService = await buildInventoryService({
        shopDomain: shop,
        billingAttemptId,
        failureReason,
      });

      result = await inventoryService.run();
      logger.info({result}, 'Completed Unavailable Inventory error');
    } else {
      const dunningService = await buildDunningService({
        shopDomain: shop,
        billingAttemptId,
        failureReason,
      });

      result = await dunningService.run();
      logger.info({result}, 'Completed DunningService');
    }
  }
}
Source: app/jobs/dunning/DunningStartJob.ts:12-50

Failure Reason Handling

The job routes failures to different services based on the error code:
error_code
enum
  • INSUFFICIENT_INVENTORY - Product stock is unavailable
  • INVENTORY_ALLOCATIONS_NOT_FOUND - Inventory allocation failed
These errors trigger the Inventory Service which:
  • Notifies merchants about inventory issues
  • May pause or skip the billing cycle
  • Sends inventory failure emails
All other error codes trigger the Dunning Service which:
  • Creates or updates dunning tracker records
  • Schedules payment retry attempts
  • Manages retry logic and backoff strategies
  • May pause contracts after exhausting retries

Error Codes

Common subscription billing attempt error codes:
  • PAYMENT_METHOD_DECLINED
  • PAYMENT_METHOD_EXPIRED
  • PAYMENT_GATEWAY_NOT_ENABLED
  • INVALID_PAYMENT_METHOD
  • CUSTOMER_NOT_FOUND
  • INSUFFICIENT_INVENTORY
  • INVENTORY_ALLOCATIONS_NOT_FOUND
The dunning service implementation handles retry configuration, including retry intervals, maximum attempts, and notification triggers.

Webhook Trigger

This job is typically enqueued from the subscriptions/billing_attempts/failure webhook:
import {DunningStartJob} from '~/jobs/dunning';
import {jobs} from '~/jobs';

export async function handleBillingAttemptFailure(
  shop: string,
  payload: Webhooks.SubscriptionBillingAttemptFailure,
) {
  const job = new DunningStartJob({shop, payload});
  await jobs.enqueue(job);
}

DunningStopJob

Terminates the dunning process when a billing attempt succeeds, reactivating paused contracts and marking dunning as complete.

Parameters

shop
string
required
Shop domain where the billing succeeded
payload.admin_graphql_api_id
string
required
GraphQL Admin API ID of the successful billing attempt
payload.admin_graphql_api_subscription_contract_id
string
required
GraphQL Admin API ID of the subscription contract

Configuration

queue
string
default:"webhooks"
Assigned to the webhooks queue

Implementation

export class DunningStopJob extends Job<
  Jobs.Parameters<Webhooks.SubscriptionBillingAttemptSuccess>
> {
  public queue: string = 'webhooks';

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

    const dunningTracker = await this.getDunningTracker(
      shop,
      billingAttemptId,
      contractId,
    );

    if (dunningTracker === null) {
      this.logger.info('No DunningTracker found, terminating');
      return;
    }

    await markCompleted(dunningTracker);
    this.logger.info('Completed DunningTracker');

    await this.reactivateFailedContract(shop, contractId);
  }

  async getDunningTracker(
    shop: string,
    billingAttemptId: string,
    contractId: string,
  ) {
    const billingAttempt = await findSubscriptionBillingAttempt(
      shop,
      billingAttemptId,
    );

    const {originTime: date} = billingAttempt;

    const {subscriptionContract, subscriptionBillingCycle} =
      await findSubscriptionContractWithBillingCycle({
        shop,
        contractId,
        date,
      });

    const {cycleIndex: billingCycleIndex} = subscriptionBillingCycle;

    const dunningTracker = await prisma.dunningTracker.findFirst({
      where: {
        shop,
        contractId: subscriptionContract.id,
        billingCycleIndex,
      },
    });

    return dunningTracker;
  }

  async reactivateFailedContract(shop: string, contractId: string) {
    const {admin} = await unauthenticated.admin(shop);
    const response = await admin.graphql(SubscriptionContractResume, {
      variables: {
        subscriptionContractId: contractId,
      },
    });

    const {data} = await response.json();

    if (
      !data?.subscriptionContractActivate ||
      data.subscriptionContractActivate?.userErrors.length > 0
    ) {
      const message = `Failed to reactivate failed subscription contract ${contractId}`;
      const userErrors = data?.subscriptionContractActivate?.userErrors;
      this.logger.warn({userErrors}, message);
      throw Error(message);
    }

    this.logger.info(
      `Successfully reactivated subscription contract ${contractId}`,
    );
  }
}
Source: app/jobs/dunning/DunningStopJob.ts:10-93

Behavior

  1. Lookup Dunning Tracker: Finds the active dunning tracker for the subscription and billing cycle
  2. Mark Complete: Updates the dunning tracker status to completed
  3. Reactivate Contract: Calls subscriptionContractActivate mutation to resume the subscription

Early Termination

If no dunning tracker is found, the job terminates early without error. This handles cases where:
  • The billing succeeded on the first attempt (no dunning initiated)
  • The dunning tracker was already completed
  • The contract was never in dunning
If contract reactivation fails, the job throws an error to trigger retry. The dunning tracker remains completed, but the contract may need manual intervention.

Webhook Trigger

This job is typically enqueued from the subscriptions/billing_attempts/success webhook:
import {DunningStopJob} from '~/jobs/dunning';
import {jobs} from '~/jobs';

export async function handleBillingAttemptSuccess(
  shop: string,
  payload: Webhooks.SubscriptionBillingAttemptSuccess,
) {
  const job = new DunningStopJob({shop, payload});
  await jobs.enqueue(job);
}

Dunning Tracker Model

Dunning jobs interact with the DunningTracker database model to track retry state:
model DunningTracker {
  id               String   @id @default(uuid())
  shop             String
  contractId       String
  billingCycleIndex Int
  status           String   // 'active', 'completed'
  retryCount       Int      @default(0)
  nextRetryAt      DateTime?
  createdAt        DateTime @default(now())
  updatedAt        DateTime @updatedAt
}

Key Operations

Create Tracker (in DunningService):
await prisma.dunningTracker.create({
  data: {
    shop,
    contractId,
    billingCycleIndex,
    status: 'active',
    retryCount: 0,
  },
});
Mark Completed:
import {markCompleted} from '~/models/DunningTracker/DunningTracker.server';

await markCompleted(dunningTracker);

Job Registration

Dunning jobs are registered in app/jobs/index.ts:
export const jobs = (() => {
  // ... scheduler setup
})().register(
  DunningStartJob,
  DunningStopJob,
  // ... other jobs
);
Source: app/jobs/index.ts:72-91

Retry Configuration

Dunning jobs inherit retry behavior from the base Job class, which automatically retries on non-terminal errors.

Terminal Errors (No Retry)

  • SessionNotFoundError - Shop session expired
  • HTTP 401, 402, 403, 404, 423 from Shopify API
  • MaxMetaobjectDefinitionsExceededError

Retryable Errors

All other errors trigger the scheduler’s retry logic:
  • Network failures
  • Temporary API errors
  • Database connection issues
Source: app/lib/jobs/Job.ts:32-38

Build docs developers (and LLMs) love