Stripe guide
A card fails on Stripe,
your customer gets a WhatsApp.
One webhook handler listens to invoice.paid, invoice.payment_failed and customer.subscription.trial_will_end, and sends the receipt, the dunning reminder or the trial notice by email, WhatsApp or SMS from your own accounts. Free, with the code for Next.js.
In a hurry? Summarize this article with an AI
The short answer
How do you send an email, WhatsApp or SMS from a Stripe webhook?
Create a webhook endpoint, verify each event with stripe.webhooks.constructEvent on the raw body, answer 2xx at once, then send the message with one POST /message call. Stripe sends customer.subscription.trial_will_end 3 days before a trial ends and invoice.payment_failed after each failed attempt, so each event maps to one message.
Stripe already does part of the job. Smart Retries retry a failed subscription payment, up to 8 times over 2 weeks by default on new accounts, and Stripe's customer emails can warn about a failed payment, a trial ending in 7 days or an expiring card. They are emails only.
An email about a failed card is easy to miss. A WhatsApp or an SMS from a number the customer recognises gets read, and the link to the hosted invoice page lets them pay in a minute. Your webhook decides which channel to use and when: email and WhatsApp on the first failure, SMS on the next attempts.
Stripe can deliver the same event twice and in any order, and retries a live endpoint for up to 3 days when it does not get a 2xx. Your handler has to be fast and idempotent: process each event.id once.
Related: transactional notifications by WhatsApp, and how to avoid a WhatsApp ban.

Step by step
How do you build it, step by step?
Five steps in a Next.js App Router project, about 20 minutes. Any backend works the same way: verify, answer 2xx, send.
Collect the three secrets
Your Stripe secret key, the signing secret (whsec_) of your webhook endpoint, and an Unlimited Messaging API key once your mailbox, WhatsApp number or Android phone is connected. All three stay server-side, never with a NEXT_PUBLIC_ prefix.
.env.local.env.local# .env.local (server only, never NEXT_PUBLIC_) STRIPE_SECRET_KEY=sk_test_xxxx # Printed by "stripe listen" in dev, or shown on the endpoint in the Dashboard STRIPE_WEBHOOK_SECRET=whsec_xxxx UM_API_KEY=um_live_xxxxAdd the notify helper
One server-only function around POST /message. It skips the channels that have no destination and marks the message transactional, so it goes ahead of any bulk send waiting in your queue.
lib/notify.tsTypeScript// lib/notify.ts import 'server-only'; export type Channel = 'EMAIL' | 'WHATSAPP' | 'SMS'; export async function notify(input: { email?: string; phone?: string; // E.164, e.g. +33612345678 channels: Channel[]; subject: string; text: string; }) { const channels = input.channels .filter((c) => (c === 'EMAIL' ? input.email : input.phone)) .map((channel) => ({ channel })); if (channels.length === 0) return null; const res = await fetch('https://api.unlimitedmessaging.app/message', { method: 'POST', headers: { Authorization: `Bearer ${process.env.UM_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ to: { email: input.email, phone: input.phone }, channels, subject: input.subject, text: input.text, priority: 'transactional', }), }); if (!res.ok) throw new Error(`Unlimited Messaging ${res.status}: ${await res.text()}`); return res.json(); }Create the webhook route
Read the raw body with req.text(): Stripe signs the exact bytes, and parsing JSON first breaks the check. constructEvent also rejects events older than its 5-minute default tolerance. Return 200 at once and send inside after().
app/api/stripe/webhook/route.tsRoute Handler// app/api/stripe/webhook/route.ts import type Stripe from 'stripe'; import { after } from 'next/server'; import { stripe, handleStripeEvent } from '@/lib/stripe-notify'; export async function POST(req: Request) { // The raw body: parsing it as JSON first breaks the signature check const body = await req.text(); const signature = req.headers.get('stripe-signature') ?? ''; let event: Stripe.Event; try { event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!); } catch { return new Response('Invalid signature', { status: 400 }); } // Answer Stripe with a 2xx right away, send the messages after the response after(() => handleStripeEvent(event).catch(console.error)); return new Response('ok', { status: 200 }); }Map each event to a message
invoice.paid sends a receipt by email. invoice.payment_failed sends email and WhatsApp with the hosted invoice link, then adds SMS once attempt_count is above 1. customer.subscription.trial_will_end arrives 3 days before the trial ends: fetch the customer for the email and phone.
lib/stripe-notify.tsTypeScript// lib/stripe-notify.ts import 'server-only'; import Stripe from 'stripe'; import { notify, type Channel } from '@/lib/notify'; export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); const money = (amount: number, currency: string) => new Intl.NumberFormat('en', { style: 'currency', currency }).format(amount / 100); export async function handleStripeEvent(event: Stripe.Event) { // Stripe can send the same event twice: skip the ones already handled if (!(await markProcessed(event.id))) return; switch (event.type) { case 'invoice.paid': { const inv = event.data.object; await notify({ email: inv.customer_email ?? undefined, channels: ['EMAIL'], subject: 'Payment received', text: `Thanks, we received ${money(inv.amount_paid, inv.currency)}.`, }); break; } case 'invoice.payment_failed': { const inv = event.data.object; // First failure: email + WhatsApp. Next retries: SMS as well. const channels: Channel[] = inv.attempt_count > 1 ? ['EMAIL', 'WHATSAPP', 'SMS'] : ['EMAIL', 'WHATSAPP']; await notify({ email: inv.customer_email ?? undefined, phone: inv.customer_phone ?? undefined, channels, subject: 'Your payment did not go through', text: `Your payment of ${money(inv.amount_due, inv.currency)} failed. Update your card here: ${inv.hosted_invoice_url}`, }); break; } case 'customer.subscription.trial_will_end': { const sub = event.data.object; // sent 3 days before the trial ends const customer = await stripe.customers.retrieve(sub.customer as string); if (customer.deleted) break; await notify({ email: customer.email ?? undefined, phone: customer.phone ?? undefined, channels: ['EMAIL', 'WHATSAPP'], subject: 'Your trial ends in 3 days', text: 'Your trial ends in 3 days. Nothing to do if your card is up to date.', }); break; } } } async function markProcessed(eventId: string): Promise<boolean> { // e.g. INSERT INTO stripe_events (id) VALUES ($1) ON CONFLICT DO NOTHING, // then return true only when a row was inserted return true; }Test with the Stripe CLI, then go live
stripe listen forwards test events to localhost and prints the signing secret to use. Trigger a failed payment, check the messages, then add your production URL as an endpoint in the Dashboard with the three events selected and its own whsec_ secret.
TerminalStripe CLI# Forward test events to your local route (prints a whsec_ secret) stripe listen --forward-to localhost:3000/api/stripe/webhook # In another terminal: fire a failed payment stripe trigger invoice.payment_failed
Vibe coding
Using Cursor, Lovable or Claude Code? Paste this prompt
The prompt gives your AI editor the safe pattern: signature check on the raw body, fast 2xx, idempotent handler, messages by channel. It asks for your test contacts before sending.
Add customer notifications on Stripe events to this Next.js (App Router) project with Unlimited Messaging: email, WhatsApp and SMS sent from my own accounts.
1. Use STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET and UM_API_KEY from .env.local, and list them in .env.example. Never use a NEXT_PUBLIC_ prefix.
2. Create lib/notify.ts (import 'server-only') exporting notify({ email, phone, channels, subject, text }) that sends one POST https://api.unlimitedmessaging.app/message with the header Authorization: Bearer UM_API_KEY and the JSON { to: { email, phone }, channels: [{ channel: 'EMAIL' | 'WHATSAPP' | 'SMS' }], subject, text, priority: 'transactional' }. Skip channels without a destination.
3. Create app/api/stripe/webhook/route.ts: read the raw body with req.text(), verify it with stripe.webhooks.constructEvent and the stripe-signature header, return 400 on failure, return 200 at once and handle the event inside after() from next/server.
4. Handle invoice.paid (receipt by email), invoice.payment_failed (email + WhatsApp with hosted_invoice_url, add SMS when attempt_count > 1) and customer.subscription.trial_will_end (retrieve the customer, email + WhatsApp). Store event.id in a table with a unique constraint and skip events already processed.
5. API reference: https://docs.unlimitedmessaging.app. Stripe webhooks: https://docs.stripe.com/webhooks.
Show me the diff and the stripe listen / stripe trigger commands to test, and ask for my test email and phone number before sending anything.The options
Stripe's built-in emails or your own webhook messages?
Stripe's customer emails need no code. A webhook handler adds the channels and the timing you choose. They work together.
| Stripe customer emailsBuilt into Billing | Webhook + email APIFor example Resend | Webhook + TwilioSMS API | Webhook + Unlimited MessagingEmail, WhatsApp and SMS | |
|---|---|---|---|---|
| Channels | SMS, WhatsApp with Meta templates | Email, WhatsApp and SMS in one request | ||
| Failed payment | An email after each failed card payment | Your email, your timing | Your SMS, your timing | Escalate from email to WhatsApp and SMS |
| Trial ending | Reminder email 7 days before | Event 3 days before, your email | Event 3 days before, your SMS | Event 3 days before, any channel |
| Cost of the messages | Part of Stripe Billing | Free up to 3,000 emails a month, 100 a day | Billed per SMS segment | Free plan, no per-message fee |
| Code | None, Dashboard settings | One webhook handler | One webhook handler | One webhook handler |
| Sender | Stripe, with your branding | Your verified domain | A Twilio number | Your own mailbox, WhatsApp number and phone |
Checked on 9 November 2026 on Stripe's documentation, resend.com/pricing and twilio.com (links in the sources). You can keep Stripe's emails on and add WhatsApp or SMS on top.
The catch
What are the limits?
Free removes the per-message bill. Webhooks still need care, and WhatsApp and SMS still have rules.
Stripe phone numbers are free text
customer_phone and customer.phone hold whatever was typed. WhatsApp and SMS need E.164 (+33612345678): normalise numbers when you collect them, for example with phone number collection in Checkout, and skip the phone channels when a number is missing.
Answering 200 first means you own the retries
after() sends the messages once Stripe has its 2xx, so Stripe will not redeliver if your send fails. For messages you cannot lose, store the event and process it from a queue that retries.
Duplicates and order are not guaranteed
Stripe may deliver an event more than once and does not guarantee order. Deduplicate on event.id, not on created, and fetch the latest object from the API when the current state matters.
WhatsApp is unofficial: dunning yes, blasts no
Your number is linked as a device, outside Meta's Business Platform. A payment reminder to your own customer is expected; a campaign to every past customer is the kind of traffic that gets numbers banned.
SMS needs an Android phone that stays on
SMS leave from your phone's SIM, on your plan. Android asks for confirmation beyond 30 SMS in 30 minutes, so the app paces sends to about 30 every 30 minutes: plenty for dunning, not for a mass campaign.
Free plan messages carry a footer
On the Free plan, messages end with a short "Sent with UnlimitedMessaging.app" line, and media and delivery webhooks are paid features. Medium and Max remove the line.
FAQ
Questions about Stripe webhook notifications
Sources
Sources & references
To check the information in this article for yourself:
- Using webhooks with subscriptions (trial_will_end 3 days before, invoice.payment_failed)Stripe Docs
- Receive Stripe events in your webhook endpoint (signatures, 2xx, duplicates, 5-minute tolerance)Stripe Docs
- Automate payment retries (Smart Retries: up to 8 retries in 2 weeks by default)Stripe Docs
- Automate customer emails (failed payments, trial reminders 7 days before)Stripe Docs
- after (stable since v15.1.0)Next.js Docs
- route.js (Route Handlers)Next.js Docs
- Pricing (Free: 3,000 emails a month, 100 a day)Resend
- SMS pricing, per segment and per countryTwilio
- SmsUsageMonitor (30 SMS in 30 minutes threshold)Android Open Source Project
- API reference: POST /messageUnlimited Messaging Docs

Written by
Ilian · Senior Software Engineer
A senior software engineer, he founds and runs several tech companies. He builds products around messaging APIs, automation and AI integration, and shares here what he learns while building.
Turn failed payments into WhatsApp reminders today.
Create a free account, connect a mailbox, a WhatsApp number or an Android phone, paste the handler. No credit card, no per-message fee.