SaaS · Payments · Full-stack
RetainCue
RetainCue sits in front of a billing portal. It asks why someone is cancelling, offers the discount, free cycle or pause that fits the answer, and records whether Stripe actually applied it.
- Role
- Sole developer — product design, frontend, schema, Stripe integration, test suite
- Year
- 2026
- Live
- retaincue.com
- Next.js 14
- TypeScript
- Supabase
- PostgreSQL
- Stripe Connect
- Stripe webhooks
- Resend
- Tailwind CSS
- Vitest

What it does
A cancel button ends the conversation at the worst possible moment. The subscription closes, the reason goes unrecorded, and nobody gets the chance to put a real alternative in front of someone who might have taken it.
RetainCue replaces that button with a hosted page. The customer says why they are leaving, the page offers what fits that reason, and if they accept, the change is applied to the real subscription through the merchant's own connected Stripe account.
Nothing is applied until the customer accepts, and the subscription can still be cancelled at any point in the flow.
Built for subscription businesses billing through Stripe that want the cancellation reason and a retention attempt without building either themselves.
What I did
I am the sole author of every commit in the repository: the product, the schema, the Stripe integration and the tests.
The part I spent most of the time on is not the flow a customer sees — it is what happens after they accept. Money-moving code has to survive duplicate events, dropped responses and a payment provider that is the real source of truth.
That meant a durable webhook inbox, an atomic claim so two workers cannot process the same event, exponential backoff into a dead-letter state, and an hourly sweep that looks for records that have drifted away from Stripe.
Parts of the implementation were written with AI assistance. The architecture, the failure model and the test strategy are mine.
- Commits, all mine
- 23
- Test files
- 172
- SQL migrations
- 22
- Offer types executed against Stripe
- 4
How it is put together
The customer-facing flow is the small half. The larger half is a webhook pipeline built on the assumption that events arrive twice, out of order, or not at all.
The flow a customer sees
Your cancel link points here
One link swap, from the billing settings page to a hosted RetainCue page. No SDK, no frontend work, no change to how the merchant bills.
Reason
The customer picks why they are leaving, and can say it in their own words.
Offer
An eligibility engine picks the offer configured for that reason — a percentage discount, a free billing cycle, a payment pause, or nothing.
Accept
The executor applies it to the live subscription through Stripe Connect on the merchant's connected account.
What happens after the money moves
Signed webhook, one endpoint per mode
Separate test and live endpoints with separate signing secrets. The signature is verified against the raw body before anything else happens.
Durable inbox
The event is sanitised and inserted immediately. A unique constraint on the Stripe event id makes a repeated delivery a no-op rather than a second charge.
Claim
A scheduled run every five minutes claims rows atomically with FOR UPDATE SKIP LOCKED, so two workers never take the same event.
Handle, or back off
Typed handlers per event family. A row ends as processed, ignored, failed with a next retry time on an exponential backoff, or dead-lettered once it is exhausted.
Reconcile, hourly
A separate sweep looks for what the event stream can still miss: redemptions stuck mid-flight, unhealthy connections, pauses past their date, and redemptions whose recorded state has drifted from Stripe's.
Drawn from lib/retention-offers/webhooks and the project's phase-9 design note.
What is in it
The offer follows the reason
"Too expensive" and "not using it" are not the same problem and should not get the same discount. Rules are set per project, per reason, and the flow shows the customer exactly what their next invoice becomes.
Four things it can actually do
A percentage discount, a free billing cycle, a payment pause, or a clean cancellation — each executed against the connected Stripe account, not simulated.
Displayed, accepted, applied — stored separately
An offer shown is not an offer accepted, and an offer accepted is not an offer Stripe applied. Keeping those as distinct states is what lets the dashboard report what happened rather than what was attempted.
It still works with no Stripe connection
If the merchant has not connected Stripe, or the subscription is not one it can act on, the page drops to collecting the reason. A degraded flow beats an error page at the moment someone is already leaving.
Test and live are kept apart
Separate webhook endpoints, separate signing secrets, separate connection records. There is no mode flag that could be set wrong in production.
A suite sized for what it guards
172 test files, with dedicated runs for webhooks, security, concurrency and accessibility, plus a suite that exercises a Stripe sandbox.
npm run test:webhooks · test:security · test:concurrency · test:stripe

What was hard
A webhook must be neither lost nor applied twice
Stripe retries. A network can drop a response after the work has already been done. The design answers both at once: store the event before processing it, make the Stripe event id unique so a replay collides harmlessly, and claim rows with FOR UPDATE SKIP LOCKED so concurrent workers cannot pick up the same row.
The payment provider is the source of truth, not the database
Events can be missed entirely, and then the local record is quietly wrong. The hourly reconciliation exists for the failures the event stream cannot tell you about — a redemption stuck in flight, a pause that should have ended, a record that no longer matches the subscription it describes.
Acting on somebody else's Stripe account
Stripe Connect means operating under permissions the merchant granted and can revoke. Capabilities are checked before an offer type is even offered, so the flow does not promise a customer something the connection cannot deliver.
Where it stands
- The product is live at retaincue.com, including the hosted flow with sample data.
- 172 test files, 22 SQL migrations, and separate test and live Stripe webhook paths.
- Integration is a single link swap — no SDK and no change to how a merchant bills.
Next case study
ChartLens AI
An iOS app that reads a screenshot of a trading chart and talks it through.