Skip to main content

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.

Drawing of the Unlimited Messaging app showing a list of messages with their status (read, delivered, sent, queued), with the headline “Stripe dunning by WhatsApp, SMS and email”.

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.

  1. 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_xxxx
  2. Add 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();
    }
  3. 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 });
    }
  4. 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;
    }
  5. 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.

Prompt to copy
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's built-in emails or your own webhook messages?
Stripe customer emailsBuilt into BillingWebhook + email APIFor example ResendWebhook + TwilioSMS APIWebhook + Unlimited MessagingEmail, WhatsApp and SMS
ChannelsEmailEmailSMS, WhatsApp with Meta templatesEmail, WhatsApp and SMS in one request
Failed paymentAn email after each failed card paymentYour email, your timingYour SMS, your timingEscalate from email to WhatsApp and SMS
Trial endingReminder email 7 days beforeEvent 3 days before, your emailEvent 3 days before, your SMSEvent 3 days before, any channel
Cost of the messagesPart of Stripe BillingFree up to 3,000 emails a month, 100 a dayBilled per SMS segmentFree plan, no per-message fee
CodeNone, Dashboard settingsOne webhook handlerOne webhook handlerOne webhook handler
SenderStripe, with your brandingYour verified domainA Twilio numberYour 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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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

Ilian, senior software engineer

Written by

· 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.

View LinkedIn profileUpdated

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.