Put your Stripe events on your phone
Everything you actually want to know about your business, Stripe already knows the second it happens — the first sale of the day, the renewal that just bounced, the customer who quietly canceled. It tells you by webhook. Most of us find out by opening the dashboard for the fifth time.
plan: Pro (monthly) · card: visa ···4242
Eleven seconds after the customer taps Pay.
This post wires Stripe's webhooks into godozo — the tiny relay whose whole job is telling you and asking you. Today is the telling half: a ~40-line receiver that turns the four events worth a buzz into notify calls. It's runnable as-is, and testable end-to-end from your laptop with the Stripe CLI before anything touches production.
The receiver
Stripe delivers events as signed HTTP POSTs. The receiver has exactly three jobs: verify the signature, ack fast, and forward the interesting ones to your phone.
// stripe-hook.mjs — npm i express stripe, plus godozo (npm link the repo) import express from 'express'; import Stripe from 'stripe'; import { createGodozo } from 'godozo'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); const gd = createGodozo(); const app = express(); const usd = (c) => `$${(c / 100).toFixed(2)}`; // Stripe amounts are integer cents app.post('/stripe', express.raw({ type: 'application/json' }), async (req, res) => { let event; try { event = stripe.webhooks.constructEvent( req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET); } catch (err) { return res.status(400).send(`bad signature: ${err.message}`); } res.sendStatus(200); // ack first — Stripe retries anything that isn't a prompt 2xx const o = event.data.object; switch (event.type) { case 'checkout.session.completed': await gd.notify({ title: '💰 new sale', message: `${usd(o.amount_total)} · ${o.customer_details?.email ?? 'guest'}` }); break; case 'invoice.payment_failed': await gd.notify({ title: '💸 renewal failed', message: `${usd(o.amount_due)} · ${o.customer_email} · attempt ${o.attempt_count}` }); break; case 'customer.subscription.deleted': await gd.notify({ title: '👋 churn', message: `subscription canceled · ${o.items?.data?.[0]?.price?.nickname ?? o.id}` }); break; case 'charge.dispute.created': await gd.notify({ title: '⚔️ dispute opened', message: `${usd(o.amount)} · reason: ${o.reason}` }); break; } }); app.listen(4242);
That's the whole program. Sales, failed renewals, churn, and disputes land on your phone the moment they happen; everything else Stripe sends is verified, acked, and ignored.
The classic trap:express.json()breaks signature verification.constructEventneeds the raw request bytes — the exact body Stripe signed. A global JSON body-parser re-serializes it first, the signature never matches, and every event 400s. That's why the route usesexpress.rawon this one path. If your app already hasapp.use(express.json()), mount the webhook route before it.
Why these four events
Your phone is sacred — the fastest way to kill this setup is to forward everything. A useful filter: notify on the events you'd want to know about at dinner.
checkout.session.completed— a sale. The good buzz. (If you do hundreds a day, congratulations — skip this one and wait for the daily digest instead.)invoice.payment_failed— the start of dunning. Caught on attempt one, a failed renewal is often just an expired card and a friendly email away from saved revenue.customer.subscription.deleted— churn. You want to feel each one, at least while they're countable.charge.dispute.created— money is being pulled back and a clock is ticking. This one deserves more than a ping — it gets its own post.
Notably absent: payment_intent.succeeded (fires on every charge, including each renewal — that's digest material, not phone material) and anything ending in .updated (noise).
Test it without deploying anything
Here's the part that makes this a ten-minute job instead of an afternoon: the Stripe CLI will stream your account's events to your laptop over an outbound connection — no public URL, no tunnel config, nothing listening on the internet:
stripe listen --forward-to localhost:4242/stripe # prints: Your webhook signing secret is whsec_… → that's STRIPE_WEBHOOK_SECRET # in another terminal, fire fake events at it: stripe trigger checkout.session.completed stripe trigger invoice.payment_failed
Run the receiver, run stripe listen, trigger an event, and your phone buzzes with a fake $0.00 sale. The whole loop — Stripe → your laptop → Telegram → your pocket — verified before you've touched a production key. It's the same outbound-only shape godozo itself uses: in dev, nothing in this stack accepts an inbound connection.
When you're ready for real events, deploy the 40 lines anywhere that can hold a port open, add the endpoint in the Stripe dashboard (Developers → Webhooks), subscribe it to just the four event types above, and swap in the endpoint's own whsec_… secret. godozo keeps running beside it, outbound-only as ever.
Wire it in (paste into Claude Code)
Prompt — set up Stripe pings. Read./llms.txtin the godozo repo. Createstripe-hook.mjsas described above with handlers forcheckout.session.completed,invoice.payment_failed,customer.subscription.deleted, andcharge.dispute.created. Useexpress.rawon the webhook route and verify signatures withSTRIPE_WEBHOOK_SECRET. Then start it, runstripe listen --forward-to localhost:4242/stripe, andstripe trigger checkout.session.completed. Do not tell me you're done until I confirm the "new sale" notification arrived on my phone. If the signature check fails, show me the exact error and stop.
Same acceptance criterion as always: the agent doesn't get to claim success — a human has to confirm the buzz.
What this replaces
Stripe will email you some of this, eventually, if you find the right toggles. The dashboard has all of it, if you keep a tab open. But a webhook-to-phone line is a different thing: it's ambient. You stop checking, because you know it'll find you — good news and bad news in the same thread, seconds after they happen, with godozo's local audit log keeping the receipts.
And notifications are only godozo's warm-up verb. Some Stripe events don't just want you to know — they want you to decide, with real money on the table and a deadline attached. Fraud warnings. Disputes. Refunds. That's the blocking-approval half, and it's next.
← More from the godozo blog