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

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 purchasesubscription_created- New subscription startedsubscription_updated- Subscription changed (upgrade, downgrade, pause, resume)subscription_cancelled- Subscription cancelledsubscription_payment_failed- Payment failedlicense_key_created- License key generated
Set Up
npm install sequenzynpm install resendnpm install @sendgrid/mail# .env
LEMON_SQUEEZY_WEBHOOK_SECRET=your_webhook_secretWebhook Handler (Next.js)
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 });
}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 });
}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
- Webhook handler for orders, subscriptions, and license keys
- HMAC signature verification for security
- Native integration with Sequenzy to skip webhook code
- 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.