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