Recipe

Order / payment receipt email

Triggered by your Stripe (or other PSP) webhook the moment a payment succeeds. Highest-deliverability email your app sends — recipients actively expect it.

Pattern

  1. Stripe sends payment_intent.succeeded webhook to your app
  2. Your app verifies the Stripe signature
  3. Your app fetches the order from your DB by payment_intent_id
  4. Your app POSTs to SendBolt /transactional/send with the receipt template
  5. Customer gets the email within 5 seconds of payment

Template variables

Pre-built template: 5e963c7ec41921db5cb05fdd0e883c41 (or create your own). Variables you should populate:

  • FirstName
  • OrderID — your customer-visible order number, not Stripe's payment_intent
  • OrderDate — formatted date in the customer's timezone
  • LineItems — list of {name, qty, price} (template renders as table)
  • Subtotal
  • Tax
  • Total
  • BillingAddress
  • InvoiceURL — link to the downloadable PDF
  • SupportEmail

Code

// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
import { sendTransactional } from "@/lib/sendbolt";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

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

  let event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (e) {
    return new Response("invalid signature", { status: 400 });
  }

  if (event.type === "payment_intent.succeeded") {
    const pi = event.data.object as Stripe.PaymentIntent;
    const order = await db.orders.findByPaymentIntent(pi.id);
    if (!order) return new Response("order not found", { status: 200 });

    // Fire-and-forget; don't block the webhook ACK on email delivery
    void sendTransactional({
      to: order.customer.email,
      template_id: process.env.MP_TPL_RECEIPT!,
      template_vars: {
        FirstName: order.customer.firstName,
        OrderID: order.publicId,
        OrderDate: order.createdAt.toLocaleDateString(),
        LineItems: order.items.map(i => ({ name: i.name, qty: i.qty, price: i.price })),
        Subtotal: order.subtotal,
        Tax: order.tax,
        Total: order.total,
        BillingAddress: order.billingAddress,
        InvoiceURL: order.invoiceURL,
        SupportEmail: "support@yourdomain.com",
      },
      extra_metadata: {
        order_id: order.id,
        payment_intent: pi.id,
      },
    });
  }

  return new Response("ok");
}

Deliverability notes

  • Don't include unsubscribe footer — receipts are transactional and exempt from CAN-SPAM's unsub requirement. Including it weakens the "real receipt" signal to Gmail
  • Set List-Unsubscribe: NotificationOnly if you must — but better to omit entirely
  • From address: real person + your domain — e.g. billing@acme.com, not noreply@acme.com
  • Subject: include the order ID — "Your Acme order #4521 — receipt" reads as 1:1 mail, lands in Primary
  • Plain HTML — no full-width logo banner. Receipts that look like marketing emails get classified as marketing

What to include / exclude

IncludeSkip
Order summary tableMarketing CTAs
Total amount + payment method (last 4)"You may also like…" product cross-sells
Billing addressSurvey requests
Invoice PDF linkApp-store badges
Support email + order IDSocial media follow icons
Refund policy linkNewsletter signup invitation

If you want to upsell, do it in a SEPARATE post-purchase email sent 24-48h later. Mixing the two contaminates your transactional domain's reputation.

Idempotency

Stripe webhooks can fire multiple times for the same event (delivery retries, network blips). Add a processed_payment_intents table in your DB and check before sending — otherwise customers get 2-5 receipt emails per purchase.

// Insert into processed_payment_intents with payment_intent.id as PK.
// If the row already exists, INSERT raises constraint violation → skip.
try {
  await db.processedPaymentIntents.insert({ paymentIntentId: pi.id });
} catch (e) {
  return new Response("already_processed", { status: 200 });
}
// ... now safe to send receipt

Failure modes

  • SendBolt returns 5xx — log + queue locally, retry in 5min. Don't block the Stripe webhook ACK
  • Customer's email is suppressed — receipts still go out (transactional bypasses the standard suppression for receipts), but mark the user with email_undeliverable=true for marketing-track sends
  • Customer changes email after order — receipt goes to email AT PURCHASE TIME, not current email. Store the recipient address on the order, not the user.

Tax compliance note: if you operate in EU / India / Australia, your receipt is also a legal tax document (invoice). Make sure your template includes the tax breakdown + your business registration number. Consult your accountant before launching.