Migrate Subscriptions to Shopify: Keep the Cards

To migrate subscriptions to Shopify without asking every subscriber for a new card, import each vaulted card by reference with customerPaymentMethodRemoteCreate, then create the contract with subscriptionContractAtomicCreate and point it at that payment method. It works for cards held in Stripe, Braintree or Authorize.net, and for PayPal billing agreements. It does not work for anything else, and after the import your app, not Shopify, has to trigger every renewal.

Everything below is written against Admin GraphQL 2026-10, the current stable version as of October 2026, with Shopify’s Subscriptions API migration guide as the primary source.

Which payment methods can actually move?

Four gateways, and the limits are tighter than the list suggests.

From Stripe, only card payment methods migrate. Shopify’s customer migration guide says the process needs Stripe’s Payment Intents API, is also compatible with the Charges API, and does not carry customised charge flows across. From Braintree, cards and Apple Pay move; PayPal methods stored in Braintree do not, and the legacy Braintree integration cannot bill multi-currency contracts. Authorize.net migrates through customer profiles. PayPal Express is its own path through billing agreements, and the merchant has to be approved by PayPal for reference transactions first.

There is no input for a card vaulted anywhere else, and none for Shopify Payments. If your current platform holds cards with Adyen, Checkout.com, Worldpay or a bespoke vault, the subscriber has to enter the card again, and your migration becomes a customer-communication project with an API step at the end.

The mechanism explains the restriction. Shopify does not copy the card number. The old gateway stays connected to the Shopify store as a legacy subscription gateway and bills the imported contracts, while the docs say “all new or updated contracts charge against Shopify Payments”. The imported method is a pointer to a token that still lives in Stripe or Braintree. Disconnect that gateway later and the pointer has nothing to charge.

The overview page adds one more constraint: subscriptions that bundle different products are not supported by this import.

How does the merchant connect the old gateway?

Your app cannot do this by API. For Stripe, Braintree and Authorize.net the guide has you send the merchant to a Shopify admin URL of the form https://admin.shopify.com/store/{shop}/settings/payments/third-party-providers/{id}?secondarySubscriptionGateway=true, where {id} is Shopify’s provider ID for the gateway (the guide lists 39813121 for Stripe, 47742977 for Braintree and 44859393 for Authorize.net).

Then poll shop.features until the store is ready.

query MigrationReadiness {
  shop {
    features {
      eligibleForSubscriptions
      eligibleForSubscriptionMigration
      legacySubscriptionGatewayEnabled
      paypalExpressSubscriptionGatewayStatus
    }
  }
}

legacySubscriptionGatewayEnabled flips to true once the Stripe, Braintree or Authorize.net connection exists. paypalExpressSubscriptionGatewayStatus returns ENABLED, DISABLED or PENDING, and DISABLED means the merchant has to talk to PayPal, not to you. Do not start importing payment methods before the relevant flag is set. The revocation reasons STRIPE_GATEWAY_NOT_ENABLED and BRAINTREE_GATEWAY_NOT_ENABLED are what comes back when an import runs before the connection exists.

Pause billing on the old platform before this point. The guide’s words are “We strongly recommend that you pause any billing in your current application”. A contract billed in both places for one cycle means a refund, and an apology, per affected customer.

How do you import a card by reference?

Create or update the Shopify customer first (the customer migration post covers customerSet and consent state). Shopify needs an email, first name and last name on the record because it sends contract emails itself.

Then, per card, call customerPaymentMethodRemoteCreate with the gateway’s own identifiers. For Stripe that is the Stripe customer_id and payment_method_id; for Braintree it is customer_id and payment_method_token; for Authorize.net it is customerProfileId and customerPaymentProfileId. Shopify does not fall back to the gateway’s default method, so look up the exact ID on the source side.

mutation CreateRemoteCreditCard(
  $customerId: ID!
  $remoteReference: CustomerPaymentMethodRemoteInput!
) {
  customerPaymentMethodRemoteCreate(
    customerId: $customerId
    remoteReference: $remoteReference
  ) {
    customerPaymentMethod {
      id
      instrument {
        ... on CustomerCreditCard {
          brand
          lastDigits
        }
      }
    }
    userErrors {
      field
      message
    }
  }
}
{
  "customerId": "gid://shopify/Customer/1",
  "remoteReference": {
    "stripePaymentMethod": {
      "customerId": "cus_stripe",
      "paymentMethodId": "pm_1234567890"
    }
  }
}

The input accepts exactly one of stripePaymentMethod, braintreePaymentMethod or authorizeNetCustomerPaymentProfile. PayPal uses a separate mutation, customerPaymentMethodPaypalBillingAgreementCreate, which takes customerId, a billingAgreementId that starts with B-, and an optional billingAddress. Both need the write_customers and write_customer_payment_methods scopes.

The reference says the returned payment method “may initially be in an incomplete state”, because Shopify processes the import in a background job and only then checks the token against the gateway. The overview page says the first response may describe a placeholder method rather than the real one. Do not treat a returned ID as success. Subscribe to the customer_payment_methods/create, customer_payment_methods/update and customer_payment_methods/revoke webhook topics (all three need read_customer_payment_methods), or poll customerPaymentMethod and check revokedAt and revokedReason.

The revocation reasons are the useful error messages here. STRIPE_PAYMENT_METHOD_NOT_CARD and BRAINTREE_PAYMENT_METHOD_NOT_CARD mean you passed a bank account or wallet token. STRIPE_RETURNED_NO_PAYMENT_METHOD and BRAINTREE_RETURNED_NO_PAYMENT_METHOD usually mean the merchant connected the wrong gateway account to Shopify, not that your IDs are wrong. MERGED is benign: Shopify found an identical card already vaulted and moved the contracts onto it.

What does subscriptionContractAtomicCreate need?

One call per contract, with the customer, the imported payment method, the lines and the policies together; the contract migration tutorial walks the same shape. The atomic in the name is the point: there is no draft to commit, so a half-built contract cannot exist.

mutation ImportContract(
  $customerId: ID!
  $paymentMethodId: ID!
  $variantId: ID!
  $sellingPlanId: ID!
  $nextBillingDate: DateTime!
) {
  subscriptionContractAtomicCreate(
    input: {
      customerId: $customerId
      nextBillingDate: $nextBillingDate
      currencyCode: GBP
      lines: [
        {
          line: {
            productVariantId: $variantId
            sellingPlanId: $sellingPlanId
            quantity: 1
            currentPrice: 24.00
          }
        }
      ]
      contract: {
        status: ACTIVE
        paymentMethodId: $paymentMethodId
        billingPolicy: { interval: MONTH, intervalCount: 1 }
        deliveryPolicy: { interval: MONTH, intervalCount: 1 }
        deliveryPrice: 3.95
        deliveryMethod: {
          shipping: {
            address: {
              firstName: "Alex"
              lastName: "Morgan"
              address1: "14 Market Street"
              city: "Stoke-on-Trent"
              zip: "ST1 1AA"
              countryCode: GB
            }
          }
        }
      }
    }
  ) {
    contract {
      id
      status
      nextBillingDate
    }
    userErrors {
      field
      message
    }
  }
}

Three fields deserve a second look.

currentPrice is the line price the contract bills, so it should hold what the subscriber actually pays rather than the variant’s catalogue price. Carry a grandfathered price over here, and use the line’s pricingPolicy (a basePrice plus cycleDiscounts) if the old plan steps the price after a number of cycles. The optional discounts array on each line takes a title, a recurringCycleLimit and a fixed-amount value for migrated promo pricing.

sellingPlanId ties the contract to a plan you created ahead of the import. The selling plans post covers that model; what matters here is that the plan has to exist first, and that the overview lists recreating discount codes, delivery profiles and tax rules as earlier steps for the same reason.

nextBillingDate is required and, per the input reference, “not managed by Shopify”. It is metadata your app sets and reads. Nothing in Shopify fires on that date.

Each contract needs write_own_subscription_contracts, and the user running the import needs the manage_orders_information permission. If you have several thousand contracts, wrap the mutation in bulkOperationRunMutation (the bulk operations post has the JSONL mechanics); the tutorial recommends exactly that to stay clear of the rate limit.

Who charges the card on renewal day?

You do. Shopify’s Subscriptions API has no scheduler. When a contract’s date arrives your app calls subscriptionBillingAttemptCreate with the contract ID and an idempotencyKey you generated. A successful attempt creates the order. The mutation returns a Pending state and the result arrives through the subscription_billing_attempts/success, /failure and /challenged webhooks, the last of which is a 3D Secure challenge the customer has to complete. (ready is deprecated in 2026-10 in favour of state.)

mutation BillContract($contractId: ID!, $key: String!) {
  subscriptionBillingAttemptCreate(
    subscriptionContractId: $contractId
    subscriptionBillingAttemptInput: { idempotencyKey: $key }
  ) {
    subscriptionBillingAttempt {
      id
      state {
        __typename
      }
    }
    userErrors {
      field
      message
    }
  }
}

Derive the key from the contract ID and the cycle (a hash of contractId:billingDate is enough) so a retried job cannot double-charge. If a contract was imported without a usable payment method, the tutorial’s way out is paymentProcessingPolicy: SKIP_PAYMENT_AND_CREATE_UNPAID_ORDER on the attempt, which creates an order to be paid by invoice. Otherwise the default, FAIL_UNLESS_VALID_PAYMENT_METHOD, does what it says, and customerPaymentMethodSendUpdateEmail sends the customer a secure link to replace the card.

When is this the wrong route?

If the cards are not in Stripe, Braintree, Authorize.net or PayPal, this route is not available, and no amount of API work changes that. What remains is a re-consent campaign before cutover, and some subscribers will not complete it.

If the merchant is on Shopify Payments and wants to leave Stripe behind entirely, this import does not get them there. The imported methods bill through the legacy gateway for as long as those contracts live. Moving them onto Shopify Payments means each customer entering a card again at some point, so decide whether that happens now, in one message, or over a year of card-expiry emails.

If the book is a few hundred contracts on a standard monthly plan, a subscription app with a built-in migration tool will do this for you and own the renewal scheduler afterwards. Write the importer yourself when the pricing logic, cycle discounts or external reconciliation would not survive someone else’s data model, and when you are already committed to running the billing attempts.

Whoooop handles replatforming onto Shopify for UK merchants, including the subscription book, the gateway handover and the renewal jobs that follow. If you are weighing up whether your current gateway qualifies for this import, our Shopify development team can tell you before any code is written.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch