Handling payment emails for Payments & Stripe developers
Email is often the only touchpoint between your platform and a customer after a transaction completes. Whether it's a receipt after a successful charge, a notification of a failed payment, or a reminder to update payment details, these messages build trust and reduce support volume. In this instalment of our Payment Engineering series, we'll walk through how to build a robust email system that integrates seamlessly with Stripe webhooks, respects customer preferences, and delivers the information users actually need.
Send receipts from webhooks
The most reliable way to trigger payment receipts is via Stripe webhooks rather than relying on client-side logic. When a customer completes a payment, network failures, redirects, or browser crashes can prevent your frontend from knowing the outcome. Webhooks guarantee that you'll process the event exactly once, even if your server restarts or a request times out.
Set up a webhook handler that listens for charge.succeeded events. Once received, generate and send a receipt email before acknowledging the webhook to Stripe. If email delivery fails, you can retry with exponential backoff or queue the message for manual investigation.
import Stripe from 'stripe';
import { sendEmail } from './emailService';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function handleChargeSucceeded(charge: Stripe.Charge) {
// Retrieve full charge details including metadata
const fullCharge = await stripe.charges.retrieve(charge.id);
const customerEmail = fullCharge.receipt_email || fullCharge.billing_details?.email;
if (!customerEmail) {
console.warn(`No email found for charge ${charge.id}`);
return;
}
// Fetch customer from your database to personalize receipt
const customer = await db.customers.findByStripeId(fullCharge.customer);
const receiptData = {
chargeId: fullCharge.id,
amount: fullCharge.amount / 100, // Convert from cents
currency: fullCharge.currency.toUpperCase(),
timestamp: new Date(fullCharge.created * 1000),
description: fullCharge.description,
metadata: fullCharge.metadata,
customerName: customer?.name || 'Valued Customer',
customerEmail: customerEmail,
};
// Send receipt email
await sendEmail({
to: customerEmail,
template: 'receipt',
data: receiptData,
});
// Log successful email for audit trail
await db.emails.create({
customerId: customer?.id,
type: 'receipt',
chargeId: charge.id,
sentAt: new Date(),
status: 'sent',
});
}
Store the fact that you've sent an email in your database. This helps prevent duplicate sends if the webhook is retried, and provides an audit trail for customer service teams investigating missing receipts.
Include order details and support contact
A receipt isn't just a proof of payment—it's a customer service tool. Include everything the customer needs to understand what they bought, when they'll receive it (if applicable), and who to contact if something is wrong. This reduces support emails and disputes.
At minimum, include the order ID, item description, amount, timestamp, and transaction ID (Stripe's charge ID is perfect for this). Add order-specific details from your system—SKUs, quantities, shipping address, expected delivery date. Make the support contact obvious: a link to your help center, a specific support email, and ideally a direct link to the order in your system so customers can track progress.
interface ReceiptEmailData {
chargeId: string;
orderId: string;
items: Array<{
name: string;
quantity: number;
unitPrice: number;
subtotal: number;
}>;
subtotal: number;
tax: number;
shipping: number;
total: number;
currency: string;
timestamp: Date;
// Customer info
customerName: string;
customerEmail: string;
shippingAddress: {
line1: string;
line2?: string;
city: string;
state: string;
postalCode: string;
country: string;
};
// Support
supportEmail: string;
supportUrl: string;
orderTrackingUrl: string;
// For SaaS subscriptions
nextBillingDate?: Date;
subscriptionId?: string;
}
export async function buildReceiptData(
charge: Stripe.Charge,
order: Order
): Promise {
return {
chargeId: charge.id,
orderId: order.id,
items: order.lineItems.map(item => ({
name: item.productName,
quantity: item.quantity,
unitPrice: item.unitPrice / 100,
subtotal: (item.unitPrice * item.quantity) / 100,
})),
subtotal: order.subtotal / 100,
tax: order.tax / 100,
shipping: order.shipping / 100,
total: charge.amount / 100,
currency: charge.currency.toUpperCase(),
timestamp: new Date(charge.created * 1000),
customerName: order.customerName,
customerEmail: charge.receipt_email || order.customerEmail,
shippingAddress: order.shippingAddress,
supportEmail: 'support@yourcompany.com',
supportUrl: 'https://help.yourcompany.com',
orderTrackingUrl: `https://yourcompany.com/orders/${order.id}`,
};
}
When designing your email template, structure it clearly: order items first (customers want to verify they paid for the right thing), then totals, then delivery info or subscription details, then support contact. Use Stripe's charge ID and your own order ID—customers will reference these when reaching out.
Failed payment emails with update links
Declined cards and expired payment methods cost you revenue. When a payment fails, email the customer immediately with a clear explanation and a direct link to update their payment method. The sooner they act, the faster you can retry and recover the order.
Listen for charge.failed events from Stripe. Include the reason for the failure (insufficient funds, expired card, etc.) so the customer knows what to fix. Generate a secure, time-limited link that takes them directly to a payment update page in your app, skipping the friction of searching for their account.
import crypto from 'crypto';
export async function handleChargeFailed(charge: Stripe.Charge) {
const customerEmail = charge.receipt_email || charge.billing_details?.email;
if (!customerEmail) return;
const customer = await db.customers.findByStripeId(charge.customer);
if (!customer) return;
// Generate secure token for payment update link
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
await db.paymentTokens.create({
customerId: customer.id,
token,
type: 'update_payment',
expiresAt,
chargeId: charge.id,
});
const updatePaymentUrl = `https://yourcompany.com/update-payment?token=${token}`;
// Map Stripe decline reason to user-friendly message
const failureReasons: Record = {
card_declined: 'Your card was declined. Please check with your bank or try a different card.',
expired_card: 'Your card has expired. Please update it with a new expiration date.',
insufficient_funds: 'Your account has insufficient funds. Please verify your available balance.',
processing_error: 'A temporary payment processing error occurred. Please try again.',
lost_card: 'Your card has been flagged as lost. Please contact your bank or use a different card.',
};
const reason = failureReasons[charge.decline_code] || 'Payment could not be processed.';
await sendEmail({
to: customerEmail,
template: 'payment-failed',
data: {
customerName: customer.name,
orderId: charge.metadata?.orderId,
amount: charge.amount / 100,
currency: charge.currency.toUpperCase(),
failureReason: reason,
updatePaymentUrl,
supportEmail: 'support@yourcompany.com',
},
});
// Log failed payment email
await db.emails.create({
customerId: customer.id,
type: 'payment_failed',
chargeId: charge.id,
sentAt: new Date(),
status: 'sent',
});
}
The update link should be one-click and secure. Don't require the customer to log in again—use a time-limited token that validates their identity. Redirect them to a form pre-populated with their existing payment method (last 4 digits of the card) so they can quickly confirm or replace it. After they update, attempt to charge again immediately or notify them that you'll retry.
Respect email preferences
Not every customer wants to receive email for every event. Some prefer transactional emails only; others may have unsubscribed entirely. Stripe can handle some of this via its email settings in the Dashboard, but you should also manage preferences in your own system and respect them in your code.
Store an emailPreferences object on each customer that controls what types of emails they receive. Transactional emails like receipts and payment failures should usually be exempt from unsubscribe lists (they're legally required in many jurisdictions), but marketing emails and abandoned cart reminders should honor user preferences.
interface EmailPreferences {
receipts: boolean; // Transactional: usually true always
paymentNotifications: boolean; // Failed charges, expiring cards
orderUpdates: boolean; // Shipping, delivery status
marketing: boolean; // Promotions, new features
newsletter: boolean; // Regular newsletter
unsubscribedAt?: Date;
}
export async function shouldSendEmail(
customer: Customer,
emailType: 'receipt' | 'payment_failed' | 'order_update' | 'marketing' | 'newsletter'
): Promise {
// Transactional emails should always be sent
if (emailType === 'receipt' || emailType === 'payment_failed') {
return true;
}
// Check preferences
const prefs = customer.emailPreferences;
switch (emailType) {
case 'order_update':
return prefs.orderUpdates !== false;
case 'marketing':
return prefs.marketing !== false && !prefs.unsubscribedAt;
case 'newsletter':
return prefs.newsletter !== false && !prefs.unsubscribedAt;
default:
return false;
}
}
export async function handleChargeSucceeded(charge: Stripe.Charge) {
const customerEmail = charge.receipt_email;
const customer = await db.customers.findByStripeId(charge.customer);
// Receipts are always sent (legal requirement)
if (!shouldSendEmail(customer, 'receipt')) {
console.warn(`Customer ${customer.id} has opted out of all email, but receipt is required`);
}
// Proceed with receipt sending
// ...
}
Always include an unsubscribe link in marketing and non-transactional emails. The link should allow customers to manage preferences without requiring login. For transactional emails, include a note like "You're receiving this because you have an active account" or similar, as these emails must be sent regardless of preference.
Common mistakes
- Sending emails synchronously in the webhook handler. If your email provider is slow or down, you'll timeout the webhook and miss the event. Queue the email asynchronously using a job queue (Redis, Bull, AWS SQS) and let the webhook return immediately.
- Not idempotent email logic. If Stripe retries a webhook, you'll send the same receipt twice. Track sent emails by (customerId, emailType, chargeId) or use database unique constraints to prevent duplicates.
- Hardcoding support contact information. When it changes, your old receipts are outdated. Store it in your database or configuration so you can update it globally.
- Forgetting to include the Stripe charge ID. When customers ask for support, they'll give you their order ID but support agents need the charge ID to investigate in Stripe. Always include both.
- Using customer-facing email templates without testing. Send test emails to yourself and teammates before going live. Check rendering across Gmail, Outlook, Apple Mail, and mobile. Stripe provides a receipt preview in the Dashboard—use it.
- Ignoring bounce rates and complaints. Monitor email delivery metrics. High bounce rates suggest stale email addresses; complaints suggest your template or content is poor. Most email providers let you track these via webhooks or APIs.
Key takeaways
- Always trigger receipt and payment notification emails from Stripe webhooks, not frontend code, to guarantee delivery even if the browser disconnects.
- Include clear order details, support contact information, and tracking links in every receipt so customers have everything they need to self-serve.
- For failed payments, send a prompt email with the reason for failure and a secure, time-limited link to update payment details—this maximizes recovery rates.
- Respect customer email preferences by storing them in your database and checking them before sending promotional or non-transactional emails; transactional emails (receipts, payment alerts) should always be sent.
- Treat email as an audit trail: log every send with timestamp, email type, customer ID, and charge ID so your team can investigate delivery issues or customer disputes.
- Test email rendering across devices and clients, monitor bounce and complaint rates, and keep support contact information in a single source of truth so it can be updated globally.






Comments
Loading comments…