# How to Send Custom Stripe Receipts and Invoices with React Email (2026)

_By Tayyab Mughal, Founder & Chief Architect · 06 November 2026 · 5 min read_

> Learn how to replace generic Stripe receipts with branded React Email templates, listen to billing webhooks with idempotency, and dispatch via SadaSend.

A complete developer tutorial on capturing Stripe invoice and checkout webhooks, rendering customized React Email receipts, and dispatching via SadaSend.

## Why default Stripe billing emails cost SaaS companies revenue and retention

Stripe provides automated receipt emails out of the box, but they come with severe limitations for modern software companies. The default templates look generic, lack custom brand typography, and offer zero ability to showcase related product features, onboarding steps, or customer referral bonuses.

Worse, default billing emails are sent from shared Stripe mail domains rather than your authenticated company domain (such as billing.yourdomain.com). This breaks DMARC identifier alignment, reduces domain authority, and trains your customers to ignore transactional emails.

By intercepting Stripe billing webhooks (such as invoice.payment_succeeded and checkout.session.completed) and pairing them with React Email and SadaSend, you gain complete design control, sub-150ms delivery from European infrastructure, and bulletproof idempotency.

| Capability | Default Stripe Receipts | SadaSend + React Email Custom Pipeline |
| --- | --- | --- |
| Domain Authentication | Sent from stripe.com shared IP pools | Native DKIM and SPF alignment on your custom domain |
| Template Customization | Basic logo and accent color only | Full React Email components with design system components |
| Idempotency Defense | Tied to Stripe internal dispatch | Header-level idempotency prevents double-sending on retry |
| Customer Onboarding | Plain invoice link with no onboarding context | Dynamic next-steps, API credentials, or upgrade perks |
| Failed Payment Dunning | Generic system dunning notice | Personalized smart recovery with direct billing portal link |

## 1. Verifying and routing Stripe webhooks in Next.js 15 Route Handlers

The foundation of custom billing emails is a secure webhook handler that verifies the stripe-signature header to prevent forged payment events.

In Next.js 15 App Router, ensure you read the raw request body as an ArrayBuffer or text buffer before passing it to stripe.webhooks.constructEvent. Here is the production route handler:

```typescript
// app/api/webhooks/stripe/route.ts
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { render } from '@react-email/render';
import { SadaSend } from 'sadasend';
import { InvoiceReceiptEmail } from '@/emails/InvoiceReceiptEmail';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-12-18.acacia',
});

const sada = new SadaSend({ apiKey: process.env.SADASEND_API_KEY! });

export async function POST(req: Request) {
  const body = await req.text();
  const sig = req.headers.get('stripe-signature');

  if (!sig || !process.env.STRIPE_WEBHOOK_SECRET) {
    return NextResponse.json({ error: 'Missing webhook signature' }, { status: 400 });
  }

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return NextResponse.json({ error: 'Invalid webhook signature' }, { status: 400 });
  }

  if (event.type === 'invoice.payment_succeeded') {
    const invoice = event.data.object as Stripe.Invoice;
    await handleInvoicePaymentSucceeded(invoice);
  }

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

## 2. Rendering production React Email receipt templates

React Email gives developers the flexibility to compose responsive, email-client-compatible HTML using familiar React components. We can loop over invoice line items, calculate net amounts, and render a clean payment summary:

```tsx
// emails/InvoiceReceiptEmail.tsx
import * as React from 'react';
import {
  Html, Head, Preview, Body, Container, Section, Row, Column, Text, Button, Hr,
} from '@react-email/components';

interface ReceiptProps {
  customerName: string;
  invoiceNumber: string;
  amountPaid: string;
  currency: string;
  date: string;
  lines: Array<{ description: string; amount: string }>;
  hostedInvoiceUrl?: string;
}

export function InvoiceReceiptEmail({
  customerName, invoiceNumber, amountPaid, currency, date, lines, hostedInvoiceUrl,
}: ReceiptProps) {
  return (
    <Html>
      <Head />
      <Preview>Receipt for Invoice #{invoiceNumber}</Preview>
      <Body style={{ backgroundColor: '#090d16', color: '#f3f4f6', fontFamily: 'sans-serif' }}>
        <Container style={{ maxWidth: '580px', margin: '40px auto', padding: '24px', backgroundColor: '#111827', borderRadius: '8px' }}>
          <Text style={{ fontSize: '20px', fontWeight: 'bold', color: '#10b981' }}>Payment Received</Text>
          <Text style={{ color: '#9ca3af', fontSize: '14px' }}>Hi {customerName}, thank you for your business. Here is your official receipt.</Text>
          <Hr style={{ borderColor: '#374151', margin: '20px 0' }} />
          <Section>
            {lines.map((item, idx) => (
              <Row key={idx} style={{ marginBottom: '8px' }}>
                <Column><Text style={{ color: '#d1d5db' }}>{item.description}</Text></Column>
                <Column align="right"><Text style={{ fontWeight: '600' }}>{item.amount}</Text></Column>
              </Row>
            ))}
          </Section>
          <Hr style={{ borderColor: '#374151', margin: '20px 0' }} />
          <Row>
            <Column><Text style={{ fontWeight: 'bold' }}>Total Paid</Text></Column>
            <Column align="right"><Text style={{ fontSize: '18px', fontWeight: 'bold', color: '#10b981' }}>{currency.toUpperCase()} {amountPaid}</Text></Column>
          </Row>
          {hostedInvoiceUrl && (
            <Section style={{ textAlign: 'center', marginTop: '24px' }}>
              <Button href={hostedInvoiceUrl} style={{ backgroundColor: '#059669', color: '#ffffff', padding: '12px 24px', borderRadius: '6px' }}>
                Download Official PDF Invoice
              </Button>
            </Section>
          )}
        </Container>
      </Body>
    </Html>
  );
}
```

## 3. Enforcing idempotency keys with SadaSend to prevent duplicate receipts

Because Stripe retries unacknowledged webhooks multiple times over 72 hours, an unhandled race condition could send duplicate receipts to paying customers.

SadaSend supports an Idempotency-Key header on all send requests. By tying the idempotency key directly to the Stripe invoice ID (such as idempotencyKey: `stripe-receipt-${invoice.id}`), SadaSend automatically returns the cached send record if the webhook is executed more than once:

```typescript
async function handleInvoicePaymentSucceeded(invoice: Stripe.Invoice) {
  const customerEmail = invoice.customer_email;
  if (!customerEmail) return;

  const html = await render(
    InvoiceReceiptEmail({
      customerName: invoice.customer_name || 'Customer',
      invoiceNumber: invoice.number || invoice.id,
      amountPaid: (invoice.amount_paid / 100).toFixed(2),
      currency: invoice.currency,
      date: new Date(invoice.created * 1000).toLocaleDateString(),
      lines: invoice.lines.data.map((l) => ({
        description: l.description || 'Subscription',
        amount: (l.amount / 100).toFixed(2),
      })),
      hostedInvoiceUrl: invoice.hosted_invoice_url || undefined,
    })
  );

  await sada.emails.send({
    from: 'billing@yourdomain.com',
    to: customerEmail,
    subject: `Payment Receipt: Invoice #${invoice.number || invoice.id}`,
    html,
    headers: {
      'Idempotency-Key': `stripe-receipt-${invoice.id}`,
    },
  });
}
```

## 4. Automated payment failure dunning workflows

When a subscription renewal fails due to an expired credit card or insufficient funds, Stripe emits invoice.payment_failed.

Rather than letting Stripe send a cold, threatening automated notice, you can intercept this event and send an empathetic dunning email with a direct 1-click update link to the customer portal:

```typescript
if (event.type === 'invoice.payment_failed') {
  const invoice = event.data.object as Stripe.Invoice;
  const customerId = invoice.customer as string;

  // Generate an authenticated Stripe customer portal session
  const portalSession = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: 'https://yourdomain.com/settings/billing',
  });

  await sada.emails.send({
    from: 'billing@yourdomain.com',
    to: invoice.customer_email!,
    subject: 'Action Required: Update payment method for your subscription',
    html: `<p>We were unable to process your subscription renewal. Please update your payment method here: <a href="${portalSession.url}">Update Billing Details</a></p>`,
    headers: {
      'Idempotency-Key': `stripe-dunning-${invoice.id}-${invoice.attempt_count}`,
    },
  });
}
```

---

_Tags: Stripe, React Email, Billing, Next.js 15, Webhooks_
