How to Send Emails from Paddle Webhooks (2026 Guide)

Paddle is a merchant of record for SaaS. It handles payments, taxes, and invoicing globally. But beyond basic receipts, you need to send your own customer emails: welcome messages, subscription updates, and failed payment alerts.
This guide covers how to send emails when Paddle events fire using webhooks.
How Paddle Webhooks Work
Paddle sends webhook notifications for billing events. You register an endpoint in the Paddle dashboard (Developer Tools > Notifications), select event types, and Paddle sends POST requests with event data and a signature for verification.
Key events:
transaction.completed- Payment succeededsubscription.created- New subscriptionsubscription.updated- Plan change, pause, or resumesubscription.canceled- Subscription cancelledsubscription.past_due- Payment failed
Set Up
npm install sequenzynpm install resendnpm install @sendgrid/mail# .env
PADDLE_WEBHOOK_SECRET=your_paddle_webhook_secretWebhook Handler (Next.js)
import { NextRequest, NextResponse } from "next/server";
import Sequenzy from "sequenzy";
import crypto from "crypto";
const sequenzy = new Sequenzy();
function verifyWebhook(body: string, signature: string, secret: string): boolean {
const ts = signature.split(";").find((s) => s.startsWith("ts="))?.split("=")[1];
const h1 = signature.split(";").find((s) => s.startsWith("h1="))?.split("=")[1];
if (!ts || !h1) return false;
const payload = ts + ":" + body;
const expected = crypto.createHmac("sha256", secret).update(payload).digest("hex");
return crypto.timingSafeEqual(Buffer.from(h1), Buffer.from(expected));
}
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("paddle-signature") ?? "";
if (!verifyWebhook(body, signature, process.env.PADDLE_WEBHOOK_SECRET!)) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
const event = JSON.parse(body);
switch (event.event_type) {
case "transaction.completed": {
const txn = event.data;
const email = txn.customer?.email;
if (!email) break;
await sequenzy.transactional.send({
to: email,
subject: "Payment confirmed!",
body: `
<h1>Thanks for your purchase!</h1>
<p>Your payment of <strong>${txn.details.totals.total} ${txn.currency_code}</strong> has been confirmed.</p>
<a href="${process.env.APP_URL}/dashboard"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Go to Dashboard
</a>
`,
});
await sequenzy.subscribers.create({
email,
tags: ["customer", "paddle"],
});
break;
}
case "subscription.canceled": {
const sub = event.data;
const email = sub.customer?.email;
if (!email) break;
await sequenzy.transactional.send({
to: email,
subject: "Your subscription has been cancelled",
body: `
<h2>We're sorry to see you go</h2>
<p>Your subscription has been cancelled. You'll have access until the end of your billing period.</p>
<a href="${process.env.APP_URL}/pricing"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Resubscribe
</a>
`,
});
break;
}
case "subscription.past_due": {
const sub = event.data;
const email = sub.customer?.email;
if (!email) break;
await sequenzy.transactional.send({
to: email,
subject: "Payment failed - action needed",
body: `
<h2>Your payment failed</h2>
<p>We couldn't process your latest payment. Please update your payment method to keep your subscription active.</p>
<a href="${sub.management_urls?.update_payment_method ?? process.env.APP_URL + '/billing'}"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Update Payment Method
</a>
`,
});
break;
}
}
return NextResponse.json({ received: true });
}import { NextRequest, NextResponse } from "next/server";
import { Resend } from "resend";
import crypto from "crypto";
const resend = new Resend(process.env.RESEND_API_KEY);
const FROM = "Your App <noreply@yourdomain.com>";
function verifyWebhook(body: string, signature: string, secret: string): boolean {
const ts = signature.split(";").find((s) => s.startsWith("ts="))?.split("=")[1];
const h1 = signature.split(";").find((s) => s.startsWith("h1="))?.split("=")[1];
if (!ts || !h1) return false;
const payload = ts + ":" + body;
const expected = crypto.createHmac("sha256", secret).update(payload).digest("hex");
return crypto.timingSafeEqual(Buffer.from(h1), Buffer.from(expected));
}
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("paddle-signature") ?? "";
if (!verifyWebhook(body, signature, process.env.PADDLE_WEBHOOK_SECRET!)) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
const event = JSON.parse(body);
switch (event.event_type) {
case "transaction.completed": {
const txn = event.data;
const email = txn.customer?.email;
if (!email) break;
await resend.emails.send({
from: FROM, to: email,
subject: "Payment confirmed!",
html: `<h1>Thanks for your purchase!</h1>
<p>Your payment of <strong>${txn.details.totals.total} ${txn.currency_code}</strong> has been confirmed.</p>`,
});
break;
}
case "subscription.canceled": {
const sub = event.data;
const email = sub.customer?.email;
if (!email) break;
await resend.emails.send({
from: FROM, to: email,
subject: "Your subscription has been cancelled",
html: `<h2>We're sorry to see you go</h2><p>You'll have access until the end of your billing period.</p>`,
});
break;
}
case "subscription.past_due": {
const sub = event.data;
const email = sub.customer?.email;
if (!email) break;
await resend.emails.send({
from: FROM, to: email,
subject: "Payment failed - action needed",
html: `<h2>Your payment failed</h2><p>Please update your payment method.</p>`,
});
break;
}
}
return NextResponse.json({ received: true });
}import { NextRequest, NextResponse } from "next/server";
import sgMail from "@sendgrid/mail";
import crypto from "crypto";
sgMail.setApiKey(process.env.SENDGRID_API_KEY!);
const FROM = "noreply@yourdomain.com";
function verifyWebhook(body: string, signature: string, secret: string): boolean {
const ts = signature.split(";").find((s) => s.startsWith("ts="))?.split("=")[1];
const h1 = signature.split(";").find((s) => s.startsWith("h1="))?.split("=")[1];
if (!ts || !h1) return false;
const payload = ts + ":" + body;
const expected = crypto.createHmac("sha256", secret).update(payload).digest("hex");
return crypto.timingSafeEqual(Buffer.from(h1), Buffer.from(expected));
}
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("paddle-signature") ?? "";
if (!verifyWebhook(body, signature, process.env.PADDLE_WEBHOOK_SECRET!)) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
const event = JSON.parse(body);
switch (event.event_type) {
case "transaction.completed": {
const txn = event.data;
const email = txn.customer?.email;
if (!email) break;
await sgMail.send({
to: email, from: FROM,
subject: "Payment confirmed!",
html: `<h1>Thanks for your purchase!</h1>
<p>Your payment of <strong>${txn.details.totals.total} ${txn.currency_code}</strong> has been confirmed.</p>`,
});
break;
}
case "subscription.canceled": {
const sub = event.data;
const email = sub.customer?.email;
if (!email) break;
await sgMail.send({
to: email, from: FROM,
subject: "Your subscription has been cancelled",
html: `<h2>We're sorry to see you go</h2><p>You'll have access until the end of your billing period.</p>`,
});
break;
}
case "subscription.past_due": {
const sub = event.data;
const email = sub.customer?.email;
if (!email) break;
await sgMail.send({
to: email, from: FROM,
subject: "Payment failed - action needed",
html: `<h2>Your payment failed</h2><p>Please update your payment method.</p>`,
});
break;
}
}
return NextResponse.json({ received: true });
}Skip the Webhooks: Native Integration
If you're using Sequenzy, you can connect Paddle directly in the dashboard (Settings > Integrations). The native integration automatically handles all payment events, applies tags (customer, trial, cancelled, churned, past-due), and triggers automated sequences. No webhook code needed.
Wrapping Up
- Webhook handler for transactions, subscriptions, and failed payments
- Paddle signature verification with timestamp + HMAC
- Native integration with Sequenzy to skip webhook code
- Subscriber tagging for automated marketing sequences
For most apps, connecting Paddle to Sequenzy is the simplest path. Use manual webhooks when you need custom logic.