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
- Stripe sends
payment_intent.succeededwebhook to your app - Your app verifies the Stripe signature
- Your app fetches the order from your DB by
payment_intent_id - Your app POSTs to SendBolt
/transactional/sendwith the receipt template - Customer gets the email within 5 seconds of payment
Template variables
Pre-built template: 5e963c7ec41921db5cb05fdd0e883c41 (or create your own). Variables you should populate:
FirstNameOrderID— your customer-visible order number, not Stripe's payment_intentOrderDate— formatted date in the customer's timezoneLineItems— list of {name, qty, price} (template renders as table)SubtotalTaxTotalBillingAddressInvoiceURL— link to the downloadable PDFSupportEmail
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: NotificationOnlyif you must — but better to omit entirely - From address: real person + your domain — e.g.
billing@acme.com, notnoreply@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
| Include | Skip |
|---|---|
| Order summary table | Marketing CTAs |
| Total amount + payment method (last 4) | "You may also like…" product cross-sells |
| Billing address | Survey requests |
| Invoice PDF link | App-store badges |
| Support email + order ID | Social media follow icons |
| Refund policy link | Newsletter 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 receiptFailure 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=truefor 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.