How to migrate WordPress members and their subscriptions to a Webflow Cloud app

How to migrate WordPress members and their subscriptions to a Webflow Cloud app

Learn how to move WordPress members and their subscriptions into a Webflow Cloud app.

How to migrate WordPress members and their subscriptions to a Webflow Cloud app

Ismail Ajagbe
Technical Author
View author profile
Ismail Ajagbe
Technical Author
View author profile
Table of contents

Copy WordPress members into a Webflow Cloud app with their plans and renewal dates intact, without double charging anyone at cutover.

Moving a WordPress membership site is two migrations at once. The accounts are easy to copy, but the paid plans are not: each member's plan and renewal date sit in a subscription plugin, and the card behind each renewal sits in Stripe.

In this guide, we’ll build a small Webflow Cloud import app that checks every record before it reaches your member store, with a dry run that shows what would be written first.

What do you need to migrate WordPress users with subscription data in Webflow?

Before starting the migration, ensure you have access to the necessary tools, permissions, and export files required to extract subscriber data and process it securely.

Here is everything you need to prepare for the migration:

  • Webflow site with Webflow Cloud: The free Starter plan covers an app on the default domain; mounting it on a custom domain requires Premium or higher.
  • Node.js 22.13.0 or later: To run the local scripts and the engine floor of the Webflow CLI the app installs.
  • npm: Webflow Cloud supports only the npm package manager, so every install command in this build uses npm install rather than pnpm or yarn.
  • WordPress admin access: Administrator access to the WordPress site lets you export users and your subscription plugin's records to CSV files.
  • Member store endpoint: An HTTPS endpoint on your member store that accepts a JSON batch of members with a bearer token, plus that token itself.
  • Test export: A copy of your export with a few dozen real rows so that you can run the whole pipeline in dry-run mode before the full file.

Your member store endpoint is the only part not built here. Both files the scripts produce contain personal data, so keep them out of version control, sign a processing agreement with whichever member store you use, and delete the local copies once you've verified the import.

6 steps to migrate WordPress subscribers in Webflow Cloud

The build has two local Node scripts and one Next.js Route Handler on Webflow Cloud. CSV parsing runs on your machine, where node:fs exists, and validation and forwarding run in the deployed app.

1. Export WordPress users and subscriptions to one CSV

Produce one CSV in which each row joins a WordPress user to their current subscription. Subscription plugins keep plans and renewal dates outside the core users list, so the export usually comes from the plugin.

WooCommerce Subscriptions has no built-in CSV export, but Woo's free Subscriptions Importer and Exporter outputs each subscription with its customer, status and next payment date in UTC; MemberPress exports members and subscriptions from its own admin screens. If you bill through Stripe, include each member's Stripe customer ID as a column too.

In your subscription plugin's export screen, select the user ID, email, plan, status and next payment date columns.

Step 2 maps your chosen column names to the six pieces of data required per row: a stable user ID, the email, a display name, the plan name, the subscription status, and the next renewal or expiry date. Save the result as wp-users.csv in UTF-8.

You should now have one file whose header row names those six columns, with every paying member appearing exactly once.

2. Normalize the export into member records

Convert the CSV into typed JSON locally before anything touches the network. Running this stage on your machine catches duplicate emails while they are still cheap to fix in a spreadsheet. It also flags unknown statuses and unparseable dates for correction.

It also keeps file parsing out of the Worker, since node:fs is not available on the Workers runtime Webflow Cloud deploys to.

Each field the importer expects maps to a column in your export like this:

Importer field Example CSV column Notes
sourceId user_id The WordPress user ID, kept so you can trace any record back
email user_email Trimmed and lowercased; used to deduplicate
displayName display_name Free text; may be empty
plan subscription_plan Your plan name or SKU, passed through unchanged
status subscription_status Translated through STATUS_MAP into a fixed set
renewsAt next_payment_date Converted to an ISO 8601 string, or null
stripeCustomerId stripe_customer_id Optional; the member's Stripe customer ID (cus_), which keeps billing attached
Importer field → Example CSV column → Notes
sourceId
user_id
The WordPress user ID, kept so you can trace any record back
email
user_email
Trimmed and lowercased; used to deduplicate
displayName
display_name
Free text; may be empty
plan
subscription_plan
Your plan name or SKU, passed through unchanged
status
subscription_status
Translated through STATUS_MAP into a fixed set
renewsAt
next_payment_date
Converted to an ISO 8601 string, or null
stripeCustomerId
stripe_customer_id
Optional; the member's Stripe customer ID (cus_), which keeps billing attached

With that mapping set, create a project folder and add the normalizer.

Creating the local normalization script

Save this as scripts/normalize.mjs, then edit COLUMNS and STATUS_MAP to match your export:

// scripts/normalize.mjs
import { readFileSync, writeFileSync } from "node:fs";

// Map the importer's fields to the column headers in your CSV export.
const COLUMNS = {
  sourceId: "user_id",
  email: "user_email",
  displayName: "display_name",
  plan: "subscription_plan",
  status: "subscription_status",
  renewsAt: "next_payment_date",
  stripeCustomerId: "stripe_customer_id", // optional: leave the column out if you don't use Stripe
};
const OPTIONAL = new Set(["stripeCustomerId"]);

// Example values only: replace the keys with the statuses your export contains.
const STATUS_MAP = {
  active: "active",
  "pending-cancel": "canceling",
  "on-hold": "paused",
  cancelled: "canceled",
  expired: "expired",
  pending: "paused",
};

function parseCsv(text) {
  const rows = [];
  let row = [];
  let field = "";
  let inQuotes = false;
  for (let i = 0; i < text.length; i++) {
    const ch = text[i];
    if (inQuotes) {
      if (ch === '"') {
        if (text[i + 1] === '"') {
          field += '"';
          i++;
        } else {
          inQuotes = false;
        }
      } else {
        field += ch;
      }
    } else if (ch === '"') {
      inQuotes = true;
    } else if (ch === ",") {
      row.push(field);
      field = "";
    } else if (ch === "\n" || ch === "\r") {
      if (ch === "\r" && text[i + 1] === "\n") i++;
      row.push(field);
      rows.push(row);
      row = [];
      field = "";
    } else {
      field += ch;
    }
  }
  if (field !== "" || row.length > 0) {
    row.push(field);
    rows.push(row);
  }
  return rows.filter((r) => r.some((cell) => cell.trim() !== ""));
}

function toIso(value, warnings, sourceId) {
  const v = value.trim();
  if (v === "") return null;
  // Treat a bare "YYYY-MM-DD HH:MM:SS" timestamp as UTC.
  const candidate = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(v)
    ? v.replace(" ", "T") + "Z"
    : v;
  const date = new Date(candidate);
  if (Number.isNaN(date.getTime())) {
    warnings.push(`User ${sourceId}: unparseable date "${v}"`);
    return null;
  }
  return date.toISOString();
}

const raw = readFileSync("wp-users.csv", "utf8").replace(/^\uFEFF/, "");
const [header, ...rows] = parseCsv(raw);
const index = {};
for (const [field, column] of Object.entries(COLUMNS)) {
  const position = header.indexOf(column);
  if (position === -1 && OPTIONAL.has(field)) continue;
  if (position === -1) {
    throw new Error(`Column "${column}" (for ${field}) not found in wp-users.csv`);
  }
  index[field] = position;
}

const warnings = [];
const byEmail = new Map();

for (const row of rows) {
  const cell = (field) => (index[field] === undefined ? "" : row[index[field]] ?? "").trim();
  const sourceId = cell("sourceId");
  const email = cell("email").toLowerCase();
  if (!email.includes("@")) {
    warnings.push(`User ${sourceId}: missing or invalid email`);
    continue;
  }
  // WooCommerce Subscriptions exports statuses with a "wc-" prefix, such as "wc-active".
  const rawStatus = cell("status").toLowerCase().replace(/^wc-/, "");
  const status = STATUS_MAP[rawStatus] ?? "unknown";
  if (status === "unknown") {
    warnings.push(`User ${sourceId}: unmapped status "${rawStatus}"`);
  }
  const member = {
    sourceId,
    email,
    displayName: cell("displayName"),
    plan: cell("plan"),
    status,
    renewsAt: toIso(cell("renewsAt"), warnings, sourceId),
    stripeCustomerId: cell("stripeCustomerId") || null,
  };
  const existing = byEmail.get(email);
  if (existing) {
    warnings.push(`Duplicate email ${email} (users ${existing.sourceId} and ${sourceId})`);
    if (existing.status !== "active" && member.status === "active") {
      byEmail.set(email, member);
    }
    continue;
  }
  byEmail.set(email, member);
}

const members = [...byEmail.values()];
writeFileSync("members.json", JSON.stringify(members, null, 2));
console.log(`Wrote ${members.length} members to members.json`);
if (warnings.length > 0) {
  console.log(`${warnings.length} warnings:`);
  for (const w of warnings) console.log(`  ${w}`);
  process.exitCode = 1;
}

The script keeps the active record when two rows share an email, and it treats a bare YYYY-MM-DD HH:MM:SS timestamp as UTC, which matches the WooCommerce exporter. It strips the wc- prefix from WooCommerce statuses before mapping them. Run node scripts/normalize.mjs from the folder that holds wp-users.csv.

You should see a member count, and any warning will make the script exit with an error because every warning is a row you need to fix in the CSV before you upload anything.

3. Create the Next.js import app for Webflow Cloud

Scaffold a Next.js app and install the Webflow CLI with npm. Webflow Cloud's bring-your-own-app docs require Next.js 15 or higher, and the scaffold below pins Next.js 15. Answer the prompts with TypeScript and the App Router, and decline the src directory so the import paths in step 4 resolve as written.

These commands create the app and add the Webflow CLI as a dev dependency:

npx create-next-app@15 wp-member-import
cd wp-member-import
npm install --save-dev @webflow/webflow-cli@2

The CLI package is the scoped @webflow/webflow-cli.

The app also must not use the Next.js edge runtime. Webflow Cloud runs on Cloudflare Workers, which is an edge platform, and Webflow's docs describe deploying "using the Edge runtime" in that platform sense.

The Next.js export const runtime = 'edge' directive is a separate thing: a per-route runtime target that the OpenNext Cloudflare adapter does not support. Some Webflow docs pages still tell you to add the directive. Leave it out. You should now have a Next.js project that runs with npm run dev and has the Webflow CLI in its dev dependencies.

4. Add the authenticated import Route Handler

Write the forwarding module first, then the Route Handler that calls it. The handler checks a bearer secret, validates each record's shape, caps batch size at 100, and supports a dry run that validates without writing.

Authentication lives inside the handler rather than in middleware, so the whole check sits in one file. If you later add middleware in Next.js 16, keep the file named middleware.ts. The Next 16 rename to proxy.ts moves it onto the Node runtime, which Webflow Cloud does not support for middleware.

Create lib/store.ts to forward a validated batch to your member store (a 200 from the store only means it accepted the request, so the module reads a written count from the store's response and reports that instead):

// lib/store.ts
export type MemberStatus = "active" | "canceling" | "paused" | "canceled" | "expired";

export type Member = {
  sourceId: string;
  email: string;
  displayName: string;
  plan: string;
  status: MemberStatus;
  renewsAt: string | null;
  stripeCustomerId: string | null;
};

export async function persistMembers(
  members: Member[]
): Promise<{ ok: boolean; written: number; upstreamStatus: number }> {
  const url = process.env.MEMBER_STORE_URL;
  const token = process.env.MEMBER_STORE_TOKEN;
  if (!url || !token) {
    throw new Error("MEMBER_STORE_URL and MEMBER_STORE_TOKEN must be set");
  }
  const response = await fetch(url, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${token}`,
    },
    body: JSON.stringify({ members }),
  });
  // Trust the store's own count of written records, not just its status code.
  const result = (await response.json().catch(() => null)) as { written?: number } | null;
  return {
    ok: response.ok,
    written: response.ok ? result?.written ?? 0 : 0,
    upstreamStatus: response.status,
  };
}

That module is the only file that knows your store's URL and token.

The handler goes in app/api/import/route.ts, with no runtime directive at the top.

It rejects any status outside the fixed set and any Stripe customer ID that does not start with cus_:

// app/api/import/route.ts
import { NextResponse } from "next/server";
import { persistMembers, type Member } from "../../../lib/store";

const MAX_BATCH = 100;
const STATUSES = new Set([
  "active",
  "canceling",
  "paused",
  "canceled",
  "expired",
]);

// Constant-time comparison that behaves the same locally and on Workers.
function safeEqual(a: string, b: string): boolean {
  const encoder = new TextEncoder();
  const x = encoder.encode(a);
  const y = encoder.encode(b);
  let diff = x.length ^ y.length;
  const length = Math.max(x.length, y.length);
  for (let i = 0; i < length; i++) {
    diff |= (x[i] ?? 0) ^ (y[i] ?? 0);
  }
  return diff === 0;
}

function isMember(value: unknown): value is Member {
  if (typeof value !== "object" || value === null) return false;
  const m = value as Record<string, unknown>;
  return (
    typeof m.sourceId === "string" &&
    typeof m.email === "string" &&
    m.email.includes("@") &&
    typeof m.displayName === "string" &&
    typeof m.plan === "string" &&
    typeof m.status === "string" &&
    STATUSES.has(m.status) &&
    (m.renewsAt === null || typeof m.renewsAt === "string") &&
    (m.stripeCustomerId === null ||
      (typeof m.stripeCustomerId === "string" && m.stripeCustomerId.startsWith("cus_")))
  );
}

export async function POST(request: Request) {
  const secret = process.env.IMPORT_SECRET;
  if (!secret) {
    return NextResponse.json({ error: "IMPORT_SECRET is not set" }, { status: 500 });
  }

  const header = request.headers.get("authorization") ?? "";
  const token = header.startsWith("Bearer ") ? header.slice(7) : "";
  if (!safeEqual(token, secret)) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return NextResponse.json({ error: "Body must be JSON" }, { status: 400 });
  }

  if (typeof body !== "object" || body === null) {
    return NextResponse.json({ error: "Body must be a JSON object" }, { status: 400 });
  }
  const { members, dryRun } = body as { members?: unknown; dryRun?: unknown };
  if (!Array.isArray(members) || members.length === 0 || members.length > MAX_BATCH) {
    return NextResponse.json(
      { error: `Send between 1 and ${MAX_BATCH} members per request` },
      { status: 400 }
    );
  }

  const invalid = members
    .map((m, i) => (isMember(m) ? -1 : i))
    .filter((i) => i >= 0);
  if (invalid.length > 0) {
    return NextResponse.json({ error: "Invalid records", indexes: invalid }, { status: 422 });
  }

  if (dryRun === true) {
    return NextResponse.json({ dryRun: true, accepted: members.length });
  }

  try {
    const result = await persistMembers(members as Member[]);
    return NextResponse.json(result, { status: result.ok ? 200 : 502 });
  } catch (error) {
    const message = error instanceof Error ? error.message : "Unknown error";
    return NextResponse.json({ error: message }, { status: 500 });
  }
}

The handler uses the safeEqual helper instead of timingSafeEqual because timingSafeEqual exists on the deployed Workers runtime but not under local next dev. Code that passes in one environment can fail in the other.

A 422 response returns the indexes of the bad records, so the uploader can name the exact rows that failed. With IMPORT_SECRET set locally, start npm run dev and send a POST without an Authorization header to http://localhost:3000/api/import. You should get a 401 with {"error":"Unauthorized"}.

5. Set the import secrets as Webflow Cloud environment variables

Add three variables to the Webflow Cloud environment that will run the app: IMPORT_SECRET, MEMBER_STORE_URL and MEMBER_STORE_TOKEN. Mark the secret and the token as secret values.

Webflow Cloud makes both secret and non-secret variables available during the build and at runtime, and it redacts secret values from build logs, so the handler reads all three through process.env with no extra configuration.

Generate a long random value for IMPORT_SECRET with this Node one-liner:

node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

Paste the output into the IMPORT_SECRET value field.

Put the same three values in a local .env.local for next dev, and keep that file out of version control. For an agency build, record who holds MEMBER_STORE_TOKEN and where it rotates; that token is the credential a client's next developer will need.

The Webflow Cloud environment should now list all three variables, with the two credentials shown as secrets.

6. Deploy the app and run a batched dry run, then the real import

Deploy the app to Webflow Cloud with the Webflow CLI, and note the mount path you give it, such as /migrate. The bring-your-own-app docs walk through the deploy flow. The uploader must include that path in every request.

An external script calling your app has no idea where the app is mounted, so https://www.example.com/api/import and https://www.example.com/migrate/api/import are different URLs, and only the second reaches the handler.

Creating the batch uploader script

Save the uploader as scripts/upload.mjs next to the normalizer.

It sends batches one after another, which is not a rate limit, so keep your store's own limits in place:

// scripts/upload.mjs
import { readFileSync } from "node:fs";

const APP_URL = process.env.APP_URL;
const BASE_PATH = process.env.BASE_PATH ?? "";
const SECRET = process.env.IMPORT_SECRET;
const DRY_RUN = process.argv.includes("--dry-run");
const BATCH_SIZE = 100;

if (!APP_URL || !SECRET) {
  console.error("Set APP_URL and IMPORT_SECRET (and BASE_PATH if the app is mounted on a path).");
  process.exit(1);
}

const members = JSON.parse(readFileSync("members.json", "utf8"));
const endpoint = `${APP_URL.replace(/\/$/, "")}${BASE_PATH}/api/import`;
console.log(`${DRY_RUN ? "Dry run" : "Import"} of ${members.length} members to ${endpoint}`);

for (let start = 0; start < members.length; start += BATCH_SIZE) {
  const batch = members.slice(start, start + BATCH_SIZE);
  const response = await fetch(endpoint, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${SECRET}`,
    },
    body: JSON.stringify({ members: batch, dryRun: DRY_RUN }),
  });
  const text = await response.text();
  if (!response.ok) {
    console.error(`Batch starting at record ${start} failed with ${response.status}: ${text}`);
    process.exit(1);
  }
  console.log(`Batch ${start / BATCH_SIZE + 1}: ${text}`);
}

The script stops on the first failed batch and prints the starting record index. Rerunning with a corrected file then starts from a known record.

Running the upload script in dry-run mode

Put APP_URL, BASE_PATH and IMPORT_SECRET in a .env.upload file next to the scripts, then start with a dry run against the deployed app:

node --env-file=.env.upload scripts/upload.mjs --dry-run

Loading the values from .env.upload keeps the secret out of your shell history. For an agency handoff, rotate or delete IMPORT_SECRET, or remove the route, once the import is done.

I always do a full dry run against the deployed app before the real pass, because it exercises the deployed secret, the mount path, the validator and the Workers runtime together without writing anything.

Every batch should print {"dryRun":true,"accepted":100} (or a smaller count for the last batch). Run the same command without --dry-run, and the batches should print "ok":true as your member store receives the records.

What causes a WordPress subscriber import to Webflow Cloud to fail?

Most failures come from the build or a missing mount path; the rest come from a mismatched deployed secret or records the validator rejects.

The build fails or the import route errors after deployment, but next dev works

Cause: The route file contains export const runtime = 'edge'. Webflow Cloud builds Next.js apps through the OpenNext Cloudflare adapter, and that adapter does not support the Next.js edge runtime target.

The directive often arrives through copied sample code, or because a docs page describing Webflow Cloud as running on the edge reads like an instruction to opt each route into the edge runtime. The failure surfaces at deploy because the deployed build goes through the OpenNext Cloudflare adapter.

Fix: Delete the directive from app/api/import/route.ts and from any other route file, then redeploy. The handler already runs on Cloudflare Workers, the edge platform, without it. Search the project for runtime = so that a second copy in another route does not break the next build.

The uploader prints a 404 for every batch

Cause: The request URL is missing the app's mount path. Webflow Cloud serves the app under the path you chose at deploy time, so the handler lives at /migrate/api/import and not at /api/import.

Because the app lives under that mount path, a request from an external script has to include the path itself. The same rule applies inside the app: the bring-your-own-app docs state that client-side fetch calls must include the base path manually.

Fix: Set BASE_PATH to the exact mount path, with a leading slash and no trailing slash, and check the endpoint the uploader prints on its first line. If you later add a browser page that calls the route, add a NEXT_PUBLIC_BASE_PATH environment variable set to your mount path yourself, since Webflow Cloud does not create it, and prefix your fetch URLs with it.

Every batch returns 401 or "IMPORT_SECRET is not set" after deploying, but works locally

Cause: The deployed environment does not have the variable, or it holds a different value from the one in your shell. .env.local only feeds next dev; Webflow Cloud reads variables from the environment's own settings. A 500 with IMPORT_SECRET is not set means the variable is missing entirely. A 401 means it exists but does not match the token the uploader sent, which often comes from a trailing newline or a stray space pasted into the value field.

Fix: Open the Webflow Cloud environment the app is deployed to and confirm all three variables exist there, not in a different environment. Paste the secret again from a single source, then redeploy so the build and the running app pick up the same value. Rerun the uploader with --dry-run, which tests authentication without writing any members.

The uploader stops with a 422 "Invalid records" response

Cause: The handler's isMember check rejected one or more records in the batch. Typical reasons are a status outside the fixed set, or an empty email that slipped past the normalizer. The response's indexes array lists the positions of those records within that batch, not within the whole file.

Fix: Add the batch's starting record index from the uploader output to each index to find the row in members.json. Correct the source row in wp-users.csv or STATUS_MAP, rerun node scripts/normalize.mjs, then rerun with --dry-run. I map every unknown status before the first upload. The dry run validates each batch without forwarding its members to your store.

What you can build next with WordPress member data and Webflow

Once your members and their plans live in a store you control, you can plan how your Webflow site and Cloud app will use that data. Your Webflow site can hold the public pages. Treat content gating, plan-aware pages, a member dashboard or a webhook receiver to keep renewal dates current as separate development work, not features supplied by this import app.

Non-member content from the old WordPress site, such as posts and guides, belongs in the Webflow CMS, where editors can publish without touching the member store; the WordPress migration guide covers moving it.

For connecting the app to a database inside Webflow Cloud instead of an outside store, Webflow's developer docs cover SQLite and Key Value Store bindings.

Frequently asked questions

Can I migrate WordPress passwords along with the users?

Leave passwords behind. WordPress 6.8 and later store passwords as bcrypt hashes with a $wp$2y$ prefix, older sites use phpass $P$ hashes, and most auth providers cannot verify either without custom code. Import accounts without credentials, then send every member a password reset or magic link email at cutover, timed to arrive after their record exists in the new store.

Should I store subscribers in a Webflow CMS collection?

No. CMS collections hold content that editors manage and publish, and that is the wrong home for emails, plan status and renewal dates. Keep member data in an auth provider or a database that sits behind your Webflow Cloud app, and use the CMS for the pages and posts those members read.

Will a large import hit Webflow Cloud usage limits?

Not at batch sizes like these. Request limits are the same on every plan: a 20-second timeout and 30 seconds of CPU per request, and a batch of 100 records stays well inside them. Monthly allowances vary by plan, so check the limits page before uploading tens of thousands of records.

Is it safe to rerun the import after a failed batch?

Only if your member store upserts by email or sourceId. The handler forwards whatever it receives, so a store that inserts blindly will create duplicates. Confirm upsert behavior first, or trim members.json to begin at the record index the uploader printed when it stopped, then run the dry run again before resuming.


Last Updated
October 30, 2026
Category

Related articles


verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logoreddit logothe new york times logoideo logoupwork logodiscord logo

Get started for free

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.

Get started — it’s free
Watch demo

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.