(Updated )

Stripe Checkout with Phoenix LiveView

Stripe Checkout with Phoenix LiveView hero image

What is Stripe Checkout?

Stripe Checkout lets you receive a payment from customers without having to handle any card details or create your own payment capture form.

You redirect the user to Stripe and then wait for Stripe to let you know the payment has succeeded (or failed).

Why use Stripe Checkout?

OK, how do I set up Stripe Checkout in a Phoenix LiveView app?

Note: Check out the GitHub repo for the demo app

Note: If you have an issue that's not addressed here, let me know on my Twitter or on the GitHub repo

At a high level there are 3 parts to implementing Stripe Checkout:

  1. Prepare your app to handle a Stripe webhook response
  2. Triggering a Stripe Checkout
  3. Managing prices from Stripe

Assumptions:

Steps Summary:

  1. Prepare your app to handle a Stripe webhook response (upon successful/failed payments)
    • Set up your environment variables
    • Install and configure :stripity_stripe
    • Capture the raw body of the Stripe webhook message
    • Handle the webhook response
    • Check it works
  2. Triggering a Stripe Checkout
    • Prepare our app and database for checkout data
    • Create a web page
    • Create a checkout event handler and update LiveView assigns
    • Update the StripeHandler module
    • Check it works
  3. Managing prices from Stripe
    • Create a StripeCache module and GenServer
    • Add StripeCache to the application supervisor
    • Update our LiveView to use StripeCache

1. Prepare your app to handle a Stripe webhook response

Set up your environment variables

In a terminal, start the Stripe CLI listener for local testing (stripe listen --forward-to localhost:4000/webhook/stripe) and retrieve the signing secret from the resulting message.

The secret will look something like whsec_aBcDeFgHiJk...

Create a .env file with the following in the root directory of your Phoenix app.

# .env
export STRIPE_API_KEY=sk_test_... # Grab from Stripe Dashboard test mode
export STRIPE_WEBHOOK_SIGNING_SECRET=whsec_... # Grab from Stripe listener

Note: Ensure .env is in your .gitignore so it is NOT committed to your git repo.

Once you've updated your .env file with the secrets, execute source .env in your terminal.

Install and configure stripity_stripe

Install the :stripity_stripe dependency. Check out the stripity_stripe GitHub repo to see if there's newer versions than below. We are NOT using the latest version from Hex because at the time of this writing the maintainers do not have publishing permissions, so the version on Hex is outdated.

# mix.exs
defp deps do
  [
    ...,
    {:stripity_stripe,
      git: "https://github.com/code-corps/stripity_stripe",
      ref: "e593641f4087d669f48c1e7435be181bbe3990e0"}
  ]
end

Update your config files.

# config/dev.exs and config/runtime.exs
config :stripity_stripe,
  api_key: System.get_env("STRIPE_SECRET"),
  stripe_webhook_secret: System.get_env("STRIPE_WEBHOOK_SIGNING_SECRET")

Capture the raw body of the Stripe webhook message

Add the Stripe.WebhookPlug to your endpoint. This MUST come before the default Plug.Parsers.

# lib/stripe_checkout_example_web/endpoint.ex
plug Stripe.WebhookPlug,
  at: "/webhook/stripe",
  handler: StripeCheckoutExampleWeb.StripeHandler,
  secret: {Application, :fetch_env!, [:stripity_stripe, :stripe_webhook_secret]}
 
plug Plug.Parsers,
  ...

Handle the webhook response

Create a StripeHandler module.

# lib/stripe_checkout_example_web/stripe_handler.ex
defmodule StripeCheckoutExampleWeb.StripeHandler do
  @behaviour Stripe.WebhookHandler
 
  @impl true
  def handle_event(%Stripe.Event{type: "checkout.session.completed"} = event) do
    # The logic you want to execute on a successful payment goes here
    # For testing purposes, we will just output the result
    IO.inspect(event, label: "STRIPE EVENT")
 
    {:ok, "success"}
  end
 
  # Return HTTP 200 for unhandled events
  @impl true
  def handle_event(_event), do: :ok
end

We now have everything in place to receive a webhook from Stripe. Let's give it a try!

Check it works

You will need 3 terminals open:

  1. In the first terminal, ensure the Stripe CLI listener is running (stripe listen --forward-to localhost:4000/webhook/stripe)

  2. In the second terminal, ensure your app is running (mix phx.server). Remember you may need to run source .env in the terminal first

  3. In the third terminal, execute stripe trigger checkout.session.completed. We've only set up our webhook to watch for checkout.session.completed, but there are a lot of other types we could listen for

If you flip back to your second terminal and watch your server output, you should see something like:

[info] POST /webhook/stripe
[info] Sent 200 in 12ms
[info] POST /webhook/stripe
[info] Sent 200 in 1ms
[info] POST /webhook/stripe
[info] Sent 200 in 1ms
[info] POST /webhook/stripe
[info] Sent 200 in 307┬╡s
[info] POST /webhook/stripe
STRIPE EVENT: %Stripe.Event{
  account: nil,
  api_version: "2020-08-27",
  created: 1641526150,
  data: %{
    object: %Stripe.Session{
      ... stripe fixture data here ...
    }
  },
  id: "evt_1KF98cFSYzAEE2mTpvM7GX1G",
  livemode: false,
  object: "event",
  pending_webhooks: 4,
  request: %{id: nil, idempotency_key: nil},
  type: "checkout.session.completed"
}

2. Triggering a Stripe Checkout

Prepare our app and database for checkout data

Run mix phx.gen.context amount:integer name payment_intent_id. This will generate our database migration and schema, as well as all the standard CRUD functions. If this were a real app, we'd generally also want to set up the relationship to our :users table here.

Run mix ecto.migrate

Create a web page

We'll create a basic page with 3 buttons that trigger a Stripe Checkout with different IDs.

We will also list all the existing checkouts stored in our database.

# lib/stripe_checkout_example_web/live/page_live.html.heex
<h1>LiveView-based Checkout trigger</h1>
 
<%= for item <- @items do %>
  <button phx-click="checkout" phx-value-id={item}>Trigger checkout <%= item %></button>
<% end %>
 
<h2>Checkouts</h2>
<ul>
<%= for checkout <- @checkouts do %>
  <li><strong>Name:</strong> <%= checkout.name %> · <strong>Amount:</strong> <%= checkout.amount %> · <strong>Payment ID:</strong> <%= checkout.payment_intent_id %></li>
<% end %>
</ul>

Create a checkout event handler and update LiveView assigns

Note: In the GitHub commit for this step I converted the default controller-based page to a LiveView, but you can put this on any LiveView.

# lib/stripe_checkout_example_web/live/page_live.ex
defmodule StripeCheckoutExampleWeb.PageLive do
  use StripeCheckoutExampleWeb, :live_view
 
  alias StripeCheckoutExample.Checkouts
 
  @impl true
  def mount(_params, _session, socket) do
    socket =
      socket
      |> assign(checkouts: Checkouts.list_checkouts())
 
    {:ok, socket}
  end
 
  @impl true
  def handle_params(params, _url, socket) do
    {:noreply, apply_action(socket, socket.assigns.live_action, params)}
  end
 
  defp apply_action(socket, :index, _params) do
    socket
    |> assign(:page_title, "Stripe Checkout example")
    |> assign(:items, ["Alpha", "Bravo", "Charlie"])
  end
 
  @impl true
  def handle_event("checkout", %{"id" => id}, socket) do
    send(self(), {:create_payment_intent, id: id})
 
    {:noreply, socket}
  end
 
  @impl true
  def handle_info({:create_payment_intent, id: id}, socket) do
    price_id = "price_HARD_CODED_PRICE_HERE" # replace with your price ID
    tax_id = "txr_HARD_CODED_TAX_HERE" # replace with your tax rate ID
    url = StripeCheckoutExampleWeb.Endpoint.url()
 
    create_params = %{
      cancel_url: url,
      success_url: url,
      payment_method_types: ["card"],
      mode: "payment",
      metadata: [name: id],
      line_items: [
        %{
          price: price_id,
          quantity: 1,
          tax_rates: [tax_id]
        }
      ]
    }
 
    case Stripe.Session.create(create_params) do
      {:ok, session} ->
        {:noreply, redirect(socket, external: session.url)}
 
      {:error, error} ->
        IO.inspect(error)
        {:noreply, socket}
    end
  end
end

Update the StripeHandler module

# lib/stripe_checkout_example_web/stripe_handler.ex
defmodule StripeCheckoutExampleWeb.StripeHandler do
  @behaviour Stripe.WebhookHandler
 
  alias StripeCheckoutExample.Checkouts
 
  @impl true
  def handle_event(%Stripe.Event{type: "checkout.session.completed"} = event) do
    # The logic you want to execute on a successful payment goes here
    # For testing purposes, we will just output the result
    IO.inspect(event, label: "STRIPE EVENT")
    %{amount_total: amount, payment_intent: payment_intent} = event.data.object
    %{"name" => name} = event.data.object.metadata
 
    Checkouts.create_checkout(%{
      amount: amount,
      payment_intent_id: payment_intent,
      name: name
    })
 
    {:ok, "success"}
  end
 
  # Return HTTP 200 for unhandled events
  @impl true
  def handle_event(_event), do: :ok
end

Check it works

We will be triggering a test Checkout directly with Stripe now. Our webhook is not currently available on the internet, which means we won't be able to receive the webhook from Stripe.

To remedy this, we use ngrok to create a temporary URL that tunnels to our localhost:4000 app.

ngrok tunnelling

Download and run ngrok. Once it's open, execute ngrok http 4000. You will be presented something that looks like

Region            United States (us)
Web Interface     http://127.0.0.1:4040
Forwarding        http://8ba7-202-80-145-87.ngrok.io -> http://localhost:4000
Forwarding        https://8ba7-202-80-145-87.ngrok.io -> http://localhost:4000

Assuming your app is still running locally, if you visit the url (in above example: https://8ba7-202-80-145-87.ngrok.io) you will see your app running.

Create a test Stripe webhook endpoint

Navigate to the Stripe Dashboard > Developers > Webhooks > Add endpoint.

Now that we've created a new webhook, we need to grab our new webhook secret. There will be a Signing secret towards the top of the page, click "Reveal secret" and copy it, paste it in .env replacing the old webhook secret.

Re-run source .env and then restart our Phoenix app.

Try it out

Navigate to either localhost:4000 or the ngrok URL. Click one of the 3 buttons and it will redirect you to your Stripe Checkout.

Click Pay.

You will be redirected back to your site and your checkout should appear in the list!

3. Managing prices from Stripe

Create a StripeCache module and GenServer

Create a new StripeCache module with the following:

# lib/stripe_checkout_example/stripe_cache.ex
defmodule StripeCheckoutExample.StripeCache do
  @moduledoc """
  StripeCache GenServer for managing Stripe prices and tax rates.
  """
  use GenServer
  require Logger
 
  alias Stripe.{Price, TaxRate}
  defstruct [:tax_rates, :prices]
 
  @refresh_interval :timer.minutes(60)
 
  def start_link(_args) do
    GenServer.start_link(__MODULE__, [], name: __MODULE__)
  end
 
  @doc """
  Returns the list of active Stripe prices.
 
  ## Examples
 
      iex> list_prices()
      [%Stripe.Price{}, ...]
 
  """
  def list_prices do
    GenServer.call(__MODULE__, :list_prices)
  end
 
  @doc """
  Returns the list of active Stripe tax rates.
 
  ## Examples
 
      iex> list_taxes()
      [%Stripe.TaxRate{}, ...]
 
  """
  def list_tax_rates do
    GenServer.call(__MODULE__, :list_tax_rates)
  end
 
  @doc """
  Returns a Stripe Price ID.
 
  ## Examples
 
      iex> get_price_id("feature-xyz")
      {:ok, "price_abc123"}
 
      iex> get_price_id("non-existant")
      {:error, "No Stripe price found with lookup_key matching: \"non-existant\""}
 
  """
  def get_price_id(lookup_key) do
    price =
      list_prices()
      |> Enum.find(fn x -> x.lookup_key == lookup_key end)
 
    case price do
      nil ->
        {:error, "No Stripe price found with lookup_key matching: \"#{lookup_key}\""}
 
      _ ->
        {:ok, price.id}
    end
  end
 
  @doc """
  Returns a Stripe Tax Rate ID.
 
  ## Examples
 
      iex> get_tax_rate_id("feature-xyz")
      {:ok, "txr_abc123"}
 
      iex> get_tax_rate_id("non-existant")
      [warning]
      {:error, "No Stripe tax rate found with country matching: \"non-existant\""}
 
  """
  def get_tax_rate_id(country) do
    tax_rate =
      list_tax_rates()
      |> Enum.find(fn x -> x.country == country end)
 
    case tax_rate do
      nil ->
        {:error, "No Stripe tax rate found with country matching: \"#{country}\""}
 
      _ ->
        {:ok, tax_rate.id}
    end
  end
 
  # Server callbacks
 
  @doc false
  def init(_state) do
    with {:ok, %Stripe.List{data: prices}} <- Price.list(%{active: true}),
         {:ok, %Stripe.List{data: tax_rates}} <- TaxRate.list(%{active: true}) do
      schedule_refresh()
      {:ok, %__MODULE__{prices: prices, tax_rates: tax_rates}}
    else
      {:error, %Stripe.Error{message: message, code: code}} ->
        raise "Failed to initialise StripeCache: #{code} (#{message}). Ensure you have set up the Stripe Secret environment variable."
    end
  end
 
  @doc false
  def handle_info(:refresh, state) do
    schedule_refresh()
 
    with {:ok, %Stripe.List{data: prices}} <- Price.list(%{active: true}),
         {:ok, %Stripe.List{data: tax_rates}} <- TaxRate.list(%{active: true}) do
      {:noreply, %__MODULE__{prices: prices, tax_rates: tax_rates}}
    else
      {:error, reason} ->
        Logger.warn("Failed to refresh StripeCache: #{reason}. Using old state")
        {:noreply, state}
    end
  end
 
  @doc false
  def handle_call(:list_prices, _from, %{prices: prices} = state) do
    {:reply, prices, state}
  end
 
  @doc false
  def handle_call(:list_tax_rates, _from, %{tax_rates: tax_rates} = state) do
    {:reply, tax_rates, state}
  end
 
  @doc false
  defp schedule_refresh do
    Process.send_after(self(), :refresh, @refresh_interval)
  end
end

There is a lot happening in this file, to summarise:

Add StripeCache to the application supervisor

# lib/stripe_checkout_example/application.ex
def start(_type, _args) do
  children = [
    ...,
    StripeCheckoutExample.StripeCache
  ]
 
  opts = [strategy: :one_for_one, name: StripeCheckoutExample.Supervisor]
  Supervisor.start_link(children, opts)
end

Update our LiveView to use StripeCache

Replace "feature-item" and "AU" with your relevant price lookup_key and country code.

# lib/stripe_checkout_example_web/live/page_live.ex
alias StripeCheckoutExample.{Checkouts, StripeCache} # updated
 
def handle_info({:create_payment_intent, id: id}, socket) do
  {:ok, price_id} = StripeCache.get_price_id("feature-item") # updated
  {:ok, tax_id} = StripeCache.get_tax_rate_id("AU") # updated
  ...

Common issues

If you have an issue that's not on here, let me know on my Twitter.

Secrets not being read by the application

This is evident by an error like:

* 1st argument: not an iodata term

  :erlang.iolist_to_binary(nil)
  (crypto 5.0.4) crypto.erl:604: :crypto.mac/4
  ...

To resolve: Ensure you have configured :stripity_stripe correctly in your config/*.exs files.

Incorrect webhook secret set

This is evident by your test webhook events getting 400 responses from your app like:

[info] POST /webhook/stripe
[info] Sent 400 in 102┬╡s

If you triggered the event via the Stripe dashboard, you may also see an error like No signatures found matching the expected signature for payload

To resolve: Ensure you are copying the webhook secret from the terminal and/or Stripe Dashboard. It should look like whsec_x1FDrewr4543sdf346y