Stripe with Elixir and Phoenix
Integrating Stripe in a Phoenix application via stripity_stripe
Simon Rydell

Stripe payments for subscriptions with Phoenix

This is the blog post I would have wanted to read when I first started with Stripe and Elixir. The idea is to take the absolute minimum to set up Stripe on a new website that accommodates subscriptions. You should be able to extrapolate this to build a full featured, subscription based product as was done with Tolc.

Stripe basically works like this:

  1. A user interacts with Stripe to pay for a product
  2. Stripe handles the necessary charge, creating corresponding Customer and Subscription objects
  3. Stripe sends a webhook notifying your server about the change
  4. Your server reacts to the webhook

We will take a look at all of these.

Overview

We are The Company developing a Phoenix app called MyApp to sell subscriptions to Our Stuff and we want to use Stripe in order to make that easier.

Here’s an overview of what we will cover:

  • Prerequisites
  • Configuration
  • Webhooks
  • Sending test webhooks from the command line
  • Let your users send you webhooks
    • The Stripe Session (for new customers)
    • The Stripe Billing Portal (for existing customers)

Prerequisites

In order to follow along you need:

  1. A Stripe account (create it here)
  2. A Stripe API (test) key (get it here)
  3. A Stripe webhook (test) secret (get it here)
  4. A Stripe product (create one here)
  5. A Stripe price id for that product (looks like price_xx...)
  6. The Stripe CLI (get it here)
  7. An Elixir Phoenix project

Let’s go!

Configuration

This tutorial uses the excellent stripity_stripe library to make it easy to work with Stripe while using Elixir. Start by installing stripity_stripe:

# mix.exs

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

While writing this, stripity_stripe seem to have a problem with write access to hex so installing it via git is the recommended option for now.

The necessary config is read at runtime to avoid accidentally adding secrets to the repository:

# config/runtime.exs

# Stripe setup is read the same for both prod/dev,
# just with different keys
stripe_api_key =
  System.get_env("STRIPE_API_KEY") ||
    raise """
    environment variable STRIPE_API_KEY is missing.
    You can obtain it from the stripe dashboard: https://dashboard.stripe.com/test/apikeys
    """

stripe_webhook_key =
  System.get_env("STRIPE_WEBHOOK_SIGNING_SECRET") ||
    raise """
    environment variable STRIPE_WEBHOOK_SIGNING_SECRET is missing.
    You can obtain it from the stripe dashboard: https://dashboard.stripe.com/account/webhooks
    """

config :stripity_stripe,
  api_key: stripe_api_key,
  signing_secret: stripe_webhook_key

aswell as a .env file that we can source before starting our Phoenix server:

# .env

export STRIPE_API_KEY="sk_test_..."
export STRIPE_WEBHOOK_SIGNING_SECRET="whsec_..."

Note that these are the test keys. They allow you to use test cards with fake money to test your Stripe integration. When you want to deploy, you should use the production keys. Let’s make use of the keys and create some webhooks.

Webhooks

A webhook is essentially just a request with some data sent to a predefined endpoint. It’s easy to setup endpoints in Phoenix, but there are some pitfalls related to Stripe that we need to deal with. The webhooks coming from Stripe gets authenticated via the raw request_body. By default Phoenix’s Plug.Parsers deletes the raw request body, so we need to set up the authentication before that happens in the chain. Fortunately for us, stripity_stripe makes this really easy. Just use Stripe.WebhookPlugbeforePlug.Parsers in the Endpoint pipeline:

# lib/my_app_web/endpoint.ex

defmodule MyApp.Endpoint do
  ...

  # Since Plug.Parsers removes the raw request_body in body_parsers
  # we need to parse out the Stripe webhooks before this
  plug Stripe.WebhookPlug,
    at: "/webhook/stripe",
    handler: MyApp.StripeHandler,
    secret: {Application, :get_env, [:stripity_stripe, :signing_secret]}

  ...

  plug Plug.Parsers,
    parsers: [:urlencoded, :multipart, :json],
    pass: ["*/*"],
    json_decoder: Phoenix.json_library()

  ...

end

This does three things:

  1. Sends all authenticated webhooks to /webhook/stripe
  2. Sets the handler of the webhooks to our custom module MyApp.StripeHandler
  3. Reads our environment variable signing_secret defined before

Next, let’s implement the handler MyApp.StripeHandler:

# lib/my_app/stripe_handler.ex

defmodule MyApp.StripeHandler do
  @behaviour Stripe.WebhookHandler

  @impl true
  def handle_event(%Stripe.Event{type: "invoice.paid"} = event) do
    IO.inspect("Payment Success")
    IO.inspect(event)
    # Continue to provision the subscription as payments continue to be made.
    # Store the status in your database and check when a user accesses your service.
    # This approach helps you avoid hitting rate limits.
    :ok
  end

  @impl true
  def handle_event(%Stripe.Event{type: "invoice.payment_failed"} = event) do
    IO.inspect("Payment Failed")
    IO.inspect(event)
    # The payment failed or the customer does not have a valid payment method.
    # The subscription becomes past_due. Notify your customer and send them to the
    # customer portal to update their payment information.
    :ok
  end

  @impl true
  def handle_event(%Stripe.Event{type: "checkout.session.completed"} = event) do
    IO.inspect("Checkout Session Completed")
    IO.inspect(event)
    # Payment is successful and the subscription is created.
    # You should provision the subscription and save the customer ID to your database.
    :ok
  end

  # Return HTTP 200 for unhandled events
  @impl true
  def handle_event(_event), do: :ok
end

A full list of the events you can handle can be found here, but the ones defined here should be the absolute minimum. Now we should be able to send some test webhooks!

Sending test webhooks from the command line

To test your Stripe integration you need to use the Stripe command line tool. It will help you do two things for now:

  1. Forward your local Stripe API calls to your localhost based server (let’s not send to production just yet)
  2. Send test webhooks without needing customers

Start by loging in via the Stripe cli:

$ stripe login
...

Now you can start forwarding requests to your localhost endpoint (the previously configured /webhook/stripe)

$ stripe listen --forward-to localhost:4000/webhook/stripe

Make sure this is running at all times while testing your integration locally.

Assuming our server is running you can send it some test data from another terminal:

$ stripe trigger checkout.session.completed

In your terminal running Phoenix, you should see a bunch of events finally ending in:

Checkout Session Completed
%Stripe.Event{
  ...
}

That’s pretty good! Now let’s create a user facing interface to this.

Let your users send you webhooks

Now you can host your own payments forms, but the easiest (and probably safest) is to let Stripe host them for us. There are two different forms that we care about:

  1. The Stripe Session - To subscribe new customers
  2. The Stripe Billing Portal - To let our customers manage their subscriptions

Let’s start with the Stripe Session.

The Stripe Session (for new customers)

In order to create the session we need some things set up first:

  • A way for users to initiate the session from our site
  • A success url when a new user subscribes
  • A cancel url when a user decides to cancel the subscription process

We will wrap it all under /products to make it easier to follow.

Start by putting on your UX hat and create a great big button to help our users get to the session:

# lib/tolc_main_web/templates/products/index.html.heex

<.form let={f} for={@conn} action={Routes.products_path(@conn, :new)}>
    <%= email_input f, :email, [class: "input", required: true] %>
    <%= submit "Get Our Stuff Now!", class: "button" %>
</.form>

When pushing the button, we will send a post request to ProductsController with the users email and call the function (action in Phoenix talk) new.

Let’s define the endpoints we need:

# lib/my_app_web/router.ex

scope "/", MyAppWeb do
  pipe_through :browser

  ...

  # The page holding the button
  get "/products", ProductsController, :index
  # Where we handle clicks to the button
  post "/products/new", ProductsController, :new
  # Where to send our users when they've exited the Stripe session
  get "/products/new/success", ProductsController, :success
  get "/products/new/cancel", ProductsController, :cancel
end

Create a view to render our products pages:

# lib/my_app_web/views/products_view.ex

defmodule MyAppWeb.ProductsView do
  use MyAppWeb, :view
end

And finally, the controller to create the session with the portal url:

# lib/my_app_web/controllers/products_controller.ex

defmodule MyAppWeb.ProductsController do
  use MyAppWeb, :controller
  alias MyAppWeb.Router.Helpers, as: Routes

  def index(conn, _params) do
    # Our button
    render(conn, "index.html")
  end

  def success(conn, _params) do
    conn
    |> put_flash(:info, "Thanks for buying Our Stuff!")
    |> redirect(to: Routes.page_path(conn, :index))
  end

  def cancel(conn, _params) do
    conn
    |> put_flash(:info, "Sorry you didn't like Our Stuff.")
    |> redirect(to: Routes.page_path(conn, :index))
  end

  defp get_customer_from_email(email) do
    # TODO: Handle storing and retrieving customer_id
    # Is on the format
    # customer_id = "cus_xxxxxxxxxxxxxx"
    nil
  end

  def new(conn, %{"email" => email}) do
    # Or if it is a recurring customer, you can provide customer_id
    customer_id = get_customer_from_email(email)
    # Get this from the Stripe dashboard for your product
    price_id = "price_xxxxxxxxxxxxxxxxxxxxxxxx"
    quantity = 1

    session_config = %{
      success_url: Routes.products_url(conn, :success),
      cancel_url: Routes.products_url(conn, :cancel),
      mode: "subscription",
      line_items: [
        %{
          price: price_id,
          quantity: quantity
        }
      ]
    }

    # Previous customer? customer_id else customer_email
    # The stripe API only allows one of {customer_email, customer}
    session_config =
      if customer_id,
        do: Map.put(session_config, :customer, customer_id),
        else: Map.put(session_config, :customer_email, email)

    case Stripe.Session.create(session_config) do
      {:ok, session} ->
        redirect(conn, external: session.url)

      {:error, stripe_error} ->
        # Handle error (object Stripe.Error)
    end
  end

end

That was a mouthful. Let’s break it down a bit;

We created two functions to handle when our user exits the portal:

# lib/my_app_web/controllers/products_controller.ex

...

  def success(conn, _params) do
    conn
    |> put_flash(:info, "Thanks for buying Our Stuff")
    |> redirect(to: Routes.page_path(conn, :index))
  end

  def cancel(conn, _params) do
    conn
    |> put_flash(:info, "Order cancellation successful.")
    |> redirect(to: Routes.page_path(conn, :index))
  end

...

and connected them in our session config:

# lib/my_app_web/controllers/products_controller.ex

...

session_config = %{
  success_url: Routes.products_url(conn, :success),
  cancel_url: Routes.products_url(conn, :cancel),
  mode: "subscription",
  line_items: [
    %{
      price: price_id,
      quantity: quantity
    }
  ]
}

...

We differentiated between new customers and existing ones by providing an email for new ones and a customer_id for existing ones:

# lib/my_app_web/controllers/products_controller.ex

...

session_config =
  if customer_id,
    do: Map.put(session_config, :customer, customer_id),
    else: Map.put(session_config, :customer_email, email)

...

To store and retrieve customer_id is outside the scope of this post, but you should save them for each new customer in your checkout.session.completed webhook.

Finally, we created the Stripe Session object, and redirected to its temporary url:

# lib/my_app_web/controllers/products_controller.ex

...

    case Stripe.Session.create(session_config) do
      {:ok, session} ->
        redirect(conn, external: session.url)

      {:error, stripe_error} ->
        # TODO: Handle error (object Stripe.Error)
        IO.inspect(stripe_error)
    end

...

Try it out! You should see a customer portal hosted by Stripe that offers your product.

Customer Portal for Tolc AB

Pretty rewarding. If you somehow kept on that UX hat, you can even configure the looks of the portal here.

The Stripe Billing Portal (for existing customers)

Before creating a Billing Portal, Stripe requires that you’ve saved their Billing Portal configuration at least once. You can find the configuration here. This also requires a link to your Terms of Service, aswell as your Privacy Policy.

Alright, now that customers can create subscriptions, we must provide a way to manage them. This includes cancelling the subscription, or updating payment methods. This is exactly what a Stripe Billing Portal is for.

Let’s create another button next to the previous one and an endpoint to match it:

# lib/tolc_main_web/templates/products/index.html.heex

<.form let={f} for={@conn} action={Routes.products_path(@conn, :edit)}>
    <%= email_input f, :email, [class: "input", required: true] %>
    <%= submit "Manage Our Stuff Now!", class: "button" %>
</.form>
# lib/my_app_web/router.ex

scope "/", MyAppWeb do
  pipe_through :browser

  ...

  # The billing portal
  post "/products/manage", ProductsController, :edit

  ...
end

Finally, in our ProductsController, let’s create the edit action:

...

def edit(conn, %{"email" => email})
  customer_id = get_customer_from_email(email)

  case
    Stripe.BillingPortal.Session.create(%{
      customer: customer_id,
      return_url: Routes.page_url(conn, :index)
    })
 do
    {:ok, session} ->
      redirect(conn, external: session.url)

    {:error, stripe_error} ->
      # TODO: Handle error (object Stripe.Error)
      IO.inspect(stripe_error)
  end
end

...

Note that this requires there to be a user with an existing customer_id. Try it out, and you can always check the stripe listen --forward-to terminal to see which events are passed when.

That is it! We have created an initial integration with Stripe! Next up is filling out the blanks with your business logic and make some decisions about storage, but you’re almost there. This tutorial was based on the Stripe tutorial over here, but since it doesn’t include Elixir as one of their languages, I thought this would add something. From here on you could:

  • Add more products
  • Add trial periods
  • Add different tiers to your subscriptions
  • etc. etc.

Plug.Shameless

If you want to use WebAssembly with C++ within your project there is no easier way to do it than with Tolc. Take a look at the WebAssembly introduction project to learn more.

Good luck with your product!