Back to Blog
polarrevenuecatmigrationsubscriptionsdeveloper-tools

Switch from RevenueCat to Polar in 30 Minutes

A pragmatic migration guide for indie devs and small SaaS teams moving from RevenueCat to Polar. Real TypeScript diffs, webhook reroute, cutover playbook — and an honest take on what you lose.

Q
Q

RevenueCat is good. Polar is good. They are not the same tool.

Let me say the unfashionable thing first: RevenueCat is excellent. If you ship to the App Store and Play Store, the receipt validation, sandboxing, and entitlement primitives are the closest thing to "subscriptions, solved." This post is not a takedown.

But you may have landed here because the per-event pricing crossed a number you don't want to defend, or because Polar's developer-first DX matches the way you actually build, or because your product is web-and-API rather than mobile-first. If that's you, the migration is smaller than you think. Thirty minutes for a Node or Next.js shop selling digital products. A weekend for anything more involved.

Here is the path I would take.


When You Should Consider Switching

Before the diff, the decision. Be honest about the fit.

Stay on RevenueCat if

  • The majority of your revenue is App Store and Play Store subscriptions
  • You depend on mature mobile SDKs for iOS, Android, Flutter, Unity, React Native
  • You need server-side receipt validation with the platform stores as a first-class feature
  • You actively use cohort analysis, charts, and experiments at the team-tool level
  • You're below RevenueCat's free tier and have no urgency

Consider Polar if

  • You sell digital products on the web — SaaS, downloads, license keys, API access
  • You want a Merchant of Record that handles EU VAT and global sales tax for you
  • Your stack is TypeScript-first and you want an SDK that feels like Stripe used to feel
  • You're paying RevenueCat's percentage on revenue you process via web checkout and resent it
  • You want license key issuance baked in instead of bolted on
  • Your AI agents read MCP servers to manage billing — Polar publishes one

If your answer to "where does the money come from" is "the App Store," stay. If it's "Stripe webhooks I wrote myself last year," keep reading.


What You Will Lose

Migrating away has costs. I will not pretend otherwise.

  • Mobile receipt validation — RevenueCat's bread and butter. Polar is web-first; native iOS/Android subscriptions through Polar require your own bridging or are not a fit.
  • The mature mobile SDK surface — RevenueCat ships polished Swift, Kotlin, Flutter, Unity, React Native, and Capacitor SDKs with years of battle-testing.
  • Built-in experimentation — RevenueCat's paywall A/B testing and cohort tools are real, and Polar does not match them feature-for-feature.
  • Charts and analytics depth — RevenueCat's Charts API exposes 21 chart types. Polar's analytics are improving but lighter.

If those bullets describe load-bearing infrastructure for you, this post ends here. Otherwise, the trade buys you a lot.


What You Will Gain

  • Merchant of Record on web — Polar collects, remits, and files EU VAT and other indirect taxes. You stop spelunking through tax-jurisdiction CSVs.
  • License keys as a first-class benefit — issue, revoke, and validate without a second SaaS.
  • Predictable pricing — flat platform fee on the transaction, no per-seat or per-event line items showing up at month-end.
  • A TypeScript SDK that ages well — generated from the OpenAPI spec, typed end-to-end, no surprises.
  • MCP support — your AI coding assistant can read and write your Polar org via the official MCP server, and a community one (@stackcurious/polar-mcp, shipping alongside this post) extends the analytics surface.
  • Webhook signing on the standardwebhooks spec — verifiable with one well-maintained npm library, not a bespoke HMAC dance.

Stripe taught a generation what good developer experience felt like. Polar is the first billing tool since that gets out of your way the same way.


The Migration, Step by Step

Eight steps. Pour a coffee.

1. Set Up Your Polar Org and First Product

Sign in at polar.sh, create an organization, and create your first product. A product in Polar has a name, a description, and one or more prices (one-time or recurring). It can also have benefits attached — these are the things customers actually receive.

Generate an organization access token from the dashboard. This is your server-side API key. Keep it in POLAR_ACCESS_TOKEN.

If you sell more than one tier, model each tier as a separate product. Polar does not have RevenueCat's offering/package abstraction — products and prices are the unit.

2. Map RevenueCat Entitlements to Polar Benefits

This is the conceptual step that catches teams off guard. They are not the same shape.

RevenueCat Polar
Entitlement (pro_access) Benefit (custom, license key, file download, GitHub repo access, Discord role, etc.)
Offering A page or a checkout link grouping products
Package A specific price on a product
Product (store SKU) Price (one-time or recurring)

The cleanest mental model: a Polar benefit is what an entitlement promises, made literal. A pro_access entitlement becomes a custom benefit you check server-side. A premium_assets entitlement might map to Polar's file-download benefit. A "you get a license" entitlement maps to Polar's license-key benefit and stops being your problem.

Write the mapping in a spreadsheet before you touch code. Two columns: RC entitlement identifier, Polar benefit ID.

3. Map Subscriptions to Polar Subscriptions

Recurring prices in Polar map to subscription objects. The lifecycle states you care about — active, canceled, past_due, incomplete — exist in both systems. The status names don't match exactly, but the moments do.

If you previously stored RevenueCat's entitlement_active flag on your user row, plan to replace it with a join against Polar's subscription state. I keep a small helper:

type Access = { active: boolean; tier: "free" | "pro" | "team" };

export async function resolveAccess(userId: string): Promise<Access> {
  const sub = await db.subscriptions.findFirst({
    where: { userId, status: { in: ["active", "trialing"] } },
    orderBy: { createdAt: "desc" },
  });
  if (!sub) return { active: false, tier: "free" };
  return { active: true, tier: sub.tier };
}

The helper is the seam. Whatever writes the row — RevenueCat webhook today, Polar webhook tomorrow — the rest of your app calls resolveAccess and doesn't care.

4. Reroute Webhooks

Polar signs webhooks using the standardwebhooks specification. That means there is one well-maintained library that verifies the signature for you. No hand-rolled HMAC.

npm install standardwebhooks @polar-sh/sdk

Then a Next.js route handler:

import { Webhook } from "standardwebhooks";
import { NextRequest, NextResponse } from "next/server";

const wh = new Webhook(process.env.POLAR_WEBHOOK_SECRET!);

export async function POST(req: NextRequest) {
  const body = await req.text();
  const headers = {
    "webhook-id": req.headers.get("webhook-id") ?? "",
    "webhook-timestamp": req.headers.get("webhook-timestamp") ?? "",
    "webhook-signature": req.headers.get("webhook-signature") ?? "",
  };

  let event: { type: string; data: Record<string, unknown> };
  try {
    event = wh.verify(body, headers) as typeof event;
  } catch {
    return NextResponse.json({ error: "invalid signature" }, { status: 400 });
  }

  switch (event.type) {
    case "subscription.created":
    case "subscription.updated":
      await upsertSubscription(event.data);
      break;
    case "subscription.canceled":
      await cancelSubscription(event.data);
      break;
    case "order.created":
      await recordOrder(event.data);
      break;
  }

  return NextResponse.json({ ok: true });
}

Register the endpoint in the Polar dashboard, copy the secret into POLAR_WEBHOOK_SECRET, and you are receiving signed events.

5. Migrate Customer Data

Export from RevenueCat via the dashboard or the REST API. You want, at minimum: customer email, the active entitlement, the platform the subscription lives on, the renewal date, and the original transaction identifier.

In Polar you create customers via the SDK as you encounter them. For a backfill:

import { Polar } from "@polar-sh/sdk";

const polar = new Polar({ accessToken: process.env.POLAR_ACCESS_TOKEN! });

for (const row of rcExport) {
  const customer = await polar.customers.create({
    email: row.email,
    externalId: row.appUserId, // keep your RC app_user_id as the external id
    metadata: {
      migrated_from: "revenuecat",
      rc_original_transaction_id: row.originalTransactionId,
    },
  });
  await db.users.update({
    where: { email: row.email },
    data: { polarCustomerId: customer.id },
  });
}

Two rules. Preserve your RC app user identifier as Polar's externalId so the rest of your app keeps working without a remap. Tag every migrated customer in metadata so you can identify them in support and analytics later.

For active subscribers on the App Store or Play Store, leave them on RevenueCat until their current term ends and onboard new web purchases through Polar. Forced re-purchase will churn them.

6. Replace SDK Calls

The smallest, most satisfying part of the migration. Before:

import Purchases from "@revenuecat/purchases-js";

const purchases = Purchases.configure({
  apiKey: process.env.RC_PUBLIC_KEY!,
  appUserId: user.id,
});

const offerings = await purchases.getOfferings();
const pkg = offerings.current?.availablePackages[0];
if (!pkg) throw new Error("no package");

const { customerInfo } = await purchases.purchase({ rcPackage: pkg });
const isPro = customerInfo.entitlements.active["pro_access"] !== undefined;

After:

import { Polar } from "@polar-sh/sdk";

const polar = new Polar({ accessToken: process.env.POLAR_ACCESS_TOKEN! });

const checkout = await polar.checkouts.create({
  products: [process.env.POLAR_PRODUCT_ID_PRO!],
  externalCustomerId: user.id,
  successUrl: `${origin}/billing/success?checkout_id={CHECKOUT_ID}`,
});

// redirect the browser to checkout.url

Server-side access check, after the webhook has updated your row:

const access = await resolveAccess(user.id);
const isPro = access.active && access.tier !== "free";

The diff is roughly: remove the in-process SDK that owned purchase state, add a redirect to a hosted checkout, trust your own database as the source of truth.

7. Switch the Paywall

Two cases.

Web app. Replace your RevenueCat paywall component with a button that calls your /api/checkout route, which calls polar.checkouts.create, and redirects to checkout.url. Polar hosts the checkout. You skip building card forms.

Mobile app where you still want to bill on the web. Open Polar checkout in an in-app browser (SFSafariViewController on iOS, Custom Tabs on Android) and listen for the successUrl redirect. This is the same pattern Stripe customers have used for years. It is not as smooth as native StoreKit, and Apple's rules on external purchases vary by jurisdiction and app category — read the current guidelines for your category before shipping.

If the app is store-distributed and Apple's rules require IAP, keep RevenueCat for the IAP path and use Polar only for the web path. Dual-stack is fine.

8. Cutover Playbook

Do not flip a single switch. Run both systems in parallel for one full billing cycle.

  1. Parallel write. All new web purchases go through Polar. RC webhooks continue to update existing rows.
  2. Reconcile nightly. A small job compares Polar subscription state and RC subscription state for users that exist in both, and flags drift to a Slack channel.
  3. Freeze new RC products. Stop publishing new offerings on RevenueCat the day after Polar goes live.
  4. Migrate at term boundary. As RC subscriptions hit renewal, redirect those users to a Polar upgrade flow at the same price. Do not auto-charge on the new system without explicit consent.
  5. Decommission. Once RC's active subscriber count for web hits zero, revoke the API keys and downgrade the plan.

The reconciliation job is the part teams skip and regret. Write it.


The 30-Minute Path

If your shop is Node or Next.js, you sell a single digital product, and your auth is already wired:

  • Minute 0-5. Create the org, one product, one price, copy the access token and webhook secret.
  • Minute 5-15. Drop in the webhook handler from step 4, point Polar at it, send a test event from the dashboard, watch your row update.
  • Minute 15-25. Replace the paywall button with a checkout-create call. Redirect to checkout.url. Verify the round trip with a real card on a $0.50 product.
  • Minute 25-30. Swap your access check to read from your own DB, delete the RC SDK from the bundle, ship.

That is the actual path. The complications are real for larger surface areas, and the eight-step plan above handles them. But thirty minutes is not marketing — it is the floor.


What I Use to Make This Easier

Two things.

@stackcurious/polar-mcp — an MCP server that gives your AI coding assistant typed access to Polar's products, customers, subscriptions, and analytics. Install it, point Claude or Cursor at your Polar org, and ask "what changed in revenue this week" without leaving your editor. It pairs naturally with the migration above — your agent can verify the cutover is healthy without you opening a dashboard.

getqpro.com/demo/polar — a live demo against a real Polar org. Watch the SDK calls fire, see the webhook events arrive, copy the patterns. Reading code on a page is faster than scrolling docs.


RevenueCat is the right tool when the App Store is the business. Polar is the right tool when the web is the business. Most of you are one or the other and have known it for a while.

Migrate when the math, not the marketing, says to migrate. And when it does, give it the half hour it deserves.

— Q

Q

See Q in action

AI agent orchestration with governance, trust scoring, and specialist agents.