How to Send Emails From Stripe Webhooks (With Code, and Without)

Kevin Scotton October 9, 2026

Short answer: create a webhook endpoint in Stripe and subscribe it to the events you want to email about, such as customer.subscription.trial_will_end, invoice.payment_failed and customer.subscription.deleted. In your handler, verify the Stripe-Signature header against the raw request body, skip events you've already processed, then send the email or pass the event to your email platform. Or skip the code and connect Stripe to an email tool that reads these events natively.

Stripe tells you about everything that happens in your account through events, and webhooks deliver those events to a URL you control. That makes them the obvious way to send an email when a trial is ending or a card fails. The basic handler is short. The work is in the details: knowing which event means what, and handling retries, duplicates and the cases where the obvious event is the wrong one.

This guide covers both routes. First the code, for teams that want to own the pipeline, then the no-code version. Everything about Stripe's behavior here comes from Stripe's own documentation as of October 2026.

The events you need for each email

Email Stripe event Notes
Welcome a new customer customer.created Only fires when a Customer object is created; see guest checkouts below
Trial onboarding customer.subscription.created Check status is trialing
Trial ending reminder customer.subscription.trial_will_end 3 days before the trial ends by default; the timing is a Dashboard setting
Welcome to the paid plan customer.subscription.updated Status moves from trialing to active
Renewal reminder invoice.upcoming A few days before renewal, set by the "Upcoming renewal events" setting; not sent for send_invoice subscriptions
Payment receipt or upsell invoice.paid For one-off payments, checkout.session.completed or payment_intent.succeeded
Failed payment (dunning) invoice.payment_failed Fires on each failed attempt, including retries
Past-due heads-up customer.subscription.updated Status changes to past_due
Cancellation scheduled customer.subscription.updated cancel_at_period_end becomes true (or cancel_at is set, on flexible billing)
Subscription ended customer.subscription.deleted Fires when the subscription actually ends
Refund follow-up charge.refunded

Stripe's subscription webhooks guide and the full event list cover the rest. Notice how much hangs on customer.subscription.updated. It fires for any change to a subscription, so your handler has to work out which change happened.

Step 1: Verify every event

Anyone can send a POST request to your endpoint, so check that each one really came from Stripe before acting on it. Stripe signs every delivery with the Stripe-Signature header and your endpoint's signing secret (it starts with whsec_). The official libraries check it for you, but only against the raw request body. If your framework parses the JSON first, verification fails.

Here's a minimal Express handler in Node.js:

JavaScript
import express from "express";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET; // whsec_...
const app = express();

// Raw body on this route only. Register it before any app.use(express.json()).
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"], endpointSecret);
  } catch (err) {
    return res.status(400).send(`Webhook signature verification failed: ${err.message}`);
  }

  // Answer quickly, then do the work.
  res.sendStatus(200);
  await handleEvent(event);
});

Stripe's signature troubleshooting page covers the common failures: a body that was parsed or re-encoded, the wrong secret (test and live endpoints have different ones) or a disabled timestamp check.

Step 2: Expect duplicates and any order

Two things about delivery will bite you if you assume otherwise:

  • The same event can arrive more than once. Stripe retries deliveries that don't get a 2xx response, for up to three days in live mode with exponential backoff, and only a few times over a few hours in a sandbox. Even successful deliveries can occasionally repeat. Log each event.id you've handled and skip repeats. Stripe also notes that two separate events can describe the same change, so for emails, dedupe on the object and event type too.
  • Events aren't guaranteed to arrive in order. Don't assume customer.subscription.created lands before invoice.paid. When the order matters, fetch the current object from the API instead of trusting the event's snapshot.

A simple guard for the email case:

JavaScript
async function alreadySent(key) {
  // A table with a unique constraint on `key` works well here.
  const inserted = await db.sentEmails.insertIfAbsent({ key });
  return !inserted;
}

async function handleEvent(event) {
  if (await alreadySent(`event:${event.id}`)) return;
  // ...route the event (step 3)
}

Step 3: Route events to emails

Then map each event to the email it should send. Note the checks on customer.subscription.updated, which carries the previous values in data.previous_attributes:

JavaScript
async function routeEvent(event) {
  const object = event.data.object;
  const before = event.data.previous_attributes || {};

  switch (event.type) {
    case "customer.subscription.trial_will_end":
      return sendTrialEnding(object);

    case "invoice.payment_failed":
      return sendPaymentFailed(object); // object.attempt_count tells you which attempt this was

    case "customer.subscription.updated":
      if (before.status === "trialing" && object.status === "active") return sendWelcomeToPaid(object);
      if (object.status === "past_due" && before.status !== "past_due") return sendPastDue(object);
      if (object.cancel_at_period_end && before.cancel_at_period_end === false) return sendCancellationSave(object);
      return;

    case "customer.subscription.deleted":
      return scheduleWinBack(object, { delayDays: 3 });
  }
}

The send… functions call your email provider. To keep the emails, timing and follow-ups out of your code, have the handler fire an event at your email platform instead and let an automation do the rest. With Audienceful that's one call to the trigger event API:

JavaScript
await fetch("https://api.audienceful.com/v2/automations/event", {
  method: "POST",
  headers: { "Content-Type": "application/json", "X-Api-Key": process.env.AUDIENCEFUL_API_KEY },
  body: JSON.stringify({ email, event: "payment_failed", add_person: true, fields: { plan } }),
});

The traps that send the wrong email

These are the cases that look right in testing and go wrong with real customers.

Scheduled cancellations aren't cancellations. When a customer cancels at the end of the period, Stripe sends customer.subscription.updated with cancel_at_period_end: true. The subscription stays active, and the customer can change their mind. customer.subscription.deleted only arrives when the period ends. Send your "sorry to see you go" email on the first event and you've given up on a customer who could still be saved. On flexible billing mode, Stripe has deprecated cancel_at_period_end in favor of cancel_at, so check which one your account uses.

Short trials get the reminder immediately. If a trial is shorter than the "trial ending" lead time, trial_will_end fires as soon as the trial starts. A "your trial ends in 3 days" email on day one of a 2-day trial reads as a bug. Use the real trial_end timestamp in the copy.

Check before you chase payment. Stripe recommends checking the subscription status and latest invoice before sending a payment reminder off trial_will_end, because the customer may already have paid or added a card.

Guest checkouts have no Customer. In payment mode, Stripe Checkout only creates a Customer when it needs one (customer_creation: if_required). A one-off buyer from Checkout, or from a Payment Link, often has no Customer object, so customer.created never fires and data.object.customer is empty. The buyer's email is still on the Checkout Session's customer_details and on the charge's billing_details. Read it from there, or set customer_creation: always.

Failed payments fire on every retry. invoice.payment_failed arrives on each failed attempt, and Stripe can retry many times. Use attempt_count to send your first, second and final notice rather than the same email each time.

Stripe may already be emailing them. Stripe can send its own failed-payment, trial-ending and renewal emails from your Billing settings. Decide which emails come from Stripe and which come from you, so customers don't get two. Here's what Stripe sends on its own.

Test mode doesn't email like live mode. Test payments don't send receipts automatically, and Stripe's billing emails in a sandbox only go to your verified domain or team members.

Test it locally with the Stripe CLI

You don't need a public URL to test. With the Stripe CLI, run stripe login, then forward events to your local server:

Bash
stripe listen --forward-to localhost:4242/webhooks/stripe

The command prints a whsec_ signing secret for local use. In a second terminal, fire test events:

Bash
stripe trigger invoice.payment_failed
stripe trigger customer.subscription.trial_will_end

Each delivery shows up in the first terminal with your endpoint's response code. Add --events to stripe listen to forward only the events you handle.

What you're signing up to maintain

The handler is a few dozen lines. Running it well is more: signature checks, a deduplication table, order-independent logic, the cancellation and guest-checkout edge cases, retries when your email provider is down, and the email sequences themselves (timing, follow-ups, stopping when the customer fixes the problem). Every new email means a code change and a deploy.

That's worth it when email is tightly tied to product logic. For most lifecycle email, it's plumbing you don't need to own.

The no-code version

A native integration does all of the above for you. In Audienceful, you connect Stripe with Stripe Connect, and the events arrive as automation triggers: trial started, trial will end, subscription activated, past due, scheduled to cancel, canceled, paused, resumed, payment succeeded, payment failed, charge refunded and customer created. It handles the traps from this guide:

  • Scheduled cancellations are their own trigger, separate from cancellations.
  • Payment events cover invoices, Checkout and Payment Links, and guest buyers become contacts from the email Checkout collected.
  • Goals stop a dunning flow once the subscription is active again, so nobody gets a final notice after paying.
  • Product filters run a flow only for specific Stripe products, on subscription and invoice payments.

The emails, waits and follow-ups live in a visual builder rather than in your code, and every flow can start from a Stripe template. See every event and what it can send on the Stripe integration page, or read the full guide to Stripe email automation. Still weighing Zapier? Here's Zapier vs a native integration.

Audienceful is free up to 1,000 contacts, then paid plans start at $29/month billed yearly ($37.50 monthly) for 3,000 contacts. Start free.

FAQs

Which Stripe webhook fires when a trial is about to end?

customer.subscription.trial_will_end. By default it fires three days before the trial ends, and you can change the lead time under "Trial ending events" in your Stripe Billing settings. If the trial is shorter than that, it fires right away.

Which webhook should I use for cancellation emails?

Use customer.subscription.updated with cancel_at_period_end set to true for a save email while the customer still has access, and customer.subscription.deleted for a win-back once the subscription has ended.

Why does my Stripe webhook signature check fail?

Usually because the request body was parsed or changed before verification. constructEvent needs the exact raw body Stripe sent. The other common causes are using the wrong signing secret (each endpoint, and test and live mode, has its own) and checking a resent event against an old signature.

Can Stripe send these emails without webhooks?

Some of them. Stripe can email receipts, invoices, failed-payment notices, trial-ending reminders and renewal reminders from its own settings. It doesn't run sequences, segments or marketing campaigns. See what Stripe emails your customers.

Do I need a server to send emails from Stripe events?

Not if your email platform connects to Stripe natively. Audienceful receives the events itself and turns them into automation triggers, so there's no endpoint to host.

Sources

Email? Easy.

Audienceful is how smart companies do AI email newsletters, marketing automation, and transactional email.

The Audienceful editor