Polar.sh Webhook Confirmation Email with Rovela (2026): Standard Webhooks + Idempotency-Key
Verify Polar webhook-id / webhook-timestamp / webhook-signature on the raw body, handle order.paid / subscription.created, and send branded confirmations via Rovela POST /emails with Idempotency-Key = polar-order.paid-{id} or polar-subscription.created-{id}. Polar owns OSS/indie checkout; Rovela delivers. Distinct from Lemon Squeezy, Paddle, and Stripe webhook posts.
Polar owns OSS checkout — Rovela delivers the confirmation
Polar notifies your server when an order is paid or a subscription starts — the commerce layer many indie and open-source SaaS teams use for GitHub-linked products and recurring plans. Polar’s own customer receipts are commerce-side; they are not a custom HTTP send from your verified product domain with your template and your Idempotency-Key. Rovela queues, DKIM-signs, and delivers that branded transactional mail when your webhook handler calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication, Templates).
This guide wires a Polar webhook that:
- Verifies the Standard Webhooks-style headers (
webhook-id,webhook-timestamp,webhook-signature) against yourPOLAR_WEBHOOK_SECRETon the raw body (Handle & monitor webhook deliveries) - Handles
order.paidand/orsubscription.created(from the eventtype) - Reads the buyer email from the nested
customerobject on the payload - Sends a purchase or subscription confirmation through Rovela
- Sets a stable
Idempotency-Keyso Polar retries never double-mail
Rovela does not create Polar checkouts, register webhook endpoints, or verify Polar signatures. Polar owns commerce and event delivery. Your app owns mapping webhook → customer email → template variables. Rovela only delivers.
Distinct from:
- Lemon Squeezy Webhook Confirmation Email —
X-Signaturehex HMAC of the raw body +order_created/subscription_created, not Polar Standard Webhooks headers - Paddle Billing Webhook Email —
Paddle-Signature(ts:rawBody) +transaction.completed, notorder.paid - Stripe Webhook Receipt Email —
Stripe-Signature+ Checkout/Invoice/PaymentIntent events
What Polar does vs what Rovela sends
| Concern | Owner |
|---|---|
| Checkout, products, subscriptions, benefits, payouts | Polar |
Webhook URL + signing secret + webhook-* headers | Your server + Polar |
| Choosing which events trigger mail | Your app |
| Customer email, order id, product name, amounts | Polar webhook payload (your mapping) |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key = event type + resource id | Your webhook handler |
A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).
Prerequisites
- A verified sending domain in the Rovela dashboard. Unverified
fromdomains are refused with400(Quick Start). Prefer something likeorders.example.comormail.example.com. - An API key shaped like
re_…. Prefersending_accessfor the webhook worker (Authentication). - A Polar organization webhook endpoint pointing at your HTTPS URL, with a
whsec_…signing secret from the Polar dashboard (Setup Webhooks). Subscribe at least toorder.paidandsubscription.created(Webhook Events). - Keep
POLAR_WEBHOOK_SECRET,ROVELA_API_KEY, andROVELA_FROMonly on the server. Never in a browser bundle, marketing site, or mobile app.
Environment
export POLAR_WEBHOOK_SECRET=whsec_your_polar_endpoint_secret
export ROVELA_API_KEY=re_your_sending_access_key
export ROVELA_FROM='Acme Orders <orders@mail.example.com>'
# optional: dashboard template UUIDs
export ROVELA_ORDER_TEMPLATE_ID=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
export ROVELA_SUB_TEMPLATE_ID=ffffffff-1111-2222-3333-444444444444
Rotate Rovela keys by creating a new one, deploying, then revoking the old key (Authentication). Rotate the Polar signing secret from your organization’s webhook settings when you recreate the endpoint.
Polar documents that secrets generated on or after 8 September 2026, 00:00 UTC follow Standard Webhooks key handling, while older secrets use Polar HMAC. Prefer the official SDK validators (they try both schemes on recent versions) rather than hand-rolling the HMAC (Handle & monitor webhook deliveries).
Why Idempotency-Key matters for Polar
Polar retries failed deliveries (non-2xx or timeouts) with exponential backoff — up to 10 attempts — and can disable an endpoint after 10 consecutive failures (Handle & monitor webhook deliveries). Polar also recommends responding within about 2 seconds and doing heavy work asynchronously; the delivery timeout is currently 10 seconds.
Each delivery includes a unique webhook-id header useful for processing de-duplication. Still pin Rovela’s send key to the commerce resource you care about (one confirmation per paid order, one welcome per subscription), not only to webhook-id, so a legitimate redelivery of the same business event and a manual “send again” path stay consistent.
Rovela’s Idempotency-Key header (up to 256 characters) makes a retry of the same send within 24 hours return the original response instead of mailing again (Send Email API).
Recommended keys:
| Key | Use when |
|---|---|
polar-order.paid-{data.id} | One purchase confirmation per paid order |
polar-subscription.created-{data.id} | One subscription welcome per subscription |
polar-{type}-{data.id} | Generic pattern for other events you later add |
Important overlap: a new subscription commonly emits subscription.created and later order.paid (and often order.created while the order is still pending). Prefer:
order.paid→ purchase / receipt confirmation (money collected;statusis paid)subscription.created→ subscription onboarding (plan/product name, portal copy) with a different template and idempotency key
Or listen to only one event for the product email path. Prefer order.paid over order.created for “thanks for paying” mail — order.created can fire while payment is still pending, especially on renewals (Webhook Events).
For renewals Polar fires subscription.cycled → order.created (pending) → then order.updated / order.paid after collection. If you mail on every order.paid, renewals get a renewal receipt (good). Do not also mail a full “welcome” from subscription.cycled unless that copy is intentionally different.
Shared Rovela sender (server-only)
// src/rovela.ts
const ROVELA_URL = "https://api.rovela.dev/emails";
export class RovelaError extends Error {
constructor(
public status: number,
public name: string,
message: string,
) {
super(message);
}
}
export async function sendViaRovela(opts: {
to: string;
subject?: string;
html?: string;
text?: string;
template?: { id: string; variables: Record<string, string> };
idempotencyKey: string;
replyTo?: string;
tags?: Record<string, string> | Array<{ name: string; value: string }>;
}) {
const apiKey = process.env.ROVELA_API_KEY;
const from = process.env.ROVELA_FROM;
if (!apiKey || !from) {
throw new Error("ROVELA_API_KEY and ROVELA_FROM must be set on the server");
}
const key = opts.idempotencyKey.slice(0, 256);
const body: Record<string, unknown> = {
from,
to: opts.to,
};
if (opts.template) {
// template is mutually exclusive with html/text — see Templates docs
body.template = opts.template;
} else {
if (!opts.html && !opts.text) {
throw new Error("html, text, or template is required");
}
if (opts.subject) body.subject = opts.subject;
if (opts.html) body.html = opts.html;
if (opts.text) body.text = opts.text;
}
if (opts.replyTo) body.reply_to = opts.replyTo;
if (opts.tags) body.tags = opts.tags;
const res = await fetch(ROVELA_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
throw new RovelaError(
res.status,
String((json as { name?: string }).name ?? "Error"),
String((json as { message?: string }).message ?? "send failed"),
);
}
return json as { object: "email"; id: string };
}
Always prefer both html and text when you are not using a dashboard template. Escape any customer-controlled strings before putting them in HTML or in template variables — template substitution is not HTML-escaped (Templates).
Verify Polar signatures with the official SDK
Polar’s TypeScript and Python SDKs validate webhook-id / webhook-timestamp / webhook-signature against your secret and parse the event. Use express.raw (or the framework equivalent) so verification sees the exact bytes Polar signed (Handle & monitor webhook deliveries).
// src/routes/polar-webhook.ts
import express from "express";
import { webhooks } from "@polar-sh/sdk/2026-10";
import { sendViaRovela, RovelaError } from "../rovela";
type PolarCustomer = {
id?: string;
email?: string;
name?: string;
};
type PolarOrder = {
id?: string;
status?: string;
paid?: boolean;
billing_reason?: string;
invoice_number?: string;
currency?: string;
total_amount?: number;
customer?: PolarCustomer;
product?: { name?: string; id?: string };
subscription_id?: string | null;
};
type PolarSubscription = {
id?: string;
status?: string;
customer?: PolarCustomer;
product?: { name?: string; id?: string };
};
export const polarWebhook = express.Router();
polarWebhook.post(
"/webhooks/polar",
express.raw({ type: "application/json" }),
async (req, res) => {
const secret = process.env.POLAR_WEBHOOK_SECRET;
if (!secret) {
return res.status(500).send("webhook secret not configured");
}
try {
const event = await webhooks.validateEvent(
req.body,
{
"webhook-id": req.header("webhook-id") ?? "",
"webhook-timestamp": req.header("webhook-timestamp") ?? "",
"webhook-signature": req.header("webhook-signature") ?? "",
},
secret,
);
await handlePolarEvent(event as { type?: string; data?: unknown });
// Polar docs acknowledge with 202
return res.status(202).send("");
} catch (error) {
if (error instanceof webhooks.PolarWebhookVerificationError) {
return res.status(403).send("");
}
if (error instanceof webhooks.PolarWebhookUnknownTypeError) {
// Newer event than this SDK build — ack so Polar does not retry forever
return res.status(202).send("");
}
console.error("polar webhook handler failed", error);
// Non-2xx → Polar retries. Prefer enqueue-then-202 if send is slow.
return res.status(500).send("");
}
},
);
async function handlePolarEvent(event: { type?: string; data?: unknown }) {
switch (event.type) {
case "order.paid": {
const order = event.data as PolarOrder;
const id = order?.id;
const to = order?.customer?.email;
if (!id || !to) return;
const product = order.product?.name || "your purchase";
const total = formatMoney(order.total_amount, order.currency);
const invoice = String(order.invoice_number ?? "");
const reason = String(order.billing_reason ?? "");
await sendOrderConfirmation({
to,
name: order.customer?.name || "",
product: String(product),
total,
invoice,
billingReason: reason,
resourceId: id,
});
break;
}
case "subscription.created": {
const sub = event.data as PolarSubscription;
const id = sub?.id;
const to = sub?.customer?.email;
if (!id || !to) return;
await sendSubscriptionWelcome({
to,
name: sub.customer?.name || "",
product: String(sub.product?.name || "your plan"),
status: String(sub.status || "active"),
resourceId: id,
});
break;
}
default:
// Ignore checkout.*, refunds, benefit grants, etc. for this path
break;
}
}
function formatMoney(amount: number | undefined, currency: string | undefined) {
if (typeof amount !== "number") return "";
// Polar amounts are minor units (e.g. cents for USD) — format for humans
const major = amount / 100;
const cur = (currency || "usd").toUpperCase();
try {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: cur,
}).format(major);
} catch {
return `${major.toFixed(2)} ${cur}`;
}
}
async function sendOrderConfirmation(opts: {
to: string;
name: string;
product: string;
total: string;
invoice: string;
billingReason: string;
resourceId: string;
}) {
const templateId = process.env.ROVELA_ORDER_TEMPLATE_ID;
const isRenewal = opts.billingReason === "subscription_cycle";
const subject = isRenewal
? opts.total
? `Renewal receipt — ${opts.product} (${opts.total})`
: `Renewal receipt — ${opts.product}`
: opts.total
? `Order confirmed — ${opts.product} (${opts.total})`
: `Order confirmed — ${opts.product}`;
const html = `
<p>Hi ${escapeHtml(opts.name || "there")},</p>
<p>Thanks for your ${isRenewal ? "renewal" : "purchase"} of <b>${escapeHtml(opts.product)}</b>.</p>
<p><b>Total:</b> ${escapeHtml(opts.total || "—")}</p>
${
opts.invoice
? `<p><b>Invoice:</b> ${escapeHtml(opts.invoice)}</p>`
: ""
}
<p>If you did not make this payment, contact support.</p>
`;
const text = [
`Hi ${opts.name || "there"},`,
`Thanks for your ${isRenewal ? "renewal" : "purchase"} of ${opts.product}.`,
`Total: ${opts.total || "—"}`,
opts.invoice ? `Invoice: ${opts.invoice}` : "",
"If you did not make this payment, contact support.",
]
.filter(Boolean)
.join("\n");
const idempotencyKey = `polar-order.paid-${opts.resourceId}`.slice(0, 256);
try {
if (templateId) {
await sendViaRovela({
to: opts.to,
template: {
id: templateId,
variables: {
name: opts.name || "there",
product: opts.product,
total: opts.total || "—",
invoice: opts.invoice || "",
billing_reason: opts.billingReason || "",
},
},
idempotencyKey,
tags: { polar_event: "order.paid", polar_id: opts.resourceId },
});
} else {
await sendViaRovela({
to: opts.to,
subject,
html,
text,
idempotencyKey,
tags: { polar_event: "order.paid", polar_id: opts.resourceId },
});
}
} catch (err) {
if (err instanceof RovelaError) {
console.error("Rovela send failed", err.status, err.name, err.message);
}
throw err;
}
}
async function sendSubscriptionWelcome(opts: {
to: string;
name: string;
product: string;
status: string;
resourceId: string;
}) {
const templateId = process.env.ROVELA_SUB_TEMPLATE_ID;
const subject = `Welcome to ${opts.product}`;
const html = `
<p>Hi ${escapeHtml(opts.name || "there")},</p>
<p>Your subscription to <b>${escapeHtml(opts.product)}</b> is ${escapeHtml(opts.status || "active")}.</p>
<p>We will email you about renewals and important account changes from this address.</p>
`;
const text = [
`Hi ${opts.name || "there"},`,
`Your subscription to ${opts.product} is ${opts.status || "active"}.`,
"We will email you about renewals and important account changes from this address.",
].join("\n");
const idempotencyKey = `polar-subscription.created-${opts.resourceId}`.slice(
0,
256,
);
if (templateId) {
await sendViaRovela({
to: opts.to,
template: {
id: templateId,
variables: {
name: opts.name || "there",
product: opts.product,
status: opts.status,
},
},
idempotencyKey,
tags: { polar_event: "subscription.created", polar_id: opts.resourceId },
});
} else {
await sendViaRovela({
to: opts.to,
subject,
html,
text,
idempotencyKey,
tags: { polar_event: "subscription.created", polar_id: opts.resourceId },
});
}
}
function escapeHtml(s: string) {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
Mounting tip
import express from "express";
import { polarWebhook } from "./routes/polar-webhook";
const app = express();
app.use(polarWebhook); // raw body route first
app.use(express.json()); // JSON for the rest of the API
If a global JSON parser runs first, signature verification fails because the signature covers the exact bytes Polar sent.
Next.js App Router route
// app/api/webhooks/polar/route.ts
import { NextRequest, NextResponse } from "next/server";
import { webhooks } from "@polar-sh/sdk/2026-10";
import { sendViaRovela } from "@/lib/rovela";
export const runtime = "nodejs";
export async function POST(req: NextRequest) {
const secret = process.env.POLAR_WEBHOOK_SECRET;
if (!secret) {
return new NextResponse("secret not set", { status: 500 });
}
const rawBody = Buffer.from(await req.arrayBuffer());
let event: { type?: string; data?: Record<string, any> };
try {
event = (await webhooks.validateEvent(
rawBody,
{
"webhook-id": req.headers.get("webhook-id") ?? "",
"webhook-timestamp": req.headers.get("webhook-timestamp") ?? "",
"webhook-signature": req.headers.get("webhook-signature") ?? "",
},
secret,
)) as typeof event;
} catch (error) {
if (error instanceof webhooks.PolarWebhookVerificationError) {
return new NextResponse("", { status: 403 });
}
if (error instanceof webhooks.PolarWebhookUnknownTypeError) {
return new NextResponse("", { status: 202 });
}
throw error;
}
if (event.type === "order.paid") {
const order = event.data ?? {};
const id = order.id as string | undefined;
const to = order.customer?.email as string | undefined;
const product = (order.product?.name as string | undefined) || "your purchase";
if (id && to) {
const amount = order.total_amount as number | undefined;
const currency = String(order.currency || "usd").toUpperCase();
const total =
typeof amount === "number"
? new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount / 100)
: "";
await sendViaRovela({
to,
subject: total
? `Order confirmed — ${product} (${total})`
: `Order confirmed — ${product}`,
html: `<p>Thanks for your purchase of <b>${product}</b>.</p><p><b>Total:</b> ${total || "—"}</p>`,
text: `Thanks for your purchase of ${product}.\nTotal: ${total || "—"}`,
idempotencyKey: `polar-order.paid-${id}`.slice(0, 256),
});
}
}
if (event.type === "subscription.created") {
const sub = event.data ?? {};
const id = sub.id as string | undefined;
const to = sub.customer?.email as string | undefined;
const product = (sub.product?.name as string | undefined) || "your plan";
if (id && to) {
await sendViaRovela({
to,
subject: `Welcome to ${product}`,
html: `<p>Your subscription to <b>${product}</b> is ready.</p>`,
text: `Your subscription to ${product} is ready.`,
idempotencyKey: `polar-subscription.created-${id}`.slice(0, 256),
});
}
}
return new NextResponse("", { status: 202 });
}
Optional: dashboard templates
Create templates once (needs full_access or the dashboard), publish them, then send with template.id + variables from the webhook (Templates):
curl -X POST https://api.rovela.dev/templates \
-H "Authorization: Bearer re_your_full_access_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Polar order confirmation",
"subject": "Order confirmed — {{product}} ({{total}})",
"html": "<p>Hi {{name}},</p><p>Thanks for buying <b>{{product}}</b>.</p><p>Total: {{total}}</p>",
"text": "Hi {{name}}, thanks for buying {{product}}. Total: {{total}}."
}'
Then POST /templates/:id/publish. Production webhook workers can keep using a sending_access key — they only call POST /emails.
Remember: template cannot be mixed with html/text on the same send, and POST /emails/batch rejects template (Send Email API).
Metadata and external_id → your app user
Polar customers can carry external_id and order/subscription metadata you set at checkout time. Use those to link the purchase to your own user row before or while sending mail — still take customer.email from the Polar payload for the to address unless you intentionally override with a verified address from your DB.
Production checklist
- Verify Polar signatures with the SDK (or a Standard Webhooks library) on the raw body; never skip this behind a public URL (Handle & monitor webhook deliveries).
- Return 2xx quickly (Polar examples use 202). If Rovela or your DB is slow, enqueue
{ type, resourceId, payload }and send asynchronously; still use the stable idempotency key. - Prefer
order.paidfor money-collected confirmations; treatorder.createdas “invoice may still be pending.” - Use distinct Idempotency-Keys for
order.paidvssubscription.createdso a subscription sale can send two different messages without colliding (Send Email API). - Prefer
sending_accesskeys in the worker; never expose them to the client (Authentication). - Send html + text, or a published template — not an empty body.
- Escape user-influenced fields before HTML / template variables (Templates).
- Confirm delivery with
GET /emails/:idor Rovela webhooks if you need ops visibility — a send200is queue acceptance only (Quick Start). - If you terminate TLS on Cloudflare, watch Bot Fight Mode / WAF rules — Polar documents that bot challenges can yield
403and disable your endpoint after repeated failures.
What this does not cover
order.refunded,subscription.canceled/subscription.revoked,subscription.past_due— same verify → map → send pattern with different copy and keys- Polar benefit grants / seat claims — fulfillment side-effects, not Rovela’s job
- Attaching a custom PDF you generate yourself — see Send an Invoice Email with a PDF Attachment
- Lemon Squeezy, Paddle, or Stripe webhook signatures — see the dedicated posts linked above
- Rovela inbound or delivery webhooks — separate from Polar’s signing secret
Summary
Polar verifies that the event is real and that an order was paid or a subscription was created. Your webhook maps order.paid / subscription.created to a recipient and confirmation fields. Rovela’s POST /emails with a stable Idempotency-Key (polar-order.paid-{id} / polar-subscription.created-{id}) delivers one message per logical event even when Polar retries. Keep checkout in Polar, delivery in Rovela, and secrets on the server.