Plan and run a migration from an existing shop
An agency has a customer on another shop platform and a date to switch. This guide is the plan for that date: what moves and what stays behind, the order to load it in, how you prove the new tenant matches the old shop, the DNS switch, and the way back if the switch goes wrong. At the end you have the pieces of a runbook the customer can sign off (the rehearsal, the checks, the cutover plan, the cutover and the rollback: steps 2 and 4 to 7), and the old shop is still intact behind it.
One fact shapes the whole plan: plan for the order history to stay behind. The supported way to create an order is to place it. Placing stamps today's date, draws a new number from the tenant's range and starts the order as placed, and no field on that call sets an original date, number or status (public API and Orders app, checked 2026-10). The Orders app also declares a CSV import for the order header, keyed on the order number. This guide did not test it, it has no counterpart for order lines, and nothing documents it as a way to load history (as of 2026-10), so this guide does not rely on it. Step 1 says what to do with the history instead.
This is for integrators and agencies running the move. It owns the sequence and the cutover, and links to the guides that do each piece. You need:
- A tenant and its slug. The tenant is the customer's own space on the platform, and the slug is its short name, sent on every call. You also need a second tenant to rehearse on (get a test tenant). A key identifies one tenant, so the rehearsal tenant has its own key (API keys).
- An API key whose scopes cover the apps you load,
iofor imports, and the list calls in step 4 (which scope covers which app). - An export of the old shop: products, customers, prices and stock, in files you can read. Nothing here reads the old platform for you, and there is no migration tool (see Bring an existing frontend).
- A new storefront that already runs on a preview or on the generated hostname. The frontend is its own piece of work: Bring an existing frontend covers rebuilding it.
- Access to the customer's DNS zone, or the person who has it, and a login to Cockpit, the admin UI at
app.revenexx.com, for the domain step.
Every call goes through the API gateway with X-Revenexx-Tenant and X-Revenexx-Api-Key.
export REVENEXX_TENANT="<TENANT_SLUG>"
export REVENEXX_API_KEY="rvxk_..."
What moves and what stays behind
| Old shop holds | On revenexx | How it gets there |
|---|---|---|
| Products, attributes, variants | Moves, after you model it | Model a catalog, then a file through /v1/io |
| Product images and datasheets | Moves, as a separate job | An import does not carry media. Manage product images and documents |
| Companies and contacts | Moves, created through their own routes | One call per company and per contact (step 3) |
| Passwords | Does not move | Buyers set a new one. Plan the mail (step 5) |
| Price lists, contract prices | Moves | Price list routes, B2B pricing |
| Stock levels | Moves as an opening balance | A receipt booking per item (step 3) |
| Open carts | Usually not | Skip them unless a B2B buyer has a large saved cart (step 3) |
| Order history | No documented route | Stays in the old system, or in an archive (step 1) |
| Pages and content | Rebuilt | Editors recreate pages in the visual editor |
| URLs | Needs a plan | Host redirects through domains, path maps in your Theme (step 5) |
1. Decide what moves, and where the history lives
Go through the table with the customer and write a yes or no per row. Two decisions cannot wait: what happens to the order history, and who owns each entity afterwards.
Order history. Check what a buyer can do with history today: reorder from it, look up an invoice, answer a warranty question. Then pick one:
- Keep the old shop as a read-only archive on its own address, and link to it from the new account pages. This is the default. It keeps every number, date and document exactly as it was.
- Export it to files (CSV or PDF per order) and store them where the customer's support team can search them.
- Load a summary into your own system, and keep it out of the revenexx order list.
Do not place old orders to fill the new list. The place call works, and it keeps the buyer's PO number and your source id (customer_order_number, external_id). But the order gets today's date and a new order number, it starts as placed and unfulfilled, and it cannot be deleted afterwards: DELETE /v1/orders/{id} answers 405, so the only way out is a cancel. The platform also announces it as a newly placed order. Anything subscribed to order.placed, such as the ERP handoff in Hand an order to the ERP, books it again. (An order above the tenant's approval threshold starts as a request instead, and announces order.requested.) The number a customer quotes from last year would match nothing.
target: "shadow" on the import call stages the rows in a sibling table instead of writing live, but no documented route compares or switches them over, so it is not a rehearsal. The generic /v1/io import is a different matter: GET /v1/io/entities lists what it can write on your tenant, and the Orders app declares an order-header import keyed on the number. This guide could not confirm that it creates usable orders, and order lines have no import at all. If you want to try it, use five rows on the rehearsal tenant and read the result back through GET /v1/orders before you plan around it.Which system owns each entity afterwards. If an ERP or a PIM keeps feeding the shop after the move, decide that now, per entity, in Choose a system of record. A migration that loads data once and an integration that keeps overwriting it will disagree, and nothing flags it.
2. Rehearse on a second tenant
Run steps 3 and 4 completely on the rehearsal tenant first. A load is the one thing here you cannot cleanly undo: there is no documented rollback for an import job, nothing in the API that selects the rows one job wrote (as of 2026-10), and a full-sync deletes everything in the entity that is not in the file. A rehearsal tells you how long the load takes, which rows reject, and what the counts should read, so the real run is a repeat.
Write down per entity: the file, the import mode, the duration, counts.rejected, and the fixes you made to the source data. The runbook reuses those numbers.
3. Load in dependency order
Each row depends on the ones above it. Most links between apps are not checked on write, so a row that points at something not loaded yet is usually not rejected. It loads, and it quietly does nothing: a price entry for a SKU that does not exist is accepted and prices nothing, and a company whose price_list code names no list prices nothing. That is why step 4 compares results and not only job statuses.
| Load order | What | Depends on | Route | Guide |
|---|---|---|---|---|
| 1 | Catalog model: attributes, families, variant rules | Nothing | Products routes | Model a catalog |
| 2 | Products | The model | A file through /v1/io | Import a BMECAT catalog, Import and export |
| 3 | Images and documents | Products | Storage and catalog assets | Manage product images and documents |
| 4 | Companies, then their contacts | Nothing from the catalog | POST /v1/customers/organizations, then POST /v1/customers/contacts | Organizations, Contacts |
| 5 | Price lists and entries, then contract lists | Products. Contract lists also need the company ids from row 4 | POST /v1/prices/lists, bulk entries | B2B pricing |
| 6 | Opening stock | Products | POST /v1/inventories/receive | Stock |
| 7 | Open carts, if you keep them | Products, contacts | POST /v1/carts/import | Carts |
Five things in that table catch people:
- Create companies and contacts through their own routes, not through a file. An import writes rows and skips what the route does on a write. A company created by an import is not set up for its buyers to sign in. A contact create makes the person and their login together, and a contact created through the API starts as
invited(Contacts). Create the company first: a contact'sorganization_idcannot be changed after the create, so a contact filed under the wrong company is deleted and created again. - A contract price joins on a UUID, not on the customer number. Look the company up by
customer_numberfirst, then scope the list to itsid. The lookup keys are in Choose a system of record. - Stock is a ledger.
on_handis the sum of bookings, so you cannot set it. Book each opening balance once withPOST /v1/inventories/receive(it creates the stock row), with the same reason on every line so you can find them later. A receipt for an item the tenant has never seen fires no out-of-stock event. Rows at or below their reorder point do show up in the hourly low-stock alerts, so load the reorder points deliberately if an alert workflow is already running (Send low-stock alerts to the ERP). - Creates are not upserts, and nothing here retries for you. A company or contact create answers
201, or409when itscustomer_number,external_idoremailis taken, so a rerun after a timeout does not duplicate them but does need you to treat409as "already there" and check it is the same record. There is noIdempotency-Keyheader (Retries and idempotency). A file import withkeysdoes upsert, onskufor products. - Open carts are rarely worth moving. A cart needs an owner and carries the prices you send. By default a cart idle for a day is marked abandoned, and each one fires
cart.abandoned(as of 2026-10), which is the trigger a recovery-mail workflow listens to (Carts). Migrated carts would start that mail. Load one only for a buyer who will miss it, such as a long B2B shopping list, and expect to re-price it.
Three of these loads have no guide of their own, so here is the shape of each call. Run them in this order, and keep the ids they return. If the company create answers 409 on a rerun, look the company up with GET /v1/customers/organizations?customer_number=10042 and use that id, because jq would otherwise hand null to the contact call:
ORG_ID=$(curl -s -X POST https://api.revenexx.com/v1/customers/organizations \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Beispiel Industrietechnik GmbH","customer_number":"10042"}' | jq -r .id)
curl -s -X POST https://api.revenexx.com/v1/customers/contacts \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"email\":\"einkauf@example.com\",\"organization_id\":\"$ORG_ID\",\"external_id\":\"CT-90210\"}"
curl -s -X POST https://api.revenexx.com/v1/inventories/receive \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"sku":"ACME-4711-BLK","quantity":120,"reason":"opening balance"}'
The products file import is the same four calls as in Import and export: mint an upload URL, PUT the file, register the job with keys: ["sku"], poll it. Price lists and contract lists are in B2B pricing. Send every entry with a price: an entry without unit_price defaults to 0. Bulk entries take up to 5,000 per call, and one price list holds up to 10,000 entries (as of 2026-09), so a larger price book needs several lists (B2B pricing). The carts import is in Carts.
After the product load, recompute category rules and completeness. An import changes what the rules select, and neither updates itself (After a bulk catalog change).
Pace the work. A catalog import holds itself to 5 calls a second, but a script that creates ten thousand contacts one by one shares the tenant's request budget with everything else. Size a launch covers the 5-per-second figure, the tenant's budget and the 429 backoff.
4. Verify before anyone switches
Compare the new tenant to the old shop on three things, and write the result down.
- Counts. Products, companies, contacts, price entries. For each import job, read
countsonGET /v1/io/bulk-jobs/{id}:rejectedmust be zero or explained, because apartialstatus is a success with rejects. On a re-run,writtencan be lower thanreadminusrejected, since rows that did not change are skipped. For the count, list the entity withlimit=1and readpage.total. Products, price lists, price entries, companies and contacts all return it there. - Prices. Pick fifty SKUs across lists and ladders, and three buyers with contract prices. Ask what each sees with the resolve call in B2B pricing, step 6, and compare to the old shop's price for the same buyer and quantity.
- A sample of orders, by hand. Take ten recent orders from the old shop and rebuild each as a cart for the same buyer. Compare lines, prices, tax, shipping and total. This is the check that finds a missing tax rate or a shipping rule nobody listed.
Then a buyer walkthrough on the preview or the generated hostname: reset the password of one migrated contact, sign in, find a product, see the contract price, check out. Place the test order on the rehearsal tenant. On the production tenant, stop before the final step for now: an order cannot be deleted, so a test order there stays in the order list as a cancelled record. The one exception is the deliberate order you place and cancel at cutover (step 6).
5. Plan the cutover
The switch is a DNS change plus a short freeze. Settle each of these in the runbook, with a name and a time next to it.
Freeze and delta. The old shop keeps taking orders until the switch, so what you loaded in step 3 is already out of date. Agree a freeze window. In it, reload what changed: products and prices by re-running the import with the same keys, stock as new receipts or adjustments. Orders placed in the old shop during the freeze stay there, and your archive plan from step 1 covers them.
Order numbers. The new tenant draws its own numbers. Give it a prefix that cannot be confused with the old shop's, so a customer quoting a number points at one system. Create a range (POST /v1/orders/number-ranges with a code and a prefix) and point the order_number_range_code setting at it (Number ranges), before the first real order.
Buyer accounts. Passwords do not transfer, and a contact created through the API starts as invited (you can set status on the create if you want another value). Decide who tells buyers, when, and how they reach a first password: the two-step password recovery in Storefront accounts is the route the storefront offers. Try it on the rehearsal tenant with a migrated contact before you rely on it (not verified end to end for a contact created through the API, as of 2026-10). The email is the login on a storefront, so a migrated buyer needs an address that receives the recovery mail.
Keys. Create the production keys for the storefront and integrations, scoped to what each uses, and store them in the secret manager. Do not reuse the rehearsal tenant's keys: a key identifies one tenant, so create new ones for production. Go live with a project has the whole list: keys, caching, logs, support. Work through it now rather than on the day.
DNS. Adding a domain is a Cockpit action with no public API (as of 2026-10), so a person does it. A storefront domain needs one CNAME, copied from the domain's DNS & TLS view, and an apex needs flattening or a redirect domain (Domains). Two preparations:
- Lower the TTL on the live record a few days ahead, to a few minutes, so the switch and a revert both propagate fast. The old TTL decides how long some visitors still see the old shop, so lower it before, never at the cutover.
- Check existing CAA records. If the zone has any, they must permit Let's Encrypt, or the certificate fails after the domain verifies.
Verification works by resolving the one record to revenexx, so it happens at the switch, not before. Rehearse the domain path with a staging subdomain first (staging.example.com, pointed at the site the same way), and you see the record, the verification and the certificate issue once on a name that costs nothing if it fails.
Old URLs. Search engines and bookmarks point at the old shop's paths. A domain redirect preserves path and query and changes only the host (Domains), so it fits a host change such as example.com to www.example.com. It does not map /p/12345 to a new product path. The docs describe no path-redirect feature (as of 2026-10), so treat a path map as code in your Theme, which is a Nuxt app, and as unconfirmed until you have tested it on a preview. Check the list of top URLs from the old analytics against the new site before the switch.
6. Cut over
On the day, in this order. Each line is one person's job.
- Announce the freeze and put the old shop in read-only or maintenance mode.
- Run the delta from step 5 and compare counts again.
- Set the live order range and confirm the production keys.
- Activate the deployment the domain should serve. Check it on the generated hostname first. See Deployments.
- Change the DNS record to the value in the DNS & TLS view, then trigger verification there.
- Watch the first hour. Place one deliberate order end to end and cancel it (an order cannot be deleted), read the storefront's failures in Activity, and look for
429in your own clients (Logs). - Tell the buyers their accounts exist and how to set a password.
7. Roll back
Decide before the day what a rollback is, because there are three different undos and only some of them are cheap.
| What went wrong | Undo | Cost |
|---|---|---|
| The new storefront build is broken | Activate an earlier ready deployment. No rebuild, no DNS change (Instant rollbacks) | Near-instant |
| The switch itself is wrong | Put the old DNS record back and reopen the old shop | The TTL you lowered, so a few minutes |
| Loaded data is wrong | Fix the rows and re-run the import with the same keys, or delete the wrong rows one by one. A bulk job has no documented undo, products and customers delete per record, orders only cancel, and full-sync deletes rows not in the file | Hours, and the reason to rehearse |
The DNS revert only works if the old shop is still running and has not taken a different set of orders. That is why it stays read-only after the switch rather than being shut down: orders placed on the new tenant after the switch are not in the old shop, and they are not copied back. If you revert, the orders placed on the new side in the meantime need a hand-carried list, or you accept a gap. Set a point after which you stop reverting and fix forward. Agree it in the runbook; it is the customer's decision.
8. After the switch
- Keep the old shop read-only until the customer has agreed the archive is enough, and until its data is out of the old system's retention scope. Write down who pays for it in the meantime.
- Re-check the system of record. If an ERP feeds products or stock, switch its feed to the new tenant and turn the old feed off, so the old shop stops being fed.
- Raise the TTL again on the DNS record.
- Watch the redirects. The old URLs that still draw traffic show up in the storefront's failures as 404s. Add the ones that matter.
Troubleshooting
- A job ends
partial. Some rows were rejected. Readcounts.rejectedanderror_messageon the job, fix the source rows and run again with the same keys. - A rerun created duplicates. The load had no unique key. Delete the extras and add the key.
full-syncis not a repair; it deletes anything not in the file. - A contract price does not show. The list is scoped to the wrong company id, or the company's
price_listcode names no list. See B2B pricing. - A buyer cannot sign in. The company was created by an import, or the buyer has no password yet. Create companies through their own route and send the password recovery.
- Verification fails but the record looks right. Compare the value to the DNS & TLS view, and check for CAA records (Domains).
Where to go next
- Go live with a project: the checklist to finish before the date.
- Bring an existing frontend: what happens to the old storefront.
- Choose a system of record: who owns each entity after the move.
- Size a launch: the request budget for the load and the first day.