Finance & Business

Get a Push Notification for Every Stripe Payment

Stripe fires a webhook the moment a payment succeeds or fails. Point it at your phone and know about a failed charge before the customer emails you.

Red and black halftone illustration: a cash drawer sliding open on a dark counter, coins settled in the trays (TheNotificationApp)

Stripe's dashboard is excellent and you check it roughly twice a week. Meanwhile a subscription renewal failed on Tuesday, the retry failed on Thursday, and the customer's account gets downgraded on Saturday without anyone at your end noticing.

That entire sequence is preventable, because Stripe fired a webhook at every step. It just went to an endpoint that logged it.

Decide what deserves your attention first

Stripe has hundreds of event types and the temptation is to subscribe to everything. Do not. The events actually worth a phone buzz are the ones where a human doing something quickly changes the outcome:

  • invoice.payment_failed: the most valuable one on the list. A card that failed can often be fixed by an email before the subscription lapses.
  • charge.dispute.created: chargebacks have deadlines. Finding out a week late costs you the dispute.
  • customer.subscription.deleted: someone cancelled. Whether you reach out is your call, but you should know.
  • payment_intent.succeeded: only if you are small enough that a payment is still an event. Turn it off when it stops being one.

Notice that three of the four are bad news. That is the correct ratio for something that interrupts you.

The no-code version

Create an application in TheNotificationApp, call it Stripe, and copy the app_key. Then in the Stripe dashboard go to Developers, then Webhooks, then Add endpoint and use:

https://thenotification.app/api/webhook/static/?app_key=YOUR_API_KEY&title=Stripe

Select the events above and save. Stripe posts its event JSON, the static webhook formats it into readable text, and your phone buzzes.

This works and takes two minutes. Its weakness is that Stripe amounts are in cents, so a payment of forty-nine francs shows up in the notification as 4900, buried in a wall of other fields. Fine for failures, where the fact is the point. Less good for successes, where the number is.

The version that reads properly

A small route handler formats the money and drops everything you do not need. In Next.js:

// app/api/stripe-webhook/route.ts
import { NextResponse } from "next/server";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

async function notify(title: string, body: string, link?: string) {
  await fetch("https://thenotification.app/api/sendNotification", {
    method: "POST",
    headers: {
      app_key: process.env.TNA_APP_KEY!,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ title, body: body.slice(0, 180), link }),
  });
}

const money = (amount: number, currency: string) =>
  new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: currency.toUpperCase(),
  }).format(amount / 100);

export async function POST(request: Request) {
  const signature = request.headers.get("stripe-signature");
  const raw = await request.text();

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      raw,
      signature!,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (error) {
    return NextResponse.json({ error: "bad signature" }, { status: 400 });
  }

  switch (event.type) {
    case "invoice.payment_failed": {
      const invoice = event.data.object as Stripe.Invoice;
      await notify(
        "Payment FAILED",
        `${money(invoice.amount_due, invoice.currency)} from ${invoice.customer_email ?? "a customer"}`,
        invoice.hosted_invoice_url ?? undefined
      );
      break;
    }
    case "charge.dispute.created": {
      const dispute = event.data.object as Stripe.Dispute;
      await notify(
        "Dispute opened",
        `${money(dispute.amount, dispute.currency)}. Reason: ${dispute.reason}`,
        "https://dashboard.stripe.com/disputes"
      );
      break;
    }
    case "payment_intent.succeeded": {
      const intent = event.data.object as Stripe.PaymentIntent;
      await notify("Payment received", money(intent.amount, intent.currency));
      break;
    }
  }

  return NextResponse.json({ received: true });
}

Now a failed payment reads "Payment FAILED, $49.00 from alex@acme.com" and tapping it opens the hosted invoice, which is the page you would have navigated to anyway.

Verify the signature, properly

The signature check above is not optional, and it has a specific trap in modern frameworks.

stripe.webhooks.constructEvent needs the raw request body, byte for byte. If your framework has already parsed the JSON and you re-serialise it, the signature will not match, because key ordering and whitespace change. That is why the handler calls request.text() and never request.json().

In Express you need the raw body middleware on that route specifically:

app.post(
  "/stripe-webhook",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const event = stripe.webhooks.constructEvent(
      req.body,                       // a Buffer, not an object
      req.headers["stripe-signature"],
      process.env.STRIPE_WEBHOOK_SECRET
    );
    // ...
  }
);

Without verification, anyone who finds your endpoint URL can post a fake "Payment received, $10,000" event. Harmless-ish for a notification, and genuinely dangerous the moment the handler touches your database.

Return 200 quickly

Stripe expects a response within a few seconds and retries if it does not get one. If your notification call is slow, you can end up sending several notifications for one payment because Stripe retried while you were still working.

For a single fast call, awaiting it is fine. If the handler grows, acknowledge first and do the work after:

import { waitUntil } from "@vercel/functions";

waitUntil(notify("Payment received", money(intent.amount, intent.currency)));
return NextResponse.json({ received: true });

Test it without spending money

The Stripe CLI replays real event shapes at your local handler, which beats making test payments:

stripe listen --forward-to localhost:3000/api/stripe-webhook
stripe trigger invoice.payment_failed

Your phone should buzz within a second. Note that stripe listen prints its own webhook signing secret, which is different from the dashboard one, so set STRIPE_WEBHOOK_SECRET to that value while testing locally.

The honest part

The free tier is 100 notifications for the lifetime of the account, not per month. Failures and disputes only, on a small product, is a handful a month and lasts a long time. Every successful payment on a business doing fifty transactions a day is gone in two days.

Start with the failure events. Add successes only while they still feel like news. Pro is $2.99 a month for 1,000, and this reaches iPhone only since it rides Apple's push service.

Where this fits

The same webhook idea for an e-commerce store is in Shopify push notifications with webhooks, and the general shape is in webhook push notifications. Parameters are in the static webhook docs.

Grab a free key at thenotification.app and stop finding out about failed payments from the customer.