Back to Blog

How to Send Emails from Polar Webhooks (2026 Guide)

12 min read

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 subscription
  • subscription.created - New subscription started
  • subscription.updated - Subscription changed (upgrade/downgrade/cancellation)
  • subscription.active - Subscription became active
  • subscription.revoked - Subscription ended

Set Up

Terminal
npm install sequenzy
Terminal
npm install resend
Terminal
npm install @sendgrid/mail
# .env
POLAR_WEBHOOK_SECRET=your_polar_webhook_secret

Webhook Handler (Next.js)

app/api/webhooks/polar/route.ts
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 });
}
app/api/webhooks/polar/route.ts
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 });
}
app/api/webhooks/polar/route.ts
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

  1. Webhook handler for order, subscription, and cancellation events
  2. Signature verification for security
  3. Native integration with Sequenzy to skip webhook code
  4. 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.