Skip to content

Engineering stories · 04

Migrating 66 subscriptions from Loop to Recharge

  • Recharge
  • Migration

Moving 66 live subscribers from Loop to Recharge with a controlled CSV import when the tidy API route was closed, and why a bounded migration deserved judgment instead of a migration engine.

Shopify · Recharge · Shop Pay · Subscription data migration · Operational care

The store was moving its subscriptions off Loop and onto Recharge. Both are Shopify-native subscription apps, billing runs through Shop Pay, and checkout stays on Shopify. Recharge was already live and processing the store's newer subscriptions. What was left was a bounded set of existing Loop subscribers who needed to land on Recharge without a gap, a double charge, or a wrong price. Sixty-six of them.

This is the operational data side of the subscription work. The storefront side, the custom buy box built on native Shopify selling plans, is its own story (Deep dive 01). Here the concern is narrower and, in one sense, higher-stakes: moving real, recurring commitments between two processors without touching a customer's money by accident.


The constraint that closed the API path

My first instinct was the clean route: export from Loop, transform the data, import into Recharge over its API. Two things closed it.

First, Loop's bulk export is gated behind its PRO tier. No CSV came out of the plan the store was on, so there was no tidy source file to transform.

Second, Shopify stopped allowing admin-created custom apps as of 2026-01-01. A custom app now has to go through the Dev Dashboard or the CLI, which is a heavier setup than this task justified. For a one-time move of 66 records, standing up an app, wiring OAuth, and mapping two subscription data models in code was disproportionate to the work.

The decision: right-size it

The mature move was to not build a system. Sixty-six active subscriptions is a controlled, one-time import, not a migration platform.

So I skipped the API entirely. I built the Recharge subscription import CSV from Loop's own detail screens and each product's .json for variant IDs, then validated it field by field. Recharge validates that CSV before it commits anything, which is exactly the safety net a bounded, careful migration wants. The judgment here was recognizing that a validated 66-row import, scanned before commit, carries less risk than a bespoke migration app built and data-mapped in code to move a one-time list. Choosing not to over-engineer was the engineering decision.

The data decisions, where correctness actually mattered

This is where a "small" job earns its care. Each column was a decision about someone's money.

recurring_price: bake in the discount, no separate code. I set recurring_price to the price each subscriber was already paying, discount included. The obvious move is to carry a discount code across, but Recharge does not re-apply a selling-plan discount to migrated rows. A discount_code sitting on top of an already-discounted price would have discounted twice. So I baked the existing discount into the price and left discount_code empty. Concretely, a 1-jar subscriber's $35.99 became the $28.79 they actually pay, and a 2-jar subscription came to $57.58. No code, no double discount, same price they signed up for.

presentment_currency: omitted on purpose. The store's default currency is USD, and USD is what these customers are billed. One product's .json briefly showed a foreign currency (Polish zloty), but that was only browser-locale presentment, not the billing currency. Writing a presentment currency off that reading would have been a quiet trap. Leaving the field blank lets every charge settle in the store default.

Shipping: pinned per subscription against the threshold. Recharge lets you fix shipping per subscription with original_shipping_title and original_shipping_price, and I set both by hand against the store's free-shipping threshold (about $50). A 2-jar subscription at $57.58 sits above the threshold, so it ships at 0.00. A 1-jar subscription at $28.79 sits below it, so it keeps the $8.00 shipping the customer already pays. Getting this wrong in either direction either quietly eats margin or surprises a customer with a charge they never had.

Payment method: omitted so Recharge auto-links Shop Pay. I left shopify_payment_method_id blank rather than guess an ID. Because billing runs on Shop Pay, Recharge resolves each customer's valid vaulted method on its own. Letting the platform do the linking is safer than asserting it.

The smaller fields still counted: province values reduced to their two-letter codes, status set to active, is_prepaid set to no. None of these are clever. All of them are the difference between a clean import and a support ticket.

A gotcha you only find by running it

Recharge rejected the file until I added an empty last_success_charge header. The column is unused for a fresh migration, but its header has to be present or the import fails validation outright. That is the kind of detail no spec surfaces cleanly. You find it by actually attempting the import and reading the error.

Validation, then the canary

Before committing anything, I ran Recharge's scan. It reads the CSV and reports problems without creating records, and it came back 66 of 66 valid, 0 errors.

Then the part that actually protects the customer. The real risk in a processor migration is a double charge: Loop bills the old subscription while Recharge bills the new one for the same period. So I found the subscriber with the soonest next-charge date and treated them as the canary. I finished that customer's import and cancelled their Loop subscription before their earliest Recharge charge, which fell on 2026-07-04, so the handoff had no overlap. If anything was going to go wrong, it would show first on the earliest charge date, and that was the one I was watching deliberately.

The outcome

The migrate step (run 2026-06-30) created 66 customers and 66 subscriptions, all Active, each with the correct price, frequency, and next-charge date, and a payment status of Valid across all 66 because Shop Pay auto-linked each method. A pre-existing Recharge subscriber, already on the platform before this batch, was left untouched: the import added rows, it did not overwrite anyone.

To be precise about what that claim rests on: this is verified by Recharge's own import scan and status view, not by a business metric. 66 valid rows, 66 active subscriptions, valid payment on each. I am reporting what the platform confirmed, nothing more.

A staged cutover, on purpose

One deliberate loose end. New subscriptions still create in Loop for now, because the storefront's new-subscriber flow had not been cut over to Recharge yet. Rather than block the whole migration on that larger change, I migrated the current list first and planned to top up newer Loop subscribers as later batches, using the same manual process. For a trickle of new subscriptions, repeating a proven CSV is cheaper and safer than rushing a storefront cutover to avoid a second pass. Staged, not indecisive.

What this was really about

  • Match effort to scale. The senior decision was choosing not to build a migration system. A bounded 66-row migration is judgment and validation work, not infrastructure work, and treating it as infrastructure would have added risk rather than removed it.
  • Be careful with live customer money. Every field was a decision that could double-charge, undercharge, or drop a subscription. The care went into the price, the shipping threshold, and the charge timing, not into clever code.
  • Know the platform model. The double-discount behavior, the presentment-currency trap, the Shop Pay auto-linking, the required-but-empty header: none of these come from generic CSV skill. They come from understanding how Shopify, Recharge, and Shop Pay actually split the work between them.

Technical references

  • Source of truth: Loop's per-subscription detail screens (price, interval, next charge, shipping) and each product's .json for variant IDs. No bulk export existed to rely on.
  • Target file: a Recharge subscription import CSV. Fields decided by hand: recurring_price (discount baked in), discount_code (empty), presentment_currency (omitted), original_shipping_title / original_shipping_price (per subscription, against the ~$50 threshold), shopify_payment_method_id (omitted for Shop Pay auto-link), province (2-letter), status (active), is_prepaid (no), plus the required empty last_success_charge header.
  • Validation: Recharge's import scan (66/66 valid, 0 errors), then migrate (66 customers / 66 subscriptions Active, payment Valid on all).
  • Related: Deep dive 01 covers the storefront subscription buy box on native selling plans. This article is the operational data side of that same subscription system.
Back to the caseLumway

Need a Shopify engineer who can work across the full commerce path?

I work where storefront UX, product logic, platform constraints, measurement, and production delivery meet.

Start a conversation