Migrate from Shopify
This guide maps Shopify’s model onto Flightdeck so you can plan a move with your eyes open. Some things transfer cleanly and immediately; some are operator-assisted today because the self-serve surface has not shipped yet. We name each one honestly rather than implying parity that does not exist.
The short version: Flightdeck’s strong, shipped story is consuming a
provisioned store headlessly — you build a frontend against
@dscodotco/sdk and place real orders through
checkout.submit. Getting your catalog in is a
scripted loop over the merchant API, not a one-click importer. Plan for that and
the migration is straightforward.
If you have not read it yet, Build a storefront from scratch is the end-to-end tutorial for the consumer side. This page is about getting your Shopify data and concepts across; that tutorial is about building the store on top.
Concept mapping
| Shopify | Flightdeck | Notes |
|---|---|---|
| Product | Product (slug, name, description, status) | slug is unique per tenant and is the natural idempotency key on import. |
| Variant | Variant (sku, price_cents, label, currency) | Money is integer cents everywhere. label is the human option name (e.g. 5mg vial). |
| Options (Size / Color) | Variant label | There is no separate structured option-set model in the storefront shape today; the variant carries a single label. See Variants and options. |
| Collection (manual / smart) | Collection (slug, title, description) | Read-only over the SDK. Collection membership is operator-managed — there is no self-serve smart-rule engine. |
| Product image | Content-addressed asset (image_asset_ref, image_url) | Uploaded via presign then attached; the storefront product carries a resolved image_url. |
| Storefront API token | x-storefront-token | An opaque secret (no fixed prefix), provisioned by the platform operator; platform-wide today. Server-side only, checkout-authorizing. Consumer env var FLIGHTDECK_STOREFRONT_TOKEN. |
| Admin API token | fdk_ merchant credential | Authorization: Bearer, scope-bound (e.g. manage:own_catalog). Self-serve to mint (see Get your API keys). |
| Checkout (hosted / redirect) | One-shot checkout.submit | You submit cart + card together to your own server; there is no hosted redirect. See below. |
orders/create webhook | orders.placed outbound webhook | Delivered from the outbound webhook edge. See Webhooks. |
| Subscriptions app (Recharge etc.) | Native subscriptions module | Subscribe-and-save at checkout with recorded consent. See Add subscriptions. |
| Discount codes | coupon_code at checkout | See Discounts and coupons. |
| Gift cards / store credit | gift_card_code, apply_store_credit | Both are checkout tenders; store credit has a balance read. |
What transfers cleanly, and what is operator-assisted
Being blunt about the seams saves you a surprise mid-migration.
Honest gap table. These are the areas where Flightdeck does not yet offer the self-serve surface a Shopify migrator expects. None of them block a migration; several of them mean an operator does a step for you today.
| Capability | State today | What you do instead |
|---|---|---|
| Bulk / CSV catalog import | Not shipped | Run the scripted create loop below. A native importer is planned. |
| Catalog writes from the SDK | Not in the SDK | Call the fdk_ merchant routes directly (raw fetch or the low-level client). The storefront SDK is read + checkout only. |
| Product status transition (draft to active) | Create-time only | status is set when the product is created and defaults to active; there is no self-serve draft-to-active PATCH. Import as active after validating, or import as draft and coordinate going live with an operator. |
| Collection management | Operator-managed | Collections are read-only over the SDK; membership and smart rules are set operator-side. |
| Storefront token | Operator-provisioned | The storefront token is an opaque secret (no fixed prefix), provisioned by the platform operator; platform-wide today. Merchant fdk_ keys, by contrast, are self-serve — see Get your API keys. |
| Customer / password import | Not self-serve | Shopper accounts are created on first sign-in; there is no bulk customer importer. Historical orders do not backfill. |
| Redirect / hosted checkout | Not offered by design | You own the payment-form UI in front of checkout.submit (see PCI note). |
Orders and webhooks
On Shopify you would subscribe to orders/create. On Flightdeck the equivalent
is the orders.placed outbound webhook, dispatched from the outbound webhook
edge with an envelope that carries a required tenant_ref and is
self-contained (a consumer never has to call back to use it). Point your
fulfillment or ERP integration at that instead of orders/create. The full
event catalog, envelope shape, and delivery/retry semantics are in
Webhooks; order state values (placed, processing,
shipped, refunded, …) are in Order lifecycle.
Checkout is one shot, not a redirect
This is the biggest behavioral difference from Shopify. There is no hosted checkout page and no redirect. Your server submits the cart and the card together in a single call, and the platform authorizes payment, runs the fraud floor, prices the authoritative total (shipping + tax), and places the order — or returns an honest decline.
// Server-side only — a Route Handler, server action, or edge function.
const result = await store.checkout.submit(
{
customer_ref: "guest:shopper@example.com",
items: [{ variant_id: "var_123", quantity: 1 }],
card: { ccnumber: "4111111111111111", ccexp: "12/28", cvv: "123" },
},
{ idempotencyKey: `cart-${cartId}:attempt-1` },
);Because your server touches raw card fields, that server is inside PCI-DSS scope (SAQ D territory) — the same fact of life as any non-hosted integration. Keep card data on the browser-to-your-HTTPS-handler-to-API path, never logged or stored. The full body shape, the tender order (store credit, then gift card, then card), the fraud floor, and how to read every outcome branch are in Checkout.
Importing your Shopify catalog
There is no bulk importer. The shipped, honest pattern is a scripted, idempotent
loop over the merchant catalog API, keyed on the product slug so it converges
on re-run instead of duplicating. These are fdk_ merchant routes under scope
manage:own_catalog; the tenant is carried by the credential.
| Method | Path | Purpose |
|---|---|---|
POST | /v1/merchant/catalog/products | Create one product. |
POST | /v1/merchant/catalog/products/:productId/variants | Add a variant (SKU + price). |
PUT | /v1/merchant/catalog/products/:id/image | Attach an image ref. |
Export from Shopify
Export your products from Shopify admin (Products, Export, CSV) or via the Admin
API. You only need the fields Flightdeck accepts: a product needs a slug and a
name; each variant needs a sku and a non-negative integer price_cents.
Shopify prices are decimal strings ("49.99"), so convert to cents
(4999) as you shape the rows — money is integer cents on this platform.
Shape the rows
type ImportRow = {
slug: string; // Shopify "Handle" maps here — unique per tenant
name: string; // Shopify "Title"
description?: string; // Shopify "Body (HTML)" — strip or keep as needed
status?: "draft" | "active"; // set at create time only; default active
variants: { sku: string; price_cents: number; label?: string }[];
};
// Shopify's decimal price string -> integer cents.
const toCents = (price: string) => Math.round(Number(price) * 100);status is set only at create time and defaults to active. There is no
self-serve draft-to-active transition. Validate your rows first and import as
active, or import as draft and coordinate going live with an operator.
Run the idempotent create loop
Run this server-side with an fdk_ key. A 409 slug_exists is treated as
“already imported”, so a partial run is safe to re-run. Every failure is the
nested envelope { error: { code, message } }; the loop stops on any code it
does not recognize as an idempotent replay — a partial import never reports
success.
const API = "https://api.ruo.pro";
const KEY = process.env.FLIGHTDECK_FDK_KEY!; // manage:own_catalog
async function call(method: string, path: string, body: unknown) {
const res = await fetch(`${API}${path}`, {
method,
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: JSON.stringify(body),
});
return { status: res.status, json: await res.json() };
}
for (const row of rows) {
const created = await call("POST", "/v1/merchant/catalog/products", {
slug: row.slug,
name: row.name,
description: row.description ?? null,
status: row.status ?? "active",
});
let productId: string;
if (created.status === 201) {
productId = created.json.product.id;
} else if (created.json.error?.code === "slug_exists") {
// Imported on a previous run — look it up and continue.
const found = await call("GET", "/v1/merchant/catalog/products", {});
productId = found.json.products.find((p: any) => p.slug === row.slug).id;
} else {
throw new Error(`create failed for ${row.slug}: ${created.json.error?.message}`);
}
for (const v of row.variants) {
const vres = await call("POST", `/v1/merchant/catalog/products/${productId}/variants`, {
sku: v.sku,
price_cents: v.price_cents,
label: v.label ?? null,
currency: "usd",
});
// A duplicate SKU is likewise refused, not duplicated — safe to re-run.
if (vres.status !== 201 && vres.json.error?.code !== "sku_exists") {
throw new Error(`variant ${v.sku} failed: ${vres.json.error?.message}`);
}
}
}Move product images
Shopify image URLs do not transfer directly; Flightdeck imagery is a separate
content-addressed upload. For each product image, follow the presign, PUT,
finalize, attach flow and set the returned asset_ref with
PUT /v1/merchant/catalog/products/:id/image. See
Add an image to a product.
Verify before going live
Read the catalog back through the merchant surface and reconcile counts against your Shopify export before flipping the store live.
curl https://api.ruo.pro/v1/merchant/catalog/products \
-H "Authorization: Bearer fdk_your_key_here"For a disposable trial, POST /v1/merchant/catalog/demo-seed stocks a
draft store with clearly-labeled sample products in one idempotent call —
a good way to exercise the import shape before wiring your Shopify data. Full
detail in Import an existing catalog.
A realistic migration order
Get your credentials from your operator
Ask your operator for a tenant ref and the storefront token (an opaque secret,
no fixed prefix, provisioned by the platform operator; platform-wide today). The
fdk_ merchant key scoped manage:own_catalog you mint yourself — merchant keys
are self-serve (POST /v1/my/credentials/initial via an owner identity session,
then POST /v1/my/credentials, subset-gated). See
Get your API keys.
Import the catalog into a draft store
Run the loop above against a draft store so nothing serves publicly while you reconcile.
Build (or clone) the frontend
Either follow Build a storefront from scratch for a from-scratch Next.js store, or clone the Next.js Commerce fork for a ready-made one.
Wire fulfillment to orders.placed
Repoint whatever consumed Shopify’s orders/create at the orders.placed
outbound webhook.
Coordinate go-live with your operator
Collections, going from draft to active, and domain verification are the operator-assisted steps. Line them up, then cut over DNS.
Related
- Build a storefront from scratch — the flagship consumer tutorial.
- Import an existing catalog — the scripted import in full.
- Checkout — the one-shot order-placement contract.
- Webhooks — the
orders.placedevent and delivery semantics. - Migrate from a headless CMS — if you are coming from a custom storefront rather than Shopify.