Skip to content
Aiden Azraya, home

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
  • Next.js 14
  • TypeScript
  • Supabase
  • PostgreSQL
  • Stripe Connect
  • Stripe webhooks
  • Resend
  • Tailwind CSS
  • Vitest
retaincue.com
The RetainCue home page, showing the hosted cancellation flow with a reason selected and a matching offer.

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

  1. 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.

  2. Reason

    The customer picks why they are leaving, and can say it in their own words.

  3. Offer

    An eligibility engine picks the offer configured for that reason — a percentage discount, a free billing cycle, a payment pause, or nothing.

  4. Accept

    The executor applies it to the live subscription through Stripe Connect on the merchant's connected account.

What happens after the money moves

  1. 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.

  2. 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.

  3. Claim

    A scheduled run every five minutes claims rows atomically with FOR UPDATE SKIP LOCKED, so two workers never take the same event.

  4. 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.

  5. 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

retaincue.com
The hosted flow: a reason of 'too expensive', an offer of 30% off for three cycles, and the resulting invoice amounts.
The hosted flow with sample data. The reason picks the offer, and the panel shows what the next invoice becomes if it is accepted.

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.