Back to Blog

How to Send Emails from Lemon Squeezy Webhooks (2026 Guide)

18 min read

Lemon Squeezy is a merchant of record for digital products and SaaS. It handles payments, sales tax, license keys, and subscription management globally. Like Paddle, it takes care of the financial side — but you still need to send your own product emails: welcome messages, onboarding, subscription updates, license key delivery, and failed payment alerts.

This guide covers how to send emails from Lemon Squeezy webhook events: verifying signatures, handling orders and subscriptions, delivering license keys, managing subscription pausing/resuming, and building production-ready error handling. All code examples use TypeScript and let you switch between email providers.

How Lemon Squeezy Webhooks Work

Lemon Squeezy sends webhooks when payment and subscription events happen. You create a webhook in the dashboard under Settings > Webhooks, provide an endpoint URL and a signing secret, and select which events to listen for. Each request includes:

  • A JSON body with the event data in JSON:API format
  • An X-Signature header with an HMAC-SHA256 hash for verification
  • An X-Event-Name header with the event type
  • Event metadata in body.meta including event_name and custom_data

The JSON:API format is unique to Lemon Squeezy among payment providers — event data lives in body.data.attributes rather than directly in the body.

Which Events to Listen For

EventWhen It FiresEmail to Send
order_createdNew purchase (one-time or first subscription)Welcome + order confirmation
subscription_createdNew subscription startedSubscription welcome
subscription_updatedStatus change, plan change, pause, or resumeAppropriate confirmation
subscription_cancelledSubscription set to cancel at period endCancellation follow-up
subscription_expiredSubscription access endedAccess removed notice
subscription_resumedPaused subscription resumedWelcome back
subscription_pausedSubscription pausedPause confirmation
subscription_payment_failedPayment declinedUpdate payment method
license_key_createdLicense key generatedKey delivery email

Configure these in the Lemon Squeezy Dashboard under Settings > Webhooks > Add Webhook.

Set Up Your Email Provider

Terminal
npm install sequenzy
Terminal
npm install resend
Terminal
npm install @sendgrid/mail

Add your keys to .env:

LEMON_SQUEEZY_WEBHOOK_SECRET=your_webhook_signing_secret
APP_URL=https://app.yoursite.com
.env
SEQUENZY_API_KEY=sq_your_api_key_here
.env
RESEND_API_KEY=re_your_api_key_here
.env
SENDGRID_API_KEY=SG.your_api_key_here

Initialize the clients:

lib/email.ts
import Sequenzy from "sequenzy";

// Reads SEQUENZY_API_KEY from env automatically
export const sequenzy = new Sequenzy();
lib/email.ts
import { Resend } from "resend";

export const resend = new Resend(process.env.RESEND_API_KEY);
export const FROM = "Your App <noreply@yourdomain.com>";
lib/email.ts
import sgMail from "@sendgrid/mail";

sgMail.setApiKey(process.env.SENDGRID_API_KEY!);
export { sgMail };
export const FROM = "noreply@yourdomain.com";

Verify Lemon Squeezy Webhook Signatures

Lemon Squeezy signs every webhook with HMAC-SHA256 using your webhook secret. The signature is in the X-Signature header:

// lib/lemon-squeezy.ts
import crypto from "crypto";
 
export function verifyLemonSqueezyWebhook(
  rawBody: string,
  signature: string,
  secret: string
): boolean {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");
 
  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );
  } catch {
    return false;
  }
}

Understand the Event Payload

Lemon Squeezy uses JSON:API format, which is different from Stripe or Paddle. Here's the structure:

// Types for Lemon Squeezy webhook payloads
interface LemonSqueezyWebhookEvent {
  meta: {
    event_name: string;
    custom_data?: Record<string, string>; // Your passthrough data from checkout
  };
  data: {
    id: string;
    type: string;
    attributes: Record<string, unknown>;
    relationships: Record<string, unknown>;
  };
}
 
// Key attributes by event type:
//
// order_created:
//   - user_email, user_name
//   - total, total_formatted, currency
//   - first_order_item: { product_name, variant_name }
//   - urls: { receipt }
//   - status: "paid"
//
// subscription_*:
//   - user_email, user_name
//   - product_name, variant_name
//   - status: "active" | "cancelled" | "expired" | "paused" | "past_due" | "on_trial"
//   - renews_at, ends_at, trial_ends_at
//   - urls: { update_payment_method, customer_portal }
//
// license_key_created:
//   - key (the actual license key string)
//   - activation_limit
//   - expires_at

Organize Email Templates

// lib/lemon-squeezy-emails.ts
 
function layout(content: string): string {
  return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"></head>
<body style="margin:0;padding:0;background:#f6f9fc;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
  <table role="presentation" width="100%" cellpadding="0" cellspacing="0">
    <tr><td align="center" style="padding:40px 20px;">
      <table role="presentation" width="480" cellpadding="0" cellspacing="0"
             style="background:#fff;border-radius:8px;overflow:hidden;">
        <tr><td style="padding:40px;">${content}</td></tr>
      </table>
    </td></tr>
  </table>
</body>
</html>`;
}
 
function cta(text: string, href: string): string {
  return `<a href="${href}"
    style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;
           border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;margin-top:16px;">
    ${text}
  </a>`;
}
 
function escapeHtml(text: string): string {
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
 
// --- Order Confirmation ---
 
export function orderConfirmationEmail(params: {
  userName: string;
  productName: string;
  totalFormatted: string;
  receiptUrl?: string;
  appUrl: string;
}): string {
  return layout(`
    <h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Thanks for your purchase!</h1>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      Hi ${escapeHtml(params.userName)}, your order has been confirmed.
    </p>
    <table style="width:100%;border-collapse:collapse;margin:16px 0;">
      <tr>
        <td style="padding:12px 0;border-bottom:1px solid #e5e7eb;color:#374151;">Product</td>
        <td style="padding:12px 0;border-bottom:1px solid #e5e7eb;text-align:right;color:#374151;">
          ${escapeHtml(params.productName)}
        </td>
      </tr>
      <tr>
        <td style="padding:12px 0;font-weight:600;color:#111827;">Total</td>
        <td style="padding:12px 0;text-align:right;font-weight:600;color:#111827;">
          ${escapeHtml(params.totalFormatted)}
        </td>
      </tr>
    </table>
    ${params.receiptUrl ? `<p><a href="${params.receiptUrl}" style="color:#f97316;font-size:14px;">View receipt</a></p>` : ""}
    ${cta("Go to Dashboard", `${params.appUrl}/dashboard`)}
  `);
}
 
// --- Subscription Welcome ---
 
export function subscriptionWelcomeEmail(params: {
  userName: string;
  productName: string;
  variantName?: string;
  appUrl: string;
}): string {
  const planDisplay = params.variantName
    ? `${escapeHtml(params.productName)} (${escapeHtml(params.variantName)})`
    : escapeHtml(params.productName);
 
  return layout(`
    <h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Welcome aboard!</h1>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      Hi ${escapeHtml(params.userName)}, your <strong>${planDisplay}</strong>
      subscription is now active. You have full access to all features.
    </p>
    ${cta("Get Started", `${params.appUrl}/dashboard`)}
  `);
}
 
// --- Trial Started ---
 
export function trialStartedEmail(params: {
  userName: string;
  productName: string;
  trialEndDate: string;
  appUrl: string;
}): string {
  return layout(`
    <h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Your Trial Has Started</h1>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      Hi ${escapeHtml(params.userName)}, welcome! You have full access to
      <strong>${escapeHtml(params.productName)}</strong> until
      <strong>${params.trialEndDate}</strong>.
    </p>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      No charge today. You'll only be billed if you continue after the trial.
    </p>
    ${cta("Start Exploring", `${params.appUrl}/dashboard`)}
  `);
}
 
// --- Payment Failed ---
 
export function paymentFailedEmail(params: {
  updatePaymentUrl: string;
  appUrl: string;
}): string {
  return layout(`
    <h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Payment Failed</h1>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      We couldn't process your latest payment. Please update your payment method
      to keep your subscription active.
    </p>
    ${cta("Update Payment Method", params.updatePaymentUrl)}
    <p style="font-size:14px;color:#6b7280;margin-top:24px;">
      If you need help, reply to this email.
    </p>
  `);
}
 
// --- Subscription Cancelled ---
 
export function cancellationEmail(params: {
  productName: string;
  endDate: string;
  pricingUrl: string;
}): string {
  return layout(`
    <h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Subscription Cancelled</h1>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      We're sorry to see you go. Your <strong>${escapeHtml(params.productName)}</strong>
      subscription has been cancelled.
    </p>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      You'll continue to have access until <strong>${params.endDate}</strong>.
    </p>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      Changed your mind? You can resubscribe at any time.
    </p>
    ${cta("Resubscribe", params.pricingUrl)}
  `);
}
 
// --- Subscription Expired ---
 
export function expiredEmail(params: {
  productName: string;
  pricingUrl: string;
}): string {
  return layout(`
    <h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Access Removed</h1>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      Your <strong>${escapeHtml(params.productName)}</strong> subscription has
      expired and your access has been removed.
    </p>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      Want to come back? Resubscribe to regain full access.
    </p>
    ${cta("Resubscribe", params.pricingUrl)}
  `);
}
 
// --- Subscription Paused ---
 
export function pausedEmail(params: {
  productName: string;
  resumeUrl: string;
}): string {
  return layout(`
    <h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Subscription Paused</h1>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      Your <strong>${escapeHtml(params.productName)}</strong> subscription has been paused.
      You won't be charged during this time.
    </p>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      Ready to come back? Resume your subscription at any time.
    </p>
    ${cta("Resume Subscription", params.resumeUrl)}
  `);
}
 
// --- Subscription Resumed ---
 
export function resumedEmail(params: {
  productName: string;
  appUrl: string;
}): string {
  return layout(`
    <h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Welcome Back!</h1>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      Your <strong>${escapeHtml(params.productName)}</strong> subscription has been
      resumed. Full access is restored.
    </p>
    ${cta("Go to Dashboard", `${params.appUrl}/dashboard`)}
  `);
}
 
// --- License Key Delivery ---
 
export function licenseKeyEmail(params: {
  productName: string;
  licenseKey: string;
  activationLimit: number;
  expiresAt?: string;
}): string {
  return layout(`
    <h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Your License Key</h1>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      Here's your license key for <strong>${escapeHtml(params.productName)}</strong>:
    </p>
    <div style="background:#f3f4f6;border-radius:8px;padding:20px;margin:16px 0;text-align:center;">
      <code style="font-size:20px;color:#111827;letter-spacing:1px;word-break:break-all;">
        ${escapeHtml(params.licenseKey)}
      </code>
    </div>
    <table style="width:100%;border-collapse:collapse;margin:16px 0;">
      <tr>
        <td style="padding:8px 0;color:#6b7280;font-size:14px;">Activation limit</td>
        <td style="padding:8px 0;text-align:right;font-size:14px;color:#374151;">
          ${params.activationLimit} device${params.activationLimit !== 1 ? "s" : ""}
        </td>
      </tr>
      ${params.expiresAt ? `
      <tr>
        <td style="padding:8px 0;color:#6b7280;font-size:14px;">Expires</td>
        <td style="padding:8px 0;text-align:right;font-size:14px;color:#374151;">
          ${params.expiresAt}
        </td>
      </tr>` : ""}
    </table>
    <p style="font-size:14px;color:#6b7280;">
      Keep this key safe. You can also find it in your customer portal.
    </p>
  `);
}

The Complete Webhook Handler

app/api/webhooks/lemon-squeezy/route.ts
import { NextRequest, NextResponse } from "next/server";
import { sequenzy } from "@/lib/email";
import { verifyLemonSqueezyWebhook } from "@/lib/lemon-squeezy";
import {
orderConfirmationEmail,
subscriptionWelcomeEmail,
trialStartedEmail,
paymentFailedEmail,
cancellationEmail,
expiredEmail,
pausedEmail,
resumedEmail,
licenseKeyEmail,
} from "@/lib/lemon-squeezy-emails";

const APP_URL = process.env.APP_URL!;

export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("x-signature") ?? "";

if (!verifyLemonSqueezyWebhook(body, signature, process.env.LEMON_SQUEEZY_WEBHOOK_SECRET!)) {
  return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}

const event = JSON.parse(body);

try {
  await handleLemonSqueezyEvent(event);
} catch (error) {
  console.error(`LS webhook failed for ${event.meta.event_name}:`, error);
}

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

interface LemonSqueezyEvent {
meta: {
  event_name: string;
  custom_data?: Record<string, string>;
};
data: {
  id: string;
  type: string;
  attributes: Record<string, unknown>;
};
}

async function handleLemonSqueezyEvent(event: LemonSqueezyEvent) {
const eventName = event.meta.event_name;
const attrs = event.data.attributes;
const email = attrs.user_email as string;
const userName = (attrs.user_name as string) ?? "there";

if (!email) return;

switch (eventName) {
  case "order_created": {
    const firstItem = attrs.first_order_item as {
      product_name: string;
      variant_name: string;
    };
    const totalFormatted = attrs.total_formatted as string;
    const urls = attrs.urls as { receipt?: string } | undefined;

    await sequenzy.transactional.send({
      to: email,
      subject: `Order confirmed — ${totalFormatted}`,
      body: orderConfirmationEmail({
        userName,
        productName: firstItem.product_name,
        totalFormatted,
        receiptUrl: urls?.receipt,
        appUrl: APP_URL,
      }),
    });

    // Add as subscriber for marketing
    await sequenzy.subscribers.create({
      email,
      firstName: userName,
      tags: ["customer", "lemon-squeezy"],
      customAttributes: {
        product: firstItem.product_name,
        variant: firstItem.variant_name,
      },
    });
    break;
  }

  case "subscription_created": {
    const productName = attrs.product_name as string;
    const variantName = attrs.variant_name as string;
    const status = attrs.status as string;

    if (status === "on_trial") {
      const trialEndsAt = attrs.trial_ends_at as string | null;
      const trialEndDate = trialEndsAt
        ? new Date(trialEndsAt).toLocaleDateString()
        : "soon";

      await sequenzy.transactional.send({
        to: email,
        subject: "Your free trial has started!",
        body: trialStartedEmail({
          userName,
          productName,
          trialEndDate,
          appUrl: APP_URL,
        }),
      });
    } else {
      await sequenzy.transactional.send({
        to: email,
        subject: `Welcome! Your ${productName} subscription is active`,
        body: subscriptionWelcomeEmail({
          userName,
          productName,
          variantName,
          appUrl: APP_URL,
        }),
      });
    }

    await sequenzy.subscribers.create({
      email,
      firstName: userName,
      tags: [status === "on_trial" ? "trial" : "customer", "lemon-squeezy"],
      customAttributes: {
        product: productName,
        variant: variantName,
        lsSubscriptionId: event.data.id,
      },
    });
    break;
  }

  case "subscription_updated": {
    const status = attrs.status as string;
    const productName = attrs.product_name as string;
    const urls = attrs.urls as {
      update_payment_method?: string;
      customer_portal?: string;
    } | undefined;

    // Handle different status transitions
    if (status === "paused") {
      await sequenzy.transactional.send({
        to: email,
        subject: "Your subscription has been paused",
        body: pausedEmail({
          productName,
          resumeUrl: urls?.customer_portal ?? `${APP_URL}/billing`,
        }),
      });
    } else if (status === "past_due") {
      await sequenzy.transactional.send({
        to: email,
        subject: "Payment failed — action required",
        body: paymentFailedEmail({
          updatePaymentUrl: urls?.update_payment_method ?? `${APP_URL}/billing`,
          appUrl: APP_URL,
        }),
      });

      await sequenzy.subscribers.tags.add({
        email,
        tag: "past-due",
      });
    }
    break;
  }

  case "subscription_cancelled": {
    const productName = attrs.product_name as string;
    const endsAt = attrs.ends_at as string | null;
    const endDate = endsAt
      ? new Date(endsAt).toLocaleDateString()
      : "the end of your billing period";

    await sequenzy.transactional.send({
      to: email,
      subject: "Your subscription has been cancelled",
      body: cancellationEmail({
        productName,
        endDate,
        pricingUrl: `${APP_URL}/pricing`,
      }),
    });

    await sequenzy.subscribers.tags.add({
      email,
      tag: "cancelled",
    });
    break;
  }

  case "subscription_expired": {
    const productName = attrs.product_name as string;

    await sequenzy.transactional.send({
      to: email,
      subject: "Your subscription access has ended",
      body: expiredEmail({
        productName,
        pricingUrl: `${APP_URL}/pricing`,
      }),
    });

    await sequenzy.subscribers.tags.add({
      email,
      tag: "churned",
    });
    break;
  }

  case "subscription_resumed": {
    const productName = attrs.product_name as string;

    await sequenzy.transactional.send({
      to: email,
      subject: "Welcome back! Subscription resumed",
      body: resumedEmail({
        productName,
        appUrl: APP_URL,
      }),
    });

    // Remove pause/cancelled tags
    await sequenzy.subscribers.tags.remove({
      email,
      tag: "cancelled",
    });
    break;
  }

  case "subscription_paused": {
    const productName = attrs.product_name as string;
    const urls = attrs.urls as { customer_portal?: string } | undefined;

    await sequenzy.transactional.send({
      to: email,
      subject: "Your subscription has been paused",
      body: pausedEmail({
        productName,
        resumeUrl: urls?.customer_portal ?? `${APP_URL}/billing`,
      }),
    });
    break;
  }

  case "subscription_payment_failed": {
    const urls = attrs.urls as { update_payment_method?: string } | undefined;

    await sequenzy.transactional.send({
      to: email,
      subject: "Payment failed — action required",
      body: paymentFailedEmail({
        updatePaymentUrl: urls?.update_payment_method ?? `${APP_URL}/billing`,
        appUrl: APP_URL,
      }),
    });

    await sequenzy.subscribers.tags.add({
      email,
      tag: "past-due",
    });
    break;
  }

  case "license_key_created": {
    const key = attrs.key as string;
    const activationLimit = (attrs.activation_limit as number) ?? 1;
    const expiresAt = attrs.expires_at as string | null;
    const productName = (attrs.product_name as string) ?? "your purchase";

    await sequenzy.transactional.send({
      to: email,
      subject: `Your license key for ${productName}`,
      body: licenseKeyEmail({
        productName,
        licenseKey: key,
        activationLimit,
        expiresAt: expiresAt ? new Date(expiresAt).toLocaleDateString() : undefined,
      }),
    });
    break;
  }
}
}
app/api/webhooks/lemon-squeezy/route.ts
import { NextRequest, NextResponse } from "next/server";
import { resend, FROM } from "@/lib/email";
import { verifyLemonSqueezyWebhook } from "@/lib/lemon-squeezy";
import {
orderConfirmationEmail,
subscriptionWelcomeEmail,
trialStartedEmail,
paymentFailedEmail,
cancellationEmail,
expiredEmail,
pausedEmail,
resumedEmail,
licenseKeyEmail,
} from "@/lib/lemon-squeezy-emails";

const APP_URL = process.env.APP_URL!;

export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("x-signature") ?? "";

if (!verifyLemonSqueezyWebhook(body, signature, process.env.LEMON_SQUEEZY_WEBHOOK_SECRET!)) {
  return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}

const event = JSON.parse(body);

try {
  await handleLemonSqueezyEvent(event);
} catch (error) {
  console.error(`LS webhook failed for ${event.meta.event_name}:`, error);
}

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

async function handleLemonSqueezyEvent(event: Record<string, unknown>) {
const meta = event.meta as { event_name: string };
const data = event.data as { id: string; attributes: Record<string, unknown> };
const attrs = data.attributes;
const email = attrs.user_email as string;
const userName = (attrs.user_name as string) ?? "there";

if (!email) return;

switch (meta.event_name) {
  case "order_created": {
    const firstItem = attrs.first_order_item as { product_name: string };
    const totalFormatted = attrs.total_formatted as string;
    const urls = attrs.urls as { receipt?: string } | undefined;

    await resend.emails.send({
      from: FROM, to: email,
      subject: `Order confirmed — ${totalFormatted}`,
      html: orderConfirmationEmail({
        userName, productName: firstItem.product_name,
        totalFormatted, receiptUrl: urls?.receipt, appUrl: APP_URL,
      }),
    });
    break;
  }

  case "subscription_created": {
    const productName = attrs.product_name as string;
    const variantName = attrs.variant_name as string;
    const status = attrs.status as string;

    if (status === "on_trial") {
      const trialEndsAt = attrs.trial_ends_at as string | null;
      await resend.emails.send({
        from: FROM, to: email,
        subject: "Your free trial has started!",
        html: trialStartedEmail({
          userName, productName,
          trialEndDate: trialEndsAt ? new Date(trialEndsAt).toLocaleDateString() : "soon",
          appUrl: APP_URL,
        }),
      });
    } else {
      await resend.emails.send({
        from: FROM, to: email,
        subject: `Welcome! Your ${productName} subscription is active`,
        html: subscriptionWelcomeEmail({
          userName, productName, variantName, appUrl: APP_URL,
        }),
      });
    }
    break;
  }

  case "subscription_cancelled": {
    const productName = attrs.product_name as string;
    const endsAt = attrs.ends_at as string | null;

    await resend.emails.send({
      from: FROM, to: email,
      subject: "Your subscription has been cancelled",
      html: cancellationEmail({
        productName,
        endDate: endsAt ? new Date(endsAt).toLocaleDateString() : "the end of your billing period",
        pricingUrl: `${APP_URL}/pricing`,
      }),
    });
    break;
  }

  case "subscription_expired": {
    await resend.emails.send({
      from: FROM, to: email,
      subject: "Your subscription access has ended",
      html: expiredEmail({
        productName: attrs.product_name as string,
        pricingUrl: `${APP_URL}/pricing`,
      }),
    });
    break;
  }

  case "subscription_paused": {
    const urls = attrs.urls as { customer_portal?: string } | undefined;
    await resend.emails.send({
      from: FROM, to: email,
      subject: "Your subscription has been paused",
      html: pausedEmail({
        productName: attrs.product_name as string,
        resumeUrl: urls?.customer_portal ?? `${APP_URL}/billing`,
      }),
    });
    break;
  }

  case "subscription_resumed": {
    await resend.emails.send({
      from: FROM, to: email,
      subject: "Welcome back! Subscription resumed",
      html: resumedEmail({
        productName: attrs.product_name as string, appUrl: APP_URL,
      }),
    });
    break;
  }

  case "subscription_payment_failed": {
    const urls = attrs.urls as { update_payment_method?: string } | undefined;
    await resend.emails.send({
      from: FROM, to: email,
      subject: "Payment failed — action required",
      html: paymentFailedEmail({
        updatePaymentUrl: urls?.update_payment_method ?? `${APP_URL}/billing`,
        appUrl: APP_URL,
      }),
    });
    break;
  }

  case "license_key_created": {
    await resend.emails.send({
      from: FROM, to: email,
      subject: `Your license key for ${attrs.product_name ?? "your purchase"}`,
      html: licenseKeyEmail({
        productName: (attrs.product_name as string) ?? "your purchase",
        licenseKey: attrs.key as string,
        activationLimit: (attrs.activation_limit as number) ?? 1,
        expiresAt: attrs.expires_at ? new Date(attrs.expires_at as string).toLocaleDateString() : undefined,
      }),
    });
    break;
  }
}
}
app/api/webhooks/lemon-squeezy/route.ts
import { NextRequest, NextResponse } from "next/server";
import { sgMail, FROM } from "@/lib/email";
import { verifyLemonSqueezyWebhook } from "@/lib/lemon-squeezy";
import {
orderConfirmationEmail,
subscriptionWelcomeEmail,
trialStartedEmail,
paymentFailedEmail,
cancellationEmail,
expiredEmail,
pausedEmail,
resumedEmail,
licenseKeyEmail,
} from "@/lib/lemon-squeezy-emails";

const APP_URL = process.env.APP_URL!;

export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("x-signature") ?? "";

if (!verifyLemonSqueezyWebhook(body, signature, process.env.LEMON_SQUEEZY_WEBHOOK_SECRET!)) {
  return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}

const event = JSON.parse(body);

try {
  await handleLemonSqueezyEvent(event);
} catch (error) {
  console.error(`LS webhook failed for ${event.meta.event_name}:`, error);
}

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

async function handleLemonSqueezyEvent(event: Record<string, unknown>) {
const meta = event.meta as { event_name: string };
const data = event.data as { id: string; attributes: Record<string, unknown> };
const attrs = data.attributes;
const email = attrs.user_email as string;
const userName = (attrs.user_name as string) ?? "there";

if (!email) return;

switch (meta.event_name) {
  case "order_created": {
    const firstItem = attrs.first_order_item as { product_name: string };
    const totalFormatted = attrs.total_formatted as string;
    const urls = attrs.urls as { receipt?: string } | undefined;

    await sgMail.send({
      to: email, from: FROM,
      subject: `Order confirmed — ${totalFormatted}`,
      html: orderConfirmationEmail({
        userName, productName: firstItem.product_name,
        totalFormatted, receiptUrl: urls?.receipt, appUrl: APP_URL,
      }),
    });
    break;
  }

  case "subscription_created": {
    const productName = attrs.product_name as string;
    const variantName = attrs.variant_name as string;
    const status = attrs.status as string;

    if (status === "on_trial") {
      const trialEndsAt = attrs.trial_ends_at as string | null;
      await sgMail.send({
        to: email, from: FROM,
        subject: "Your free trial has started!",
        html: trialStartedEmail({
          userName, productName,
          trialEndDate: trialEndsAt ? new Date(trialEndsAt).toLocaleDateString() : "soon",
          appUrl: APP_URL,
        }),
      });
    } else {
      await sgMail.send({
        to: email, from: FROM,
        subject: `Welcome! Your ${productName} subscription is active`,
        html: subscriptionWelcomeEmail({
          userName, productName, variantName, appUrl: APP_URL,
        }),
      });
    }
    break;
  }

  case "subscription_cancelled": {
    const productName = attrs.product_name as string;
    const endsAt = attrs.ends_at as string | null;

    await sgMail.send({
      to: email, from: FROM,
      subject: "Your subscription has been cancelled",
      html: cancellationEmail({
        productName,
        endDate: endsAt ? new Date(endsAt).toLocaleDateString() : "the end of your billing period",
        pricingUrl: `${APP_URL}/pricing`,
      }),
    });
    break;
  }

  case "subscription_expired": {
    await sgMail.send({
      to: email, from: FROM,
      subject: "Your subscription access has ended",
      html: expiredEmail({
        productName: attrs.product_name as string,
        pricingUrl: `${APP_URL}/pricing`,
      }),
    });
    break;
  }

  case "subscription_paused": {
    const urls = attrs.urls as { customer_portal?: string } | undefined;
    await sgMail.send({
      to: email, from: FROM,
      subject: "Your subscription has been paused",
      html: pausedEmail({
        productName: attrs.product_name as string,
        resumeUrl: urls?.customer_portal ?? `${APP_URL}/billing`,
      }),
    });
    break;
  }

  case "subscription_resumed": {
    await sgMail.send({
      to: email, from: FROM,
      subject: "Welcome back! Subscription resumed",
      html: resumedEmail({
        productName: attrs.product_name as string, appUrl: APP_URL,
      }),
    });
    break;
  }

  case "subscription_payment_failed": {
    const urls = attrs.urls as { update_payment_method?: string } | undefined;
    await sgMail.send({
      to: email, from: FROM,
      subject: "Payment failed — action required",
      html: paymentFailedEmail({
        updatePaymentUrl: urls?.update_payment_method ?? `${APP_URL}/billing`,
        appUrl: APP_URL,
      }),
    });
    break;
  }

  case "license_key_created": {
    await sgMail.send({
      to: email, from: FROM,
      subject: `Your license key for ${attrs.product_name ?? "your purchase"}`,
      html: licenseKeyEmail({
        productName: (attrs.product_name as string) ?? "your purchase",
        licenseKey: attrs.key as string,
        activationLimit: (attrs.activation_limit as number) ?? 1,
        expiresAt: attrs.expires_at ? new Date(attrs.expires_at as string).toLocaleDateString() : undefined,
      }),
    });
    break;
  }
}
}

Passing Custom Data Through Checkout

Lemon Squeezy lets you pass custom data through the checkout that arrives in your webhook. This is useful for linking a purchase to a user in your app:

// When creating a checkout URL, pass your user ID
const checkoutUrl = `https://yourstore.lemonsqueezy.com/checkout/buy/variant-id?checkout[custom][user_id]=${userId}`;
 
// In your webhook handler, access it
const customData = event.meta.custom_data;
const userId = customData?.user_id;
 
if (userId) {
  // Provision the user's account
  await db.update(users)
    .set({ plan: "pro", subscriptionId: event.data.id })
    .where(eq(users.id, userId));
}

Lemon Squeezy's Customer Portal

Lemon Squeezy provides a customer portal where users can manage their subscriptions. Each subscription includes a customer_portal URL. Use it in your emails and app:

const urls = attrs.urls as {
  update_payment_method: string;  // Direct link to update card
  customer_portal: string;        // Full management portal
};
 
// Use the update_payment_method URL in dunning emails
// Use the customer_portal URL in your app's billing page

You can also generate portal sessions via the API:

const response = await fetch(
  "https://api.lemonsqueezy.com/v1/customer-portal-urls",
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.LEMON_SQUEEZY_API_KEY}`,
      "Content-Type": "application/vnd.api+json",
    },
    body: JSON.stringify({
      data: {
        type: "customer-portal-urls",
        attributes: { customer_id: customerId },
      },
    }),
  }
);

Express Version

// routes/lemon-squeezy-webhook.ts
import express from "express";
import { verifyLemonSqueezyWebhook } from "../lib/lemon-squeezy";
 
const router = express.Router();
 
router.post(
  "/webhook",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const body = req.body.toString();
    const signature = req.headers["x-signature"] as string ?? "";
 
    if (!verifyLemonSqueezyWebhook(body, signature, process.env.LEMON_SQUEEZY_WEBHOOK_SECRET!)) {
      return res.status(400).send("Invalid signature");
    }
 
    const event = JSON.parse(body);
 
    try {
      await handleLemonSqueezyEvent(event);
    } catch (error) {
      console.error(`LS webhook failed:`, error);
    }
 
    res.json({ received: true });
  }
);
 
export default router;

Error Handling

Wrap email sends to prevent webhook failures:

async function sendEmailSafe(
  sendFn: () => Promise<unknown>,
  context: { event: string; email: string }
): Promise<void> {
  try {
    await sendFn();
  } catch (error) {
    console.error(
      `Failed to send ${context.event} email to ${context.email}:`,
      error
    );
  }
}

Handle Idempotency

Lemon Squeezy may retry webhooks. Each event has a unique data.id. Use it to deduplicate:

const processedEvents = new Map<string, number>();
 
function isDuplicate(eventId: string, eventName: string): boolean {
  const key = `${eventName}:${eventId}`;
  if (processedEvents.has(key)) return true;
  processedEvents.set(key, Date.now());
  return false;
}
 
// In your handler
const eventKey = `${event.meta.event_name}:${event.data.id}`;
if (isDuplicate(event.data.id, event.meta.event_name)) {
  return NextResponse.json({ received: true }); // Already processed
}

For production, use your database instead of in-memory storage.

Testing Lemon Squeezy Webhooks

Test Mode

Lemon Squeezy has a test mode. Toggle it in the dashboard (top-right dropdown). Test mode uses separate products and webhook secrets. All webhook events fire normally with test data.

Webhook Logs

Check delivery status in the Lemon Squeezy Dashboard under Settings > Webhooks. Click your endpoint to see recent deliveries, payloads, and response codes. You can replay failed deliveries.

Local Testing with Tunneling

# Expose your local server
ngrok http 3000
 
# Register the tunnel URL in Lemon Squeezy
# https://abc123.ngrok.io/api/webhooks/lemon-squeezy

Mock Payloads for Unit Tests

// test/lemon-squeezy-mocks.ts
export const mockOrderCreated = {
  meta: {
    event_name: "order_created",
    custom_data: { user_id: "user_123" },
  },
  data: {
    id: "1",
    type: "orders",
    attributes: {
      user_email: "test@example.com",
      user_name: "Test User",
      total: 4900,
      total_formatted: "$49.00",
      currency: "USD",
      first_order_item: {
        product_name: "Pro Plan",
        variant_name: "Monthly",
      },
      urls: { receipt: "https://app.lemonsqueezy.com/my-orders/..." },
      status: "paid",
    },
  },
};
 
export const mockSubscriptionCancelled = {
  meta: { event_name: "subscription_cancelled" },
  data: {
    id: "1",
    type: "subscriptions",
    attributes: {
      user_email: "test@example.com",
      user_name: "Test User",
      product_name: "Pro Plan",
      variant_name: "Monthly",
      status: "cancelled",
      ends_at: "2026-03-20T00:00:00.000000Z",
      urls: {
        update_payment_method: "https://app.lemonsqueezy.com/...",
        customer_portal: "https://app.lemonsqueezy.com/...",
      },
    },
  },
};

The Smarter Approach: Native Integration

Writing webhook handlers for every Lemon Squeezy event is boilerplate. If you're using Sequenzy, you can skip most of it.

Sequenzy has a native Lemon Squeezy integration. Connect your account in the dashboard (Settings > Integrations), and it automatically:

  • Tracks all payment events — purchase, cancellation, churn, failed payment
  • Applies status tags to subscribers — customer, trial, cancelled, churned, past-due
  • Syncs subscription data — MRR, product name, billing interval
  • Triggers automated sequences based on lifecycle events

Instead of webhook code for lifecycle emails, set up sequences in the dashboard:

SequenceTriggerStops WhenPurpose
Trial ConversionTag trial addedUser gets customer tagConvert trial to paid
DunningTag past-due addedUser no longer past-dueRecover failed payments
Win-BackTag cancelled addedUser gets customer tagRe-engage cancelled users
Churn RecoveryTag churned addedUser gets customer tagWin back churned users

You still need webhooks for custom logic (like provisioning accounts, granting access, or delivering license keys), but lifecycle email sequences are handled automatically.

Going to Production

1. Verify Your Email Domain

Add SPF, DKIM, and DMARC DNS records. Payment emails going to spam is a terrible customer experience.

2. Don't Duplicate Lemon Squeezy's Receipts

Lemon Squeezy already sends detailed receipts and invoices as the merchant of record. Your emails should focus on product communication — welcome to your app, here's how to get started, here's your license key.

3. Use Test Mode First

Always test with Lemon Squeezy's test mode before going live. Test mode uses separate webhook secrets and doesn't process real payments.

4. Handle the JSON:API Format

Remember that Lemon Squeezy uses JSON:API format. Event data is in event.data.attributes, not directly in the event body. The event type is in event.meta.event_name, not event.type.

Production Checklist

StepWhatWhy
Signature verificationVerify X-Signature headerPrevent spoofed events
IdempotencyDeduplicate by event ID + nameHandle retries safely
Domain verificationSPF, DKIM, DMARC recordsInbox delivery, not spam
Test mode firstUse test mode before liveCatch bugs before real payments
Error isolationWrap email sends in try-catchDon't crash the handler
Customer portalUse Lemon Squeezy's portal URLsDon't build your own billing UI
LoggingLog event names and outcomesDebug failures in production

Beyond Transactional: Marketing Emails and Sequences

At some point, you'll want more than one-off emails — onboarding sequences, feature announcements, re-engagement campaigns. Most teams wire together a transactional provider with a separate marketing tool.

Sequenzy handles both from one platform. Same SDK, same dashboard. Transactional sends, marketing campaigns, automated sequences, subscriber segments, and native Lemon Squeezy integration.

import { sequenzy } from "@/lib/email";
 
// When an order is created
await sequenzy.subscribers.create({
  email: customerEmail,
  firstName: userName,
  tags: ["customer", "lemon-squeezy"],
  customAttributes: {
    product: productName,
    variant: variantName,
    purchaseDate: new Date().toISOString(),
  },
});
 
// Track custom events
await sequenzy.subscribers.events.trigger({
  email: customerEmail,
  event: "onboarding.completed",
  properties: { completedSteps: 5 },
});

FAQ

How is Lemon Squeezy's webhook format different from Stripe's?

Lemon Squeezy uses JSON:API format. The event type is in event.meta.event_name (not event.type), and the data is in event.data.attributes (not event.data.object). The signature is a simple HMAC-SHA256 of the body in the X-Signature header, which is simpler than Stripe's timestamp-based format.

What's the difference between subscription_cancelled and subscription_expired?

subscription_cancelled fires when a subscription is set to cancel at the end of the billing period — the customer still has access until then. subscription_expired fires when access is actually removed (after the period ends). Send a cancellation follow-up on cancelled and an access-removed notice on expired.

How do I deliver license keys?

Listen for license_key_created events. The key is in event.data.attributes.key. The event also includes activation_limit (how many devices) and expires_at (if the key expires). Send the key in a nicely formatted email and remind users they can also find it in the Lemon Squeezy customer portal.

Does Lemon Squeezy send its own receipts?

Yes. Lemon Squeezy is a merchant of record and sends its own payment receipts and invoices. You don't need to duplicate these. Focus your emails on product communication — welcome messages, onboarding, feature access, license keys.

How do I pass my app's user ID through checkout?

Use the custom parameter in the checkout URL: ?checkout[custom][user_id]=123. This data arrives in event.meta.custom_data in your webhook, letting you link the Lemon Squeezy purchase to your app's user record.

Can I test webhooks without making real purchases?

Yes. Lemon Squeezy has a test mode (toggle in the top-right of the dashboard). All payment events fire normally with test data. You can also replay failed webhook deliveries from the webhook logs.

How do I handle subscription pausing?

Lemon Squeezy supports pausing subscriptions natively. Listen for subscription_paused to send a pause confirmation. The subscription_resumed event fires when they resume. You can also catch paused status in subscription_updated events.

What's the X-Event-Name header?

Lemon Squeezy includes the event name in both the X-Event-Name header and event.meta.event_name in the body. You can use either, but the body is more reliable since it's included in the signature verification.

How do I get the customer's payment update URL?

Subscription events include data.attributes.urls.update_payment_method — a Lemon Squeezy-hosted page where the customer can update their card. Use this in dunning emails instead of building your own payment form.

What happens if my webhook endpoint is down?

Lemon Squeezy retries failed webhook deliveries with exponential backoff. You can see delivery attempts and replay failed events in the dashboard under Settings > Webhooks. The events are stored for replay, so you won't lose data during downtime.

Wrapping Up

Here's what we covered:

  1. Webhook handler for all major Lemon Squeezy events — orders, subscriptions, trials, pauses, cancellations, expirations, license keys
  2. Signature verification with HMAC-SHA256 and timing-safe comparison
  3. Email templates organized as functions for clean, testable code
  4. JSON:API format — how to navigate Lemon Squeezy's unique payload structure
  5. License key delivery emails with activation limits and expiration dates
  6. Custom data passthrough from checkout to webhook for user linking
  7. Customer portal URLs for payment updates without building a billing UI
  8. Error handling and idempotency for production reliability
  9. Native integration with Sequenzy to skip webhook boilerplate for lifecycle emails
  10. Production checklist: test mode, domain verification, error isolation

For most apps, connecting Lemon Squeezy to Sequenzy and setting up automated sequences is the simplest path. Use manual webhooks for license key delivery, account provisioning, and custom business logic.

Frequently Asked Questions

Which Lemon Squeezy webhook events should I listen for to send emails?

The most important are order_created (purchase confirmation), subscription_created (new subscription), subscription_payment_failed (dunning), and subscription_cancelled. Start with these four and add more events as your email flows mature.

How do I verify Lemon Squeezy webhook signatures?

Use the webhook secret from your Lemon Squeezy dashboard to compute an HMAC-SHA256 hash of the raw request body. Compare it to the X-Signature header. Never skip verification in production—it prevents attackers from forging webhook events.

Can I use Lemon Squeezy's built-in emails instead of custom ones?

Lemon Squeezy sends basic transactional emails (receipts, subscription confirmations) automatically. Custom emails let you control the branding, add onboarding content, trigger automation sequences, and send emails based on custom logic. Use both—Lemon Squeezy for receipts, custom for everything else.

How do I handle duplicate webhook deliveries from Lemon Squeezy?

Store processed event IDs (from the webhook payload) in your database. Before processing an event, check if you've seen that ID before. Lemon Squeezy may retry webhooks if your endpoint doesn't respond with 200 quickly enough, so idempotency is important.

What should I do if my webhook endpoint is down when Lemon Squeezy sends an event?

Lemon Squeezy retries failed webhooks several times with increasing delays. As long as your endpoint comes back up within the retry window, you won't miss events. For critical flows, periodically poll the Lemon Squeezy API to reconcile any missed events.

How do I send different emails based on the product purchased?

Parse the product_id or variant_id from the webhook payload and use conditional logic to select the appropriate email template. Map product IDs to email templates in a configuration object for clean, maintainable code.

Should I send emails synchronously in the webhook handler?

For single emails, sending synchronously is fine since Lemon Squeezy allows up to 15 seconds for webhook responses. For complex flows (multiple emails, database updates), return 200 immediately and process asynchronously via a job queue to avoid timeouts.

How do I handle subscription renewals and payment reminders?

Listen for subscription_payment_success for renewal receipts and subscription_payment_failed for dunning emails. For upcoming renewal reminders, use subscription_updated events or schedule emails based on the renews_at timestamp from the subscription data.

Can I trigger automated email sequences from Lemon Squeezy events?

Yes. When a webhook fires, add the customer as a subscriber with relevant tags (e.g., "customer", "pro-plan") to your email platform. These tags can trigger automated sequences like onboarding drips, upsell campaigns, or usage tips.

How do I test Lemon Squeezy webhooks locally during development?

Use Lemon Squeezy's test mode to generate fake events. Expose your local server with ngrok or a similar tool and register the ngrok URL as your webhook endpoint. This lets you receive real webhook payloads against your local code.