FullstackPhoenix

Add Subscription Checkout With Live SaaS Kit and Stripe

phoenixpaymentsstripesubscriptionbilling

When I am ready to take money in a Phoenix SaaS application, I want a hosted checkout I do not have to design and a clear line between "the user paid" and "the user can use the feature." Stripe Checkout solves the first half. The second half is verified webhook handling, and that is a boundary I want to be honest about from the start.

In this tutorial I install the payments decision feature with stripe_subscription selected as the provider, set my Stripe keys, and open a real hosted checkout session for a signed-in user. The starting point is a project with authentication already installed, because the billing facade takes a user struct.

Keep in mind that this tutorial does not grant subscription access from the Stripe redirect. That step is deliberately out of scope.

Step 1 - Install payments with the Stripe variant

First step is to install the parent payments feature. It is a "decision" feature, which means it asks me which provider variant to install. The feature.toml declares the decision like this.

[decisions.provider]
question = "Which payment provider?"
description = "Choose the billing provider and purchase model to install."
required = true
options = ["stripe_subscription"]

In an interactive run, the installer prompts. In a scripted run I pass the decision on the command line.

mix saaskit.feature.install payments --decision provider=stripe_subscription
mix deps.get

The installer pulls in two pieces:

  • MyApp.Billing — a thin facade with a checkout_url(user, opts)
  • function and a @callback create_checkout_session/2.
  • MyApp.Billing.StripeSubscription — the provider implementation that
  • posts to Stripe.

Note that no migration, no controller, no pricing page, and no webhook receiver is generated. The feature is a checkout-session generator, not a subscription state manager. That is intentional.

Step 2 - Set the Stripe credentials

Next step is to give the provider its keys. I create a real Stripe account, switch to test mode, create a recurring price, and copy the secret key and the price ID.

export STRIPE_SECRET_KEY=sk_test_...
export STRIPE_SUBSCRIPTION_PRICE_ID=price_...

The injected config wires the facade to the provider and the provider to the keys.

# config/config.exs
config :my_app, :billing,
provider: MyApp.Billing.StripeSubscription
config :my_app, MyApp.Billing.StripeSubscription,
secret_key: System.get_env("STRIPE_SECRET_KEY"),
price_id: System.get_env("STRIPE_SUBSCRIPTION_PRICE_ID")

The reason for a facade plus a provider is that the rest of my app only talks to MyApp.Billing. Swapping to a different provider later is a config change, not a code change in every checkout call site.

Step 3 - Look at what the provider does

The StripeSubscription module posts to Stripe's REST API directly with Req. There is no stripity_stripe dependency. The relevant call lands at https://api.stripe.com/v1/checkout/sessions with form-encoded params.

The salient request shape:

mode subscription
customer_email user.email
line_items[0][price] price_id from config
line_items[0][quantity] 1
success_url from the caller
cancel_url from the caller

A successful call returns the Stripe session, and the provider returns the url field. That URL is what I redirect the user to.

Step 4 - Add a Subscribe action in my app

The feature does not install a Subscribe button for me. The reason is that the placement is a product decision — a marketing page, a paywall, an upgrade modal — and the facade is intentionally generic. So I write the controller action that calls MyApp.Billing.checkout_url/2.

# lib/my_app_web/controllers/subscription_controller.ex
defmodule MyAppWeb.SubscriptionController do
use MyAppWeb, :controller
def create(conn, _params) do
user = conn.assigns.current_scope.user
case MyApp.Billing.checkout_url(user,
success_url: url(~p"/billing/success"),
cancel_url: url(~p"/billing/cancelled")
) do
{:ok, url} ->
redirect(conn, external: url)
{:error, _reason} ->
conn
|> put_flash(:error, "Could not start checkout.")
|> redirect(to: ~p"/")
end
end
end

I wire one route for the action and two placeholder routes for the success and cancel landings.

# lib/my_app_web/router.ex
scope "/", MyAppWeb do
pipe_through [:browser, :require_authenticated_user]
post "/billing/checkout", SubscriptionController, :create
get "/billing/success", PageController, :home
get "/billing/cancelled", PageController, :home
end

A Subscribe button anywhere in the app posts to /billing/checkout.

Step 5 - Verify the round trip in test mode

With these changes in place, I start the server, sign in, and submit the Subscribe button.

mix phx.server

The browser is redirected to a real Stripe Checkout page in test mode. I fill in the Stripe test card 4242 4242 4242 4242 with any future expiry and any CVC, complete the checkout, and land on /billing/success.

So at this point I have proven that the facade, the provider, and my Stripe account talk to each other.

A note on the webhook boundary

However, this only creates a checkout session and a redirect. The redirect alone is not proof of payment. A user can craft a request to /billing/success without ever paying, and Stripe can also report payment failures and chargebacks after the redirect happens.

So I will not grant subscription access from the redirect. The next step that the kit deliberately leaves to me is a webhook endpoint that verifies the Stripe signature, stores the subscription id and status, and only then flips the user (or their team) into a paid state.

Final Result

I now have hosted checkout session creation behind a clean billing facade, and I have not pretended that payment is verified. The next step is to add a signed webhook handler before any feature in the app reads a "paid" flag.