← Back to blog

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.

Updated November 4, 2026
Lemon Squeezy Webhook Confirmation Email with Rovela (2026): X-Signature + Idempotency-Key

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:

  1. Verifies X-Signature (HMAC-SHA256 of the raw body with your webhook signing secret) (Signing requests)
  2. Handles order_created and/or subscription_created (from X-Event-Name / meta.event_name)
  3. Sends a purchase or subscription confirmation through Rovela
  4. Sets a stable Idempotency-Key so 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:

What Lemon Squeezy does vs what Rovela sends

ConcernOwner
Checkout, products, subscriptions, tax, payoutsLemon Squeezy
Webhook URL + signing secret + X-SignatureYour server + Lemon Squeezy
Choosing which events trigger mailYour app
Customer email, order number, product name, totalsLemon Squeezy webhook payload (your mapping)
POST /emails, DKIM, queue, bouncesRovela
Idempotency-Key = event + resource idYour webhook handler

A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).

Prerequisites

  1. A verified sending domain in the Rovela dashboard. Unverified from domains are refused with 400 (Quick Start). Prefer something like orders.example.com or mail.example.com.
  2. An API key shaped like re_…. Prefer sending_access for the webhook worker (Authentication).
  3. 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).
  4. Keep LEMONSQUEEZY_WEBHOOK_SECRET, ROVELA_API_KEY, and ROVELA_FROM only 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:

KeyUse 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 confirmation
  • subscription_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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

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

  1. Verify X-Signature on the raw body with HMAC-SHA256 + timingSafeEqual.
  2. Return 2xx quickly. If Rovela or your DB is slow, enqueue { eventName, resourceId, payload } and send asynchronously; still use the stable idempotency key.
  3. Use distinct Idempotency-Keys for order_created vs subscription_created so a subscription sale can send two different messages without colliding (Send Email API).
  4. Prefer sending_access keys in the worker; never expose them to the client (Authentication).
  5. Send html + text, or a published template — not an empty body.
  6. Escape user-influenced fields before HTML / template variables (Templates).
  7. Confirm delivery with GET /emails/:id or Rovela webhooks if you need ops visibility — a send 200 is 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.