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_failedwhen an invoice payment fails andinvoice.paidwhen 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-Signatureheader 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
202from SendFromChat means accepted, not delivered.
What you need first
- A SendFromChat workspace with a number connected. A test key also needs a connected number to check against. See getting started.
- 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.
- 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. - A Stripe secret key and a Stripe webhook signing secret (
whsec_…). - 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
200and 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
seenset does that in memory, which is lost on restart, so use a database table. TheIdempotency-Keyis 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.NumberFormatknows how many decimals each currency has, so 1999eurprints as€19.99and 5000jpyprints 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_failedandinvoice.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 freeMore guides
Send WhatsApp messages from Zapier or Make with one HTTP request
Send WhatsApp template messages from a Zap or a Make scenario using a plain webhook or HTTP step, with a test key, idempotent re-runs, refusal handling and a webhook for delivery status and replies.
WhatsApp display name rejected? The rules, the fix, and the 14-day re-register
Why Meta rejects a WhatsApp Business display name, what each rejection reason in the webhook means, how to edit and resubmit it (10 edits in 30 days), and the 14-day window to re-register the number after approval. Includes a mock-tested Node script.
WhatsApp two-step verification PIN: what to do when register asks for it
What the 6-digit two-step verification PIN does on the WhatsApp Cloud API, when the register call needs your existing one, how to change a forgotten PIN, why you should not loop the call (10 tries in 72 hours), and Meta's PIN deprecation for eligible numbers.
Send yourself WhatsApp alerts from a script (cron, CI, uptime check)
Get a WhatsApp message on your own phone when a cron job, CI run or health check fails. One approved template, one REST call, a Node script, a bash cron check and a GitHub Actions step, with no double alerts on retries.