Skip to main content

Overview

Billing attempt webhooks notify your app when subscription billing attempts succeed or fail, and when billing cycles are skipped. These webhooks are essential for implementing dunning management, payment retry logic, and customer notifications.

Webhook Topics

subscription_billing_attempts/success

Triggered when a billing attempt for a subscription contract succeeds and an order is created.
This webhook fires after Shopify successfully charges the customer’s payment method and creates a new order for the subscription.

Webhook Configuration

[[webhooks.subscriptions]]
topics = [ "subscription_billing_attempts/success" ]
uri = "/webhooks/subscription_billing_attempts/success"

Payload Structure

id
number
required
The numeric ID of the billing attempt
admin_graphql_api_id
string
required
The GraphQL Admin API ID of the billing attempt (format: gid://shopify/SubscriptionBillingAttempt/{id})
idempotency_key
string
required
A unique key that can be used to identify this billing attempt across retries
order_id
number
The numeric ID of the order created by this billing attempt (may be null initially)
admin_graphql_api_order_id
string
The GraphQL Admin API ID of the order created (may be null initially)
subscription_contract_id
number
required
The numeric ID of the subscription contract being billed
admin_graphql_api_subscription_contract_id
string
required
The GraphQL Admin API ID of the subscription contract
ready
boolean
required
Indicates whether the billing attempt is ready for processing
error_message
string
Error message if there were any issues (null for successful attempts)
error_code
string
Error code if there were any issues (null for successful attempts)

Example Payload

{
  "id": 987654321,
  "admin_graphql_api_id": "gid://shopify/SubscriptionBillingAttempt/987654321",
  "idempotency_key": "abc123def456",
  "order_id": 555555555,
  "admin_graphql_api_order_id": "gid://shopify/Order/555555555",
  "subscription_contract_id": 123456789,
  "admin_graphql_api_subscription_contract_id": "gid://shopify/SubscriptionContract/123456789",
  "ready": true,
  "error_message": null,
  "error_code": null
}

Handler Implementation

The reference app stops any active dunning processes and tags the recurring order:
// app/routes/webhooks.subscription_billing_attempts.success.tsx
import type {ActionFunctionArgs} from '@remix-run/node';
import {DunningStopJob, jobs, TagSubscriptionOrderJob} from '~/jobs';
import {RECURRING_ORDER_TAGS} from '~/jobs/tags/constants';
import {authenticate} from '~/shopify.server';
import {logger} from '~/utils/logger.server';

export const action = async ({request}: ActionFunctionArgs) => {
  const {topic, shop, payload} = await authenticate.webhook(request);

  logger.info({topic, shop, payload}, 'Received webhook');

  // Stop dunning process for this subscription
  jobs.enqueue(
    new DunningStopJob({
      shop,
      payload: payload,
    }),
  );

  // Tag the recurring order
  jobs.enqueue(
    new TagSubscriptionOrderJob({
      shop,
      payload: {
        orderId: payload.admin_graphql_api_order_id,
        tags: ['subscription', 'recurring-order'],
      },
    }),
  );

  return new Response();
};
The handler immediately stops any dunning process (payment retry sequence) since the payment was successful, and tags the order for easy identification.

subscription_billing_attempts/failure

Triggered when a billing attempt for a subscription contract fails.
This webhook is critical for implementing dunning management - the process of retrying failed payments and notifying customers about payment issues.

Webhook Configuration

[[webhooks.subscriptions]]
topics = [ "subscription_billing_attempts/failure" ]
uri = "/webhooks/subscription_billing_attempts/failure"

Payload Structure

id
number
required
The numeric ID of the billing attempt
admin_graphql_api_id
string
required
The GraphQL Admin API ID of the billing attempt
idempotency_key
string
required
A unique key for this billing attempt
order_id
number
The order ID (null for failed attempts)
admin_graphql_api_order_id
string
The GraphQL Admin API order ID (null for failed attempts)
subscription_contract_id
number
required
The numeric ID of the subscription contract
admin_graphql_api_subscription_contract_id
string
required
The GraphQL Admin API ID of the subscription contract
ready
boolean
required
Indicates whether the billing attempt is ready
error_message
string
required
A description of why the billing attempt failed
error_code
string
required
A code identifying the type of error. Common codes include:
  • insufficient_inventory - Not enough product inventory
  • inventory_allocations_not_found - Inventory allocation issues
  • payment_method_declined - Payment method was declined
  • authentication_required - Payment requires authentication (3D Secure)

Example Payload

{
  "id": 987654321,
  "admin_graphql_api_id": "gid://shopify/SubscriptionBillingAttempt/987654321",
  "idempotency_key": "xyz789abc123",
  "order_id": null,
  "admin_graphql_api_order_id": null,
  "subscription_contract_id": 123456789,
  "admin_graphql_api_subscription_contract_id": "gid://shopify/SubscriptionContract/123456789",
  "ready": true,
  "error_message": "Payment method declined",
  "error_code": "payment_method_declined"
}

Handler Implementation

The reference app starts a dunning process to handle the failed payment:
// app/routes/webhooks.subscription_billing_attempts.failure.tsx
import type {ActionFunctionArgs} from '@remix-run/node';
import {DunningStartJob, jobs} from '~/jobs';
import {authenticate} from '~/shopify.server';
import {logger} from '~/utils/logger.server';

export const action = async ({request}: ActionFunctionArgs) => {
  const {topic, shop, payload} = await authenticate.webhook(request);

  logger.info({topic, shop, payload}, 'Received webhook');

  // Start dunning process to retry payment
  jobs.enqueue(
    new DunningStartJob({
      shop,
      payload: payload,
    }),
  );

  return new Response();
};
The dunning process typically includes:
  • Sending customer notifications about the failed payment
  • Attempting to retry the payment on a schedule
  • Pausing or cancelling the subscription after multiple failures

subscription_billing_cycles/skip

Triggered when a billing cycle is skipped for a subscription contract.
Customers can skip upcoming deliveries, which triggers this webhook. This is useful for notifying customers about the skipped cycle.

Webhook Configuration

[[webhooks.subscriptions]]
topics = [ "subscription_billing_cycles/skip" ]
uri = "/webhooks/subscription_billing_cycles/skip"

Payload Structure

subscription_contract_id
string
required
The numeric ID of the subscription contract (as a string)
cycle_start_at
string
required
ISO 8601 timestamp of when the cycle starts
cycle_end_at
string
required
ISO 8601 timestamp of when the cycle ends
cycle_index
number
required
The index number of the billing cycle being skipped (0-based)
billing_attempt_expected_date
string
required
ISO 8601 timestamp of when the billing attempt was expected
skipped
boolean
required
Indicates that the cycle was skipped (will be true)
edited
boolean
required
Indicates whether the cycle was edited
contract_edit
object
Contract edit details (typically null for skip operations)

Example Payload

{
  "subscription_contract_id": "123456789",
  "cycle_start_at": "2024-03-01T00:00:00Z",
  "cycle_end_at": "2024-03-31T23:59:59Z",
  "cycle_index": 3,
  "billing_attempt_expected_date": "2024-03-01T00:00:00Z",
  "skipped": true,
  "edited": false,
  "contract_edit": null
}

Handler Implementation

The reference app sends a notification email to the customer about the skipped cycle:
// app/routes/webhooks.subscription_billing_cycles.skip.tsx
import type {ActionFunctionArgs} from '@remix-run/node';
import {composeGid} from '@shopify/admin-graphql-api-utilities';
import {CustomerSendEmailJob, jobs} from '~/jobs';
import {CustomerEmailTemplateName} from '~/services/CustomerSendEmailService';
import {authenticate} from '~/shopify.server';
import {logger} from '~/utils/logger.server';

export const action = async ({request}: ActionFunctionArgs) => {
  const {topic, shop, payload} = await authenticate.webhook(request);

  logger.info({topic, shop, payload}, 'Received webhook');

  const {subscription_contract_id: subContractId, cycle_index} = payload;

  // Convert numeric ID to GraphQL GID format
  const subscriptionContractGid = composeGid(
    'SubscriptionContract',
    subContractId,
  );

  // Send skip confirmation email
  jobs.enqueue(new CustomerSendEmailJob({
    shop,
    payload: {
      admin_graphql_api_id: subscriptionContractGid,
      emailTemplate: CustomerEmailTemplateName.SubscriptionSkipped,
      cycle_index,
    },
  }));

  return new Response();
};
The handler converts the numeric subscription contract ID to a GraphQL GID format using the composeGid utility from @shopify/admin-graphql-api-utilities.

Error Handling

When handling billing attempt failures, you should implement logic based on the error code:
Error CodeDescriptionRecommended Action
payment_method_declinedPayment method was declinedNotify customer to update payment method
authentication_required3D Secure authentication neededSend customer link to authenticate
insufficient_inventoryNot enough product stockNotify merchant, pause subscription
inventory_allocations_not_foundInventory system issueRetry later, notify merchant

Best Practices

  1. Dunning Management: Implement a progressive dunning strategy that retries payments over several days before cancelling
  2. Customer Communication: Always notify customers about failed payments and provide clear instructions for updating payment methods
  3. Inventory Errors: Handle inventory-related failures differently from payment failures
  4. Monitoring: Track billing attempt success/failure rates to identify issues early
  5. Idempotency: Use the idempotency_key to prevent duplicate processing of the same billing attempt

Build docs developers (and LLMs) love