Skip to main content
This guide covers all subscription contract management operations in the Shopify Subscriptions Reference App, from viewing contract details to performing lifecycle operations.

Viewing Subscription Contracts

List All Contracts

The main contracts page displays all subscription contracts with filtering and pagination:
import { getContracts } from '~/models/SubscriptionContract/SubscriptionContract.server';

export async function loader({ request }) {
  const { admin } = await authenticate.admin(request);
  const url = new URL(request.url);

  const { subscriptionContracts, subscriptionContractPageInfo } = await getContracts(
    admin.graphql,
    {
      first: 50,
      after: url.searchParams.get('after'),
      before: url.searchParams.get('before'),
    }
  );

  return {
    subscriptionContracts,
    subscriptionContractPageInfo,
  };
}

View Contract Details

To view a single contract with full details:
import { getContractDetails } from '~/models/SubscriptionContract/SubscriptionContract.server';

export async function loader({ params, request }) {
  const { admin } = await authenticate.admin(request);
  const id = params.id;
  const gid = composeGid('SubscriptionContract', id);

  const subscriptionContract = await getContractDetails(admin.graphql, gid);

  return {
    subscriptionContract,
    upcomingBillingCycles,
    pastBillingCycles,
  };
}
This returns comprehensive contract information including:
  • Customer details
  • Line items
  • Billing and delivery policies
  • Payment method
  • Upcoming and past billing cycles

Pausing Subscriptions

1

Navigate to contract details

Go to the subscription contract you want to pause.
2

Click the Pause action

From the contract actions menu, select “Pause”.
3

Confirm the action

The contract status will update to “Paused” and no further billing will occur.

Pause Implementation

The pause action is implemented using a custom hook in app/routes/app.contracts.$id._index/hooks/usePauseAction.ts:
import { useSubmit, useNavigation } from '@remix-run/react';

export function usePauseAction() {
  const submit = useSubmit();
  const navigation = useNavigation();

  const pauseContract = () => {
    submit(
      { action: 'pause' },
      { method: 'post', action: './pause' }
    );
  };

  const pauseLoading = navigation.state === 'submitting' &&
    navigation.formAction?.includes('pause');

  return { pauseContract, pauseLoading };
}

Server-Side Pause Handler

The route handler processes the pause request:
export async function action({ request, params }: ActionFunctionArgs) {
  const { admin } = await authenticate.admin(request);
  const contractId = composeGid('SubscriptionContract', params.id);

  const response = await admin.graphql(
    `mutation subscriptionContractPause($contractId: ID!) {
      subscriptionContractPause(subscriptionContractId: $contractId) {
        contract {
          id
          status
        }
        userErrors {
          field
          message
        }
      }
    }`,
    {
      variables: { contractId },
    }
  );

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

  if (data.subscriptionContractPause.userErrors?.length) {
    return json(
      toast(t('actions.pause.error'), { isError: true }),
      { status: 500 }
    );
  }

  return json(toast(t('actions.pause.success')));
}

Resuming Subscriptions

1

Navigate to paused contract

Go to a contract with “Paused” status.
2

Click Resume

Select “Resume” from the actions menu.
3

Billing resumes

The contract status updates to “Active” and billing resumes on the next cycle.

Resume Implementation

export function useResumeAction() {
  const submit = useSubmit();
  const navigation = useNavigation();

  const resumeContract = () => {
    submit(
      { action: 'resume' },
      { method: 'post', action: './resume' }
    );
  };

  const resumeLoading = navigation.state === 'submitting' &&
    navigation.formAction?.includes('resume');

  return { resumeContract, resumeLoading };
}

Server-Side Resume Handler

export async function action({ request, params }: ActionFunctionArgs) {
  const { admin } = await authenticate.admin(request);
  const contractId = composeGid('SubscriptionContract', params.id);

  const response = await admin.graphql(
    `mutation subscriptionContractResume($contractId: ID!) {
      subscriptionContractActivate(subscriptionContractId: $contractId) {
        contract {
          id
          status
        }
        userErrors {
          field
          message
        }
      }
    }`,
    {
      variables: { contractId },
    }
  );

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

  if (data.subscriptionContractActivate.userErrors?.length) {
    return json(
      toast(t('actions.resume.error'), { isError: true }),
      { status: 500 }
    );
  }

  return json(toast(t('actions.resume.success')));
}

Canceling Subscriptions

1

Open cancel modal

From the contract page, click “Cancel” in the actions menu.
2

Confirm cancellation

A modal appears asking for confirmation.
3

Submit cancellation

Click “Cancel subscription” to confirm.
4

Contract cancelled

The contract status updates to “Cancelled” and no further billing occurs.

Cancel Modal Component

From app/routes/app.contracts.$id._index/components/CancelSubscriptionModal/CancelSubscriptionModal.tsx:
import { Modal, Text } from '@shopify/polaris';
import { useSubmit, useNavigation } from '@remix-run/react';

interface CancelSubscriptionModalProps {
  open: boolean;
  onClose: () => void;
}

export function CancelSubscriptionModal({ 
  open, 
  onClose 
}: CancelSubscriptionModalProps) {
  const submit = useSubmit();
  const navigation = useNavigation();
  const { t } = useTranslation('app.contracts');

  const loading = navigation.state === 'submitting';

  const handleCancel = () => {
    submit(
      { action: 'cancel' },
      { method: 'post', action: './cancel' }
    );
  };

  return (
    <Modal
      open={open}
      onClose={onClose}
      title={t('actions.cancel.modal.title')}
      primaryAction={{
        content: t('actions.cancel.modal.confirm'),
        onAction: handleCancel,
        loading,
        destructive: true,
      }}
      secondaryActions={[
        {
          content: t('actions.cancel.modal.cancel'),
          onAction: onClose,
        },
      ]}
    >
      <Modal.Section>
        <Text as="p">
          {t('actions.cancel.modal.description')}
        </Text>
      </Modal.Section>
    </Modal>
  );
}

Server-Side Cancel Handler

export async function action({ request, params }: ActionFunctionArgs) {
  const { admin } = await authenticate.admin(request);
  const contractId = composeGid('SubscriptionContract', params.id);

  const response = await admin.graphql(
    `mutation subscriptionContractCancel($contractId: ID!) {
      subscriptionContractCancel(subscriptionContractId: $contractId) {
        contract {
          id
          status
        }
        userErrors {
          field
          message
        }
      }
    }`,
    {
      variables: { contractId },
    }
  );

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

  if (data.subscriptionContractCancel.userErrors?.length) {
    return json(
      toast(t('actions.cancel.error'), { isError: true }),
      { status: 500 }
    );
  }

  return redirect(`/app/contracts/${params.id}`);
}
Cancellation is permanent and cannot be reversed. Consider offering a pause option as an alternative.

Updating Contract Details

Subscription contracts can be edited to modify:
  • Line items (add, remove, update quantities)
  • Delivery frequency
  • Product prices
  • Billing and delivery policies

Edit Flow Implementation

From app/routes/app.contracts.$id.edit/route.tsx:
export async function action({ request, params }: ActionFunctionArgs) {
  const { admin, session } = await authenticate.admin(request);
  const shopDomain = session.shop;
  const contractId = params.id;
  const gid = composeGid('SubscriptionContract', contractId);

  // Validate form data
  const validationResult = await validateFormData(
    getContractEditFormSchema(t),
    await request.formData()
  );

  if (validationResult.error) {
    return json(
      toast(t('edit.actions.updateContract.error'), { isError: true })
    );
  }

  const { lines, deliveryPolicy } = validationResult.data;

  // Get current contract state
  const { lines: initialLines, deliveryPolicy: initialDeliveryPolicy } =
    await getContractEditDetails(admin.graphql, gid);

  // Determine changes
  const linesToAdd = getLinesToAdd(initialLines, lines);
  const lineIdsToRemove = getLineIdsToRemove(initialLines, lines);
  const linesToUpdate = getLinesToUpdate(initialLines, lines);

  const deliveryPolicyChanged =
    deliveryPolicy.interval !== initialDeliveryPolicy.interval ||
    deliveryPolicy.intervalCount !== initialDeliveryPolicy.intervalCount;

  // Create a draft to apply changes
  const draft = await buildDraftFromContract(shopDomain, gid, admin.graphql);

  // Add new lines
  if (linesToAdd.length > 0) {
    await Promise.all(
      linesToAdd.map(async (line) => {
        await draft.addLine(line);
      })
    );
  }

  // Remove lines
  if (lineIdsToRemove.length > 0) {
    await Promise.all(
      lineIdsToRemove.map(async (lineId) => {
        await draft.removeLine(lineId);
      })
    );
  }

  // Update existing lines
  if (linesToUpdate.length > 0) {
    await Promise.all(
      linesToUpdate.map(async (line) => {
        const lineUpdateInput = {
          quantity: line.quantity,
          currentPrice: line.price,
          ...(line.pricingPolicy && { pricingPolicy: line.pricingPolicy }),
        };

        await draft.updateLine(line.id, lineUpdateInput);
      })
    );
  }

  // Update delivery policy if changed
  if (deliveryPolicyChanged) {
    await draft.update({
      billingPolicy: deliveryPolicy,
      deliveryPolicy,
    });
  }

  // Commit all changes
  const committed = await draft.commit();

  if (!committed) {
    return json(
      toast(t('edit.actions.updateContract.error'), { isError: true })
    );
  }

  return json(toast(t('edit.actions.updateContract.success')));
}

Updating Customer Address

Customers can update their shipping address:
export async function action({ request, params }: ActionFunctionArgs) {
  const { admin } = await authenticate.admin(request);
  const formData = await request.formData();
  const contractId = composeGid('SubscriptionContract', params.id);
  const addressId = formData.get('addressId');

  const response = await admin.graphql(
    `mutation subscriptionContractSetNextBillingDate(
      $contractId: ID!
      $shippingAddress: MailingAddressInput!
    ) {
      subscriptionDraftUpdate(
        draftId: $contractId
        input: { deliveryMethod: { shipping: { address: $shippingAddress } } }
      ) {
        draft {
          id
        }
        userErrors {
          field
          message
        }
      }
    }`,
    {
      variables: {
        contractId,
        shippingAddress: formatAddressInput(formData),
      },
    }
  );

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

  if (data.subscriptionDraftUpdate.userErrors?.length) {
    return json(
      toast(t('customer.address.error'), { isError: true }),
      { status: 500 }
    );
  }

  return json(toast(t('customer.address.success')));
}

Updating Payment Method

Customers can request a payment method update email:
import { CustomerPaymentMethodSendUpdateEmailMutation } from '~/graphql/CustomerPaymentMethodSendUpdateEmailMutation';

export async function action({ request, params }: ActionFunctionArgs) {
  const { admin } = await authenticate.admin(request);
  const formData = await request.formData();
  const paymentMethodId = formData.get('paymentMethodId') as string;

  const response = await admin.graphql(
    CustomerPaymentMethodSendUpdateEmailMutation,
    {
      variables: {
        customerPaymentMethodId: paymentMethodId,
      },
    }
  );

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

  if (data.customerPaymentMethodSendUpdateEmail.userErrors?.length) {
    return json(
      toast(t('payment.updateEmail.error'), { isError: true }),
      { status: 500 }
    );
  }

  return json(toast(t('payment.updateEmail.success')));
}

Contract Statuses

Subscription contracts have the following statuses:
StatusDescriptionAvailable Actions
ActiveContract is active and billingPause, Cancel, Edit
PausedTemporarily paused by merchant/customerResume, Cancel, Edit
CancelledPermanently cancelledNone
FailedBilling attempt failedPause, Cancel, Retry Billing
ExpiredContract has reached its end dateNone

Best Practices

Communication
  • Always notify customers before making changes to their subscriptions
  • Send confirmation emails after pause, resume, or cancel actions
  • Provide clear explanations for any billing failures
Draft Pattern
  • Use the draft pattern for complex updates to ensure atomicity
  • Validate all changes before committing the draft
  • Handle errors gracefully and provide clear feedback
Error Handling
  • Always check for userErrors in GraphQL responses
  • Provide helpful error messages to users
  • Log errors for debugging and monitoring

Next Steps

Handling Webhooks

Process subscription lifecycle events

Testing

Test subscription flows and edge cases

Build docs developers (and LLMs) love