Stripe payment alerts with a cash register on your phone

Stripe's dashboard is where you look things up. It isn't where you find out. A webhook pointed at your phone turns every payment, every failed charge and every dispute into a sound you recognise without reading anything.

Setting up a Stripe webhook

Stripe sends a JSON payload over HTTPS to an endpoint you register, every time a subscribed event happens in your account. The endpoint has to be a publicly reachable HTTPS URL, and you can register up to 16 event destinations.

  1. In the Webhooky app, create an endpoint. Call it Stripe, set the message, pick the cash_register sound and copy the URL.
  2. In Stripe, open Workbench → Webhooks and add an endpoint with that URL. (Workbench replaces the older Developers dashboard; creating an endpoint there still works, but Stripe recommends Workbench.)
  3. Select the events you want and save.

Stripe's payloads are large, nested event objects. Webhooky doesn't parse them – since the body has no title or message field, it falls back to the title, text and sound you configured on the endpoint. You get an instant, distinctly-sounding alert, no server in between.

The events worth their own sound

This is where a per-event endpoint beats the dashboard. One endpoint per event, one sound per endpoint:

  • checkout.session.completed – a Checkout session was paid. The cash register.
  • payment_intent.succeeded – a payment went through, for custom flows.
  • payment_intent.payment_failed – a payment failed. A buzzer.
  • charge.refunded – you refunded a charge.
  • charge.dispute.created – a chargeback. This one deserves to be unpleasant.
  • invoice.payment_failed – a subscription renewal failed. Catch it before the customer churns.
  • customer.subscription.deleted – someone cancelled.

The two most valuable here are usually not the happy ones. invoice.payment_failed and charge.dispute.created are the events where reacting within an hour instead of a week actually changes the outcome, and they're exactly the ones that disappear in an email list.

What Stripe does when your endpoint is down

Stripe retries with exponential backoff in live mode for up to three days. You can see when the next attempt is due on the Event deliveries tab of your event destination. In a sandbox it's gentler: three attempts within a few hours. If your destination is disabled or deleted when a retry comes round, that event is not retried again.

Webhooky always answers 200, including when you've used up the free tier's 100 notifications – you get a separate alert about the limit instead. That matters because a long string of failures is exactly how a payment alert quietly stops working.

If you want the amount and the customer

To say "€129.00 · [email protected]" rather than "a payment arrived", take the webhook in your own handler, verify it, and forward a formatted message:

import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

export async function onStripeWebhook(req, res) {
  let event;
  try {
    event = stripe.webhooks.constructEvent(
      req.rawBody,                              // the RAW body, not parsed JSON
      req.get("Stripe-Signature"),
      process.env.STRIPE_WEBHOOK_SECRET,        // starts with whsec_
    );
  } catch {
    return res.sendStatus(400);
  }
  res.sendStatus(200);

  if (event.type !== "checkout.session.completed") return;
  const s = event.data.object;

  await fetch(process.env.WEBHOOKY_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      title: "💳 Stripe payment",
      message: `${(s.amount_total / 100).toFixed(2)} ${s.currency.toUpperCase()} · ${s.customer_details?.email ?? ""}`,
      sound: "cash_register",
    }),
  });
}

The single most common bug here: Stripe needs the raw request body to check the signature. If your framework parses JSON before you get to it, the bytes change and verification fails every time. In Express that means express.raw({type: "application/json"}) on this route specifically, not the global express.json().

The signing secret for each endpoint starts with whsec_ and is shown on the webhook's settings page in Workbench. Rotate it periodically, or immediately if you think it leaked.

Testing before you trust it

Use the Stripe CLI to fire a real event at your endpoint without taking a payment:

stripe trigger payment_intent.succeeded

If you pointed the endpoint straight at Webhooky, your phone should ring within a second or two. If you built a handler, stripe listen --forward-to localhost:3000/webhook lets you develop against live events without deploying.

Why not just use Stripe's email receipts?

Most people run both. Email is the paper trail; a push with its own sound is the thing that reaches you on a Sunday. The real argument is for the unhappy events – nobody checks their inbox fast enough for a dispute, and invoice.payment_failed on a subscription is worth money if you see it the same day. Each endpoint keeps its own history, and there's no account to create: install the app, make an endpoint, paste the URL into Workbench.

Get it on Google Play Download on the App Store

Frequently asked questions

How do I get a notification for every Stripe payment?

Add a webhook endpoint in Workbench → Webhooks subscribed to checkout.session.completed (or payment_intent.succeeded), and point it at a URL that notifies you. A Webhooky endpoint turns that POST into a push with your own sound, no server needed.

Can I get a sound when a Stripe payment comes in?

Not from Stripe itself, but yes through a webhook. Point the payment event at a notification endpoint that supports sounds and assign a cash register to it. Webhooky has 40 sounds, set per endpoint or overridden per message.

How long does Stripe retry a failed webhook?

With exponential backoff for up to three days in live mode. In a sandbox it's three attempts within a few hours. The next scheduled attempt is visible on the Event deliveries tab.

Why does my Stripe signature verification keep failing?

Almost always because the signature is being computed over parsed and re-serialised JSON instead of the raw request body. Stripe requires the raw body exactly as sent; any reformatting breaks the check.