Stripe Webhook Receipt Email with Rovela (2026): Signature + Idempotency-Key
Verify Stripe-Signature on the raw body, handle checkout.session.completed / invoice.paid / payment_intent.succeeded, and send a Rovela receipt with Idempotency-Key = Stripe event id. Stripe owns payments; Rovela delivers. Distinct from ecommerce-order and invoice-attachment posts.
Stripe owns the charge — Rovela delivers the receipt
Stripe tells your server when money moved. It does not send your branded payment-confirmation or receipt email for you when you want a custom template, a verified product domain, and an HTTP send path you control. Rovela queues, DKIM-signs, and delivers that mail when your webhook handler calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication, Templates).
This guide wires a Stripe webhook that:
- Verifies the
Stripe-Signatureagainst the raw request body - Handles
checkout.session.completed,invoice.paid, and/orpayment_intent.succeeded - Sends a receipt / payment-confirmation email through Rovela
- Sets
Idempotency-Key= the Stripe eventidso Stripe retries never double-mail
Rovela does not process cards, create Checkout Sessions, or verify Stripe signatures. Stripe owns payments and event delivery. Your app owns mapping event → customer email → template variables. Rovela only delivers.
Distinct from:
- E-Commerce Order Emails — generic order-lifecycle triggers, not Stripe webhook signature verification or Stripe event IDs as idempotency keys
- Send an Invoice Email with a PDF Attachment — attaching a PDF you already rendered; this post is payment-event → HTML/template receipt without requiring a PDF attachment
- Welcome email from a template — onboarding copy, not payment confirmation
What Stripe does vs what Rovela sends
| Concern | Owner |
|---|---|
| Checkout, PaymentIntents, Invoices, refunds | Stripe |
Webhook signing secret + constructEvent | Your server + Stripe |
| Choosing which event types trigger mail | Your app |
| Customer email, amount, currency, receipt URL fields | Stripe event payload (your mapping) |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key = Stripe event.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 likereceipts.example.comormail.example.com. - An API key shaped like
re_…. Prefersending_accessfor the webhook worker (Authentication). - A Stripe account with a webhook endpoint pointing at your server, and the endpoint’s signing secret (
whsec_…) in server env. - Keep
STRIPE_WEBHOOK_SECRET,STRIPE_SECRET_KEY(if you need follow-up Stripe API reads),ROVELA_API_KEY, andROVELA_FROMonly on the server. Never in a browser bundle or mobile app.
Environment
export STRIPE_WEBHOOK_SECRET=whsec_your_endpoint_secret
export ROVELA_API_KEY=re_your_sending_access_key
export ROVELA_FROM='Acme Receipts <receipts@mail.example.com>'
# optional: dashboard template UUID for payment receipts
export ROVELA_RECEIPT_TEMPLATE_ID=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
Rotate Rovela keys by creating a new one, deploying, then revoking the old key (Authentication). Rotate the Stripe signing secret from the Stripe Dashboard when you recreate the endpoint.
Why Idempotency-Key = Stripe event id
Stripe retries webhooks when your endpoint is slow or returns non-2xx. The same evt_… can arrive more than once. 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).
Using event.id is the natural key: one Stripe event → at most one logical receipt send. Truncate to 256 characters if you ever compose a longer key (event.id alone is well under that).
Do not key only on payment_intent id or customer email alone if multiple distinct events (for example a partial capture path you also mail on) could legitimately send different messages — when in doubt, prefer the event id for “this webhook delivery’s mail.”
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).
Express: verify Stripe, then send
Stripe signature verification must see the raw body bytes. Register express.raw on the webhook route (or exclude it from express.json()).
// src/routes/stripe-webhook.ts
import express from "express";
import Stripe from "stripe";
import { sendViaRovela, RovelaError } from "../rovela";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-11-20.acacia", // use the API version your account pins
});
export const stripeWebhook = express.Router();
stripeWebhook.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
const sig = req.headers["stripe-signature"];
if (!sig || Array.isArray(sig)) {
return res.status(400).send("missing Stripe-Signature");
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body, // Buffer from express.raw
sig,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return res.status(400).send("invalid signature");
}
try {
await handleStripeEvent(event);
// Acknowledge quickly after the send (or enqueue then 200)
return res.status(200).json({ received: true });
} catch (err) {
console.error("stripe webhook handler failed", err);
// Non-2xx → Stripe retries. Prefer queueing mail and returning 200
// if your send path is slow; only fail when verification/business logic must retry.
return res.status(500).json({ error: "handler failed" });
}
},
);
function moneyLabel(amount: number | null | undefined, currency: string | null | undefined) {
if (amount == null || !currency) return "";
// Stripe amounts are usually in the smallest currency unit
const major = (amount / 100).toFixed(2);
return `${major} ${currency.toUpperCase()}`;
}
async function handleStripeEvent(event: Stripe.Event) {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
if (session.payment_status !== "paid" && session.payment_status !== "no_payment_required") {
return; // wait for a paid state if you only mail successful charges
}
const to = session.customer_details?.email || session.customer_email;
if (!to) return;
const amount = moneyLabel(session.amount_total, session.currency);
const ref =
session.payment_intent && typeof session.payment_intent === "string"
? session.payment_intent
: session.id;
await sendReceipt({
to,
amountLabel: amount,
reference: ref,
eventId: event.id,
kind: "checkout",
});
break;
}
case "invoice.paid": {
const invoice = event.data.object as Stripe.Invoice;
const to = invoice.customer_email;
if (!to) return;
await sendReceipt({
to,
amountLabel: moneyLabel(invoice.amount_paid, invoice.currency),
reference: invoice.number || invoice.id,
eventId: event.id,
kind: "invoice",
receiptUrl: invoice.hosted_invoice_url || invoice.invoice_pdf || undefined,
});
break;
}
case "payment_intent.succeeded": {
const pi = event.data.object as Stripe.PaymentIntent;
// Often Checkout already mailed on checkout.session.completed — skip
// duplicate product mail if you also listen to Checkout events.
if (pi.metadata?.skip_rovela_receipt === "1") return;
const to = pi.receipt_email;
if (!to) return;
await sendReceipt({
to,
amountLabel: moneyLabel(pi.amount_received || pi.amount, pi.currency),
reference: pi.id,
eventId: event.id,
kind: "payment_intent",
});
break;
}
default:
// Ignore other event types
break;
}
}
async function sendReceipt(opts: {
to: string;
amountLabel: string;
reference: string;
eventId: string;
kind: string;
receiptUrl?: string;
}) {
const templateId = process.env.ROVELA_RECEIPT_TEMPLATE_ID;
const subject = opts.amountLabel
? `Payment received — ${opts.amountLabel}`
: "Payment received";
const html = `
<p>Thanks for your payment.</p>
<p><b>Amount:</b> ${escapeHtml(opts.amountLabel || "—")}</p>
<p><b>Reference:</b> ${escapeHtml(opts.reference)}</p>
${
opts.receiptUrl
? `<p><a href="${escapeHtml(opts.receiptUrl)}">View receipt</a></p>`
: ""
}
<p>If you did not make this payment, contact support.</p>
`;
const text = [
"Thanks for your payment.",
`Amount: ${opts.amountLabel || "—"}`,
`Reference: ${opts.reference}`,
opts.receiptUrl ? `Receipt: ${opts.receiptUrl}` : "",
"If you did not make this payment, contact support.",
]
.filter(Boolean)
.join("\n");
try {
if (templateId) {
await sendViaRovela({
to: opts.to,
template: {
id: templateId,
variables: {
amount: opts.amountLabel || "—",
reference: opts.reference,
receipt_url: opts.receiptUrl || "",
kind: opts.kind,
},
},
idempotencyKey: opts.eventId,
tags: { stripe_event: opts.eventId, kind: opts.kind },
});
} else {
await sendViaRovela({
to: opts.to,
subject,
html,
text,
idempotencyKey: opts.eventId,
tags: { stripe_event: opts.eventId, kind: opts.kind },
});
}
} catch (err) {
if (err instanceof RovelaError) {
console.error("Rovela send failed", err.status, err.name, err.message);
}
throw err;
}
}
function escapeHtml(s: string) {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
Mounting tip
import express from "express";
import { stripeWebhook } from "./routes/stripe-webhook";
const app = express();
app.use(stripeWebhook); // raw body route first
app.use(express.json()); // JSON for the rest of the API
If a global JSON parser runs first, constructEvent fails because the signature covers the exact bytes Stripe sent.
Next.js App Router route
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { NextRequest, NextResponse } from "next/server";
import { sendViaRovela } from "@/lib/rovela";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-11-20.acacia",
});
export const runtime = "nodejs"; // raw crypto + Stripe SDK
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const sig = req.headers.get("stripe-signature");
if (!sig) {
return new NextResponse("missing signature", { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
sig,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return new NextResponse("invalid signature", { status: 400 });
}
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const to = session.customer_details?.email || session.customer_email;
if (to) {
const amount =
session.amount_total != null && session.currency
? `${(session.amount_total / 100).toFixed(2)} ${session.currency.toUpperCase()}`
: "";
await sendViaRovela({
to,
subject: amount ? `Payment received — ${amount}` : "Payment received",
html: `<p>Thanks for your payment.</p><p><b>Amount:</b> ${amount || "—"}</p>`,
text: `Thanks for your payment.\nAmount: ${amount || "—"}`,
idempotencyKey: event.id,
});
}
}
return NextResponse.json({ received: true });
}
Optional: dashboard template for receipts
Create a template once (needs full_access or the dashboard), publish it, 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": "Payment receipt",
"subject": "Payment received — {{amount}}",
"html": "<p>Thanks for your payment of <b>{{amount}}</b>.</p><p>Reference: {{reference}}</p>",
"text": "Thanks for your payment of {{amount}}. Reference: {{reference}}"
}'
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).
Choosing among the three event types
| Event | Typical use |
|---|---|
checkout.session.completed | Hosted Checkout / Payment Links — often the best single hook for “customer finished paying” |
invoice.paid | Subscriptions and Stripe Invoices — includes hosted invoice / PDF URLs you can link in the mail |
payment_intent.succeeded | Custom PaymentElement / Charges API flows — set receipt_email on the PaymentIntent if you rely on it |
Pick one primary event per product path so customers do not get two Rovela receipts for the same charge. Stripe’s own receipt emails (if enabled in the Dashboard) are separate from Rovela — turn Stripe’s automatic receipts off if Rovela is your only branded channel, or accept that customers may see both.
Production checklist
- Verify signatures on the raw body. Never trust a parsed JSON body for HMAC.
- Return 2xx quickly. If Rovela or your DB is slow, enqueue
{ eventId, payload }and send asynchronously; still useevent.idasIdempotency-Key. - Use
Idempotency-Key: event.idon every Rovela send driven by the webhook (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).
What this does not cover
- Refunds (
charge.refunded) or failed payments (payment_intent.payment_failed) — same pattern, different copy; still key idempotency toevent.id - Attaching a custom PDF you generate yourself — see Send an Invoice Email with a PDF Attachment
- Generic cart/OMS order emails without Stripe — see E-Commerce Order Emails
- Rovela inbound or delivery webhooks — separate from Stripe’s signing secret
Summary
Stripe verifies that the event is real and that payment succeeded. Your webhook maps the event to a recipient and receipt fields. Rovela’s POST /emails with Idempotency-Key = Stripe event.id delivers one confirmation message even when Stripe retries. Keep payments in Stripe, delivery in Rovela, and secrets on the server.