DEV Community

Cover image for Stripe, Merchant of Record, or In-App Purchases? Picking a Payments Stack Without Regretting It
Sarah
Sarah

Posted on Originally published at Medium

Stripe, Merchant of Record, or In-App Purchases? Picking a Payments Stack Without Regretting It

TL;DR

  • There are three real options: a payment processor (Stripe), a Merchant of Record (Paddle, Lemon Squeezy), or app store billing (Apple/Google, usually via RevenueCat).
  • The decision is about who is legally the seller — that determines who owns tax, disputes, and refunds. Fees are secondary.
  • Native mobile app selling digital features? IAP isn't a choice, it's a constraint. Plan a cheaper web checkout alongside it.
  • Whatever you pick, you'll write a unified entitlement layer fed by webhooks. Build it before your second payment channel, not after.
  • Code for the webhook handler, entitlement table, and cross-platform access check is below.

The moment every app hits

Product works. A few people want to pay. Suddenly you're reading about VAT nexus, webhook idempotency, and why Apple wants 30%.

Payments look like a checkout button. Underneath it's a question of who is on the receipt, and that question decides most of your operational load for the next two years.

The three options

1. Payment processor (Stripe, Adyen, regional gateways)

You are the seller. Stripe moves money; everything else is yours.

  • Lowest fees, around 3%.
  • Full control over checkout and pricing logic.
  • Tax is your problem. Stripe Tax calculates it; registering and filing is still you.
  • Chargebacks and dispute evidence are yours.
  • Your entitlement logic lives entirely in how well you handle webhooks.

Here's the minimum viable Stripe webhook handler. The important parts are signature verification and idempotency — Stripe will retry events, and you will get duplicates.

// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
import { db } from "@/lib/db";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const body = await req.text();
  const sig = req.headers.get("stripe-signature")!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return new Response("Bad signature", { status: 400 });
  }

  // Idempotency: bail if we've already processed this event id
  const seen = await db.processedEvents.findUnique({ where: { id: event.id } });
  if (seen) return new Response("ok");

  switch (event.type) {
    case "checkout.session.completed":
    case "invoice.paid": {
      const sub = await stripe.subscriptions.retrieve(
        (event.data.object as any).subscription as string
      );
      await grantEntitlement({
        userId: sub.metadata.userId,
        plan: sub.items.data[0].price.lookup_key ?? "pro",
        source: "stripe",
        expiresAt: new Date(sub.current_period_end * 1000),
      });
      break;
    }
    case "customer.subscription.deleted": {
      const sub = event.data.object as Stripe.Subscription;
      await revokeEntitlement({ userId: sub.metadata.userId, source: "stripe" });
      break;
    }
  }

  await db.processedEvents.create({ data: { id: event.id } });
  return new Response("ok");
}
Enter fullscreen mode Exit fullscreen mode

That's the skeleton. Proration, trials, dunning, and upgrade-mid-cycle all add branches to that switch statement.

Best for: web SaaS with a team that can own billing, marketplaces, custom pricing, or businesses selling into one or two tax jurisdictions.

2. Merchant of Record (Paddle, Lemon Squeezy, FastSpring)

They are the seller. Their name is on the receipt. They calculate, collect, and remit tax everywhere, eat the chargebacks, and send you a payout.

  • Higher take, roughly 5% plus a fixed fee.
  • Global tax just disappears as a problem. Sell to the EU, UK, India, Australia on day one with zero registrations.
  • Less control: you live inside their checkout and subscription model.
  • Slower payouts, typically bi-weekly or monthly.

The webhook handler looks almost identical to the Stripe one — different event names, same shape. Which is the point: your entitlement layer shouldn't care which provider fired the event.

// Paddle-style events map onto the same grant/revoke calls
switch (event.event_type) {
  case "subscription.activated":
  case "subscription.updated":
    await grantEntitlement({ userId, plan, source: "paddle", expiresAt });
    break;
  case "subscription.canceled":
    await revokeEntitlement({ userId, source: "paddle" });
    break;
}
Enter fullscreen mode Exit fullscreen mode

Best for: indie products, small teams selling internationally, digital downloads, anyone without a finance function.

3. App store billing (Apple IAP, Google Play, via RevenueCat)

If you ship a native app and sell digital features inside it, you don't get to choose. The platform is the seller.

  • 15–30% depending on program tier and revenue.
  • Two platforms, two receipt formats, two server-notification systems with different semantics. This is why RevenueCat exists.
  • Subscriptions are managed in the store. Cancellations and refunds happen there; you react to events.
  • Cross-platform users are the hard part: subscribed on iPhone, logged in on web, expects access.

Client-side in React Native, RevenueCat collapses both stores into one call:

import Purchases from "react-native-purchases";

Purchases.configure({ apiKey: Platform.OS === "ios" ? IOS_KEY : ANDROID_KEY });
await Purchases.logIn(user.id); // ties store purchases to YOUR user id

const { customerInfo } = await Purchases.purchasePackage(pkg);
const hasPro = customerInfo.entitlements.active["pro"] !== undefined;
Enter fullscreen mode Exit fullscreen mode

Server-side, RevenueCat's webhook feeds the same entitlement table as Stripe and Paddle:

// RevenueCat webhook → same grant/revoke
if (["INITIAL_PURCHASE", "RENEWAL", "UNCANCELLATION"].includes(event.type)) {
  await grantEntitlement({
    userId: event.app_user_id,
    plan: event.entitlement_ids[0],
    source: event.store.toLowerCase(), // "app_store" | "play_store"
    expiresAt: new Date(event.expiration_at_ms),
  });
}
if (["CANCELLATION", "EXPIRATION"].includes(event.type)) {
  await revokeEntitlement({ userId: event.app_user_id, source: event.store.toLowerCase() });
}
Enter fullscreen mode Exit fullscreen mode

Best for: any native app selling digital features. Not a preference — a constraint to design around early.

The part you'll write regardless: the entitlement layer

Every option above ends in the same two functions. One table, one question: does this user have this plan right now?

create table entitlements (
  user_id     text not null,
  plan        text not null,
  source      text not null,          -- 'stripe' | 'paddle' | 'app_store' | 'play_store'
  expires_at  timestamptz not null,
  updated_at  timestamptz default now(),
  primary key (user_id, source)
);
Enter fullscreen mode Exit fullscreen mode
export async function hasAccess(userId: string, plan = "pro") {
  const row = await db.entitlements.findFirst({
    where: { userId, plan, expiresAt: { gt: new Date() } },
  });
  return !!row;
}
Enter fullscreen mode Exit fullscreen mode

Notice source is part of the primary key. A user can hold a Stripe subscription and an App Store one at the same time (it happens more than you'd think). hasAccess doesn't care where it came from. That single design choice is what makes "subscribed on iPhone, logged in on web" work.

If you're generating the app scaffold rather than hand-wiring every project, tools like RapidNative ship this entitlement pattern with the provider hooks already in place for React Native, which lets you make the provider decision without also paying the integration tax each time.

The decision table

Processor (Stripe) Merchant of Record App Store IAP
Who is the seller You Them Apple / Google
Typical fee ~3% ~5% + fixed 15–30%
Global tax Your problem (tools help) Handled Handled
Checkout control Full Limited Minimal
Webhook complexity High Medium High (two platforms)
Payout speed Daily Bi-weekly / monthly Monthly
Best fit Web SaaS, custom billing Indie, global, small teams Native mobile

What actually decides it

Fees get all the attention and matter least under a few thousand in MRR. What actually decides it:

  • Where is the app? Native mobile means IAP for the in-app path. Plan web checkout as a separate, cheaper channel from the start.
  • Where are the customers? More than two or three tax jurisdictions and an MoR pays for itself in hours not spent.
  • Who owns billing bugs? If nobody on the team wants to debug a proration edge case at 11pm, that's an MoR signal.
  • Do you need one entitlement layer? Yes, the moment you have a second channel. Build it first.

Two mistakes I keep seeing: picking Stripe because it's the "serious" choice and discovering three months in that the founder is filing VAT returns instead of talking to users. And wiring IAP first, then realising web checkout could have captured the same users at a third of the fee.

The short version

Native mobile → IAP in-app plus web checkout on the side. Small team selling globally → Merchant of Record. Web SaaS with engineering capacity and custom billing → Stripe.

Pick based on who you want responsible for tax and disputes, not the fee percentage. The fee is visible. The operational cost isn't, and it's usually bigger.

Which stack are you running, and would you pick it again? Drop it in the comments — especially if you've moved between two of these, I'd like to hear how the migration went.

Top comments (0)