Stripe
Updated
Send emails from Stripe webhooks with Lumail
Stripe tells you when money moves. Lumail sends the email from your own domain. The webhook in between has two jobs: prove the request really came from Stripe, and make sure a retried event never sends a second email.
TL;DR
await request.text(), verify it with stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET), then switch on event.type. On invoice.paid send a receipt, on invoice.payment_failed send a fix-your-card email, both with an idempotency key built from event.id. Return a 2xx once the email is queued and a 5xx if it failed, so Stripe retries.Prerequisites
- A Stripe account and its secret key, plus the Stripe CLI for local testing.
- A Next.js App Router project (the same handler works in any framework that gives you the raw request body).
- A Lumail API token and a sending domain verified in the same organization.
1. Install the packages
2. Set your environment variables
You need three secrets on the server. STRIPE_WEBHOOK_SECRET starts with whsec_ and is different for the Stripe CLI and for each dashboard endpoint, so set the right one per environment.
3. Create the Lumail client
Same server-only module as every other guide. The webhook route imports it.
4. Verify the signature, then send
Signature verification needs the exact bytes Stripe sent. In an App Router route handler, await request.text() gives you the raw body; never parse it to JSON first.
event.type narrows event.data.object, so inside each case the invoice is fully typed. customer_email can be null, so guard it, and skip zero-amount invoices such as trial starts so nobody gets a receipt for $0.
5. Test locally with the Stripe CLI
stripe listen forwards events to your dev server and prints a whsec_ signing secret for that session. Put it in .env.local, restart, then trigger test events.
Test-mode invoices use the customer's email. Create test customers with fixture addresses such as [email protected] so Lumail queues the email without delivering it.
6. Register the production endpoint
In the Stripe Dashboard, add a webhook endpoint pointing at https://yourdomain.com/api/stripe/webhook, subscribe it to invoice.paid and invoice.payment_failed, and copy its signing secret into your production STRIPE_WEBHOOK_SECRET.
If Stripe also emails receipts to your customers, turn that off in your Stripe email settings so customers do not get two receipts.
Handle errors
Answer 400 when the signature is invalid: that request did not come from Stripe and must never send anything. Answer 5xx when Lumail returns an error, so Stripe retries the event later.
Ignore event types you do not handle by returning a 2xx. Unhandled types that return errors get retried for days and clutter your dashboard.
| Status | error.name | What to do |
|---|---|---|
| 401 | missing_api_key | Missing or invalid token. Check the env var on the server that sends. |
| 403 | missing_permission | The token lacks the emails permission. Mint one that has it. |
| 402 | plan limit | The organization used its plan's email volume. Upgrade or wait for the next period. |
| 4xx | validation_error | Missing field, more than one body format, or a from domain that is not verified. |
| 429 | RECIPIENT_RATE_LIMITED | That mailbox hit 5 sends in 10 minutes or 20 in 24 hours. Honor Retry-After. |
| none | network_error / timeout | The request never got an answer. Safe to retry with the same idempotency key. |
Make retries safe with idempotency
Stripe can deliver the same event more than once, and it retries every event that did not get a 2xx. Using stripe: plus the event id as the Lumail idempotency key makes every delivery of one event map to one email.
That is what makes returning a 5xx on a failed send safe: the retry either sends the email for the first time or returns the one already queued.
Verify your sending domain
Lumail only sends from a domain you have verified. Add the domain in your organization's Domains settings, then publish the SPF, DKIM and DMARC records it shows at your DNS provider. Until the domain verifies, every send fails with an error saying the domain is not authorized or verified.
Use a subdomain such as mail.yourdomain.com if your root domain already sends from another provider. Start DMARC at p=none, then tighten it once reports look clean.
- Email domains - Add a domain and the SPF, DKIM and DMARC records.
- Add a DMARC record - Publish a policy, then tighten it safely.
- Mail tester - Send a real email and check authentication and spam signals.
Production checklist
- The handler reads the raw body and rejects requests whose signature fails before doing anything else.
- Each environment has its own
STRIPE_WEBHOOK_SECRET, and Stripe's built-in receipt emails are off if Lumail sends them. - The
fromaddress is on a domain verified in the same organization as the token, with SPF, DKIM and a DMARC record. LUMAIL_API_KEYis set on every environment that sends, never committed, never in a public or client-side variable. Use one token per environment.- Every send that can be retried (webhooks, queues, jobs) passes an idempotency key derived from the triggering event.
- One-time codes and magic links set
tracking: { links: false, open: false }so links are not rewritten. - End-to-end tests send to fixture addresses such as
[email protected], which return an id without sending real email. - A failed email is logged with
error.nameand never fails the user's request on its own.
Frequently asked questions
Why does constructEvent fail with a signature error?
Almost always because the body was parsed before verification. constructEvent needs the raw request body exactly as Stripe sent it. In a Next.js route handler use await request.text(); in Express use express.raw({ type: "application/json" }) on the webhook route.
Which Stripe events should send emails?
For subscriptions, invoice.paid covers receipts and invoice.payment_failed covers failed charges. For one-off Checkout payments, checkout.session.completed is the usual trigger for an order confirmation.
How do I stop duplicate emails when Stripe retries a webhook?
Pass the Stripe event id as Lumail's idempotencyKey. Every delivery of the same event then resolves to the same queued email, so retries and duplicate deliveries never send twice.
Should the webhook return an error if the email fails?
Yes, return a 5xx. Stripe retries the event with backoff, and the idempotency key guarantees the retry cannot double-send. Return 2xx for event types you choose to ignore.
Can I send these emails with React Email templates?
Yes. Render the template with render from @react-email/render inside the case and pass the HTML string as html instead of markdown. Keep the same idempotency key.
Should the customer also become a Lumail subscriber?
If you want them in marketing workflows, call lumail.subscribers.create with their email and a tag such as customer. It upserts, so calling it on every payment is safe. Transactional emails do not require it.
Keep building
- GuideSend React Email templates with LumailBuild templates as components, render to HTML, send through the API.
- GuideSend emails from Next.js with LumailApp Router route handlers and server actions with the lumail SDK.
- GuideSend emails from Node.js with LumailA script, an Express route, batch sends and a safe retry helper.
- DocsSDK: lumail.emails.sendSignature, options and the result shape.
- DocsSend Email API referenceEvery field, option and error for POST /api/v2/emails.
- DocsCreate subscriber APITag paying customers for your marketing workflows.
- DocsTest-mode recipientsFixture addresses that never send real email.
- DocsLumail docsAPI reference, SDK and tutorials.
- Free toolMail testerCheck SPF, DKIM, DMARC and spam signals on a real send.
Send your first email from code today.
3,000 emails a month free. Transactional and marketing email on one verified domain, with unlimited subscribers on every plan.