Back to Blog

How to Send Emails from Polar Webhooks (2026 Guide)

18 min read

Polar is a payment platform built for developers, open-source projects, and digital products. It handles subscriptions, one-time purchases, and benefit management (license keys, file downloads, Discord access, etc.). But like most payment platforms, Polar doesn't handle your product's customer communication — welcome emails, onboarding sequences, failed payment alerts, or cancellation follow-ups.

This guide covers how to send emails from Polar webhook events: verifying signatures, sending order confirmations, handling the full subscription lifecycle, reacting to benefit grants, and building production-ready error handling. All code examples use TypeScript and let you switch between email providers.

How Polar Webhooks Work

Polar sends webhook notifications when billing and subscription events happen. You register an endpoint in the Polar dashboard under Settings > Webhooks, select which event types you want, and Polar sends POST requests with:

  • A JSON body containing the event type and data
  • A webhook-id, webhook-timestamp, and webhook-signature header for verification (using the standard Svix webhook format)

Polar uses Svix for webhook delivery, which means you get automatic retries, delivery logging, and a standardized signature format.

Which Events to Listen For

EventWhen It FiresEmail to Send
order.createdNew purchase (one-time or first subscription payment)Welcome + order confirmation
subscription.createdNew subscription startedWelcome / getting started
subscription.activeSubscription became activeActivation confirmation
subscription.updatedPlan change or status changeChange confirmation
subscription.canceledSubscription set to cancel at period endCancellation follow-up
subscription.revokedSubscription access removedAccess revoked notice
subscription.uncanceledCancellation reversedWelcome back
benefit_grant.createdBenefit granted to customerBenefit delivery email
benefit_grant.revokedBenefit revokedAccess revoked notice

Configure these in the Polar Dashboard under Settings > Webhooks > Add Endpoint.

Set Up Your Email Provider

Terminal
npm install sequenzy svix
Terminal
npm install resend svix
Terminal
npm install @sendgrid/mail svix

The svix package provides a standardized way to verify Polar's webhook signatures. You can also verify manually (shown later).

Add your keys to .env:

POLAR_WEBHOOK_SECRET=whsec_your_polar_webhook_secret
APP_URL=https://app.yoursite.com
.env
SEQUENZY_API_KEY=sq_your_api_key_here
.env
RESEND_API_KEY=re_your_api_key_here
.env
SENDGRID_API_KEY=SG.your_api_key_here

Initialize the clients:

lib/email.ts
import Sequenzy from "sequenzy";

// Reads SEQUENZY_API_KEY from env automatically
export const sequenzy = new Sequenzy();
lib/email.ts
import { Resend } from "resend";

export const resend = new Resend(process.env.RESEND_API_KEY);
export const FROM = "Your App <noreply@yourdomain.com>";
lib/email.ts
import sgMail from "@sendgrid/mail";

sgMail.setApiKey(process.env.SENDGRID_API_KEY!);
export { sgMail };
export const FROM = "noreply@yourdomain.com";

Verify Polar Webhook Signatures

Polar uses the Svix webhook standard. You can verify with the svix package or manually:

Using the Svix Package

// lib/polar.ts
import { Webhook } from "svix";
 
export function verifyPolarWebhook(
  body: string,
  headers: Record<string, string>
): Record<string, unknown> {
  const wh = new Webhook(process.env.POLAR_WEBHOOK_SECRET!);
 
  // Svix expects these specific headers
  return wh.verify(body, {
    "webhook-id": headers["webhook-id"] ?? "",
    "webhook-timestamp": headers["webhook-timestamp"] ?? "",
    "webhook-signature": headers["webhook-signature"] ?? "",
  }) as Record<string, unknown>;
}

Manual Verification

If you don't want the svix dependency:

// lib/polar-verify.ts
import crypto from "crypto";
 
export function verifyPolarWebhookManual(
  body: string,
  webhookId: string,
  timestamp: string,
  signature: string,
  secret: string
): boolean {
  // The secret is base64-encoded, prefixed with "whsec_"
  const secretBytes = Buffer.from(secret.replace("whsec_", ""), "base64");
 
  // The signed content is: webhook_id.timestamp.body
  const signedContent = `${webhookId}.${timestamp}.${body}`;
 
  const expectedSignature = crypto
    .createHmac("sha256", secretBytes)
    .update(signedContent)
    .digest("base64");
 
  // The signature header may contain multiple signatures separated by spaces
  // Each is prefixed with "v1,"
  const signatures = signature.split(" ");
  return signatures.some((sig) => {
    const sigValue = sig.replace("v1,", "");
    try {
      return crypto.timingSafeEqual(
        Buffer.from(expectedSignature),
        Buffer.from(sigValue)
      );
    } catch {
      return false;
    }
  });
}

Organize Email Templates

Keep templates separate from your webhook handler:

// lib/polar-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>`;
}
 
function escapeHtml(text: string): string {
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
 
// --- Order Confirmation ---
 
export function orderConfirmationEmail(params: {
  productName: string;
  amount: string;
  currency: string;
  appUrl: string;
}): string {
  return layout(`
    <h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Thanks for your purchase!</h1>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      Your order for <strong>${escapeHtml(params.productName)}</strong> has been confirmed.
    </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;">
          ${escapeHtml(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>
    ${cta("Go to Dashboard", `${params.appUrl}/dashboard`)}
  `);
}
 
// --- Subscription Welcome ---
 
export function subscriptionWelcomeEmail(params: {
  productName: string;
  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 <strong>${escapeHtml(params.productName)}</strong> subscription is now active.
      You have full access to all features.
    </p>
    ${cta("Get Started", `${params.appUrl}/dashboard`)}
  `);
}
 
// --- Subscription Cancelled ---
 
export function cancellationEmail(params: {
  productName: string;
  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 <strong>${escapeHtml(params.productName)}</strong>
      subscription has been cancelled.
    </p>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      You'll continue to have access until <strong>${params.endDate}</strong>.
      After that, your account will be downgraded.
    </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)}
  `);
}
 
// --- Subscription Revoked (Access Removed) ---
 
export function revokedEmail(params: {
  productName: string;
  pricingUrl: string;
}): string {
  return layout(`
    <h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Access Removed</h1>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      Your <strong>${escapeHtml(params.productName)}</strong> subscription has ended
      and your access has been removed.
    </p>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      Want to come back? You can resubscribe at any time to regain full access.
    </p>
    ${cta("Resubscribe", params.pricingUrl)}
  `);
}
 
// --- Uncancelled (Welcome Back) ---
 
export function uncancelledEmail(params: {
  productName: string;
  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;">
      Great news! Your <strong>${escapeHtml(params.productName)}</strong> subscription
      has been reactivated. Your access continues uninterrupted.
    </p>
    ${cta("Go to Dashboard", `${params.appUrl}/dashboard`)}
  `);
}
 
// --- Benefit Granted ---
 
export function benefitGrantedEmail(params: {
  benefitType: string;
  productName: string;
  details: string;
  appUrl: string;
}): string {
  return layout(`
    <h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Your Benefit is Ready</h1>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      As part of your <strong>${escapeHtml(params.productName)}</strong> purchase,
      you've been granted access to:
    </p>
    <div style="background:#f3f4f6;border-radius:6px;padding:16px;margin:16px 0;">
      <p style="font-size:14px;color:#6b7280;margin:0 0 4px;">
        ${escapeHtml(params.benefitType)}
      </p>
      <p style="font-size:16px;color:#111827;margin:0;font-weight:600;">
        ${escapeHtml(params.details)}
      </p>
    </div>
    ${cta("Access Your Benefits", `${params.appUrl}/benefits`)}
  `);
}
 
// --- Plan Changed ---
 
export function planChangeEmail(params: {
  oldProduct: string;
  newProduct: string;
  newAmount: string;
  currency: 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 subscription has been changed from
      <strong>${escapeHtml(params.oldProduct)}</strong> to
      <strong>${escapeHtml(params.newProduct)}</strong>.
    </p>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      Your new rate is <strong>${params.newAmount} ${params.currency}</strong>.
    </p>
    ${cta("Go to Dashboard", `${params.appUrl}/dashboard`)}
  `);
}

The Complete Webhook Handler

app/api/webhooks/polar/route.ts
import { NextRequest, NextResponse } from "next/server";
import { sequenzy } from "@/lib/email";
import { verifyPolarWebhook } from "@/lib/polar";
import {
orderConfirmationEmail,
subscriptionWelcomeEmail,
cancellationEmail,
revokedEmail,
uncancelledEmail,
benefitGrantedEmail,
planChangeEmail,
} from "@/lib/polar-emails";

const APP_URL = process.env.APP_URL!;

export async function POST(request: NextRequest) {
const body = await request.text();

let event: Record<string, unknown>;
try {
  event = verifyPolarWebhook(body, {
    "webhook-id": request.headers.get("webhook-id") ?? "",
    "webhook-timestamp": request.headers.get("webhook-timestamp") ?? "",
    "webhook-signature": request.headers.get("webhook-signature") ?? "",
  });
} catch {
  return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}

try {
  await handlePolarEvent(event);
} catch (error) {
  console.error(`Polar webhook failed for ${event.type}:`, error);
}

return NextResponse.json({ received: true });
}

async function handlePolarEvent(event: Record<string, unknown>) {
const data = event.data as Record<string, unknown>;

switch (event.type) {
  case "order.created": {
    const customer = data.customer as { email: string };
    const product = data.product as { name: string };
    const amount = data.amount as number;
    const currency = (data.currency as string) ?? "USD";

    const formatted = formatAmount(amount, currency);

    await sequenzy.transactional.send({
      to: customer.email,
      subject: `Order confirmed — ${formatted}`,
      body: orderConfirmationEmail({
        productName: product.name,
        amount: formatted,
        currency,
        appUrl: APP_URL,
      }),
    });

    // Add as subscriber for marketing
    await sequenzy.subscribers.create({
      email: customer.email,
      tags: ["customer", "polar"],
      customAttributes: {
        product: product.name,
        polarOrderId: data.id as string,
      },
    });
    break;
  }

  case "subscription.created":
  case "subscription.active": {
    const customer = data.customer as { email: string };
    const product = data.product as { name: string };

    await sequenzy.transactional.send({
      to: customer.email,
      subject: `Welcome! Your ${product.name} subscription is active`,
      body: subscriptionWelcomeEmail({
        productName: product.name,
        appUrl: APP_URL,
      }),
    });

    await sequenzy.subscribers.create({
      email: customer.email,
      tags: ["customer", "polar", "subscriber"],
      customAttributes: {
        product: product.name,
        subscriptionId: data.id as string,
      },
    });
    break;
  }

  case "subscription.updated": {
    const customer = data.customer as { email: string };
    const product = data.product as { name: string };
    const status = data.status as string;

    // Handle cancellation set (subscription.canceled is separate)
    if (status === "canceled") {
      const currentPeriodEnd = data.current_period_end as string | undefined;
      const endDate = currentPeriodEnd
        ? new Date(currentPeriodEnd).toLocaleDateString()
        : "the end of your billing period";

      await sequenzy.transactional.send({
        to: customer.email,
        subject: "Your subscription has been cancelled",
        body: cancellationEmail({
          productName: product.name,
          endDate,
          pricingUrl: `${APP_URL}/pricing`,
        }),
      });

      await sequenzy.subscribers.tags.add({
        email: customer.email,
        tag: "cancelled",
      });
      break;
    }

    // Handle plan change (product changed)
    // Compare previous product if available
    const priceAmount = data.amount as number | undefined;
    const currency = (data.currency as string) ?? "USD";

    if (priceAmount) {
      await sequenzy.transactional.send({
        to: customer.email,
        subject: `Your plan has been updated`,
        body: planChangeEmail({
          oldProduct: "Previous plan",
          newProduct: product.name,
          newAmount: formatAmount(priceAmount, currency),
          currency,
          appUrl: APP_URL,
        }),
      });
    }
    break;
  }

  case "subscription.canceled": {
    const customer = data.customer as { email: string };
    const product = data.product as { name: string };
    const currentPeriodEnd = data.current_period_end as string | undefined;
    const endDate = currentPeriodEnd
      ? new Date(currentPeriodEnd).toLocaleDateString()
      : "the end of your billing period";

    await sequenzy.transactional.send({
      to: customer.email,
      subject: "Your subscription has been cancelled",
      body: cancellationEmail({
        productName: product.name,
        endDate,
        pricingUrl: `${APP_URL}/pricing`,
      }),
    });

    await sequenzy.subscribers.tags.add({
      email: customer.email,
      tag: "cancelled",
    });
    break;
  }

  case "subscription.uncanceled": {
    const customer = data.customer as { email: string };
    const product = data.product as { name: string };

    await sequenzy.transactional.send({
      to: customer.email,
      subject: "Welcome back! Subscription reactivated",
      body: uncancelledEmail({
        productName: product.name,
        appUrl: APP_URL,
      }),
    });

    // Remove cancelled tag
    await sequenzy.subscribers.tags.remove({
      email: customer.email,
      tag: "cancelled",
    });
    break;
  }

  case "subscription.revoked": {
    const customer = data.customer as { email: string };
    const product = data.product as { name: string };

    await sequenzy.transactional.send({
      to: customer.email,
      subject: "Your subscription access has ended",
      body: revokedEmail({
        productName: product.name,
        pricingUrl: `${APP_URL}/pricing`,
      }),
    });

    await sequenzy.subscribers.tags.add({
      email: customer.email,
      tag: "churned",
    });
    break;
  }

  case "benefit_grant.created": {
    const customer = data.customer as { email: string };
    const benefit = data.benefit as {
      type: string;
      description: string;
    };
    const product = data.product as { name: string } | undefined;

    const benefitLabels: Record<string, string> = {
      license_keys: "License Key",
      downloadables: "File Download",
      discord: "Discord Access",
      github_repository: "GitHub Repository Access",
      custom: "Custom Benefit",
    };

    await sequenzy.transactional.send({
      to: customer.email,
      subject: `Your benefit is ready: ${benefit.description}`,
      body: benefitGrantedEmail({
        benefitType: benefitLabels[benefit.type] ?? benefit.type,
        productName: product?.name ?? "your purchase",
        details: benefit.description,
        appUrl: APP_URL,
      }),
    });
    break;
  }
}
}

function formatAmount(amount: number, currency: string): string {
try {
  return new Intl.NumberFormat("en", {
    style: "currency",
    currency,
  }).format(amount / 100);
} catch {
  return `${(amount / 100).toFixed(2)} ${currency}`;
}
}
app/api/webhooks/polar/route.ts
import { NextRequest, NextResponse } from "next/server";
import { resend, FROM } from "@/lib/email";
import { verifyPolarWebhook } from "@/lib/polar";
import {
orderConfirmationEmail,
subscriptionWelcomeEmail,
cancellationEmail,
revokedEmail,
uncancelledEmail,
benefitGrantedEmail,
} from "@/lib/polar-emails";

const APP_URL = process.env.APP_URL!;

export async function POST(request: NextRequest) {
const body = await request.text();

let event: Record<string, unknown>;
try {
  event = verifyPolarWebhook(body, {
    "webhook-id": request.headers.get("webhook-id") ?? "",
    "webhook-timestamp": request.headers.get("webhook-timestamp") ?? "",
    "webhook-signature": request.headers.get("webhook-signature") ?? "",
  });
} catch {
  return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}

try {
  await handlePolarEvent(event);
} catch (error) {
  console.error(`Polar webhook failed for ${event.type}:`, error);
}

return NextResponse.json({ received: true });
}

async function handlePolarEvent(event: Record<string, unknown>) {
const data = event.data as Record<string, unknown>;

switch (event.type) {
  case "order.created": {
    const customer = data.customer as { email: string };
    const product = data.product as { name: string };
    const amount = data.amount as number;
    const currency = (data.currency as string) ?? "USD";

    await resend.emails.send({
      from: FROM, to: customer.email,
      subject: `Order confirmed — ${formatAmount(amount, currency)}`,
      html: orderConfirmationEmail({
        productName: product.name,
        amount: formatAmount(amount, currency),
        currency, appUrl: APP_URL,
      }),
    });
    break;
  }

  case "subscription.created":
  case "subscription.active": {
    const customer = data.customer as { email: string };
    const product = data.product as { name: string };

    await resend.emails.send({
      from: FROM, to: customer.email,
      subject: `Welcome! Your ${product.name} subscription is active`,
      html: subscriptionWelcomeEmail({
        productName: product.name, appUrl: APP_URL,
      }),
    });
    break;
  }

  case "subscription.canceled": {
    const customer = data.customer as { email: string };
    const product = data.product as { name: string };
    const currentPeriodEnd = data.current_period_end as string | undefined;

    await resend.emails.send({
      from: FROM, to: customer.email,
      subject: "Your subscription has been cancelled",
      html: cancellationEmail({
        productName: product.name,
        endDate: currentPeriodEnd
          ? new Date(currentPeriodEnd).toLocaleDateString()
          : "the end of your billing period",
        pricingUrl: `${APP_URL}/pricing`,
      }),
    });
    break;
  }

  case "subscription.uncanceled": {
    const customer = data.customer as { email: string };
    const product = data.product as { name: string };

    await resend.emails.send({
      from: FROM, to: customer.email,
      subject: "Welcome back! Subscription reactivated",
      html: uncancelledEmail({
        productName: product.name, appUrl: APP_URL,
      }),
    });
    break;
  }

  case "subscription.revoked": {
    const customer = data.customer as { email: string };
    const product = data.product as { name: string };

    await resend.emails.send({
      from: FROM, to: customer.email,
      subject: "Your subscription access has ended",
      html: revokedEmail({
        productName: product.name,
        pricingUrl: `${APP_URL}/pricing`,
      }),
    });
    break;
  }

  case "benefit_grant.created": {
    const customer = data.customer as { email: string };
    const benefit = data.benefit as { type: string; description: string };
    const product = data.product as { name: string } | undefined;

    await resend.emails.send({
      from: FROM, to: customer.email,
      subject: `Your benefit is ready: ${benefit.description}`,
      html: benefitGrantedEmail({
        benefitType: benefit.type, details: benefit.description,
        productName: product?.name ?? "your purchase", appUrl: APP_URL,
      }),
    });
    break;
  }
}
}

function formatAmount(amount: number, currency: string): string {
try {
  return new Intl.NumberFormat("en", {
    style: "currency", currency,
  }).format(amount / 100);
} catch {
  return `${(amount / 100).toFixed(2)} ${currency}`;
}
}
app/api/webhooks/polar/route.ts
import { NextRequest, NextResponse } from "next/server";
import { sgMail, FROM } from "@/lib/email";
import { verifyPolarWebhook } from "@/lib/polar";
import {
orderConfirmationEmail,
subscriptionWelcomeEmail,
cancellationEmail,
revokedEmail,
uncancelledEmail,
benefitGrantedEmail,
} from "@/lib/polar-emails";

const APP_URL = process.env.APP_URL!;

export async function POST(request: NextRequest) {
const body = await request.text();

let event: Record<string, unknown>;
try {
  event = verifyPolarWebhook(body, {
    "webhook-id": request.headers.get("webhook-id") ?? "",
    "webhook-timestamp": request.headers.get("webhook-timestamp") ?? "",
    "webhook-signature": request.headers.get("webhook-signature") ?? "",
  });
} catch {
  return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}

try {
  await handlePolarEvent(event);
} catch (error) {
  console.error(`Polar webhook failed for ${event.type}:`, error);
}

return NextResponse.json({ received: true });
}

async function handlePolarEvent(event: Record<string, unknown>) {
const data = event.data as Record<string, unknown>;

switch (event.type) {
  case "order.created": {
    const customer = data.customer as { email: string };
    const product = data.product as { name: string };
    const amount = data.amount as number;
    const currency = (data.currency as string) ?? "USD";

    await sgMail.send({
      to: customer.email, from: FROM,
      subject: `Order confirmed — ${formatAmount(amount, currency)}`,
      html: orderConfirmationEmail({
        productName: product.name,
        amount: formatAmount(amount, currency),
        currency, appUrl: APP_URL,
      }),
    });
    break;
  }

  case "subscription.created":
  case "subscription.active": {
    const customer = data.customer as { email: string };
    const product = data.product as { name: string };

    await sgMail.send({
      to: customer.email, from: FROM,
      subject: `Welcome! Your ${product.name} subscription is active`,
      html: subscriptionWelcomeEmail({
        productName: product.name, appUrl: APP_URL,
      }),
    });
    break;
  }

  case "subscription.canceled": {
    const customer = data.customer as { email: string };
    const product = data.product as { name: string };
    const currentPeriodEnd = data.current_period_end as string | undefined;

    await sgMail.send({
      to: customer.email, from: FROM,
      subject: "Your subscription has been cancelled",
      html: cancellationEmail({
        productName: product.name,
        endDate: currentPeriodEnd
          ? new Date(currentPeriodEnd).toLocaleDateString()
          : "the end of your billing period",
        pricingUrl: `${APP_URL}/pricing`,
      }),
    });
    break;
  }

  case "subscription.uncanceled": {
    const customer = data.customer as { email: string };
    const product = data.product as { name: string };

    await sgMail.send({
      to: customer.email, from: FROM,
      subject: "Welcome back! Subscription reactivated",
      html: uncancelledEmail({
        productName: product.name, appUrl: APP_URL,
      }),
    });
    break;
  }

  case "subscription.revoked": {
    const customer = data.customer as { email: string };
    const product = data.product as { name: string };

    await sgMail.send({
      to: customer.email, from: FROM,
      subject: "Your subscription access has ended",
      html: revokedEmail({
        productName: product.name,
        pricingUrl: `${APP_URL}/pricing`,
      }),
    });
    break;
  }

  case "benefit_grant.created": {
    const customer = data.customer as { email: string };
    const benefit = data.benefit as { type: string; description: string };
    const product = data.product as { name: string } | undefined;

    await sgMail.send({
      to: customer.email, from: FROM,
      subject: `Your benefit is ready: ${benefit.description}`,
      html: benefitGrantedEmail({
        benefitType: benefit.type, details: benefit.description,
        productName: product?.name ?? "your purchase", appUrl: APP_URL,
      }),
    });
    break;
  }
}
}

function formatAmount(amount: number, currency: string): string {
try {
  return new Intl.NumberFormat("en", {
    style: "currency", currency,
  }).format(amount / 100);
} catch {
  return `${(amount / 100).toFixed(2)} ${currency}`;
}
}

Polar Benefits: Sending Delivery Emails

One of Polar's unique features is its benefit system. When a customer purchases a product, they can receive benefits like license keys, file downloads, Discord access, or GitHub repository access. You can send delivery emails when these benefits are granted:

case "benefit_grant.created": {
  const customer = data.customer as { email: string };
  const benefit = data.benefit as {
    type: string;
    description: string;
    properties: Record<string, unknown>;
  };
 
  switch (benefit.type) {
    case "license_keys": {
      // License key is in the grant properties
      const properties = data.properties as {
        license_key?: { key: string; activations: number };
      };
      const licenseKey = properties.license_key?.key;
 
      if (licenseKey) {
        await sendEmail({
          to: customer.email,
          subject: "Your license key",
          html: `
            <h1>Your License Key</h1>
            <p>Here's your license key for <strong>${benefit.description}</strong>:</p>
            <div style="background:#f3f4f6;border-radius:6px;padding:16px;margin:16px 0;
                        font-family:monospace;font-size:18px;text-align:center;">
              ${licenseKey}
            </div>
            <p style="font-size:14px;color:#6b7280;">
              Keep this key safe. You can also find it in your Polar account.
            </p>
          `,
        });
      }
      break;
    }
 
    case "downloadables": {
      await sendEmail({
        to: customer.email,
        subject: `Your download is ready: ${benefit.description}`,
        html: `
          <h1>Your Download is Ready</h1>
          <p>Your file for <strong>${benefit.description}</strong> is ready to download.</p>
          <a href="${APP_URL}/downloads"
             style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;
                    border-radius:6px;text-decoration:none;">
            Download Now
          </a>
        `,
      });
      break;
    }
 
    case "discord": {
      await sendEmail({
        to: customer.email,
        subject: `Discord access: ${benefit.description}`,
        html: `
          <h1>Discord Access Granted</h1>
          <p>You now have access to our Discord server as part of your purchase.</p>
          <p>Check your Polar account to find the invite link.</p>
        `,
      });
      break;
    }
  }
  break;
}

Express Version

// routes/polar-webhook.ts
import express from "express";
import { Webhook } from "svix";
 
const router = express.Router();
 
router.post(
  "/webhook",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const body = req.body.toString();
 
    try {
      const wh = new Webhook(process.env.POLAR_WEBHOOK_SECRET!);
      const event = wh.verify(body, {
        "webhook-id": req.headers["webhook-id"] as string ?? "",
        "webhook-timestamp": req.headers["webhook-timestamp"] as string ?? "",
        "webhook-signature": req.headers["webhook-signature"] as string ?? "",
      });
 
      await handlePolarEvent(event as Record<string, unknown>);
    } catch (error) {
      console.error("Polar webhook failed:", error);
      return res.status(400).send("Invalid signature");
    }
 
    res.json({ received: true });
  }
);
 
export default router;

Error Handling

Wrap email sends so they don't crash the webhook handler:

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
    );
  }
}

Handle Idempotency

Polar (via Svix) may retry webhooks. Use the webhook-id header to deduplicate:

const processedWebhooks = new Map<string, number>();
 
function isDuplicate(webhookId: string): boolean {
  if (processedWebhooks.has(webhookId)) return true;
  processedWebhooks.set(webhookId, Date.now());
  return false;
}
 
// Use in your handler
export async function POST(request: NextRequest) {
  const webhookId = request.headers.get("webhook-id") ?? "";
 
  if (isDuplicate(webhookId)) {
    return NextResponse.json({ received: true }); // Already processed
  }
 
  // ... verify and process
}

For production, store processed webhook IDs in your database instead of in-memory.

Testing Polar Webhooks Locally

Option 1: Polar Dashboard

Polar provides webhook testing in the dashboard. Go to Settings > Webhooks, select your endpoint, and use the test functionality to send sample events.

Option 2: ngrok or Tunnel

Expose your local server and register the tunnel URL as a webhook endpoint:

ngrok http 3000
# Then register https://abc123.ngrok.io/api/webhooks/polar in Polar

Option 3: Mock Payloads

For unit tests, create mock payloads:

// test/polar-mocks.ts
export const mockOrderCreated = {
  type: "order.created",
  data: {
    id: "ord_test_123",
    amount: 4900,
    currency: "USD",
    customer: { email: "test@example.com" },
    product: { name: "Pro Plan" },
  },
};
 
export const mockSubscriptionCanceled = {
  type: "subscription.canceled",
  data: {
    id: "sub_test_123",
    status: "canceled",
    customer: { email: "test@example.com" },
    product: { name: "Pro Plan" },
    current_period_end: "2026-03-20T00:00:00Z",
  },
};

The Smarter Approach: Native Polar Integration

Writing webhook handlers for every Polar event is boilerplate. If you're using Sequenzy, you can skip most of it.

Sequenzy has a native Polar integration. Connect your Polar account in the dashboard (Settings > Integrations), and it automatically:

  • Tracks all payment events — purchase, cancellation, churn, upgrade, downgrade
  • Applies status tags to subscribers — customer, trial, cancelled, churned
  • Syncs subscription data — MRR, product name, billing interval
  • Triggers automated sequences based on lifecycle events

Instead of webhook code, set up sequences in the dashboard:

SequenceTriggerStops WhenPurpose
OnboardingEvent signup.completedEvent onboarding.completedGuide new users
Win-BackTag cancelled addedUser gets customer tagRe-engage cancelled users
Churn RecoveryTag churned addedUser gets customer tagWin back churned users

You still need webhooks for custom logic (like provisioning accounts, granting access, or delivering license keys), but lifecycle email sequences are handled automatically.

Going to Production

1. Verify Your Email Domain

Add SPF, DKIM, and DMARC DNS records through your email provider. Payment emails going to spam is a terrible customer experience.

2. Handle the Customer Portal

Polar provides a customer portal where users can manage their subscriptions. Link to it in your app and emails:

// The customer portal URL
const portalUrl = `https://polar.sh/your-org/portal`;
 
// Or use the Polar API to generate a portal session
const response = await fetch("https://api.polar.sh/v1/customer-portal/sessions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POLAR_ACCESS_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ customer_id: customerId }),
});
 
const { url } = await response.json();
// Redirect user to `url` for subscription management

3. Don't Duplicate Polar's Receipts

Polar sends its own payment receipts. Your emails should focus on product communication — welcome to your app, here's how to get started, your access has been activated, etc.

Production Checklist

StepWhatWhy
Signature verificationVerify with Svix headersPrevent spoofed events
IdempotencyDeduplicate by webhook-idHandle retries safely
Domain verificationSPF, DKIM, DMARC recordsInbox delivery, not spam
Error isolationWrap email sends in try-catchDon't crash the handler
Benefit deliveryHandle benefit_grant.createdDeliver license keys, downloads
Currency formattingUse Intl.NumberFormat with event currencySupport international customers
LoggingLog event types and outcomesDebug failures in production

Beyond Transactional: Marketing Emails and Sequences

At some point, you'll want more than one-off transactional emails — onboarding sequences, feature announcements, re-engagement campaigns. Most teams wire together a transactional provider with a separate marketing tool.

Sequenzy handles both from one platform. Same SDK, same dashboard. Transactional sends, marketing campaigns, automated sequences, subscriber segments, and native Polar integration.

Here's subscriber management alongside your Polar webhook:

import { sequenzy } from "@/lib/email";
 
// When an order is created
await sequenzy.subscribers.create({
  email: customerEmail,
  tags: ["customer", "polar"],
  customAttributes: {
    product: productName,
    purchaseDate: new Date().toISOString(),
  },
});
 
// Tag when they use a key feature
await sequenzy.subscribers.tags.add({
  email: customerEmail,
  tag: "active-user",
});
 
// Track custom events to trigger sequences
await sequenzy.subscribers.events.trigger({
  email: customerEmail,
  event: "onboarding.completed",
  properties: { completedSteps: 5 },
});

FAQ

How is Polar's webhook format different from Stripe's?

Polar uses the Svix webhook standard. The signature is split across three headers (webhook-id, webhook-timestamp, webhook-signature) instead of a single header like Stripe's stripe-signature. The signed content format is also different: Svix signs webhook_id.timestamp.body with a base64-decoded secret, while Stripe signs timestamp.body with a raw secret.

What's the difference between subscription.canceled and subscription.revoked?

subscription.canceled fires when a subscription is set to cancel at the end of the billing period — the customer still has access until then. subscription.revoked fires when access is actually removed (after the period ends, or on immediate cancellation). Send a follow-up/win-back email on canceled and an access-removed notice on revoked.

Does Polar support one-time purchases or only subscriptions?

Both. Polar supports subscriptions, one-time purchases, and pay-what-you-want pricing. One-time purchases trigger order.created without any subscription events. Subscriptions trigger both order.created (for each payment) and subscription.* events.

How do I handle benefit delivery (license keys, file downloads)?

Listen for benefit_grant.created events. The event data includes the benefit type and properties. For license keys, the key is in data.properties.license_key.key. For downloadables, link the customer to your download page. For Discord and GitHub access, Polar handles the grant automatically — your email just needs to confirm it.

Can I use the Polar SDK instead of raw webhooks?

Polar provides a @polar-sh/sdk package that includes webhook verification. If you're already using it for API calls, you can use validateEvent() from the SDK. Otherwise, the svix package or manual verification shown in this guide works fine.

How do I test webhooks without making real purchases?

Use Polar's sandbox environment with test products. You can also create test webhook deliveries from the Polar Dashboard under Settings > Webhooks. For local development, use ngrok or a similar tunneling tool to expose your local endpoint.

What happens if my webhook endpoint is down?

Polar (via Svix) retries failed webhooks with exponential backoff for up to 72 hours. You can see delivery status and manually replay failed deliveries in the Polar Dashboard under Settings > Webhooks.

Does subscription.uncanceled fire when a user resubscribes?

subscription.uncanceled fires when a user reverses a pending cancellation before the billing period ends. If the subscription has already been revoked and the user purchases again, you'll get a new subscription.created event instead.

How do I handle currency in emails?

Polar supports multiple currencies. Always use Intl.NumberFormat with the currency field from the event data rather than hardcoding USD. The amount is in the smallest currency unit (cents for USD), so divide by 100 before formatting.

Should I send a welcome email on order.created or subscription.created?

For subscriptions, prefer subscription.created or subscription.active — these confirm the subscription is set up, not just that a payment went through. For one-time purchases (no subscription), use order.created as your welcome trigger.

Wrapping Up

Here's what we covered:

  1. Webhook handler for Polar Billing events — orders, subscriptions, cancellations, uncancellations, benefit grants
  2. Signature verification using Svix format (three separate headers) with timing-safe comparison
  3. Email templates organized as functions for clean, testable code
  4. Benefit delivery emails for license keys, downloads, Discord access, and more
  5. Idempotency with webhook-id deduplication
  6. Express version with raw body parsing
  7. Error handling that isolates email failures from webhook processing
  8. Native integration with Sequenzy to skip webhook boilerplate for lifecycle emails
  9. Production checklist: domain verification, currency handling, customer portal

For most apps, connecting Polar to Sequenzy and setting up automated sequences is the simplest path. Use manual webhooks for benefit delivery and custom account provisioning.

Frequently Asked Questions

Which Polar webhook events should I use for email sending?

Start with order.created (purchase confirmation), subscription.active (welcome), subscription.canceled (cancellation), and benefit.granted (benefit delivery). These cover the essential lifecycle emails for most SaaS products.

How do I verify Polar webhook signatures?

Polar uses the Standard Webhooks specification. Verify the webhook-id, webhook-timestamp, and webhook-signature headers using the signing secret from your Polar dashboard. Use the standardwebhooks npm package for easy verification.

Does Polar send any emails automatically?

Polar sends basic purchase receipts and subscription notifications. These are Polar-branded and generic. Custom emails let you personalize the experience, trigger onboarding sequences, and send product-specific communications that match your brand.

How do I handle Polar benefits in webhook-triggered emails?

When benefit.granted fires, check the benefit type (custom, GitHub repository access, Discord invite, etc.) and send an email with the appropriate instructions or access details. Include the benefit-specific data from the webhook payload.

How do I test Polar webhooks during development?

Use Polar's sandbox environment for testing. Expose your local server with ngrok and register the tunnel URL as your webhook endpoint. Polar sandbox sends real webhook payloads that you can process locally without real charges.

Can I trigger automated email sequences from Polar events?

Yes. When a Polar webhook fires, add the customer to your email platform with tags based on the event (e.g., "customer", plan name, benefit type). These tags trigger automated sequences for onboarding, engagement, and retention.

How do I handle subscription upgrades and downgrades from Polar?

Listen for subscription.updated events and check the product_id change. Send a confirmation email noting the plan change, new pricing, and any feature additions or removals. Update the customer's tags in your email platform accordingly.

Should I process Polar webhooks synchronously or asynchronously?

Return a 200 response to Polar as quickly as possible to prevent retries. For a single email send, synchronous processing is usually fine. For complex flows involving database updates and multiple emails, return 200 immediately and use a job queue.

How do I handle refund events from Polar?

Listen for order.refunded or subscription.revoked events. Send a refund confirmation email, revoke access to benefits, and update the customer's tags (remove "customer", add "refunded"). Keep the tone professional and include support contact information.

How do I extract customer information from Polar webhooks?

Customer email and metadata are in the webhook payload under data.customer. For subscriptions, check data.subscription.customer. Polar includes the customer's email, name, and any custom metadata you've set via the API.