log in
consulting hosting industries the daily tools about contact

Lemon Squeezy: What Actually Changes When Someone Else Handles VAT

Lemon Squeezy positions itself as the merchant of record so you don't deal with VAT. Here's what that actually means in practice — and what it costs you.

I spent a week integrating Lemon Squeezy for a small SaaS product last year and came away with a clear opinion: it's a genuinely good fit for a specific situation, and a bad fit for everything else. The merchant-of-record model sounds like magic until you understand what you're actually trading away.

What "Merchant of Record" Actually Means

When you sell software through Stripe, you are the merchant. Your business name is on the receipt. You collect the money. And you are responsible for calculating, collecting, and remitting VAT/GST to the relevant tax authority in every country where you have nexus. For a US-based solo founder selling to businesses in Germany, France, Australia, and Canada, that's a compliance nightmare that has nothing to do with building software.

Lemon Squeezy flips this. They are the merchant of record. Their name is on the receipt. They collect the money, handle VAT calculation, file the returns, and remit to tax authorities globally. You get paid a net amount after their fee. From a tax compliance standpoint, selling to a customer in the EU is identical to selling to one in Iowa.

This is the thing Stripe cannot do for you out of the box. Stripe Tax gets you partway there on calculation and reporting, but you still own the liability. Paddle does the same MOR thing as Lemon Squeezy and has been doing it longer. But Lemon Squeezy is cheaper and has a developer experience that doesn't feel like it was designed in 2014.

The Integration in Practice

Lemon Squeezy's API is REST, JSON, reasonably documented. There's a first-party Laravel package — lemonsqueezy/lemonsqueezy-laravel — that wraps the webhooks and gives you a Billable trait. If you've used Cashier for Stripe, the shape of it will feel familiar, though it's thinner.

Here's how I actually set up a checkout session for a subscription product:

use LemonSqueezy\LaravelLemonSqueezy\Facades\LemonSqueezy;

$checkout = LemonSqueezy::checkout(
    storeId: config('lemon-squeezy.store'),
    variantId: config('lemon-squeezy.plans.pro'),
)->withCustomPrice(null)
 ->customData([
     'user_id' => $user->id,
 ])
 ->redirectUrl(route('dashboard'))
 ->expiresAt(now()->addHour())
 ->create($user->email, [
     'name' => $user->name,
 ]);

return redirect($checkout->url);

The customData payload comes back in every webhook, which is how you tie a Lemon Squeezy subscription back to a user in your database. Don't skip that — there's no other reliable way to correlate the two sides.

Webhook handling looks like this:

// routes/web.php
Route::lemonSqueezyWebhooks('/lemon-squeezy/webhook');
// app/Listeners/HandleSubscriptionCreated.php
use LemonSqueezy\LaravelLemonSqueezy\Events\SubscriptionCreated;

class HandleSubscriptionCreated
{
    public function handle(SubscriptionCreated $event): void
    {
        $data = $event->payload['data'] ?? [];
        $customData = $data['attributes']['custom_data'] ?? [];
        $userId = $customData['user_id'] ?? null;

        if (!$userId) {
            Log::warning('LS webhook missing user_id', $data);
            return;
        }

        $user = User::find($userId);
        $user?->update([
            'ls_subscription_id' => $data['id'],
            'ls_variant_id'      => $data['attributes']['variant_id'],
            'subscribed_at'      => now(),
        ]);
    }
}

The package fires typed events for the main lifecycle moments: SubscriptionCreated, SubscriptionUpdated, SubscriptionCancelled, SubscriptionExpired, OrderCreated. That covers 95% of what you need.

The Gotchas That Will Bite You

Custom data is untyped and unvalidated. Whatever you put in customData() comes back as a raw array in the webhook payload. Cast everything explicitly. I've had integer user IDs come back as strings. Treat it like user input.

The checkout is hosted on Lemon Squeezy's domain. You redirect to app.lemonsqueezy.com/checkout/.... You can customize colors and add your logo, but you cannot embed the checkout natively in your own UI. If your product requires a deeply branded purchase flow, this will frustrate you. Paddle has an overlay option; Lemon Squeezy does not as of when I last checked.

Customer portal is also hosted. Subscription management — updating a card, changing a plan, canceling — happens on a Lemon Squeezy-hosted portal URL. You generate a signed URL and redirect. Again, fine for most things, but it's not your UI.

$portalUrl = $user->customerPortalUrl();
return redirect($portalUrl);

Webhook signature verification has a config gotcha. The package reads LEMON_SQUEEZY_SIGNING_SECRET from your .env. If you forget to set it, the package will accept unsigned webhooks in local dev without complaint. Make sure you set a signing secret in the Lemon Squeezy dashboard and verify it's actually being checked before you go live. I burned an hour on this because local testing was working fine with no secret set.

Plan changes mid-cycle are murky. Upgrading from a monthly plan to an annual plan triggers a SubscriptionUpdated event, but the proration math is handled entirely by Lemon Squeezy. You don't control it, and it isn't clearly surfaced in the webhook payload. For most small SaaS products this is fine — you don't want to be in the proration business. But if you have complex billing logic, you'll hit walls.

No support for metered/usage-based billing. If you're billing per API call or per seat beyond a base count, Lemon Squeezy can't do it. Stripe is the clear winner for anything usage-based. Lemon Squeezy is flat-rate subscriptions and one-time purchases, full stop.

Their API rate limits are not prominently documented. I haven't hit them in production, but I've seen reports of people hammering the API in sync jobs and getting throttled without clear retry guidance. If you're syncing large customer lists, be conservative and add exponential backoff.

The Real Trade-Off: Control for Compliance

Here's the thing nobody says plainly: using Lemon Squeezy means accepting that a significant piece of your user experience is owned by a third party. The checkout page, the billing portal, the receipts — all Lemon Squeezy-branded (with your logo on top). That's the deal.

For a solo founder shipping a B2C or B2B SaaS tool and trying to avoid becoming an accidental international tax compliance expert, that deal is excellent. The fee is 5% + 50 cents per transaction, which is higher than Stripe's 2.9% + 30 cents, but you are genuinely buying something real for that delta: you never have to think about EU VAT, Australian GST, or whatever Canada decides to do next year.

For a funded company with a finance team, a dedicated legal counsel, and a CPA who already handles this stuff, Stripe is almost certainly the right call. You get more control, more flexibility, lower rates at volume, and a more mature ecosystem.

When I'd Reach for Lemon Squeezy

  • Solo project or small team, no finance department, international customers
  • Flat-rate or tiered subscription pricing (not usage-based)
  • You want to ship fast and not think about tax compliance for 2 years
  • One-time software sales (they handle this cleanly too)

When I'd Reach for Something Else

  • Usage-based or seat-based billing with complex proration
  • You need fully embedded, white-label checkout in your own UI
  • You're doing significant volume where the rate delta adds up
  • You need Stripe's ecosystem — Radar fraud tools, Connect for marketplaces, Treasury, etc.
  • Your users are exclusively in the US and you've already handled sales tax with TaxJar or Stripe Tax

For that last case, honestly, just use Stripe and set up Stripe Tax. The Lemon Squeezy trade-off only makes sense when the international compliance piece is real and painful.

Lemon Squeezy is not trying to be Stripe. It's trying to be the thing you reach for when Stripe's surface area is overkill and international tax law is genuinely scary. For that use case, it delivers. I'd use it again on the right project without hesitation.

Need help shipping something like this? Get in touch.