Paddle Billing Webhook Email with Rovela (2026): Paddle-Signature + Idempotency-Key
Verify Paddle-Signature (HMAC-SHA256 of ts:rawBody), handle transaction.completed / subscription.created, resolve buyer email, and send branded confirmations via Rovela POST /emails with Idempotency-Key = paddle-transaction.completed-{txn_id} or paddle-subscription.created-{sub_id}. Paddle owns checkout; Rovela delivers. Distinct from Stripe, Shopify, and Lemon Squeezy webhook posts.
Paddle Billing owns checkout — Rovela delivers the confirmation
Paddle Billing notifies your server when a transaction finishes processing or a subscription is created. Paddle’s own customer receipts are merchant-of-record mail from Paddle; 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 branded transactional mail when your webhook handler calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication, Templates).
This guide wires a Paddle Billing webhook that:
- Verifies the
Paddle-Signatureheader (HMAC-SHA256 ofts:rawBodywith your notification destination secret) (Signature verification) - Handles
transaction.completedand/orsubscription.created - Resolves the buyer email (Paddle transaction payloads expose
customer_id, not the address itself) - Sends a purchase or subscription confirmation through Rovela
- Sets a stable
Idempotency-Keyso Paddle retries never double-mail
Rovela does not create Paddle checkouts, register notification destinations, or verify Paddle-Signature. Paddle owns commerce, tax, 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 Paddle Billingevent_typepayloads - Shopify Order Webhook Email —
X-Shopify-Hmac-SHA256+orders/create, nottransaction.completed - Lemon Squeezy Webhook Confirmation Email —
X-Signatureover the raw body alone +order_created/subscription_created, notts:bodyHMAC
What Paddle does vs what Rovela sends
| Concern | Owner |
|---|---|
| Checkout, catalog, subscriptions, tax, MoR receipts | Paddle Billing |
Notification destination URL + endpoint secret + Paddle-Signature | Your server + Paddle |
| Choosing which events trigger mail | Your app |
Buyer email resolution (custom_data, Customer API, or customer.* events) | Your app |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key = event type + 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 Paddle notification destination of type
urlsubscribed to at leasttransaction.completedandsubscription.created, with its endpoint secret key from the dashboard orGET /notification-settings/{id}(How webhooks work, Signature verification). - A plan for buyer email: pass it in checkout
customData, call the Paddle Customers API withcustomer_id, and/or storecustomer.created/customer.updatedlocally. Transaction webhooks alone do not include the email address. - Keep
PADDLE_WEBHOOK_SECRET, optionalPADDLE_API_KEY,ROVELA_API_KEY, andROVELA_FROMonly on the server. Never in a browser bundle, marketing site, or mobile app.
Environment
export PADDLE_WEBHOOK_SECRET=pdl_ntfset_..._your_endpoint_secret
# optional: resolve email via Customers API when custom_data has none
export PADDLE_API_KEY=your_paddle_api_key
export PADDLE_API_BASE=https://api.paddle.com # or https://sandbox-api.paddle.com
export ROVELA_API_KEY=re_your_sending_access_key
export ROVELA_FROM='Acme Orders <orders@mail.example.com>'
# optional: dashboard template UUIDs
export ROVELA_TXN_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 Paddle endpoint secret from Paddle → Developer Tools → Notifications (edit destination) when you recreate the endpoint.
Why Idempotency-Key matters for Paddle
Paddle expects a fast HTTP success response and will retry deliveries with backoff if your endpoint fails or times out (How webhooks work). Each notification also carries a unique event_id / notification_id — use those to de-duplicate processing, but still pin Rovela’s send key to the commerce resource you care about (one confirmation per completed transaction, one welcome per subscription).
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 |
|---|---|
paddle-transaction.completed-{data.id} | One purchase confirmation per txn_… |
paddle-subscription.created-{data.id} | One subscription welcome per sub_… |
paddle-{event_type}-{data.id} | Generic pattern for other events you later add |
Important overlap: a recurring checkout commonly emits both transaction.completed and subscription.created (and often earlier transaction.paid). Prefer:
transaction.completed→ purchase / receipt confirmation (totals, invoice number when present)subscription.created→ subscription onboarding (plan/items, manage-billing URL) with a different template and idempotency key
Or listen to only one event for the product email path. Prefer transaction.completed over transaction.paid for “finished processing” mail — Paddle documents completed as the point internal processing (including linking subscription_id on auto-collected checkouts) has finished (transaction.completed).
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 Paddle-Signature on the raw body
Paddle sends Paddle-Signature shaped like ts=<unix>;h1=<hex>. Build the signed payload as `${ts}:${rawBody}`, HMAC-SHA256 it with the destination secret, and compare h1 with timingSafeEqual. Do not re-serialize JSON before hashing. Official SDKs also reject timestamps older than about five seconds by default to limit replay (Signature verification).
// src/paddle-verify.ts
import crypto from "node:crypto";
const DEFAULT_TOLERANCE_SEC = 5;
export function verifyPaddleSignature(
rawBody: string | Buffer,
signatureHeader: string | null | undefined,
secret: string,
toleranceSec = DEFAULT_TOLERANCE_SEC,
): boolean {
if (!signatureHeader || !secret) return false;
const parts = Object.fromEntries(
signatureHeader.split(";").map((p) => {
const i = p.indexOf("=");
return i === -1 ? [p, ""] : [p.slice(0, i), p.slice(i + 1)];
}),
) as { ts?: string; h1?: string };
const ts = parts.ts;
const h1 = parts.h1;
if (!ts || !h1) return false;
const tsNum = Number(ts);
if (!Number.isFinite(tsNum)) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - tsNum);
if (age > toleranceSec) return false;
const body =
typeof rawBody === "string" ? rawBody : rawBody.toString("utf8");
const signedPayload = `${ts}:${body}`;
const digest = crypto
.createHmac("sha256", secret)
.update(signedPayload, "utf8")
.digest("hex");
const expected = Buffer.from(digest, "utf8");
const received = Buffer.from(h1, "utf8");
if (expected.length !== received.length) return false;
return crypto.timingSafeEqual(expected, received);
}
Prefer @paddle/paddle-node-sdk’s paddle.webhooks.unmarshal(rawBody, secret, signature) in production if you already use the SDK — same raw-body rules apply.
Resolve the buyer email
Paddle Billing transaction and subscription entities in webhooks carry customer_id (ctm_…), not email. Resolve in this order:
custom_datayou set at checkout / on the transaction (for example{ "app_user_id": "usr_123", "email": "buyer@example.com" })- Your DB keyed by
custom_data.app_user_idor a previously storedcustomer_id → emailmap fromcustomer.created/customer.updated - Paddle Customers API
GET /customers/{customer_id}with a server-side Paddle API key (sandbox or live base URL)
Never invent an address. If you cannot resolve to, acknowledge the webhook (or enqueue for retry) without calling Rovela — do not guess.
// src/paddle-email.ts
type PaddleCustomer = { data?: { email?: string | null } };
export async function resolveBuyerEmail(opts: {
customerId: string | null | undefined;
customData: Record<string, unknown> | null | undefined;
}): Promise<string | null> {
const fromCustom = [
opts.customData?.email,
opts.customData?.customer_email,
opts.customData?.buyer_email,
].find((v) => typeof v === "string" && v.includes("@"));
if (typeof fromCustom === "string") return fromCustom.trim();
// Optional: look up your own user table via custom_data.app_user_id here
if (!opts.customerId || !process.env.PADDLE_API_KEY) return null;
const base = process.env.PADDLE_API_BASE || "https://api.paddle.com";
const res = await fetch(`${base}/customers/${opts.customerId}`, {
headers: {
Authorization: `Bearer ${process.env.PADDLE_API_KEY}`,
"Content-Type": "application/json",
},
});
if (!res.ok) return null;
const json = (await res.json()) as PaddleCustomer;
const email = json.data?.email;
return email && email.includes("@") ? email : null;
}
Express: verify, then send
Use express.raw on the webhook route so signature verification sees the same bytes Paddle signed.
// src/routes/paddle-webhook.ts
import express from "express";
import { verifyPaddleSignature } from "../paddle-verify";
import { resolveBuyerEmail } from "../paddle-email";
import { sendViaRovela, RovelaError } from "../rovela";
type PaddleEvent = {
event_id?: string;
event_type?: string;
notification_id?: string;
data?: {
id?: string;
status?: string;
customer_id?: string | null;
subscription_id?: string | null;
invoice_number?: string | null;
currency_code?: string;
custom_data?: Record<string, unknown> | null;
details?: {
totals?: { grand_total?: string; total?: string; currency_code?: string };
};
items?: Array<{
price?: { name?: string | null; description?: string; product_id?: string };
quantity?: number;
}>;
};
};
export const paddleWebhook = express.Router();
paddleWebhook.post(
"/webhooks/paddle",
express.raw({ type: "application/json" }),
async (req, res) => {
const secret = process.env.PADDLE_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["paddle-signature"];
const sig = Array.isArray(signature) ? signature[0] : signature;
// Widen tolerance slightly if your worker clock / queue hop is slow;
// official SDKs default to ~5s — tighten in production if you process sync.
if (!verifyPaddleSignature(rawBody, sig, secret, 5)) {
return res.status(400).send("invalid signature");
}
let event: PaddleEvent;
try {
event = JSON.parse(rawBody.toString("utf8")) as PaddleEvent;
} catch {
return res.status(400).send("invalid json");
}
try {
await handlePaddleEvent(event);
return res.status(200).json({ received: true });
} catch (err) {
console.error("paddle webhook handler failed", err);
// Non-2xx → Paddle retries. Prefer enqueue-then-200 if send is slow.
return res.status(500).json({ error: "handler failed" });
}
},
);
async function handlePaddleEvent(event: PaddleEvent) {
const data = event.data;
const id = data?.id;
if (!id || !event.event_type) return;
switch (event.event_type) {
case "transaction.completed": {
if (data.status && data.status !== "completed") return;
const to = await resolveBuyerEmail({
customerId: data.customer_id,
customData: data.custom_data,
});
if (!to) {
console.warn("skip mail: no buyer email for", id);
return;
}
const product =
data.items?.[0]?.price?.name ||
data.items?.[0]?.price?.description ||
"your purchase";
const grand =
data.details?.totals?.grand_total ||
data.details?.totals?.total ||
"";
const currency =
data.details?.totals?.currency_code || data.currency_code || "";
const totalLabel =
grand && currency ? `${formatMinor(grand, currency)} ${currency}` : grand || "";
await sendTransactionConfirmation({
to,
product: String(product),
totalLabel,
invoiceNumber: data.invoice_number || "",
transactionId: id,
subscriptionId: data.subscription_id || "",
});
break;
}
case "subscription.created": {
const to = await resolveBuyerEmail({
customerId: data.customer_id,
customData: data.custom_data,
});
if (!to) {
console.warn("skip mail: no buyer email for", id);
return;
}
const product =
data.items?.[0]?.price?.name ||
data.items?.[0]?.price?.description ||
"your plan";
await sendSubscriptionWelcome({
to,
product: String(product),
subscriptionId: id,
status: String(data.status ?? "active"),
});
break;
}
default:
break;
}
}
/** Paddle money fields are usually the lowest denomination as a string (e.g. cents). */
function formatMinor(amount: string, currency: string) {
const n = Number(amount);
if (!Number.isFinite(n)) return amount;
const zeroDecimal = new Set(["JPY", "KRW", "VND"]);
const major = zeroDecimal.has(currency.toUpperCase()) ? n : n / 100;
return major.toFixed(zeroDecimal.has(currency.toUpperCase()) ? 0 : 2);
}
async function sendTransactionConfirmation(opts: {
to: string;
product: string;
totalLabel: string;
invoiceNumber: string;
transactionId: string;
subscriptionId: string;
}) {
const templateId = process.env.ROVELA_TXN_TEMPLATE_ID;
const subject = opts.totalLabel
? `Payment confirmed — ${opts.totalLabel}`
: `Payment confirmed — ${opts.transactionId}`;
const html = `
<p>Thanks for your purchase of <b>${escapeHtml(opts.product)}</b>.</p>
<p><b>Transaction:</b> ${escapeHtml(opts.transactionId)}</p>
${
opts.invoiceNumber
? `<p><b>Invoice:</b> ${escapeHtml(opts.invoiceNumber)}</p>`
: ""
}
<p><b>Total:</b> ${escapeHtml(opts.totalLabel || "—")}</p>
<p>If you did not make this purchase, contact support.</p>
`;
const text = [
`Thanks for your purchase of ${opts.product}.`,
`Transaction: ${opts.transactionId}`,
opts.invoiceNumber ? `Invoice: ${opts.invoiceNumber}` : "",
`Total: ${opts.totalLabel || "—"}`,
"If you did not make this purchase, contact support.",
]
.filter(Boolean)
.join("\n");
const idempotencyKey =
`paddle-transaction.completed-${opts.transactionId}`.slice(0, 256);
try {
if (templateId) {
await sendViaRovela({
to: opts.to,
template: {
id: templateId,
variables: {
product: opts.product,
total: opts.totalLabel || "—",
invoice_number: opts.invoiceNumber || "",
transaction_id: opts.transactionId,
subscription_id: opts.subscriptionId || "",
},
},
idempotencyKey,
tags: {
paddle_event: "transaction.completed",
paddle_txn: opts.transactionId,
},
});
} else {
await sendViaRovela({
to: opts.to,
subject,
html,
text,
idempotencyKey,
tags: {
paddle_event: "transaction.completed",
paddle_txn: opts.transactionId,
},
});
}
} 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;
product: string;
subscriptionId: string;
status: string;
}) {
const templateId = process.env.ROVELA_SUB_TEMPLATE_ID;
const subject = `Welcome to ${opts.product}`;
const html = `
<p>Your subscription to <b>${escapeHtml(opts.product)}</b> is ${escapeHtml(opts.status)}.</p>
<p><b>Subscription:</b> ${escapeHtml(opts.subscriptionId)}</p>
<p>We will email you about renewals and important account changes from this address.</p>
`;
const text = [
`Your subscription to ${opts.product} is ${opts.status}.`,
`Subscription: ${opts.subscriptionId}`,
"We will email you about renewals and important account changes from this address.",
].join("\n");
const idempotencyKey =
`paddle-subscription.created-${opts.subscriptionId}`.slice(0, 256);
if (templateId) {
await sendViaRovela({
to: opts.to,
template: {
id: templateId,
variables: {
product: opts.product,
status: opts.status,
subscription_id: opts.subscriptionId,
},
},
idempotencyKey,
tags: {
paddle_event: "subscription.created",
paddle_sub: opts.subscriptionId,
},
});
} else {
await sendViaRovela({
to: opts.to,
subject,
html,
text,
idempotencyKey,
tags: {
paddle_event: "subscription.created",
paddle_sub: opts.subscriptionId,
},
});
}
}
function escapeHtml(s: string) {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
Mounting tip
import express from "express";
import { paddleWebhook } from "./routes/paddle-webhook";
const app = express();
app.use(paddleWebhook); // 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 Paddle sent.
Next.js App Router route
// app/api/webhooks/paddle/route.ts
import { NextRequest, NextResponse } from "next/server";
import { verifyPaddleSignature } from "@/lib/paddle-verify";
import { resolveBuyerEmail } from "@/lib/paddle-email";
import { sendViaRovela } from "@/lib/rovela";
export const runtime = "nodejs";
export async function POST(req: NextRequest) {
const secret = process.env.PADDLE_WEBHOOK_SECRET;
if (!secret) {
return new NextResponse("secret not set", { status: 500 });
}
const rawBody = await req.text();
if (!verifyPaddleSignature(rawBody, req.headers.get("paddle-signature"), secret)) {
return new NextResponse("invalid signature", { status: 400 });
}
const event = JSON.parse(rawBody) as {
event_type?: string;
data?: {
id?: string;
status?: string;
customer_id?: string | null;
custom_data?: Record<string, unknown> | null;
invoice_number?: string | null;
currency_code?: string;
details?: { totals?: { grand_total?: string } };
items?: Array<{ price?: { name?: string | null } }>;
};
};
const data = event.data;
const id = data?.id;
if (event.event_type === "transaction.completed" && id) {
const to = await resolveBuyerEmail({
customerId: data?.customer_id,
customData: data?.custom_data,
});
if (to) {
const product = data?.items?.[0]?.price?.name || "your purchase";
const total = data?.details?.totals?.grand_total || "";
await sendViaRovela({
to,
subject: total
? `Payment confirmed — ${total} ${data?.currency_code || ""}`.trim()
: `Payment confirmed — ${id}`,
html: `<p>Thanks for buying <b>${product}</b>.</p><p>Transaction: ${id}</p>`,
text: `Thanks for buying ${product}.\nTransaction: ${id}`,
idempotencyKey: `paddle-transaction.completed-${id}`.slice(0, 256),
});
}
}
if (event.event_type === "subscription.created" && id) {
const to = await resolveBuyerEmail({
customerId: data?.customer_id,
customData: data?.custom_data,
});
if (to) {
const product = data?.items?.[0]?.price?.name || "your plan";
await sendViaRovela({
to,
subject: `Welcome to ${product}`,
html: `<p>Your subscription to <b>${product}</b> is ready.</p>`,
text: `Your subscription to ${product} is ready.`,
idempotencyKey: `paddle-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": "Paddle transaction confirmation",
"subject": "Payment confirmed — {{total}}",
"html": "<p>Thanks for buying <b>{{product}}</b>.</p><p>Total: {{total}}</p><p>Transaction: {{transaction_id}}</p>",
"text": "Thanks for buying {{product}}. Total: {{total}}. Transaction: {{transaction_id}}."
}'
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
Pass structured customData when you open Paddle.js Checkout (or set custom_data on API-created transactions), for example { "app_user_id": "usr_123", "email": "buyer@example.com" }. That object appears on the transaction (and typically flows onto related subscription traffic). Use it to link the purchase to your own user row and to supply to when you do not want a Customers API round-trip.
Production checklist
- Verify
Paddle-Signatureon the raw body with HMAC-SHA256 ofts:body+timingSafeEqual(and a tight timestamp tolerance). - Return 2xx quickly. If Rovela or the Customers API is slow, enqueue
{ event_type, data.id, event_id }and send asynchronously; still use the stable idempotency key. - Use distinct Idempotency-Keys for
transaction.completedvssubscription.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
subscription.canceled,transaction.payment_failed, refunds / adjustments — same verify → resolve email → send pattern with different copy and keys- Attaching a custom PDF you generate yourself — see Send an Invoice Email with a PDF Attachment
- Stripe, Shopify, or Lemon Squeezy webhook signatures — see the dedicated posts linked above
- Rovela inbound or delivery webhooks — separate from Paddle’s endpoint secret
Summary
Paddle Billing verifies that the event is real and that a transaction completed or a subscription was created. Your webhook resolves the buyer email, maps transaction.completed / subscription.created to confirmation fields, and calls Rovela’s POST /emails with a stable Idempotency-Key (paddle-transaction.completed-{txn_id} / paddle-subscription.created-{sub_id}). Keep checkout and MoR receipts in Paddle, branded product mail in Rovela, and secrets on the server.