Lemon Squeezy Integration

Implementing payments and subscriptions with Lemon Squeezy in ShipKit

Lemon Squeezy Integration

ShipKit integrates with Lemon Squeezy for handling payments, subscriptions, and digital product delivery. This guide covers setup, payment flows, and webhook handling.

Setup

Installation

pnpm add @lemonsqueezy/lemonsqueezy.js

Configuration

// src/lib/lemon-squeezy.ts
import { LemonSqueezy } from '@lemonsqueezy/lemonsqueezy.js';
import { env } from '@/env';

export const lemonSqueezy = new LemonSqueezy(env.LEMON_SQUEEZY_API_KEY);

export const storeConfig = {
  storeId: env.LEMON_SQUEEZY_STORE_ID,
  apiVersion: 'v1',
  mode: process.env.NODE_ENV === 'production' ? 'live' : 'test',
} as const;

Payment Flows

Checkout Integration

// src/components/checkout-button.tsx
'use client';

import { useState } from 'react';
import { createCheckout } from '@lemonsqueezy/lemonsqueezy.js';
import { Button } from '@/components/ui/button';

interface CheckoutButtonProps {
  variantId: number;
  productName: string;
  checkoutOptions?: {
    email?: string;
    name?: string;
    customData?: Record<string, any>;
  };
}

export const CheckoutButton = ({
  variantId,
  productName,
  checkoutOptions,
}: CheckoutButtonProps) => {
  const [loading, setLoading] = useState(false);

  const handleCheckout = async () => {
    setLoading(true);
    try {
      const checkout = await createCheckout({
        storeId: storeConfig.storeId,
        variantId,
        checkoutOptions: {
          ...checkoutOptions,
          darkMode: false,
          media: false,
        },
      });

      window.location.href = checkout.url;
    } catch (error) {
      console.error('Checkout error:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <Button
      onClick={handleCheckout}
      disabled={loading}
    >
      {loading ? 'Loading...' : `Buy ${productName}`}
    </Button>
  );
};

Custom Checkout Page

// src/app/(checkout)/checkout/[variantId]/page.tsx
import { getProductVariant } from '@/lib/lemon-squeezy';
import { CheckoutForm } from '@/components/checkout-form';

interface CheckoutPageProps {
  params: {
    variantId: string;
  };
}

export default async function CheckoutPage({
  params,
}: CheckoutPageProps) {
  const variant = await getProductVariant(params.variantId);

  return (
    <div className="container max-w-lg py-12">
      <h1 className="text-2xl font-bold mb-6">
        Checkout: {variant.attributes.name}
      </h1>
      <CheckoutForm
        variant={variant}
        onSuccess={(checkoutUrl) => {
          window.location.href = checkoutUrl;
        }}
      />
    </div>
  );
}

Subscription Management

Subscription Status

// src/lib/subscriptions.ts
import { lemonSqueezy } from '@/lib/lemon-squeezy';

export async function getSubscriptionStatus(subscriptionId: string) {
  const subscription = await lemonSqueezy.getSubscription(subscriptionId);

  return {
    status: subscription.attributes.status,
    currentPeriodEnd: subscription.attributes.current_period_end,
    cancelAtPeriodEnd: subscription.attributes.cancel_at_period_end,
  };
}

export async function cancelSubscription(subscriptionId: string) {
  return await lemonSqueezy.cancelSubscription(subscriptionId);
}

export async function reactivateSubscription(subscriptionId: string) {
  return await lemonSqueezy.reactivateSubscription(subscriptionId);
}

Customer Portal

// src/components/customer-portal.tsx
'use client';

import { useState } from 'react';
import { Button } from '@/components/ui/button';

interface CustomerPortalProps {
  customerId: string;
}

export const CustomerPortal = ({ customerId }: CustomerPortalProps) => {
  const [loading, setLoading] = useState(false);

  const openPortal = async () => {
    setLoading(true);
    try {
      const response = await fetch('/api/customer-portal', {
        method: 'POST',
        body: JSON.stringify({ customerId }),
      });

      const { url } = await response.json();
      window.location.href = url;
    } catch (error) {
      console.error('Portal error:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <Button onClick={openPortal} disabled={loading}>
      {loading ? 'Loading...' : 'Manage Subscription'}
    </Button>
  );
};

Webhook Handling

Webhook Configuration

// src/app/api/webhooks/lemon-squeezy/route.ts
import { NextResponse } from 'next/server';
import { headers } from 'next/headers';
import { env } from '@/env';
import { db } from '@/server/db';
import { WebhookEvent } from '@lemonsqueezy/lemonsqueezy.js';
import { createHmac } from 'crypto';

export async function POST(req: Request) {
  const body = await req.text();
  const headersList = headers();
  const signature = headersList.get('x-signature');

  // Verify webhook signature
  const hmac = createHmac('sha256', env.LEMON_SQUEEZY_WEBHOOK_SECRET);
  const digest = hmac.update(body).digest('hex');

  if (signature !== digest) {
    return new NextResponse('Invalid signature', { status: 401 });
  }

  const event = JSON.parse(body) as WebhookEvent;

  try {
    await handleWebhookEvent(event);
    return new NextResponse('OK', { status: 200 });
  } catch (error) {
    console.error('Webhook error:', error);
    return new NextResponse('Internal error', { status: 500 });
  }
}

async function handleWebhookEvent(event: WebhookEvent) {
  switch (event.meta.event_name) {
    case 'subscription_created':
      await handleSubscriptionCreated(event);
      break;
    case 'subscription_updated':
      await handleSubscriptionUpdated(event);
      break;
    case 'subscription_cancelled':
      await handleSubscriptionCancelled(event);
      break;
    case 'order_created':
      await handleOrderCreated(event);
      break;
    // Handle other events...
  }
}

Event Handlers

// src/lib/webhook-handlers.ts
import { WebhookEvent } from '@lemonsqueezy/lemonsqueezy.js';
import { db } from '@/server/db';
import { subscriptions, orders } from '@/server/db/schema';

export async function handleSubscriptionCreated(event: WebhookEvent) {
  const { data } = event;

  await db.insert(subscriptions).values({
    id: data.id,
    customerId: data.attributes.customer_id,
    status: data.attributes.status,
    productId: data.attributes.product_id,
    variantId: data.attributes.variant_id,
    currentPeriodEnd: new Date(data.attributes.current_period_end),
    cancelAtPeriodEnd: data.attributes.cancel_at_period_end,
  });
}

export async function handleOrderCreated(event: WebhookEvent) {
  const { data } = event;

  await db.insert(orders).values({
    id: data.id,
    customerId: data.attributes.customer_id,
    status: data.attributes.status,
    total: data.attributes.total,
    currency: data.attributes.currency,
    orderNumber: data.attributes.order_number,
  });

  // Handle digital delivery
  if (data.attributes.status === 'paid') {
    await deliverDigitalProduct(data);
  }
}

License Verification

License Key Validation

// src/lib/license.ts
import { lemonSqueezy } from '@/lib/lemon-squeezy';

export async function validateLicenseKey(key: string) {
  try {
    const license = await lemonSqueezy.getLicenseKey(key);

    return {
      valid: license.attributes.status === 'active',
      activation_limit: license.attributes.activation_limit,
      activation_usage: license.attributes.activation_usage,
      expires_at: license.attributes.expires_at,
    };
  } catch (error) {
    return { valid: false };
  }
}

export async function activateLicense(key: string, instanceId: string) {
  return await lemonSqueezy.activateLicenseKey(key, {
    instance_name: instanceId,
  });
}

Usage Tracking

Metered Billing

// src/lib/usage.ts
import { lemonSqueezy } from '@/lib/lemon-squeezy';

export async function reportUsage(
  subscriptionId: string,
  quantity: number
) {
  return await lemonSqueezy.createSubscriptionUsage(subscriptionId, {
    quantity,
  });
}

export async function getUsage(subscriptionId: string) {
  const usage = await lemonSqueezy.getSubscriptionUsage(subscriptionId);

  return {
    current: usage.attributes.current_usage,
    period_start: usage.attributes.period_start,
    period_end: usage.attributes.period_end,
  };
}

Best Practices

  1. Security

    • Validate webhook signatures
    • Use environment variables for keys
    • Implement proper error handling
  2. Database

    • Store subscription data
    • Track order history
    • Maintain customer records
  3. User Experience

    • Handle loading states
    • Provide clear error messages
    • Implement proper redirects
  4. Testing

    • Use test mode for development
    • Validate webhook handling
    • Test subscription flows

Next Steps