How to Send Emails from Polar Webhooks (2026 Guide)

Polar is a payment platform built for developers and open-source projects. It handles subscriptions, one-time purchases, and donations. But like most payment platforms, it doesn't handle customer email communication beyond basic receipts.
This guide covers how to send emails when Polar events fire: order confirmations, subscription lifecycle emails, and failed payment alerts.
How Polar Webhooks Work
Polar sends webhooks for payment events. You register an endpoint in the Polar dashboard (Settings > Webhooks), select which events to listen for, and Polar sends POST requests with event data.
Key events:
order.created- New purchase or subscriptionsubscription.created- New subscription startedsubscription.updated- Subscription changed (upgrade/downgrade/cancellation)subscription.active- Subscription became activesubscription.revoked- Subscription ended
Set Up
npm install sequenzynpm install resendnpm install @sendgrid/mail# .env
POLAR_WEBHOOK_SECRET=your_polar_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 expected = crypto
.createHmac("sha256", secret)
.update(body)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("webhook-signature") ?? "";
if (!verifyWebhook(body, signature, process.env.POLAR_WEBHOOK_SECRET!)) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
const event = JSON.parse(body);
switch (event.type) {
case "order.created": {
const order = event.data;
const email = order.customer.email;
await sequenzy.transactional.send({
to: email,
subject: "Order confirmed!",
body: `
<h1>Thanks for your purchase!</h1>
<p>Your order for <strong>${order.product.name}</strong> has been confirmed.</p>
<p>Amount: $${(order.amount / 100).toFixed(2)}</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>
`,
});
// Add as subscriber for marketing
await sequenzy.subscribers.create({
email,
tags: ["customer", "polar"],
customAttributes: { product: order.product.name },
});
break;
}
case "subscription.active": {
const sub = event.data;
await sequenzy.transactional.send({
to: sub.customer.email,
subject: "Your subscription is active",
body: `
<h1>Welcome!</h1>
<p>Your <strong>${sub.product.name}</strong> subscription is now active.</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;">
Get Started
</a>
`,
});
break;
}
case "subscription.revoked": {
const sub = event.data;
await sequenzy.transactional.send({
to: sub.customer.email,
subject: "Your subscription has ended",
body: `
<h2>We're sorry to see you go</h2>
<p>Your <strong>${sub.product.name}</strong> subscription has ended.</p>
<p>You can resubscribe at any time to regain access.</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;
}
}
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 expected = crypto.createHmac("sha256", secret).update(body).digest("hex");
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("webhook-signature") ?? "";
if (!verifyWebhook(body, signature, process.env.POLAR_WEBHOOK_SECRET!)) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
const event = JSON.parse(body);
switch (event.type) {
case "order.created": {
const order = event.data;
await resend.emails.send({
from: FROM, to: order.customer.email,
subject: "Order confirmed!",
html: `<h1>Thanks for your purchase!</h1>
<p>Your order for <strong>${order.product.name}</strong> is confirmed.</p>
<p>Amount: $${(order.amount / 100).toFixed(2)}</p>`,
});
break;
}
case "subscription.active": {
const sub = event.data;
await resend.emails.send({
from: FROM, to: sub.customer.email,
subject: "Your subscription is active",
html: `<h1>Welcome!</h1><p>Your ${sub.product.name} subscription is now active.</p>`,
});
break;
}
case "subscription.revoked": {
const sub = event.data;
await resend.emails.send({
from: FROM, to: sub.customer.email,
subject: "Your subscription has ended",
html: `<h2>We're sorry to see you go</h2><p>Resubscribe any time.</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 expected = crypto.createHmac("sha256", secret).update(body).digest("hex");
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("webhook-signature") ?? "";
if (!verifyWebhook(body, signature, process.env.POLAR_WEBHOOK_SECRET!)) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
const event = JSON.parse(body);
switch (event.type) {
case "order.created": {
const order = event.data;
await sgMail.send({
to: order.customer.email, from: FROM,
subject: "Order confirmed!",
html: `<h1>Thanks for your purchase!</h1>
<p>Your order for <strong>${order.product.name}</strong> is confirmed.</p>`,
});
break;
}
case "subscription.active": {
const sub = event.data;
await sgMail.send({
to: sub.customer.email, from: FROM,
subject: "Your subscription is active",
html: `<h1>Welcome!</h1><p>Your ${sub.product.name} subscription is now active.</p>`,
});
break;
}
case "subscription.revoked": {
const sub = event.data;
await sgMail.send({
to: sub.customer.email, from: FROM,
subject: "Your subscription has ended",
html: `<h2>We're sorry to see you go</h2><p>Resubscribe any time.</p>`,
});
break;
}
}
return NextResponse.json({ received: true });
}Skip the Webhooks: Native Integration
If you're using Sequenzy, you can connect Polar directly in the dashboard (Settings > Integrations). The native integration automatically handles all payment events, applies tags (customer, trial, cancelled, churned), and triggers automated sequences. No webhook code needed.
Wrapping Up
- Webhook handler for order, subscription, and cancellation events
- Signature verification for security
- Native integration with Sequenzy to skip webhook code
- Subscriber tagging for automated marketing sequences
For most apps, connecting Polar to Sequenzy is the simplest path. Use manual webhooks when you need custom logic.