How to Send Emails from Paddle Webhooks (2026 Guide)

Paddle is a merchant of record for SaaS. It handles payments, sales tax, and invoicing globally — you don't need to worry about VAT, GST, or sales tax compliance. But beyond the payment receipts Paddle sends automatically, you need to send your own customer emails: welcome messages, subscription updates, dunning sequences, and in-app notifications.
This guide covers how to send emails from Paddle Billing webhook events: signature verification, transaction receipts, subscription lifecycle (created, updated, cancelled, past due), trial management, and production patterns. All code examples use TypeScript with the Paddle Billing API v2.
How Paddle Webhooks Work
Paddle sends webhook notifications to your endpoint when billing events happen. You register your endpoint in the Paddle dashboard under Developer Tools > Notifications, select which event types you want, and Paddle sends POST requests with:
- A JSON body containing the event data
- A
Paddle-Signatureheader for verification (ts=timestamp;h1=hmac_signature) - A
notification_idfor idempotency
Unlike Stripe, Paddle is the merchant of record — it sends its own payment receipts and invoices to your customers. Your job is to send the product-specific emails: welcome to your app, here's your dashboard link, your trial is ending, your account is being downgraded, etc.
Which Events to Listen For
| Event | When It Fires | Email to Send |
|---|---|---|
subscription.created | New subscription started | Welcome + getting started |
transaction.completed | Payment succeeded | Payment confirmation / receipt |
subscription.past_due | Payment failed, retrying | Update payment method |
subscription.updated | Plan change, pause, or resume | Confirmation of change |
subscription.canceled | Subscription cancelled | Cancellation follow-up |
subscription.trialing | Trial started | Trial welcome |
transaction.payment_failed | Payment attempt failed | Dunning email |
Configure these in the Paddle Dashboard under Developer Tools > Notifications > New destination.
Set Up Your Email Provider
npm install sequenzynpm install resendnpm install @sendgrid/mailAdd your keys to .env:
PADDLE_WEBHOOK_SECRET=pdl_ntfset_...
APP_URL=https://app.yoursite.comSEQUENZY_API_KEY=sq_your_api_key_hereRESEND_API_KEY=re_your_api_key_hereSENDGRID_API_KEY=SG.your_api_key_hereInitialize the clients:
import Sequenzy from "sequenzy";
// Reads SEQUENZY_API_KEY from env automatically
export const sequenzy = new Sequenzy();import { Resend } from "resend";
export const resend = new Resend(process.env.RESEND_API_KEY);
export const FROM = "Your App <noreply@yourdomain.com>";import sgMail from "@sendgrid/mail";
sgMail.setApiKey(process.env.SENDGRID_API_KEY!);
export { sgMail };
export const FROM = "noreply@yourdomain.com";Verify Paddle Webhook Signatures
Paddle signs every webhook with HMAC-SHA256. The signature is in the Paddle-Signature header in the format ts=timestamp;h1=hmac_hash. Always verify before processing:
// lib/paddle.ts
import crypto from "crypto";
export function verifyPaddleWebhook(
rawBody: string,
signature: string,
secret: string
): boolean {
// Parse the signature header: ts=1234;h1=abc123
const parts: Record<string, string> = {};
for (const part of signature.split(";")) {
const [key, value] = part.split("=");
if (key && value) parts[key] = value;
}
const timestamp = parts["ts"];
const expectedHash = parts["h1"];
if (!timestamp || !expectedHash) return false;
// Verify the signature
const payload = `${timestamp}:${rawBody}`;
const computedHash = crypto
.createHmac("sha256", secret)
.update(payload)
.digest("hex");
// Timing-safe comparison to prevent timing attacks
try {
return crypto.timingSafeEqual(
Buffer.from(computedHash),
Buffer.from(expectedHash)
);
} catch {
return false;
}
}
// Optional: check timestamp to prevent replay attacks
export function isTimestampValid(signature: string, toleranceSeconds = 300): boolean {
const ts = signature.split(";").find((s) => s.startsWith("ts="))?.split("=")[1];
if (!ts) return false;
const webhookTime = parseInt(ts, 10);
const now = Math.floor(Date.now() / 1000);
return Math.abs(now - webhookTime) <= toleranceSeconds;
}Organize Email Templates
Keep templates separate from the webhook handler. Each template is a function that returns HTML:
// lib/paddle-emails.ts
function layout(content: string): string {
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"></head>
<body style="margin:0;padding:0;background:#f6f9fc;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr><td align="center" style="padding:40px 20px;">
<table role="presentation" width="480" cellpadding="0" cellspacing="0"
style="background:#fff;border-radius:8px;overflow:hidden;">
<tr><td style="padding:40px;">${content}</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
function cta(text: string, href: string): string {
return `<a href="${href}"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;
border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;margin-top:16px;">
${text}
</a>`;
}
// --- Welcome / New Subscription ---
export function welcomeEmail(appUrl: string): string {
return layout(`
<h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Welcome aboard!</h1>
<p style="font-size:16px;line-height:1.6;color:#374151;">
Your subscription is active. Your account is ready to go.
</p>
${cta("Go to Dashboard", `${appUrl}/dashboard`)}
`);
}
// --- Payment Confirmation ---
export function paymentConfirmationEmail(params: {
amount: string;
currency: string;
productName: string;
appUrl: string;
}): string {
return layout(`
<h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Payment Confirmed</h1>
<p style="font-size:16px;line-height:1.6;color:#374151;">
Thanks for your payment. Here's your summary:
</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
<tr>
<td style="padding:12px 0;border-bottom:1px solid #e5e7eb;color:#374151;">Product</td>
<td style="padding:12px 0;border-bottom:1px solid #e5e7eb;text-align:right;color:#374151;">
${params.productName}
</td>
</tr>
<tr>
<td style="padding:12px 0;font-weight:600;color:#111827;">Total</td>
<td style="padding:12px 0;text-align:right;font-weight:600;color:#111827;">
${params.amount} ${params.currency}
</td>
</tr>
</table>
<p style="font-size:14px;color:#6b7280;">
Paddle, our payment provider, has sent you a detailed receipt and invoice.
</p>
${cta("Go to Dashboard", `${params.appUrl}/dashboard`)}
`);
}
// --- Payment Failed ---
export function paymentFailedEmail(params: {
updateUrl: string;
appUrl: string;
}): string {
return layout(`
<h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Payment Failed</h1>
<p style="font-size:16px;line-height:1.6;color:#374151;">
We couldn't process your latest payment. Please update your payment method
to keep your subscription active.
</p>
${cta("Update Payment Method", params.updateUrl)}
<p style="font-size:14px;color:#6b7280;margin-top:24px;">
If you need help, reply to this email.
</p>
`);
}
// --- Trial Started ---
export function trialStartedEmail(params: {
trialEndDate: string;
appUrl: string;
}): string {
return layout(`
<h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Your Trial Has Started</h1>
<p style="font-size:16px;line-height:1.6;color:#374151;">
Welcome! You have full access to all features until
<strong>${params.trialEndDate}</strong>.
</p>
<p style="font-size:16px;line-height:1.6;color:#374151;">
No charge today. You'll only be billed if you decide to continue after the trial.
</p>
${cta("Start Exploring", `${params.appUrl}/dashboard`)}
`);
}
// --- Subscription Cancelled ---
export function cancellationEmail(params: {
endDate: string;
pricingUrl: string;
}): string {
return layout(`
<h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Subscription Cancelled</h1>
<p style="font-size:16px;line-height:1.6;color:#374151;">
We're sorry to see you go. Your access continues until
<strong>${params.endDate}</strong>.
</p>
<p style="font-size:16px;line-height:1.6;color:#374151;">
Changed your mind? You can resubscribe at any time.
</p>
${cta("Resubscribe", params.pricingUrl)}
`);
}
// --- Plan Changed ---
export function planChangeEmail(params: {
newPlan: string;
newAmount: string;
currency: string;
effectiveDate: string;
appUrl: string;
}): string {
return layout(`
<h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Plan Updated</h1>
<p style="font-size:16px;line-height:1.6;color:#374151;">
Your plan has been changed to <strong>${params.newPlan}</strong>
at <strong>${params.newAmount} ${params.currency}/month</strong>.
</p>
<p style="font-size:14px;color:#6b7280;">
Effective: ${params.effectiveDate}
</p>
${cta("Go to Dashboard", `${params.appUrl}/dashboard`)}
`);
}
// --- Subscription Paused ---
export function pausedEmail(params: {
resumeDate: string;
appUrl: string;
}): string {
return layout(`
<h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Subscription Paused</h1>
<p style="font-size:16px;line-height:1.6;color:#374151;">
Your subscription has been paused. You won't be charged during this time.
</p>
<p style="font-size:16px;line-height:1.6;color:#374151;">
Your subscription will automatically resume on <strong>${params.resumeDate}</strong>.
</p>
${cta("Resume Now", `${params.appUrl}/billing`)}
`);
}
// --- Subscription Resumed ---
export function resumedEmail(appUrl: string): string {
return layout(`
<h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Welcome Back!</h1>
<p style="font-size:16px;line-height:1.6;color:#374151;">
Your subscription has been resumed. Full access is restored.
</p>
${cta("Go to Dashboard", `${appUrl}/dashboard`)}
`);
}The Complete Webhook Handler
Now the handler is clean — it routes events and calls template functions:
import { NextRequest, NextResponse } from "next/server";
import { sequenzy } from "@/lib/email";
import { verifyPaddleWebhook, isTimestampValid } from "@/lib/paddle";
import {
welcomeEmail,
paymentConfirmationEmail,
paymentFailedEmail,
trialStartedEmail,
cancellationEmail,
planChangeEmail,
pausedEmail,
resumedEmail,
} from "@/lib/paddle-emails";
const APP_URL = process.env.APP_URL!;
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("paddle-signature") ?? "";
if (!isTimestampValid(signature)) {
return NextResponse.json({ error: "Stale webhook" }, { status: 400 });
}
if (!verifyPaddleWebhook(body, signature, process.env.PADDLE_WEBHOOK_SECRET!)) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
const event = JSON.parse(body);
try {
await handlePaddleEvent(event);
} catch (error) {
console.error(`Paddle webhook failed for ${event.event_type}:`, error);
}
return NextResponse.json({ received: true });
}
interface PaddleEvent {
event_type: string;
notification_id: string;
data: Record<string, unknown>;
}
async function handlePaddleEvent(event: PaddleEvent) {
const data = event.data;
switch (event.event_type) {
case "subscription.created": {
const email = getCustomerEmail(data);
if (!email) break;
// Check if this is a trial or paid subscription
const status = data.status as string;
if (status === "trialing") {
const trialEnd = data.current_billing_period
? new Date((data.current_billing_period as { ends_at: string }).ends_at).toLocaleDateString()
: "soon";
await sequenzy.transactional.send({
to: email,
subject: "Your free trial has started!",
body: trialStartedEmail({ trialEndDate: trialEnd, appUrl: APP_URL }),
});
} else {
await sequenzy.transactional.send({
to: email,
subject: "Welcome! Your subscription is active",
body: welcomeEmail(APP_URL),
});
}
// Add as subscriber for marketing
await sequenzy.subscribers.create({
email,
tags: [status === "trialing" ? "trial" : "customer", "paddle"],
customAttributes: {
paddleSubscriptionId: data.id as string,
plan: getProductName(data),
},
});
break;
}
case "transaction.completed": {
const email = getCustomerEmail(data);
if (!email) break;
// Get the transaction totals
const details = data.details as { totals: { total: string } } | undefined;
const currencyCode = data.currency_code as string;
const total = details?.totals?.total ?? "0";
// Format the amount (Paddle sends amounts as strings, not cents)
const formatted = formatPaddleAmount(total, currencyCode);
await sequenzy.transactional.send({
to: email,
subject: `Payment confirmed — ${formatted}`,
body: paymentConfirmationEmail({
amount: total,
currency: currencyCode,
productName: getProductName(data),
appUrl: APP_URL,
}),
});
break;
}
case "subscription.past_due": {
const email = getCustomerEmail(data);
if (!email) break;
// Paddle provides a management URL for updating payment methods
const managementUrls = data.management_urls as {
update_payment_method?: string;
} | undefined;
const updateUrl = managementUrls?.update_payment_method
?? `${APP_URL}/billing`;
await sequenzy.transactional.send({
to: email,
subject: "Payment failed — action required",
body: paymentFailedEmail({ updateUrl, appUrl: APP_URL }),
});
// Update subscriber tags
await sequenzy.subscribers.tags.add({
email,
tag: "past-due",
});
break;
}
case "subscription.updated": {
const email = getCustomerEmail(data);
if (!email) break;
const status = data.status as string;
const scheduledChange = data.scheduled_change as {
action: string;
effective_at: string;
} | null;
// Handle pause
if (status === "paused" || scheduledChange?.action === "pause") {
const resumeDate = scheduledChange?.effective_at
? new Date(scheduledChange.effective_at).toLocaleDateString()
: "your next billing date";
await sequenzy.transactional.send({
to: email,
subject: "Your subscription has been paused",
body: pausedEmail({ resumeDate, appUrl: APP_URL }),
});
break;
}
// Handle resume
if (status === "active" && data.paused_at) {
await sequenzy.transactional.send({
to: email,
subject: "Welcome back! Subscription resumed",
body: resumedEmail(APP_URL),
});
break;
}
// Handle plan change
const items = data.items as Array<{
price: { unit_price: { amount: string; currency_code: string }; description: string };
product: { name: string };
}> | undefined;
if (items && items.length > 0) {
const item = items[0];
const price = item.price.unit_price;
await sequenzy.transactional.send({
to: email,
subject: `Plan updated to ${item.product.name}`,
body: planChangeEmail({
newPlan: item.product.name,
newAmount: formatPaddleAmount(price.amount, price.currency_code),
currency: price.currency_code,
effectiveDate: new Date().toLocaleDateString(),
appUrl: APP_URL,
}),
});
}
break;
}
case "subscription.canceled": {
const email = getCustomerEmail(data);
if (!email) break;
const currentPeriod = data.current_billing_period as {
ends_at: string;
} | undefined;
const endDate = currentPeriod?.ends_at
? new Date(currentPeriod.ends_at).toLocaleDateString()
: "the end of your billing period";
await sequenzy.transactional.send({
to: email,
subject: "Your subscription has been cancelled",
body: cancellationEmail({
endDate,
pricingUrl: `${APP_URL}/pricing`,
}),
});
await sequenzy.subscribers.tags.add({
email,
tag: "cancelled",
});
break;
}
}
}
// --- Helper functions ---
function getCustomerEmail(data: Record<string, unknown>): string | null {
// Paddle nests customer data differently per event type
const customer = data.customer as { email?: string } | undefined;
if (customer?.email) return customer.email;
// Some events have customer_id but not email — you'd need to look it up
return null;
}
function getProductName(data: Record<string, unknown>): string {
const items = data.items as Array<{
product?: { name?: string };
price?: { description?: string };
}> | undefined;
return items?.[0]?.product?.name
?? items?.[0]?.price?.description
?? "Subscription";
}
function formatPaddleAmount(amount: string, currency: string): string {
// Paddle sends amounts as strings like "4900" for $49.00
const num = parseInt(amount, 10);
if (isNaN(num)) return `${amount} ${currency}`;
return `${(num / 100).toFixed(2)} ${currency}`;
}import { NextRequest, NextResponse } from "next/server";
import { resend, FROM } from "@/lib/email";
import { verifyPaddleWebhook, isTimestampValid } from "@/lib/paddle";
import {
welcomeEmail,
paymentConfirmationEmail,
paymentFailedEmail,
trialStartedEmail,
cancellationEmail,
planChangeEmail,
pausedEmail,
resumedEmail,
} from "@/lib/paddle-emails";
const APP_URL = process.env.APP_URL!;
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("paddle-signature") ?? "";
if (!isTimestampValid(signature)) {
return NextResponse.json({ error: "Stale webhook" }, { status: 400 });
}
if (!verifyPaddleWebhook(body, signature, process.env.PADDLE_WEBHOOK_SECRET!)) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
const event = JSON.parse(body);
try {
await handlePaddleEvent(event);
} catch (error) {
console.error(`Paddle webhook failed for ${event.event_type}:`, error);
}
return NextResponse.json({ received: true });
}
interface PaddleEvent {
event_type: string;
notification_id: string;
data: Record<string, unknown>;
}
async function handlePaddleEvent(event: PaddleEvent) {
const data = event.data;
switch (event.event_type) {
case "subscription.created": {
const email = getCustomerEmail(data);
if (!email) break;
const status = data.status as string;
if (status === "trialing") {
const trialEnd = data.current_billing_period
? new Date((data.current_billing_period as { ends_at: string }).ends_at).toLocaleDateString()
: "soon";
await resend.emails.send({
from: FROM, to: email,
subject: "Your free trial has started!",
html: trialStartedEmail({ trialEndDate: trialEnd, appUrl: APP_URL }),
});
} else {
await resend.emails.send({
from: FROM, to: email,
subject: "Welcome! Your subscription is active",
html: welcomeEmail(APP_URL),
});
}
break;
}
case "transaction.completed": {
const email = getCustomerEmail(data);
if (!email) break;
const details = data.details as { totals: { total: string } } | undefined;
const currencyCode = data.currency_code as string;
const total = details?.totals?.total ?? "0";
await resend.emails.send({
from: FROM, to: email,
subject: `Payment confirmed — ${formatPaddleAmount(total, currencyCode)}`,
html: paymentConfirmationEmail({
amount: total, currency: currencyCode,
productName: getProductName(data), appUrl: APP_URL,
}),
});
break;
}
case "subscription.past_due": {
const email = getCustomerEmail(data);
if (!email) break;
const managementUrls = data.management_urls as {
update_payment_method?: string;
} | undefined;
await resend.emails.send({
from: FROM, to: email,
subject: "Payment failed — action required",
html: paymentFailedEmail({
updateUrl: managementUrls?.update_payment_method ?? `${APP_URL}/billing`,
appUrl: APP_URL,
}),
});
break;
}
case "subscription.updated": {
const email = getCustomerEmail(data);
if (!email) break;
const status = data.status as string;
const scheduledChange = data.scheduled_change as {
action: string; effective_at: string;
} | null;
if (status === "paused" || scheduledChange?.action === "pause") {
await resend.emails.send({
from: FROM, to: email,
subject: "Your subscription has been paused",
html: pausedEmail({
resumeDate: scheduledChange?.effective_at
? new Date(scheduledChange.effective_at).toLocaleDateString()
: "your next billing date",
appUrl: APP_URL,
}),
});
break;
}
if (status === "active" && data.paused_at) {
await resend.emails.send({
from: FROM, to: email,
subject: "Welcome back! Subscription resumed",
html: resumedEmail(APP_URL),
});
break;
}
const items = data.items as Array<{
price: { unit_price: { amount: string; currency_code: string } };
product: { name: string };
}> | undefined;
if (items?.length) {
const item = items[0];
const price = item.price.unit_price;
await resend.emails.send({
from: FROM, to: email,
subject: `Plan updated to ${item.product.name}`,
html: planChangeEmail({
newPlan: item.product.name,
newAmount: formatPaddleAmount(price.amount, price.currency_code),
currency: price.currency_code,
effectiveDate: new Date().toLocaleDateString(),
appUrl: APP_URL,
}),
});
}
break;
}
case "subscription.canceled": {
const email = getCustomerEmail(data);
if (!email) break;
const currentPeriod = data.current_billing_period as {
ends_at: string;
} | undefined;
await resend.emails.send({
from: FROM, to: email,
subject: "Your subscription has been cancelled",
html: cancellationEmail({
endDate: currentPeriod?.ends_at
? new Date(currentPeriod.ends_at).toLocaleDateString()
: "the end of your billing period",
pricingUrl: `${APP_URL}/pricing`,
}),
});
break;
}
}
}
function getCustomerEmail(data: Record<string, unknown>): string | null {
const customer = data.customer as { email?: string } | undefined;
return customer?.email ?? null;
}
function getProductName(data: Record<string, unknown>): string {
const items = data.items as Array<{
product?: { name?: string }; price?: { description?: string };
}> | undefined;
return items?.[0]?.product?.name ?? items?.[0]?.price?.description ?? "Subscription";
}
function formatPaddleAmount(amount: string, currency: string): string {
const num = parseInt(amount, 10);
if (isNaN(num)) return `${amount} ${currency}`;
return `${(num / 100).toFixed(2)} ${currency}`;
}import { NextRequest, NextResponse } from "next/server";
import { sgMail, FROM } from "@/lib/email";
import { verifyPaddleWebhook, isTimestampValid } from "@/lib/paddle";
import {
welcomeEmail,
paymentConfirmationEmail,
paymentFailedEmail,
trialStartedEmail,
cancellationEmail,
planChangeEmail,
pausedEmail,
resumedEmail,
} from "@/lib/paddle-emails";
const APP_URL = process.env.APP_URL!;
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("paddle-signature") ?? "";
if (!isTimestampValid(signature)) {
return NextResponse.json({ error: "Stale webhook" }, { status: 400 });
}
if (!verifyPaddleWebhook(body, signature, process.env.PADDLE_WEBHOOK_SECRET!)) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
const event = JSON.parse(body);
try {
await handlePaddleEvent(event);
} catch (error) {
console.error(`Paddle webhook failed for ${event.event_type}:`, error);
}
return NextResponse.json({ received: true });
}
interface PaddleEvent {
event_type: string;
notification_id: string;
data: Record<string, unknown>;
}
async function handlePaddleEvent(event: PaddleEvent) {
const data = event.data;
switch (event.event_type) {
case "subscription.created": {
const email = getCustomerEmail(data);
if (!email) break;
const status = data.status as string;
if (status === "trialing") {
const trialEnd = data.current_billing_period
? new Date((data.current_billing_period as { ends_at: string }).ends_at).toLocaleDateString()
: "soon";
await sgMail.send({
to: email, from: FROM,
subject: "Your free trial has started!",
html: trialStartedEmail({ trialEndDate: trialEnd, appUrl: APP_URL }),
});
} else {
await sgMail.send({
to: email, from: FROM,
subject: "Welcome! Your subscription is active",
html: welcomeEmail(APP_URL),
});
}
break;
}
case "transaction.completed": {
const email = getCustomerEmail(data);
if (!email) break;
const details = data.details as { totals: { total: string } } | undefined;
const currencyCode = data.currency_code as string;
const total = details?.totals?.total ?? "0";
await sgMail.send({
to: email, from: FROM,
subject: `Payment confirmed — ${formatPaddleAmount(total, currencyCode)}`,
html: paymentConfirmationEmail({
amount: total, currency: currencyCode,
productName: getProductName(data), appUrl: APP_URL,
}),
});
break;
}
case "subscription.past_due": {
const email = getCustomerEmail(data);
if (!email) break;
const managementUrls = data.management_urls as {
update_payment_method?: string;
} | undefined;
await sgMail.send({
to: email, from: FROM,
subject: "Payment failed — action required",
html: paymentFailedEmail({
updateUrl: managementUrls?.update_payment_method ?? `${APP_URL}/billing`,
appUrl: APP_URL,
}),
});
break;
}
case "subscription.updated": {
const email = getCustomerEmail(data);
if (!email) break;
const status = data.status as string;
const scheduledChange = data.scheduled_change as {
action: string; effective_at: string;
} | null;
if (status === "paused" || scheduledChange?.action === "pause") {
await sgMail.send({
to: email, from: FROM,
subject: "Your subscription has been paused",
html: pausedEmail({
resumeDate: scheduledChange?.effective_at
? new Date(scheduledChange.effective_at).toLocaleDateString()
: "your next billing date",
appUrl: APP_URL,
}),
});
break;
}
if (status === "active" && data.paused_at) {
await sgMail.send({
to: email, from: FROM,
subject: "Welcome back! Subscription resumed",
html: resumedEmail(APP_URL),
});
break;
}
const items = data.items as Array<{
price: { unit_price: { amount: string; currency_code: string } };
product: { name: string };
}> | undefined;
if (items?.length) {
const item = items[0];
const price = item.price.unit_price;
await sgMail.send({
to: email, from: FROM,
subject: `Plan updated to ${item.product.name}`,
html: planChangeEmail({
newPlan: item.product.name,
newAmount: formatPaddleAmount(price.amount, price.currency_code),
currency: price.currency_code,
effectiveDate: new Date().toLocaleDateString(),
appUrl: APP_URL,
}),
});
}
break;
}
case "subscription.canceled": {
const email = getCustomerEmail(data);
if (!email) break;
const currentPeriod = data.current_billing_period as {
ends_at: string;
} | undefined;
await sgMail.send({
to: email, from: FROM,
subject: "Your subscription has been cancelled",
html: cancellationEmail({
endDate: currentPeriod?.ends_at
? new Date(currentPeriod.ends_at).toLocaleDateString()
: "the end of your billing period",
pricingUrl: `${APP_URL}/pricing`,
}),
});
break;
}
}
}
function getCustomerEmail(data: Record<string, unknown>): string | null {
const customer = data.customer as { email?: string } | undefined;
return customer?.email ?? null;
}
function getProductName(data: Record<string, unknown>): string {
const items = data.items as Array<{
product?: { name?: string }; price?: { description?: string };
}> | undefined;
return items?.[0]?.product?.name ?? items?.[0]?.price?.description ?? "Subscription";
}
function formatPaddleAmount(amount: string, currency: string): string {
const num = parseInt(amount, 10);
if (isNaN(num)) return `${amount} ${currency}`;
return `${(num / 100).toFixed(2)} ${currency}`;
}Express Version
Same logic, Express routes. The key difference is using express.raw() for the raw body:
// routes/paddle-webhook.ts
import express from "express";
import { verifyPaddleWebhook } from "../lib/paddle";
const router = express.Router();
router.post(
"/webhook",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.headers["paddle-signature"] as string;
const body = req.body.toString();
if (!verifyPaddleWebhook(body, signature, process.env.PADDLE_WEBHOOK_SECRET!)) {
return res.status(400).send("Invalid signature");
}
const event = JSON.parse(body);
try {
await handlePaddleEvent(event);
} catch (error) {
console.error(`Failed to handle ${event.event_type}:`, error);
}
res.json({ received: true });
}
);
export default router;Paddle's Management URLs
One thing unique to Paddle: every subscription includes management URLs that Paddle hosts. These let customers update their payment method or cancel without you building a billing portal:
// The subscription object includes these URLs
interface PaddleManagementUrls {
update_payment_method: string; // Paddle-hosted page to update card
cancel: string; // Paddle-hosted cancellation page
}
// Use them in your emails instead of custom billing pages
const updateUrl = data.management_urls?.update_payment_method
?? `${APP_URL}/billing`;This is especially useful for failed payment emails — link directly to Paddle's update page. No need to build your own payment form.
Handle Idempotency
Paddle may retry webhooks that don't return a 2xx response. Use the notification_id to deduplicate:
// lib/idempotency.ts
const processedNotifications = new Map<string, number>();
export function isDuplicate(notificationId: string): boolean {
if (processedNotifications.has(notificationId)) {
return true;
}
processedNotifications.set(notificationId, Date.now());
return false;
}
// Clean up old entries every hour
setInterval(() => {
const oneHourAgo = Date.now() - 60 * 60 * 1000;
for (const [id, timestamp] of processedNotifications) {
if (timestamp < oneHourAgo) {
processedNotifications.delete(id);
}
}
}, 60 * 60 * 1000);For production, use your database:
async function processOnce(
notificationId: string,
handler: () => Promise<void>
): Promise<boolean> {
try {
await db.execute(
`INSERT INTO processed_paddle_notifications (notification_id, processed_at)
VALUES (?, NOW())`,
[notificationId]
);
} catch {
return false; // Already processed
}
await handler();
return true;
}Error Handling
Don't let a failed email send crash your webhook handler. Paddle will retry, causing duplicate processing:
async function sendEmailSafe(
sendFn: () => Promise<unknown>,
context: { eventType: string; email: string }
): Promise<void> {
try {
await sendFn();
} catch (error) {
console.error(
`Failed to send ${context.eventType} email to ${context.email}:`,
error
);
// Log to your error tracker but don't throw
// The webhook should still return 200
}
}
// Usage
case "subscription.past_due": {
const email = getCustomerEmail(data);
if (!email) break;
await sendEmailSafe(
() => sequenzy.transactional.send({
to: email,
subject: "Payment failed",
body: paymentFailedEmail({ updateUrl, appUrl: APP_URL }),
}),
{ eventType: "subscription.past_due", email }
);
break;
}Testing Paddle Webhooks Locally
Paddle provides a Simulator in the dashboard for testing. You can also use the Paddle CLI or a tunneling tool:
Option 1: Paddle Simulator
Go to Developer Tools > Notifications in the Paddle dashboard, click your notification destination, and use the Simulate tab to send test events.
Option 2: ngrok or Cloudflare Tunnel
Expose your local server to the internet:
# Using ngrok
ngrok http 3000
# Using Cloudflare Tunnel
cloudflared tunnel --url http://localhost:3000Set the tunnel URL as your notification destination in Paddle, then trigger real test events from the Paddle sandbox.
Option 3: Replay Events
Paddle lets you replay failed notifications from the dashboard. Go to Developer Tools > Notifications > Events, find the event, and click Replay.
Local Testing with Mock Payloads
For unit testing, create mock Paddle payloads:
// test/paddle-mocks.ts
export const mockSubscriptionCreated = {
event_type: "subscription.created",
notification_id: "ntf_test_123",
data: {
id: "sub_test_123",
status: "active",
customer: { email: "test@example.com" },
items: [{
product: { name: "Pro Plan" },
price: {
unit_price: { amount: "4900", currency_code: "USD" },
description: "Pro Plan - Monthly",
},
}],
current_billing_period: {
starts_at: "2026-02-01T00:00:00Z",
ends_at: "2026-03-01T00:00:00Z",
},
},
};
export const mockPaymentFailed = {
event_type: "subscription.past_due",
notification_id: "ntf_test_456",
data: {
id: "sub_test_123",
status: "past_due",
customer: { email: "test@example.com" },
management_urls: {
update_payment_method: "https://sandbox-checkout.paddle.com/...",
cancel: "https://sandbox-checkout.paddle.com/...",
},
},
};The Smarter Approach: Native Paddle Integration
Writing webhook handlers for every Paddle event is significant boilerplate. If you're using Sequenzy, you can skip most of it.
Sequenzy has a native Paddle integration. Connect your Paddle account in the dashboard (Settings > Integrations), and it automatically:
- Tracks all payment events — purchase, cancellation, churn, failed payment, upgrade, downgrade
- Applies status tags to subscribers —
customer,trial,cancelled,churned,past-due - Syncs subscription data — MRR, plan name, billing interval as subscriber attributes
- Triggers automated sequences based on lifecycle events
Instead of writing webhook code for every event, you set up sequences in the dashboard:
| Sequence | Trigger | Stops When | Purpose |
|---|---|---|---|
| Trial Conversion | Tag trial added | User gets customer tag | Convert trial to paid |
| Dunning | Tag past-due added | User no longer past-due | Recover failed payments |
| Win-Back | Tag cancelled added | User gets customer tag | Re-engage cancelled users |
| Onboarding | Event signup.completed | Event onboarding.completed | Guide new users |
Zero webhook code for lifecycle emails. You still need webhooks for custom logic (like provisioning accounts or updating your database), but the email sequences are handled automatically.
Going to Production
1. Use Paddle's Live Environment
Paddle has separate sandbox and live environments with different API keys and webhook secrets. Make sure your production deployment uses the live credentials.
2. Verify Your Email Domain
Add SPF, DKIM, and DMARC DNS records through your email provider. Payment-related emails going to spam is a terrible customer experience.
3. Don't Duplicate Paddle's Receipts
Paddle already sends detailed receipts and invoices as the merchant of record. Your payment confirmation emails should focus on your product — welcome to the app, here's your dashboard, here's what to do next. Don't duplicate the financial receipt.
4. Handle Currency Properly
Paddle supports 20+ currencies. Always use the currency_code from the event data, not a hardcoded currency:
function formatPaddleAmount(amount: string, currency: string): string {
const num = parseInt(amount, 10);
if (isNaN(num)) return `${amount} ${currency}`;
// Paddle amounts are in the smallest unit (cents for USD, pence for GBP)
return new Intl.NumberFormat("en", {
style: "currency",
currency,
}).format(num / 100);
}5. Handle Subscription Pausing
Paddle supports pausing subscriptions — something Stripe doesn't have natively. Make sure your webhook handler covers subscription.updated with paused/resumed states, not just plan changes.
Production Checklist
| Step | What | Why |
|---|---|---|
| Signature verification | Verify Paddle-Signature header | Prevent spoofed events |
| Idempotency | Deduplicate by notification_id | Handle Paddle retries safely |
| Live credentials | Switch from sandbox to live | Use real keys in production |
| Domain verification | SPF, DKIM, DMARC records | Inbox delivery, not spam |
| Error isolation | Don't let email failures crash the handler | Process other events normally |
| Currency handling | Use currency_code from events | Support international customers |
| Logging | Log event types and outcomes | Debug failures in production |
| Monitor | Check Paddle Dashboard > Notifications | Catch delivery failures |
Beyond Transactional: Marketing Emails and Sequences
At some point, you'll want more than one-off emails. You'll want to:
- Send onboarding sequences that guide new users through your product over several days
- Run marketing campaigns to announce features or share updates
- Automate lifecycle emails based on payment events — trial conversion, dunning, win-back
- Track engagement to see which emails drive upgrades
Most teams wire together a transactional email provider with a separate marketing tool. That means two dashboards, two billing systems, and keeping subscriber lists in sync.
Sequenzy handles both from one platform. Same SDK, same dashboard. Transactional sends, marketing campaigns, automated sequences, subscriber segments, and native Paddle integration.
Here's subscriber management alongside your Paddle webhook:
import { sequenzy } from "@/lib/email";
// When a Paddle subscription is created
await sequenzy.subscribers.create({
email: customerEmail,
tags: ["customer", "paddle"],
customAttributes: {
plan: "Pro",
paddleSubscriptionId: subscriptionId,
currency: "USD",
},
});
// Tag when they upgrade
await sequenzy.subscribers.tags.add({
email: customerEmail,
tag: "upgraded",
});
// Track custom events to trigger sequences
await sequenzy.subscribers.events.trigger({
email: customerEmail,
event: "onboarding.completed",
properties: { completedSteps: 5 },
});FAQ
Does Paddle already send receipts? Why would I send my own emails?
Yes, Paddle sends detailed financial receipts and invoices as the merchant of record. But those are generic payment confirmations. You still need to send product emails: welcome to your app, here's your dashboard link, your trial is ending, your account is being downgraded, etc. Paddle handles the financial communication; you handle the product communication.
How is Paddle's webhook signature different from Stripe's?
Paddle uses a Paddle-Signature header with the format ts=timestamp;h1=hmac_hash. The signed payload is timestamp:body (colon-separated). Stripe uses a stripe-signature header with t=timestamp,v1=hmac_hash (comma-separated) and signs timestamp.body (dot-separated). The HMAC-SHA256 algorithm is the same.
How do I get the customer email from a Paddle event?
Paddle nests customer data in a customer object within the event data: event.data.customer.email. Some events (like transaction.completed) include the full customer object. For subscription events, the customer email is always present in the nested object.
What's the difference between transaction.completed and subscription.created?
subscription.created fires when a new subscription is created (first purchase). transaction.completed fires for every successful payment, including the initial purchase and all renewals. Use subscription.created for welcome emails and transaction.completed for recurring payment confirmations.
How do I handle Paddle sandbox vs. live webhooks?
Paddle has separate sandbox and live environments. Each has its own webhook secret. Use environment variables to switch between them. In development, use sandbox credentials and the Paddle Simulator. In production, use live credentials. The event payloads and structure are identical.
Paddle uses different currencies. How do I format amounts in emails?
Use Intl.NumberFormat with the currency_code from the event data. Paddle sends amounts in the smallest currency unit (cents for USD, pence for GBP). Always divide by 100 and format with the actual currency, never hardcode USD.
How do I handle subscription pausing and resuming?
Both come through as subscription.updated events. Check data.status — if it's "paused", the subscription was paused. If it's "active" and data.paused_at exists, it was just resumed. Also check data.scheduled_change.action for upcoming pauses that haven't taken effect yet.
Can I use Paddle's Retain (cancellation flows) alongside my own emails?
Yes. Paddle Retain intercepts the cancellation flow and tries to keep the customer. Your webhook handler for subscription.canceled only fires if the customer completes the cancellation (Retain didn't save them). This means your cancellation emails are only sent to customers who actually cancelled, not those who were retained.
What happens if my webhook endpoint is down?
Paddle retries failed webhook deliveries with exponential backoff. You can see delivery attempts and retry them manually in the Paddle Dashboard under Developer Tools > Notifications > Events. The events are stored for replay, so you won't lose data during downtime.
Should I use Paddle's Node.js SDK for webhook verification?
You can, but it's not necessary. The SDK provides paddle.webhooks.unmarshal() for verification, but the HMAC verification shown in this guide is straightforward and avoids adding a dependency. If you're already using the Paddle SDK for API calls, use its built-in verification. Otherwise, the manual approach works fine.
Wrapping Up
Here's what we covered:
- Webhook handler for Paddle Billing events — subscriptions, transactions, trials, pauses, cancellations
- Signature verification with HMAC-SHA256 and timestamp validation
- Email templates organized as functions for clean, testable code
- Paddle management URLs for payment method updates without building a billing portal
- Idempotency with
notification_iddeduplication - Express version with raw body parsing for signature verification
- Testing with Paddle Simulator, tunneling tools, and mock payloads
- Native integration with Sequenzy to skip webhook boilerplate for lifecycle emails
- Production checklist: live credentials, domain verification, currency handling
For most SaaS apps, connecting Paddle to Sequenzy and setting up automated sequences is the simplest path. Use manual webhooks when you need custom logic for account provisioning or database updates.
Frequently Asked Questions
Which Paddle webhook events should I use for email sending?
Start with transaction.completed (purchase confirmation), subscription.activated (welcome), subscription.past_due (payment failed), and subscription.canceled (cancellation). These cover the core lifecycle emails. Add subscription.updated for plan change notifications.
How do I verify Paddle webhook signatures?
Paddle signs webhooks with a public key using the Paddle Billing webhook signature scheme. Verify the Paddle-Signature header against the raw request body using the public key from your Paddle dashboard. Use Paddle's SDK or the ts-verify-paddle-webhook library.
Does Paddle handle customer emails automatically?
Paddle sends basic transaction receipts and subscription notifications as the Merchant of Record. However, these are generic and Paddle-branded. Custom emails let you control branding, add onboarding content, and trigger automated sequences specific to your product.
How do I handle Paddle's sandbox vs. production webhooks?
Use different webhook endpoints or check the environment field in the webhook payload. Store sandbox and production API keys in separate environment variables. Use Paddle's sandbox for all testing to avoid sending real emails to test purchases.
How do I extract customer email from Paddle webhooks?
The customer email is in the webhook payload under data.customer.email for subscription events and data.customer_id for transaction events. You may need to call the Paddle API to get the full customer details if the email isn't in the webhook payload.
How do I handle Paddle's multi-currency pricing in email receipts?
Paddle provides the currency code and amount in the webhook payload. Use Intl.NumberFormat with the currency code to format amounts correctly (e.g., $9.99, 9,99 EUR). Always use the currency from the payload, not a hardcoded one.
Should I send emails synchronously in Paddle webhook handlers?
Return 200 to Paddle quickly to prevent retries. For simple single-email sends, synchronous is fine if your provider responds fast. For complex flows, return 200 immediately and process emails via a job queue to avoid Paddle's timeout window.
How do I handle subscription trial events from Paddle?
Listen for subscription.activated with a scheduled_change that indicates a trial period. Send a welcome email immediately, then schedule trial-ending reminders based on the current_billing_period.ends_at timestamp.
Can I trigger email automation sequences from Paddle events?
Yes. When a Paddle webhook fires, add the customer to your email platform with relevant tags (e.g., "customer", "monthly", plan name). These tags can trigger automated sequences for onboarding, upsells, or churn prevention.
How do I test Paddle webhooks locally?
Use Paddle's sandbox environment and a tunnel tool like ngrok to expose your local server. Register the ngrok URL as your webhook endpoint in Paddle's sandbox dashboard. Paddle's sandbox sends real webhook payloads to your local code for testing.