Short answer
To send an email when something happens in Stripe, add a webhook endpoint, subscribe it to the events you need (invoice.paid, invoice.payment_failed, payment_intent.payment_failed, customer.subscription.trial_will_end, checkout.session.expired), and in the handler verify the signature with stripe.webhooks.constructEvent on the raw request body, then call your email API.
Two details decide whether it works in production. Stripe can deliver the same event more than once and retries failed deliveries for up to three days, so key each send to the event id and never send twice. And events can arrive out of order, so check the current state (is the invoice paid yet?) before a follow-up email goes out.
Which Stripe event sends which email
| Event | Stream | Notes | |
|---|---|---|---|
invoice.paid | Receipt | Transactional | Skip amount_paid of 0 (trial starts). Receipt guide |
invoice.payment_failed | Payment failed, first of a series | Transactional | Stop the series on invoice.paid. Failed payment guide |
payment_intent.payment_failed | Payment didn't go through | Transactional | First and one-off payments, which have no invoice. Also fires on renewals. |
customer.subscription.trial_will_end | Trial ending | Transactional | Stripe sends it 3 days before the trial ends |
checkout.session.expired | Finish your checkout | Product | Only with recovery on and promotional consent. Below |
customer.subscription.deleted | Sorry to see you go, or a win-back a week later | Product | Respect unsubscribes |
checkout.session.completed | Welcome, or a one-time purchase receipt | Transactional | Check payment_status is paid |
Event names from Stripe's event types. Subscribe the endpoint to these events only.
Stream here means which kind of email it is. Billing notices are transactional: the customer needs them whether or not they want marketing. Checkout recovery and win-back are closer to product or promotional email, and should carry an unsubscribe link. Keeping the two apart keeps a promotional email's complaints off your payment notices; see what transactional email is.
Set up the endpoint in Stripe
- 01Create an event destinationIn Stripe's Webhooks tab, create a destination for Your account, choose the Snapshot payload (so each event carries the whole invoice or session), and select only the events from the table.
- 02Point it at your endpointA public HTTPS URL such as
https://app.example.com/stripe/webhook. - 03Copy the signing secretIt starts with
whsec_. Store it asSTRIPE_WEBHOOK_SECRET. Test and live endpoints have different secrets. - 04Test locally
stripe listen --forward-to localhost:3000/stripe/webhookforwards events and prints a secret to use while testing;stripe trigger invoice.payment_failedsends one.
Verify every event's signature
Anyone can POST to your webhook URL. Without a signature check, a forged invoice.paid could make you email a receipt for a payment that never happened. Stripe signs each delivery in the Stripe-Signature header; stripe.webhooks.constructEvent checks it against your secret and rejects stale timestamps (5 minutes by default).
The check runs over the exact bytes Stripe sent. If a JSON body parser runs first and you pass it the parsed object, verification fails. In Express, use express.raw({ type: "application/json" }) on the webhook route; in Next.js route handlers, read await request.text().
// app/stripe/webhook/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const body = await request.text(); // raw body, not request.json()
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
request.headers.get("stripe-signature")!,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return new Response("Bad signature", { status: 400 });
}
await handleStripeEvent(event); // the switch from the full example below
return new Response(null, { status: 200 });
}Never send the same email twice
Stripe's docs say an endpoint can occasionally receive the same event more than once. It also retries any delivery that didn't get a 2xx, for up to three days in live mode, and you can resend events by hand. Every one of those is a chance to email a customer twice about one payment.
- Record the event ids you've handled. Insert
event.idinto a table with a unique constraint before you send; if the insert fails, it's a duplicate and you return 200. - Send with an idempotency key from the event id. Groundmail's
Idempotency-Keyheader returns the original response for a repeated key instead of sending again, for 24 hours. That covers the race where two deliveries arrive at once, and a crash between sending and recording. - For one-per-thing emails, key on the object, not the event. Stripe notes that it can occasionally create two separate events for one change. A key like
receipt:${invoice.id}sends one receipt per invoice whatever happens. - Return 500 only when the send failed, so Stripe retries it. With the key in place, a retry that already went through returns the first result.
A full Node example
Express, the Stripe Node SDK and Groundmail's REST API. db and escapeHtml stand in for your own database client and HTML escaping helper.
import express from "express";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();
app.post("/stripe/webhook", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET,
);
} catch (err) {
return res.status(400).send(`Webhook signature check failed: ${err.message}`);
}
// Seen this event before? Acknowledge it and do nothing.
const fresh = await db.query(
"INSERT INTO stripe_events (id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING id",
[event.id],
);
if (!fresh.rows[0]) return res.sendStatus(200);
try {
await handleStripeEvent(event);
} catch (err) {
console.error(err);
await db.query("DELETE FROM stripe_events WHERE id = $1", [event.id]); // let the retry run
return res.sendStatus(500);
}
res.sendStatus(200);
});
async function handleStripeEvent(event) {
switch (event.type) {
case "invoice.paid": {
const invoice = event.data.object;
if (invoice.amount_paid === 0 || !invoice.customer_email) return;
const amount = money(invoice.amount_paid, invoice.currency);
return sendEmail(`receipt:${invoice.id}`, {
to: invoice.customer_email,
subject: `Receipt for ${amount}, invoice ${invoice.number}`,
html: `<p>We received ${amount}. Thank you.</p>
<p><a href="${invoice.hosted_invoice_url}">View invoice</a> · <a href="${invoice.invoice_pdf}">PDF</a></p>`,
});
}
case "invoice.payment_failed": {
const invoice = event.data.object;
if (!invoice.customer_email) return;
const amount = money(invoice.amount_due, invoice.currency);
const retry = invoice.next_payment_attempt
? `We'll try again on ${new Date(invoice.next_payment_attempt * 1000).toDateString()}.`
: "";
return sendEmail(`payment-failed:${invoice.id}:${invoice.attempt_count}`, {
to: invoice.customer_email,
subject: "Your payment didn't go through",
html: `<p>We couldn't take your payment of ${amount}. ${retry}</p>
<p><a href="${invoice.hosted_invoice_url}">Update your card and pay</a></p>`,
});
}
case "customer.subscription.trial_will_end": {
const sub = event.data.object;
const customer = await stripe.customers.retrieve(sub.customer);
if (customer.deleted || !customer.email) return;
const ends = new Date(sub.trial_end * 1000).toDateString();
return sendEmail(`trial-ending:${sub.id}`, {
to: customer.email,
subject: `Your trial ends on ${ends}`,
html: `<p>Hi ${escapeHtml(customer.name ?? "there")}, your trial ends on ${ends}.</p>
<p><a href="https://app.example.com/billing">Choose a plan</a></p>`,
});
}
case "checkout.session.expired": {
const session = event.data.object;
const email = session.customer_details?.email;
const url = session.after_expiration?.recovery?.url;
// Only people who agreed to promotional email, and only with a recovery link.
if (!email || !url || session.consent?.promotions !== "opt_in") return;
return sendEmail(`checkout-recovery:${email}`, {
to: email,
subject: "You left something in your cart",
html: `<p>Your checkout is saved. <a href="${url}">Pick up where you left off</a>.</p>`,
category: "product",
});
}
}
}
async function sendEmail(key, { to, subject, html, category = "transactional" }) {
const res = await fetch("https://api.groundmail.dev/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.GROUNDMAIL_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify({
from: "Example Billing <billing@mail.example.com>",
to,
subject,
html,
category,
}),
});
// 409: this key already sent an email (with a different body). Treat it as sent.
if (res.status === 409) return;
if (!res.ok) throw new Error(`Groundmail ${res.status}: ${await res.text()}`);
}
function money(amount, currency) {
// Stripe amounts are in the smallest unit; zero-decimal currencies (JPY, KRW) aren't divided.
return new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() })
.format(amount / 100);
}
app.listen(3000);The checkout recovery key is per email address, so someone who abandons three checkouts in a day gets one email: Groundmail keeps idempotency keys for 24 hours, and reusing a key with a different body returns 409, which the helper treats as already sent. For a series rather than single emails, such as a second payment reminder three days later, see the dunning series.
Stripe asks endpoints to return 2xx quickly, before slow work. One email send is usually fine inline. If you do more per event, or expect a burst of renewals at the start of the month, put the event on a queue and send from a worker.
Abandoned checkout emails
Stripe doesn't email abandoned checkouts. It gives you what you need to do it: create the Checkout Session with after_expiration[recovery][enabled]=true and consent_collection[promotions]=auto, and when the session expires (24 hours by default, or the expires_at you set, at least 30 minutes), checkout.session.expired carries after_expiration.recovery.url. Opening that URL creates a new session copied from the old one. The link works for 30 days.
Stripe's guide treats cart abandonment emails as promotional: the customer has to agree to promotional email first, and the customer's email isn't in the expired event unless they did. The example above checks consent.promotions for that reason, and sends on the product category so the email carries an unsubscribe link. To see which recovery emails worked, look for recovered_from on the later checkout.session.completed.
The no-code path: the Groundmail Stripe integration
If you'd rather not run a webhook endpoint, the Stripe integration does the receiving for you. In Integrations → Stripe, click Connect with Stripe and approve read-only access to the Groundmail app (or paste a restricted key if you'd rather not install an app). Groundmail never charges, refunds or changes anything in Stripe.
Events then start automations as stripe.<event>, for example stripe.invoice.paid, keyed to the Stripe customer, so a stop event only stops that customer's run. Automations → New → From Stripe has starters for each row of the table above: Payment failed (two emails, 3 days apart, stops on invoice.paid), Payment didn't go through, Trial ending, Receipt, Checkout recovery (stops on checkout.session.completed) and Win-back. Emails use fields such as {{event.data.customer.name}}, {{event.data.invoice.amount}}, {{event.data.invoice.url}}, {{event.data.trial.ends}} and {{event.data.checkout.url}}, go out from your verified domain in your own templates, and billing notices are sent on the transactional stream while win-back and checkout recovery go on the product stream.
Frequently asked questions
Which Stripe webhook should I use to send a receipt?
invoice.paid for subscriptions and invoices. For one-time Checkout payments without an invoice, checkout.session.completed once payment_status is paid.
Why does Stripe webhook signature verification fail?
Usually because the body was parsed before verification. constructEvent needs the raw bytes: use express.raw on the route, or request.text() in a Next.js route handler. Also check you're using the secret for this endpoint and mode (test and live differ).
Can Stripe send the same webhook event twice?
Yes. Stripe says endpoints can occasionally receive an event more than once, and it retries failed deliveries for up to three days in live mode. Record event ids and send with an idempotency key.
Are Stripe webhook events delivered in order?
No. Stripe doesn't guarantee order, so check the current state before a follow-up, for example retrieve the invoice and skip the reminder if it's already paid.
Can I send emails from Stripe events without writing a webhook?
Yes. Connect Stripe in Groundmail with one click (Connect with Stripe) and start automations from Stripe events, with starters for failed payments, receipts, trials, checkout recovery and win-back.
Sources
Facts on this page about other companies' products, prices and policies come from these public pages. They change, so check them before you decide.