Contents
- Overview
- Requirements
- Getting started
- What gets migrated
- Field mapping reference
- What is not migrated
- How the migration engine works
- Re-running and idempotency
- Error handling and field shedding
- Plans and limits
- Progress, logs and notifications
- Retrying failed items
- API reference
- Security model
- Troubleshooting
- Glossary
1. Overview
Store Shift is an embedded Shopify app that copies data from a WooCommerce store into a Shopify store. It reads WooCommerce through its REST API v3 and writes to Shopify through the Admin GraphQL API.
Three design rules shape everything else:
- Nothing runs in your browser. A migration is a set of background jobs on our servers. The page only reports on it.
- Everything is chunked. WooCommerce is read a page at a time and Shopify is written a batch at a time, so store size does not change how it behaves.
- Every record is mapped. We remember which WooCommerce record became which Shopify record, which is what makes a re-run safe.
2. Requirements
| Requirement | Detail |
|---|---|
| WordPress | 5.6 or later |
| WooCommerce | 5.0 or later |
| REST API | v3 enabled, reachable from the internet over HTTPS |
| Credentials | Consumer key + secret with at least Read access |
| Shopify | Any plan that allows app installation |
3. Getting started
Step 1 — Create WooCommerce API keys
In WordPress admin go to WooCommerce → Settings → Advanced → REST API → Add key:
- Description — anything, e.g. "Store Shift"
- User — an administrator account
- Permissions —
Readis enough; Store Shift never writes to WooCommerce
Copy the consumer key (ck_…) and secret (cs_…) — WooCommerce shows the secret only once.
Step 2 — Connect the store
In Store Shift open WooCommerce, paste the store URL, key and secret, then press Test connection. On success you will see your WooCommerce and WordPress versions. Your credentials are encrypted before they are stored and are only ever shown back masked.
Step 3 — Start a migration
Migrations → New migration. Pick what to migrate; the wizard shows how many records exist in WooCommerce and how many your plan covers. Anything your plan does not include is disabled with the reason shown.
Optional settings on this screen:
- Weight unit — WooCommerce does not report its unit through the API, so pick the one your store uses. Product weights are imported with it.
- Custom tags — added to every migrated product and customer, handy for finding or rolling back a batch later.
- Default vendor — used for products with no brand. Leave blank to let Shopify default it to your shop name.
- Email me when this finishes — a summary email when the run completes.
Step 4 — Let it run
Press start and close the tab if you like. Progress updates every few seconds while you watch, and the Activity log shows what is happening in real time.
4. What gets migrated
| Entity | Becomes | Includes |
|---|---|---|
| Products | Shopify products | Title, description, vendor, product type, status, tags, prices, compare-at prices, SKUs, inventory and tracking, weight and unit, requires-shipping, options, variants, product and variant images, metafields, brands, collection membership |
| Categories | Custom collections | Title, description, category image. Products are linked to their collections automatically |
| Customers | Shopify customers | First and last name, email, phone (reformatted to E.164), billing address, custom tags |
| Orders | Shopify orders | Line items, shipping lines, billing and shipping addresses, currency, financial status, original order date, a matching sale transaction for paid orders, tags |
5. Field mapping reference
Products
| WooCommerce | Shopify | Notes |
|---|---|---|
name | title | |
description | descriptionHtml | HTML preserved |
status | status | publish → ACTIVE, everything else → DRAFT |
regular_price / sale_price | price / compareAtPrice | When on sale, sale becomes the price and regular becomes compare-at |
sku | inventoryItem.sku | |
manage_stock, stock_quantity | tracked, inventoryQuantities | Set at your primary location |
backorders | inventoryPolicy | Anything other than "no" becomes CONTINUE |
weight | measurement.weight | Unit is the one you pick in the wizard |
virtual | requiresShipping | Inverted |
attributes (variation-enabled) | productOptions | Colour, size, etc. |
variations | variants | Matched by option combination |
images + variation images | files + variant media | De-duplicated by source URL |
categories | collections | Resolved through the mapping table |
brands | vendor, a metafield, and optionally tags | First brand becomes the vendor |
meta_data | metafields | Namespaced woocommerce; a definition is created per key so they appear in your admin |
Customers
| WooCommerce | Shopify | Notes |
|---|---|---|
first_name, last_name | firstName, lastName | Falls back to the billing name |
email | email | Required — customers without one are skipped |
billing.phone | phone | Converted to E.164 using the billing country |
billing.* | addresses[] | Address 1/2, city, province, country, ZIP |
Phone numbers are the most common source of customer rejections. WooCommerce stores whatever the shopper typed ("0300 1234567"); Shopify requires international format. Store Shift uses the billing country to add the correct calling code and strip the national trunk prefix. If a number still cannot be made valid it is left out and the customer is migrated without it.
Orders
| WooCommerce status | Shopify financial status |
|---|---|
completed, processing | PAID |
refunded | REFUNDED |
cancelled, failed | VOIDED |
pending, on-hold | PENDING |
Orders are created with their original date, inventory is bypassed so historical orders do not move your stock, and no receipts are sent to customers.
This is deliberate. Shopify treats a line item's SKU as a product reference, and when it matches no existing variant it silently creates a draft product called
Import placeholder for {sku}. Importing historical orders that way would bury your
real catalogue under hundreds of empty drafts.
6. What is not migrated
- Customer passwords — WooCommerce and Shopify hash passwords in incompatible ways. This is not a limitation we can remove; your customers set a new password on first login.
- Themes and design, page and blog content, menus.
- Discount codes and coupons, gift cards, subscriptions.
- Product reviews.
- Refund records attached to orders (the order's status is migrated, the individual refund transactions are not).
- SEO redirects from old WooCommerce URLs.
- Anything in WooCommerce plugins that is not exposed through the REST API.
7. How the migration engine works
Starting a migration creates a plan and then walks it:
Start
├─ count each selected entity in WooCommerce
├─ cap each total to your plan limit
├─ create batch rows (one per page of source data)
└─ dispatch the first batch
Batch (repeats until none are left)
├─ fetch one page from WooCommerce
├─ migrate each item into Shopify
├─ checkpoint after every item
├─ stop after ~35 seconds and continue in a fresh job
└─ hand over to the next batch
Finalize
├─ recompute the final counters
├─ set the overall status
└─ send the completion email if requested
Three properties make a run hard to break:
- Resumable. Progress is checkpointed after every single record, so a worker that dies mid-page resumes at the next record rather than replaying the page.
- Time-bounded. No job runs long enough to be considered dead and restarted underneath itself.
- Self-advancing. Each batch hands over to the next when it ends — successfully or not. One broken batch costs that batch, never the migration.
A watchdog sweeps for runs that have stopped making progress for any reason outside the app and resumes them automatically.
Dependency order
Entities always run in an order that satisfies their dependencies: categories → products (so products can join their collections) and customers → orders (so orders can be associated by email).
8. Re-running and idempotency
Every migrated record is written to a mapping table keyed by
(shop, entity type, WooCommerce ID). Before creating anything, Store Shift looks the
record up.
| Situation | What happens on a re-run |
|---|---|
| Product or category already migrated | Updated in place with the current WooCommerce data |
| Product or category deleted in Shopify | Recreated and the mapping repaired |
| Customer or order already migrated | Skipped — they are not overwritten |
| Record failed last time | Retried |
The practical result: running the same migration twice never gives you two of anything.
9. Error handling and field shedding
Real WooCommerce stores contain data Shopify refuses. Rather than failing the whole record, Store Shift removes the offending optional field and tries again, up to three rounds — so several bad fields are shed one after another. The record lands, and a warning tells you exactly what was left out.
| Record | Fields that can be dropped | Fields that never are |
|---|---|---|
| Product | Images, metafields, collection links, tags, vendor, product type, description | Title, options, variants |
| Category | Image, description | Title |
| Customer | Phone, addresses, tags | |
| Order | Payment record, addresses, shipping line, tags, note, original date | Line items, currency, financial status |
If a rejection touches something required, or an error set mixes an optional problem with a real one, the record still fails — loudly and with the reason — so genuine problems never hide behind a partial success.
There is one shortcut worth knowing: if several products in a row have their images rejected, Store Shift concludes the store's media is unreachable, logs a warning, and stops sending images for the rest of that run. This roughly halves the work on a store whose uploads folder is gone.
Rate limits
Shopify throttling is expected, not exceptional. When it happens the batch is re-queued for after the throttle window and resumes exactly where it stopped. Nothing is lost and nothing is duplicated.
10. Plans and limits
| Plan | Products | Customers | Orders | Metafields |
|---|---|---|---|---|
| Free | 10 | 10 | — | — |
| Basic — $19/mo | 2,000 | 2,000 | 1,000 | Yes |
| Pro — $49/mo | 20,000 | 20,000 | 20,000 | Yes |
| Enterprise — $149/mo | Unlimited | Unlimited | Unlimited | Yes |
Limits are enforced in three places so they cannot be worked around by accident:
- The wizard disables entities your plan does not include and shows how many of your records it covers.
- The API refuses to create a migration for an entity outside your plan.
- The engine caps every batch to the remaining allowance.
Upgrading takes effect immediately — start a new migration and the higher limit applies.
11. Progress, logs and notifications
- Per-entity progress — processed / total, successful and failed counts, updated about once a second while a run is active.
- Activity log — a live feed of what the run is doing, including plan-limit warnings, skipped fields and per-record errors.
- Downloadable report — a plain-text summary of the run, every failed item with its reason, and the full log. This is the single most useful thing to attach when contacting support.
- Completion email — optional, requested in the wizard.
Counters are derived from the mapping table rather than incremented as work happens, so the numbers you see are always the truth even if a batch was replayed.
12. Retrying failed items
A finished migration with failures shows every failed record with the exact message Shopify returned. Retry failed items re-fetches just those records from WooCommerce and re-migrates them — it does not re-run the whole migration.
Records that no longer exist in WooCommerce are marked skipped rather than retried forever. Fix the underlying problem first — that is usually a plan upgrade, a reachable image URL, or a corrected customer email — then retry.
13. API reference
The app's API is consumed by its own embedded frontend and authenticated with a Shopify session token. It is documented here for reference and for debugging; it is not a public integration API, and it is not versioned.
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /api/shop | Current shop, granted scopes, whether re-authorisation is needed |
| POST | /api/woocommerce/connect | Save and test WooCommerce credentials |
| POST | /api/woocommerce/test | Re-test the stored connection |
| GET | /api/woocommerce/preview | Available counts per entity, plus what your plan allows |
| GET | /api/migrations | Migration history (paginated) |
| POST | /api/migrations | Create and enqueue a migration |
| GET | /api/migrations/{id} | Status and per-entity progress |
| GET | /api/migrations/{id}/items | Migrated items, filterable by entity and status |
| GET | /api/migrations/{id}/logs | Activity log |
| GET | /api/migrations/{id}/download | Plain-text report |
| POST | /api/migrations/{id}/cancel | Cancel a running migration |
| POST | /api/migrations/{id}/retry-failed | Retry only the failed items |
| GET | /api/billing | Plans and the shop's current plan |
| GET/POST | /api/support/tickets | List or open support tickets |
| POST | /api/support/tickets/{id}/reply | Reply to your own ticket |
Migration status shape
{
"id": 102,
"status": "processing",
"entities": [
{ "type": "product", "total": 1250, "processed": 840,
"successful": 820, "failed": 20, "percentage": 67.2 },
{ "type": "category", "total": 35, "processed": 35,
"successful": 35, "failed": 0, "percentage": 100 }
],
"started_at": "2026-08-08T10:30:00Z",
"completed_at": null
}
Statuses are pending, processing, completed,
completed_with_errors, failed, cancelled and
paused.
14. Security model
- Credentials encrypted at rest. WooCommerce keys and Shopify tokens are encrypted in the database and only ever returned masked.
- Short-lived Shopify tokens. Offline access tokens expire and are refreshed automatically.
- HMAC verification. Every OAuth callback and webhook is verified before it is trusted.
- Session-token auth. Every API call from the embedded app carries a signed Shopify session token, validated on each request.
- Tenant isolation. Every query is scoped to a single shop.
- Read-only at the source. Store Shift never writes to or deletes from your WooCommerce store.
See the privacy policy for what is stored and for how long.
15. Troubleshooting
| Symptom | Likely cause and fix |
|---|---|
| "Test connection" fails | The REST API is not reachable. Check the store is public over HTTPS, not in maintenance mode, and that no security plugin is blocking API requests. Confirm the key has Read permission and has not been revoked. |
| Products migrate without images | Shopify downloads images from your URLs. If they 404 — a moved or deleted uploads folder, or hotlink protection — the product is migrated without them and a warning is logged. Make the image URLs publicly fetchable and re-run. |
| Customers or orders all fail with an access error | Customer and order data are protected by Shopify and need approval before an app can write them. Until that approval is in place, products and categories still migrate normally. |
| "Phone is invalid" | The number could not be converted to international format, usually because the billing country is missing. The customer is still migrated, without the phone number. |
| "Email has already been taken" | A Shopify customer with that email already exists. Existing customers are not overwritten. |
| Far fewer records than expected | A plan limit. The wizard and the activity log both state the cap that was applied. Upgrade and run again — already-migrated records are updated, not duplicated. |
| Progress seems to pause | Normal on large products: each one can involve several API calls, and Shopify throttling adds deliberate waiting. The activity log will show it is still working. |
| Order totals look right but products are not linked | Expected — order line items are custom lines. See the note in section 5. |
If none of these fit, download the migration report and send it to support. It contains everything we need.
16. Glossary
| Batch | One page of source records, processed as a single unit of work |
| Entity | A type of record: product, category, customer or order |
| Mapping | The stored link between a WooCommerce record and the Shopify record created from it |
| Idempotent | Safe to run more than once — the second run produces no extra records |
| Field shedding | Dropping an optional field Shopify rejected so the record itself still migrates |
| Upsert | Update the record if it exists, create it if it does not |
| E.164 | The international phone format Shopify requires, e.g. +923001234567 |