Back to Blog

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

11 min read

Lemon Squeezy is a merchant of record for digital products and SaaS. It handles payments, taxes, and license keys. But you still need to send your own emails for onboarding, subscription updates, and custom notifications.

This guide covers how to send emails when Lemon Squeezy events fire.

How Lemon Squeezy Webhooks Work

You create webhooks in the Lemon Squeezy dashboard (Settings > Webhooks). Select events, provide an endpoint URL and a signing secret. Lemon Squeezy sends POST requests with a HMAC signature in the X-Signature header.

Key events:

  • order_created - New purchase
  • subscription_created - New subscription started
  • subscription_updated - Subscription changed (upgrade, downgrade, pause, resume)
  • subscription_cancelled - Subscription cancelled
  • subscription_payment_failed - Payment failed
  • license_key_created - License key generated

Set Up

Terminal
npm install sequenzy
Terminal
npm install resend
Terminal
npm install @sendgrid/mail
# .env
LEMON_SQUEEZY_WEBHOOK_SECRET=your_webhook_secret

Webhook Handler (Next.js)

app/api/webhooks/lemon-squeezy/route.ts
import { NextRequest, NextResponse } from "next/server";
import Sequenzy from "sequenzy";
import crypto from "crypto";

const sequenzy = new Sequenzy();

function verifyWebhook(body: string, signature: string, secret: string): boolean {
const expected = crypto
  .createHmac("sha256", secret)
  .update(body)
  .digest("hex");
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

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

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

const event = JSON.parse(body);
const eventName = event.meta.event_name;
const data = event.data.attributes;

switch (eventName) {
  case "order_created": {
    const email = data.user_email;

    await sequenzy.transactional.send({
      to: email,
      subject: "Order confirmed!",
      body: `
        <h1>Thanks for your purchase!</h1>
        <p>Your order for <strong>${data.first_order_item.product_name}</strong> has been confirmed.</p>
        <p>Amount: ${data.total_formatted}</p>
        <a href="${process.env.APP_URL}/dashboard"
           style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
          Go to Dashboard
        </a>
      `,
    });

    await sequenzy.subscribers.create({
      email,
      tags: ["customer", "lemon-squeezy"],
      customAttributes: { product: data.first_order_item.product_name },
    });
    break;
  }

  case "subscription_cancelled": {
    const email = data.user_email;

    await sequenzy.transactional.send({
      to: email,
      subject: "Your subscription has been cancelled",
      body: `
        <h2>We're sorry to see you go</h2>
        <p>Your subscription has been cancelled. You'll have access until ${new Date(data.ends_at).toLocaleDateString()}.</p>
        <a href="${process.env.APP_URL}/pricing"
           style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
          Resubscribe
        </a>
      `,
    });
    break;
  }

  case "subscription_payment_failed": {
    const email = data.user_email;

    await sequenzy.transactional.send({
      to: email,
      subject: "Payment failed - action needed",
      body: `
        <h2>Your payment failed</h2>
        <p>We couldn't process your latest payment. Please update your payment method to keep your subscription active.</p>
        <a href="${data.urls.update_payment_method}"
           style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
          Update Payment Method
        </a>
      `,
    });
    break;
  }

  case "license_key_created": {
    const email = data.user_email;
    const key = event.data.attributes.key;

    await sequenzy.transactional.send({
      to: email,
      subject: "Your license key",
      body: `
        <h1>Here's your license key</h1>
        <p style="font-family:monospace;font-size:18px;background:#f3f4f6;padding:12px;border-radius:6px;">${key}</p>
        <p>Keep this safe. You'll need it to activate the product.</p>
      `,
    });
    break;
  }
}

return NextResponse.json({ received: true });
}
app/api/webhooks/lemon-squeezy/route.ts
import { NextRequest, NextResponse } from "next/server";
import { Resend } from "resend";
import crypto from "crypto";

const resend = new Resend(process.env.RESEND_API_KEY);
const FROM = "Your App <noreply@yourdomain.com>";

function verifyWebhook(body: string, signature: string, secret: string): boolean {
const expected = crypto.createHmac("sha256", secret).update(body).digest("hex");
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

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

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

const event = JSON.parse(body);
const eventName = event.meta.event_name;
const data = event.data.attributes;

switch (eventName) {
  case "order_created": {
    await resend.emails.send({
      from: FROM, to: data.user_email,
      subject: "Order confirmed!",
      html: `<h1>Thanks for your purchase!</h1>
        <p>Your order for <strong>${data.first_order_item.product_name}</strong> is confirmed.</p>
        <p>Amount: ${data.total_formatted}</p>`,
    });
    break;
  }

  case "subscription_cancelled": {
    await resend.emails.send({
      from: FROM, to: data.user_email,
      subject: "Your subscription has been cancelled",
      html: `<h2>We're sorry to see you go</h2><p>You'll have access until your billing period ends.</p>`,
    });
    break;
  }

  case "license_key_created": {
    const key = event.data.attributes.key;
    await resend.emails.send({
      from: FROM, to: data.user_email,
      subject: "Your license key",
      html: `<h1>Here's your license key</h1>
        <p style="font-family:monospace;font-size:18px;background:#f3f4f6;padding:12px;border-radius:6px;">${key}</p>`,
    });
    break;
  }
}

return NextResponse.json({ received: true });
}
app/api/webhooks/lemon-squeezy/route.ts
import { NextRequest, NextResponse } from "next/server";
import sgMail from "@sendgrid/mail";
import crypto from "crypto";

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

function verifyWebhook(body: string, signature: string, secret: string): boolean {
const expected = crypto.createHmac("sha256", secret).update(body).digest("hex");
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

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

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

const event = JSON.parse(body);
const eventName = event.meta.event_name;
const data = event.data.attributes;

switch (eventName) {
  case "order_created": {
    await sgMail.send({
      to: data.user_email, from: FROM,
      subject: "Order confirmed!",
      html: `<h1>Thanks for your purchase!</h1>
        <p>Your order for <strong>${data.first_order_item.product_name}</strong> is confirmed.</p>`,
    });
    break;
  }

  case "subscription_cancelled": {
    await sgMail.send({
      to: data.user_email, from: FROM,
      subject: "Your subscription has been cancelled",
      html: `<h2>We're sorry to see you go</h2><p>You'll have access until your billing period ends.</p>`,
    });
    break;
  }

  case "license_key_created": {
    const key = event.data.attributes.key;
    await sgMail.send({
      to: data.user_email, from: FROM,
      subject: "Your license key",
      html: `<h1>Here's your license key</h1>
        <p style="font-family:monospace;font-size:18px;">${key}</p>`,
    });
    break;
  }
}

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

Skip the Webhooks: Native Integration

If you're using Sequenzy, you can connect Lemon Squeezy directly in the dashboard (Settings > Integrations). The native integration automatically handles all payment events, applies tags, and triggers automated sequences. No webhook code needed.

Wrapping Up

  1. Webhook handler for orders, subscriptions, and license keys
  2. HMAC signature verification for security
  3. Native integration with Sequenzy to skip webhook code
  4. License key delivery via transactional email

For most apps, connecting Lemon Squeezy to Sequenzy is the simplest path. Use manual webhooks when you need custom logic.