Lemon Squeezy Webhook Confirmation Email with Rovela (2026): X-Signature + Idempotency-Key
Verify Lemon Squeezy X-Signature on the raw body, handle order_created / subscription_created, and send branded confirmations via Rovela POST /emails with Idempotency-Key = ls-order_created-{id} or ls-subscription_created-{id}. Lemon Squeezy owns checkout; Rovela delivers. Distinct from Stripe and Shopify webhook posts.
Lemon Squeezy owns checkout — Rovela delivers the confirmation
Lemon Squeezy notifies your server when a customer pays for a product or starts a subscription. Its built-in customer emails are store-side; they are not a custom HTTP send from your verified product domain with your template and your Idempotency-Key. Rovela queues, DKIM-signs, and delivers that custom transactional mail when your webhook handler calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication, Templates).
This guide wires a Lemon Squeezy webhook that:
- Verifies
X-Signature(HMAC-SHA256 of the raw body with your webhook signing secret) (Signing requests) - Handles
order_createdand/orsubscription_created(fromX-Event-Name/meta.event_name) - Sends a purchase or subscription confirmation through Rovela
- Sets a stable
Idempotency-Keyso Lemon Squeezy retries never double-mail
Rovela does not create Lemon Squeezy checkouts, register webhooks, or verify X-Signature. Lemon Squeezy owns commerce and event delivery. Your app owns mapping webhook → customer email → template variables. Rovela only delivers.
Distinct from:
- Stripe Webhook Receipt Email —
Stripe-Signature+ Checkout/Invoice/PaymentIntent events, not Lemon Squeezy JSON:API webhooks - Shopify Order Webhook Email —
X-Shopify-Hmac-SHA256+orders/create, not Lemon Squeezyorder_created/subscription_created - E-Commerce Order Emails — generic OMS/cart lifecycle triggers without Lemon Squeezy signature verification
What Lemon Squeezy does vs what Rovela sends
| Concern | Owner |
|---|---|
| Checkout, products, subscriptions, tax, payouts | Lemon Squeezy |
Webhook URL + signing secret + X-Signature | Your server + Lemon Squeezy |
| Choosing which events trigger mail | Your app |
| Customer email, order number, product name, totals | Lemon Squeezy webhook payload (your mapping) |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key = event + resource id | Your webhook handler |
A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).
Prerequisites
- A verified sending domain in the Rovela dashboard. Unverified
fromdomains are refused with400(Quick Start). Prefer something likeorders.example.comormail.example.com. - An API key shaped like
re_…. Prefersending_accessfor the webhook worker (Authentication). - A Lemon Squeezy store webhook pointing at your HTTPS endpoint, with a signing secret (typically 6–40 characters) you chose when creating the webhook (Webhook requests).
- Keep
LEMONSQUEEZY_WEBHOOK_SECRET,ROVELA_API_KEY, andROVELA_FROMonly on the server. Never in a browser bundle, marketing site, or mobile app.
Environment
export LEMONSQUEEZY_WEBHOOK_SECRET=your_webhook_signing_secret
export ROVELA_API_KEY=re_your_sending_access_key
export ROVELA_FROM='Acme Orders <orders@mail.example.com>'
# optional: dashboard template UUIDs
export ROVELA_ORDER_TEMPLATE_ID=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
export ROVELA_SUB_TEMPLATE_ID=ffffffff-1111-2222-3333-444444444444
Rotate Rovela keys by creating a new one, deploying, then revoking the old key (Authentication). Rotate the Lemon Squeezy signing secret from Settings → Webhooks when you recreate the endpoint.
Why Idempotency-Key matters for Lemon Squeezy
Lemon Squeezy expects HTTP 200 to acknowledge a delivery. Any other status is retried up to three more times with exponential backoff (Webhook requests). There is no dedicated delivery-id header like Shopify’s webhook id — you must derive a stable key from the payload.
Rovela’s Idempotency-Key header (up to 256 characters) makes a retry of the same send within 24 hours return the original response instead of mailing again (Send Email API).
Recommended keys:
| Key | Use when |
|---|---|
ls-order_created-{data.id} | One order confirmation per order resource (truncate to 256) |
ls-subscription_created-{data.id} | One subscription welcome per subscription resource |
ls-{event_name}-{data.id} | Generic pattern for other events you later add |
Important overlap: a subscription purchase commonly emits both order_created and subscription_created (and often subscription_payment_success). If both handlers send the same “thanks for buying” copy, customers get duplicates. Prefer:
order_created→ purchase / receipt confirmationsubscription_created→ subscription-specific onboarding (portal link, plan name) with a different template and idempotency key
Or listen to only one event for the product email path.
Shared Rovela sender (server-only)
// src/rovela.ts
const ROVELA_URL = "https://api.rovela.dev/emails";
export class RovelaError extends Error {
constructor(
public status: number,
public name: string,
message: string,
) {
super(message);
}
}
export async function sendViaRovela(opts: {
to: string;
subject?: string;
html?: string;
text?: string;
template?: { id: string; variables: Record<string, string> };
idempotencyKey: string;
replyTo?: string;
tags?: Record<string, string> | Array<{ name: string; value: string }>;
}) {
const apiKey = process.env.ROVELA_API_KEY;
const from = process.env.ROVELA_FROM;
if (!apiKey || !from) {
throw new Error("ROVELA_API_KEY and ROVELA_FROM must be set on the server");
}
const key = opts.idempotencyKey.slice(0, 256);
const body: Record<string, unknown> = {
from,
to: opts.to,
};
if (opts.template) {
// template is mutually exclusive with html/text — see Templates docs
body.template = opts.template;
} else {
if (!opts.html && !opts.text) {
throw new Error("html, text, or template is required");
}
if (opts.subject) body.subject = opts.subject;
if (opts.html) body.html = opts.html;
if (opts.text) body.text = opts.text;
}
if (opts.replyTo) body.reply_to = opts.replyTo;
if (opts.tags) body.tags = opts.tags;
const res = await fetch(ROVELA_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
throw new RovelaError(
res.status,
String((json as { name?: string }).name ?? "Error"),
String((json as { message?: string }).message ?? "send failed"),
);
}
return json as { object: "email"; id: string };
}
Always prefer both html and text when you are not using a dashboard template. Escape any customer-controlled strings before putting them in HTML or in template variables — template substitution is not HTML-escaped (Templates).
Verify X-Signature on the raw body
Lemon Squeezy sends X-Signature as a lowercase hex HMAC-SHA256 of the exact request body bytes, keyed with your signing secret (Signing requests). Compare with a constant-time equality check. Do not re-serialize JSON before hashing.
// src/lemonsqueezy-verify.ts
import crypto from "node:crypto";
export function verifyLemonSqueezySignature(
rawBody: string | Buffer,
signatureHeader: string | null | undefined,
secret: string,
): boolean {
if (!signatureHeader) return false;
const digest = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const expected = Buffer.from(digest, "utf8");
const received = Buffer.from(signatureHeader, "utf8");
if (expected.length !== received.length) return false;
return crypto.timingSafeEqual(expected, received);
}
Express: verify, then send
Use express.raw on the webhook route so signature verification sees the same bytes Lemon Squeezy signed.
// src/routes/lemonsqueezy-webhook.ts
import express from "express";
import { verifyLemonSqueezySignature } from "../lemonsqueezy-verify";
import { sendViaRovela, RovelaError } from "../rovela";
type LsWebhook = {
meta?: {
event_name?: string;
custom_data?: Record<string, unknown>;
};
data?: {
type?: string;
id?: string;
attributes?: Record<string, unknown>;
};
};
export const lemonSqueezyWebhook = express.Router();
lemonSqueezyWebhook.post(
"/webhooks/lemonsqueezy",
express.raw({ type: "application/json" }),
async (req, res) => {
const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET;
if (!secret) {
return res.status(500).send("webhook secret not configured");
}
const rawBody: Buffer = Buffer.isBuffer(req.body)
? req.body
: Buffer.from(String(req.body ?? ""), "utf8");
const signature = req.headers["x-signature"];
const sig = Array.isArray(signature) ? signature[0] : signature;
if (!verifyLemonSqueezySignature(rawBody, sig, secret)) {
return res.status(400).send("invalid signature");
}
let payload: LsWebhook;
try {
payload = JSON.parse(rawBody.toString("utf8")) as LsWebhook;
} catch {
return res.status(400).send("invalid json");
}
const eventName =
(typeof req.headers["x-event-name"] === "string"
? req.headers["x-event-name"]
: undefined) || payload.meta?.event_name;
try {
await handleLemonEvent(eventName, payload);
return res.status(200).json({ received: true });
} catch (err) {
console.error("lemonsqueezy webhook handler failed", err);
// Non-2xx → Lemon Squeezy retries. Prefer enqueue-then-200 if send is slow.
return res.status(500).json({ error: "handler failed" });
}
},
);
async function handleLemonEvent(eventName: string | undefined, payload: LsWebhook) {
const id = payload.data?.id;
const attrs = payload.data?.attributes ?? {};
if (!id) return;
switch (eventName) {
case "order_created": {
const status = String(attrs.status ?? "");
// Only mail paid orders; skip pending/failed/fraudulent
if (status && status !== "paid") return;
const to = String(attrs.user_email ?? "");
if (!to) return;
const orderNumber = String(attrs.order_number ?? id);
const total = String(attrs.total_formatted ?? "");
const name = String(attrs.user_name ?? "");
const firstItem = attrs.first_order_item as
| { product_name?: string; variant_name?: string }
| undefined;
const product =
firstItem?.product_name ||
firstItem?.variant_name ||
"your purchase";
const receiptUrl =
attrs.urls && typeof attrs.urls === "object"
? String((attrs.urls as { receipt?: string }).receipt ?? "")
: "";
await sendOrderConfirmation({
to,
name,
orderNumber,
total,
product: String(product),
receiptUrl,
resourceId: id,
});
break;
}
case "subscription_created": {
const to = String(attrs.user_email ?? "");
if (!to) return;
const product = String(attrs.product_name ?? "your plan");
const variant = String(attrs.variant_name ?? "");
const status = String(attrs.status_formatted ?? attrs.status ?? "");
const portal =
attrs.urls && typeof attrs.urls === "object"
? String((attrs.urls as { customer_portal?: string }).customer_portal ?? "")
: "";
await sendSubscriptionWelcome({
to,
name: String(attrs.user_name ?? ""),
product,
variant,
status,
portalUrl: portal,
resourceId: id,
});
break;
}
default:
// Ignore other events (refunds, payment retries, etc.)
break;
}
}
async function sendOrderConfirmation(opts: {
to: string;
name: string;
orderNumber: string;
total: string;
product: string;
receiptUrl: string;
resourceId: string;
}) {
const templateId = process.env.ROVELA_ORDER_TEMPLATE_ID;
const subject = opts.total
? `Order #${opts.orderNumber} confirmed — ${opts.total}`
: `Order #${opts.orderNumber} confirmed`;
const html = `
<p>Hi ${escapeHtml(opts.name || "there")},</p>
<p>Thanks for your purchase of <b>${escapeHtml(opts.product)}</b>.</p>
<p><b>Order:</b> #${escapeHtml(opts.orderNumber)}</p>
<p><b>Total:</b> ${escapeHtml(opts.total || "—")}</p>
${
opts.receiptUrl
? `<p><a href="${escapeHtml(opts.receiptUrl)}">View receipt</a></p>`
: ""
}
<p>If you did not make this purchase, contact support.</p>
`;
const text = [
`Hi ${opts.name || "there"},`,
`Thanks for your purchase of ${opts.product}.`,
`Order: #${opts.orderNumber}`,
`Total: ${opts.total || "—"}`,
opts.receiptUrl ? `Receipt: ${opts.receiptUrl}` : "",
"If you did not make this purchase, contact support.",
]
.filter(Boolean)
.join("\n");
const idempotencyKey = `ls-order_created-${opts.resourceId}`.slice(0, 256);
try {
if (templateId) {
await sendViaRovela({
to: opts.to,
template: {
id: templateId,
variables: {
name: opts.name || "there",
order_number: opts.orderNumber,
total: opts.total || "—",
product: opts.product,
receipt_url: opts.receiptUrl || "",
},
},
idempotencyKey,
tags: { ls_event: "order_created", ls_id: opts.resourceId },
});
} else {
await sendViaRovela({
to: opts.to,
subject,
html,
text,
idempotencyKey,
tags: { ls_event: "order_created", ls_id: opts.resourceId },
});
}
} catch (err) {
if (err instanceof RovelaError) {
console.error("Rovela send failed", err.status, err.name, err.message);
}
throw err;
}
}
async function sendSubscriptionWelcome(opts: {
to: string;
name: string;
product: string;
variant: string;
status: string;
portalUrl: string;
resourceId: string;
}) {
const templateId = process.env.ROVELA_SUB_TEMPLATE_ID;
const plan = opts.variant ? `${opts.product} (${opts.variant})` : opts.product;
const subject = `Welcome to ${plan}`;
const html = `
<p>Hi ${escapeHtml(opts.name || "there")},</p>
<p>Your subscription to <b>${escapeHtml(plan)}</b> is ${escapeHtml(opts.status || "active")}.</p>
${
opts.portalUrl
? `<p><a href="${escapeHtml(opts.portalUrl)}">Manage billing</a></p>`
: ""
}
<p>We will email you about renewals and important account changes from this address.</p>
`;
const text = [
`Hi ${opts.name || "there"},`,
`Your subscription to ${plan} is ${opts.status || "active"}.`,
opts.portalUrl ? `Manage billing: ${opts.portalUrl}` : "",
"We will email you about renewals and important account changes from this address.",
]
.filter(Boolean)
.join("\n");
const idempotencyKey = `ls-subscription_created-${opts.resourceId}`.slice(0, 256);
if (templateId) {
await sendViaRovela({
to: opts.to,
template: {
id: templateId,
variables: {
name: opts.name || "there",
product: opts.product,
variant: opts.variant,
status: opts.status,
portal_url: opts.portalUrl || "",
},
},
idempotencyKey,
tags: { ls_event: "subscription_created", ls_id: opts.resourceId },
});
} else {
await sendViaRovela({
to: opts.to,
subject,
html,
text,
idempotencyKey,
tags: { ls_event: "subscription_created", ls_id: opts.resourceId },
});
}
}
function escapeHtml(s: string) {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
Mounting tip
import express from "express";
import { lemonSqueezyWebhook } from "./routes/lemonsqueezy-webhook";
const app = express();
app.use(lemonSqueezyWebhook); // raw body route first
app.use(express.json()); // JSON for the rest of the API
If a global JSON parser runs first, HMAC verification fails because the signature covers the exact bytes Lemon Squeezy sent.
Next.js App Router route
// app/api/webhooks/lemonsqueezy/route.ts
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
import { sendViaRovela } from "@/lib/rovela";
export const runtime = "nodejs";
function verify(rawBody: string, signature: string | null, secret: string) {
if (!signature) return false;
const digest = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(digest, "utf8");
const b = Buffer.from(signature, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
export async function POST(req: NextRequest) {
const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET;
if (!secret) {
return new NextResponse("secret not set", { status: 500 });
}
const rawBody = await req.text();
if (!verify(rawBody, req.headers.get("x-signature"), secret)) {
return new NextResponse("invalid signature", { status: 400 });
}
const payload = JSON.parse(rawBody) as {
meta?: { event_name?: string };
data?: { id?: string; attributes?: Record<string, unknown> };
};
const eventName =
req.headers.get("x-event-name") || payload.meta?.event_name;
const id = payload.data?.id;
const attrs = payload.data?.attributes ?? {};
if (eventName === "order_created" && id && attrs.user_email) {
if (!attrs.status || attrs.status === "paid") {
const orderNumber = String(attrs.order_number ?? id);
const total = String(attrs.total_formatted ?? "");
await sendViaRovela({
to: String(attrs.user_email),
subject: total
? `Order #${orderNumber} confirmed — ${total}`
: `Order #${orderNumber} confirmed`,
html: `<p>Thanks for your purchase.</p><p><b>Order:</b> #${orderNumber}</p><p><b>Total:</b> ${total || "—"}</p>`,
text: `Thanks for your purchase.\nOrder: #${orderNumber}\nTotal: ${total || "—"}`,
idempotencyKey: `ls-order_created-${id}`.slice(0, 256),
});
}
}
if (eventName === "subscription_created" && id && attrs.user_email) {
const product = String(attrs.product_name ?? "your plan");
await sendViaRovela({
to: String(attrs.user_email),
subject: `Welcome to ${product}`,
html: `<p>Your subscription to <b>${product}</b> is ready.</p>`,
text: `Your subscription to ${product} is ready.`,
idempotencyKey: `ls-subscription_created-${id}`.slice(0, 256),
});
}
return NextResponse.json({ received: true });
}
Optional: dashboard templates
Create templates once (needs full_access or the dashboard), publish them, then send with template.id + variables from the webhook (Templates):
curl -X POST https://api.rovela.dev/templates \
-H "Authorization: Bearer re_your_full_access_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Lemon Squeezy order confirmation",
"subject": "Order #{{order_number}} confirmed — {{total}}",
"html": "<p>Hi {{name}},</p><p>Thanks for buying <b>{{product}}</b>.</p><p>Total: {{total}}</p>",
"text": "Hi {{name}}, thanks for buying {{product}}. Total: {{total}}. Order #{{order_number}}."
}'
Then POST /templates/:id/publish. Production webhook workers can keep using a sending_access key — they only call POST /emails.
Remember: template cannot be mixed with html/text on the same send, and POST /emails/batch rejects template (Send Email API).
Checkout custom_data → your app user
If you pass custom_data into the Lemon Squeezy checkout (for example { "app_user_id": "usr_123" }), it appears on Order / Subscription webhooks under meta.custom_data (Webhook requests). Use that to link the purchase to your own user row before or while sending mail — still take user_email from Lemon Squeezy attributes for the to address unless you intentionally override with a verified address from your DB.
Production checklist
- Verify
X-Signatureon the raw body with HMAC-SHA256 +timingSafeEqual. - Return 2xx quickly. If Rovela or your DB is slow, enqueue
{ eventName, resourceId, payload }and send asynchronously; still use the stable idempotency key. - Use distinct Idempotency-Keys for
order_createdvssubscription_createdso a subscription sale can send two different messages without colliding (Send Email API). - Prefer
sending_accesskeys in the worker; never expose them to the client (Authentication). - Send html + text, or a published template — not an empty body.
- Escape user-influenced fields before HTML / template variables (Templates).
- Confirm delivery with
GET /emails/:idor Rovela webhooks if you need ops visibility — a send200is queue acceptance only (Quick Start).
What this does not cover
order_refunded,subscription_cancelled,subscription_payment_failed— same verify → map → send pattern with different copy and keys- Attaching a custom PDF you generate yourself — see Send an Invoice Email with a PDF Attachment
- Stripe or Shopify webhook signatures — see the dedicated posts linked above
- Rovela inbound or delivery webhooks — separate from Lemon Squeezy’s signing secret
Summary
Lemon Squeezy verifies that the event is real and that a purchase or subscription was created. Your webhook maps order_created / subscription_created to a recipient and confirmation fields. Rovela’s POST /emails with a stable Idempotency-Key (ls-order_created-{id} / ls-subscription_created-{id}) delivers one message per logical event even when Lemon Squeezy retries. Keep checkout in Lemon Squeezy, delivery in Rovela, and secrets on the server.