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
- DunningStartJob - Triggered when a billing attempt fails
- 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 domain where the billing failure occurred
payload.admin_graphql_api_id
GraphQL Admin API ID of the failed billing attempt
Error code indicating the reason for billing failure
Configuration
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:
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 domain where the billing succeeded
payload.admin_graphql_api_id
GraphQL Admin API ID of the successful billing attempt
payload.admin_graphql_api_subscription_contract_id
GraphQL Admin API ID of the subscription contract
Configuration
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
- Lookup Dunning Tracker: Finds the active dunning tracker for the subscription and billing cycle
- Mark Complete: Updates the dunning tracker status to completed
- 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