Send a WhatsApp message when a Stripe payment fails (and when it succeeds)

Tell a customer on WhatsApp when their Stripe invoice payment fails, with a link to fix it, and send a receipt when it is paid. A signed-webhook Node server, two templates, no double sends on Stripe retries.

November 8, 2026 · 9 min read

A failed card payment is a quiet way to lose a customer: the email lands in a busy inbox and nobody updates the card. A short WhatsApp message with a link to the invoice is harder to miss. This guide connects a Stripe webhook to the SendFromChat REST API so that a failed invoice payment sends one message, and a paid invoice sends a receipt.

SendFromChat is an independent product and is not affiliated with Meta, WhatsApp or Stripe. It runs on Meta's official WhatsApp Cloud API; you keep your own WhatsApp Business account. Stripe details below come from Stripe's webhooks and subscription webhooks docs and the Invoice object, read on 8 Nov 2026. SendFromChat endpoints, fields and errors come from our REST API reference, read the same day.

What was tested: the server below was run on Node 24 with Stripe's Node library (version 22.6.2). It received signed test events built with the library's own generateTestHeaderString, and sent its requests to a small local mock of the SendFromChat API. That checked the signature check, the request shape, the idempotency header, the amount formatting and the skip paths. Not run: a live Stripe account, the Stripe CLI, a live or test SendFromChat key, and any real WhatsApp message. Try it with your own keys and your own phone first.

The short version

  • Stripe sends invoice.payment_failed when an invoice payment fails and invoice.paid when one is paid. Stripe's docs suggest notifying the customer on a failure.
  • A payment failure can happen at any hour, and free-form WhatsApp text only delivers within 24 hours of the person's last message to you. So the message goes out as an approved template (why).
  • Verify the Stripe-Signature header against the raw request body before you trust the event.
  • Stripe can deliver the same event more than once. Use the Stripe event ID in the Idempotency-Key, and skip event IDs you have already seen.
  • A 202 from SendFromChat means accepted, not delivered.

What you need first

  1. A SendFromChat workspace with a number connected. A test key also needs a connected number to check against. See getting started.
  2. A card on your WhatsApp Business account at Meta before you can send templates. Meta bills it directly, separate from your SendFromChat plan. The 30-day trial itself needs no card.
  3. A SendFromChat test key (sfc_test_…) from the Developers page while you build. It runs every check a real send would, then stops. Nothing is sent. Switch to a live key (sfc_live_…) when it is ready.
  4. A Stripe secret key and a Stripe webhook signing secret (whsec_…).
  5. Your customer's consent. Only message people who agreed to get WhatsApp messages from you, and keep their number in your own database. Do not message a number just because Stripe holds it. See WhatsApp opt-in.

Keep every key on your server. Never put them in browser code or commit them.

1. Create the two templates

Create two small templates. In the dashboard, or by asking your assistant to create them, use text like this.

payment_failed, language en_US:

Hi {{1}}, your payment of {{2}} didn't go through. You can update your card or pay here: {{3}} - reply to this message if you need a hand.

payment_received, language en_US:

Hi {{1}}, we received your payment of {{2}}. Thank you! Your receipt is here: {{3}} - reply to this message if anything looks wrong.

Give each variable a sample value when you submit (for example {{1}} → "Ana", {{2}} → "€19.99", {{3}} → a sample invoice link). Meta rejects templates without samples, or ones that start or end on a variable, which is why each template ends on words. The rules are in why WhatsApp templates get rejected.

Two things to expect:

  • Meta decides the category, not you. You can suggest one, and Meta may change it. Do not assume what it will be, or what it will cost. Meta's pricing page is the source for charges.
  • Review usually takes minutes, sometimes up to 24 hours. A test key will tell you if a template is not approved yet (template_not_approved).

2. The webhook server (Node 18+)

Save this as server.mjs and add the stripe package to your project. It uses Node's built-in http and fetch.

// server.mjs: Stripe webhook -> WhatsApp message through SendFromChat.
// Run: node server.mjs   (needs Node 18+ and: npm install stripe)
import http from "node:http";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const WHSEC = process.env.STRIPE_WEBHOOK_SECRET; // whsec_...
const API = process.env.SENDFROMCHAT_API ?? "https://sendfromchat.com/api/v1";

// Replace with a lookup in your own database. Only return numbers of
// customers who agreed to get WhatsApp messages from you.
const optedInPhones = new Map([["cus_demo123", "+447700900123"]]);

// Replace with a table or Redis set. Memory is lost on restart.
const seen = new Set();

function money(amountMinor, currency) {
  // Stripe amounts are in the smallest unit (cents). Intl knows the
  // decimals for each currency, so JPY is not divided by 100 by mistake.
  const code = currency.toUpperCase();
  const digits = new Intl.NumberFormat("en", { style: "currency", currency: code })
    .resolvedOptions().maximumFractionDigits;
  return new Intl.NumberFormat("en", { style: "currency", currency: code })
    .format(amountMinor / 10 ** digits);
}

async function sendTemplate(eventId, to, name, variables) {
  const res = await fetch(`${API}/messages`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.SENDFROMCHAT_KEY}`,
      "Content-Type": "application/json",
      // one Stripe event = one key, so a Stripe retry never sends twice
      "Idempotency-Key": `stripe-${eventId}`,
    },
    body: JSON.stringify({ to, template: { name, language: "en_US", variables } }),
    signal: AbortSignal.timeout(10_000),
  });
  const json = await res.json().catch(() => ({}));
  if (!res.ok) {
    console.error(`${eventId}: not sent: ${res.status} ${json.error?.type ?? "unknown"}`);
    return false;
  }
  console.log(`${eventId}: ${name} ${json.status} (${res.status})`);
  return true;
}

async function handle(event) {
  if (event.type !== "invoice.payment_failed" && event.type !== "invoice.paid") return;
  const inv = event.data.object;

  // Look the number up in your own records. Do not message a number just because Stripe has it.
  const to = optedInPhones.get(inv.customer);
  if (!to) {
    console.log(`${event.id}: no opted-in number for ${inv.customer}, skipped`);
    return;
  }
  const name = inv.customer_name ?? "there";
  const failed = event.type === "invoice.payment_failed";
  const minor = failed ? inv.amount_due : inv.amount_paid;
  if (!minor) {
    console.log(`${event.id}: zero amount, skipped`); // e.g. a free-trial invoice
    return;
  }
  const amount = money(minor, inv.currency);
  const link = inv.hosted_invoice_url;
  if (!link) {
    console.log(`${event.id}: no hosted invoice URL, skipped`);
    return;
  }

  await sendTemplate(event.id, to, failed ? "payment_failed" : "payment_received", [name, amount, link]);
}

const server = http.createServer(async (req, res) => {
  if (req.method !== "POST" || req.url !== "/stripe-webhook") {
    res.writeHead(404).end();
    return;
  }
  // Stripe signs the exact bytes it sent, so read the raw body.
  const chunks = [];
  for await (const c of req) chunks.push(c);
  const raw = Buffer.concat(chunks);

  let event;
  try {
    event = stripe.webhooks.constructEvent(raw, req.headers["stripe-signature"], WHSEC);
  } catch {
    res.writeHead(400).end("bad signature");
    return;
  }

  // Answer Stripe first; do the slow work after.
  res.writeHead(200).end("ok");

  if (seen.has(event.id)) return;
  seen.add(event.id);
  try {
    await handle(event);
  } catch (err) {
    console.error(`${event.id}: handler error`, err.message);
  }
});

server.listen(Number(process.env.PORT ?? 4242), () => console.log("listening"));

How it works:

  • Raw body. Stripe signs the exact bytes it sent, so the code reads the body as bytes and gives that to constructEvent. If a framework parses the JSON first, the check fails. Stripe's default tolerance for the timestamp is 5 minutes.
  • Answer first. Stripe's docs say to return a 2xx before complex logic runs, so the server replies 200 and then sends the message. The trade-off: if the send fails after that, Stripe will not retry it for you. The server logs the failure; in production, write it to a queue or table you can retry from.
  • No double sends. Stripe can deliver an event more than once, and says to log the event IDs you have processed. The seen set does that in memory, which is lost on restart, so use a database table. The Idempotency-Key is the second line of defence: per the reference, a retry with the same key and body returns the first response instead of sending again.
  • Amounts. Stripe amounts are integers in the smallest currency unit, with a lowercase currency code. Intl.NumberFormat knows how many decimals each currency has, so 1999 eur prints as €19.99 and 5000 jpy prints as ¥5,000. Check your own currencies.
  • Skips. The server skips an event if the customer has no opted-in number, if the invoice has no hosted invoice URL (the field can be empty), or if the amount is zero (for example a free-trial invoice).

Run it with a test key:

export STRIPE_SECRET_KEY=sk_test_...
export STRIPE_WEBHOOK_SECRET=whsec_...
export SENDFROMCHAT_KEY=sfc_test_...
node server.mjs

With a test key the log shows payment_failed test (200): every check passed and nothing was sent. With a live key it shows payment_failed accepted (202).

3. Point Stripe at it

In the Stripe dashboard, add a webhook endpoint with your server's public HTTPS URL ending in /stripe-webhook, and select the events invoice.payment_failed and invoice.paid. Copy its signing secret into STRIPE_WEBHOOK_SECRET.

To try it on your machine first, Stripe's docs describe forwarding events with the Stripe CLI (stripe listen --forward-to localhost:4242/stripe-webhook, then stripe trigger invoice.payment_failed). The CLI prints its own signing secret for forwarded events; use that one locally. We did not run the CLI for this guide, and a triggered test invoice may not carry the same fields as your real ones, so check them before you rely on it.

4. When the send fails

The server logs the error type from SendFromChat, such as template_not_approved, opted_out, window_closed, no_number or warmup_limit. Branch on type, not on the message text. The full table is in the errors section of the reference.

  • A brand-new number has a daily cap on template sends in its first days (details). Going over it is a 429 warmup_limit. A burst of failed payments is the case to plan for.
  • If the person replies, you can answer in free-form text for the next 24 hours. See receive WhatsApp replies with webhooks.

5. Know whether it arrived

The 202 accepted response means the request was accepted. To know what happened to the message, read GET /messages/{id}, or subscribe to the message.status webhook. The status moves from sent to delivered to read, or ends in failed with a reason (track sent, delivered and read).

Keep Stripe's own emails and retry settings as they are. A WhatsApp message is an extra nudge, not a replacement for your billing setup, and a customer who has not opted in will not get it.

Try it

Create a test key on the Developers page, start the server, send it a signed test event and confirm the log says payment_failed test (200). Once the templates are approved and a live key is in place, run one invoice through Stripe's test mode before you switch on live mode.

Do not have an account yet? There is a 30-day free trial, no card needed to start: sign up. For the same call in other places, see send WhatsApp messages from Node.js and test the WhatsApp API without sending a real message.

Sources

  • Stripe webhooks: signature header, raw body, tolerance, return 2xx first, duplicate events, retries. Read 8 Nov 2026.
  • Stripe subscription webhooks: invoice.payment_failed and invoice.paid. Read 8 Nov 2026.
  • Stripe Invoice object: customer_name, amount_due, amount_paid, currency, hosted_invoice_url. Read 8 Nov 2026.
  • SendFromChat REST API reference, getting started and templates: POST /messages, test and live keys, Idempotency-Key, errors and limits. Read 8 Nov 2026.
  • Meta's own pages decide template categories and charges. We did not retrieve them for this guide.

Do this from your AI assistant

SendFromChat connects Claude, ChatGPT, Cursor or any MCP client to the WhatsApp Business API — templates, bulk sends, inbox, chatbots and sequences, just by asking. 30 days free, no card.

Start free