← Back to blog

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.

Updated November 4, 2026
Stripe Webhook Receipt Email with Rovela (2026): Signature + Idempotency-Key

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:

  1. Verifies the Stripe-Signature against the raw request body
  2. Handles checkout.session.completed, invoice.paid, and/or payment_intent.succeeded
  3. Sends a receipt / payment-confirmation email through Rovela
  4. Sets Idempotency-Key = the Stripe event id so 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:

What Stripe does vs what Rovela sends

ConcernOwner
Checkout, PaymentIntents, Invoices, refundsStripe
Webhook signing secret + constructEventYour server + Stripe
Choosing which event types trigger mailYour app
Customer email, amount, currency, receipt URL fieldsStripe event payload (your mapping)
POST /emails, DKIM, queue, bouncesRovela
Idempotency-Key = Stripe event.idYour webhook handler

A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).

Prerequisites

  1. A verified sending domain in the Rovela dashboard. Unverified from domains are refused with 400 (Quick Start). Prefer something like receipts.example.com or mail.example.com.
  2. An API key shaped like re_…. Prefer sending_access for the webhook worker (Authentication).
  3. A Stripe account with a webhook endpoint pointing at your server, and the endpoint’s signing secret (whsec_…) in server env.
  4. Keep STRIPE_WEBHOOK_SECRET, STRIPE_SECRET_KEY (if you need follow-up Stripe API reads), ROVELA_API_KEY, and ROVELA_FROM only 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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

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

EventTypical use
checkout.session.completedHosted Checkout / Payment Links — often the best single hook for “customer finished paying”
invoice.paidSubscriptions and Stripe Invoices — includes hosted invoice / PDF URLs you can link in the mail
payment_intent.succeededCustom 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

  1. Verify signatures on the raw body. Never trust a parsed JSON body for HMAC.
  2. Return 2xx quickly. If Rovela or your DB is slow, enqueue { eventId, payload } and send asynchronously; still use event.id as Idempotency-Key.
  3. Use Idempotency-Key: event.id on every Rovela send driven by the webhook (Send Email API).
  4. Prefer sending_access keys in the worker; never expose them to the client (Authentication).
  5. Send html + text, or a published template — not an empty body.
  6. Escape user-influenced fields before HTML / template variables (Templates).
  7. Confirm delivery with GET /emails/:id or Rovela webhooks if you need ops visibility — a send 200 is 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 to event.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.