Stripe Webhooks in Phoenix with Elixir Pattern Matching

In life, there are some perfect pairings: wine and cheese, peanut butter and jelly, chicken and waffles, scotch and depression. Some things just work really well together. Luckily for us, one of those things (in my opinion) is Stripe Webhooks and Pattern Matching in Elixir.

Despite this, consuming Stripe Webhook Events with Phoenix is not without its pitfalls. That's why I wanted to write this tutorial; to help people navigate those pitfalls in a way that I think utilizes the power of Elixir and Phoenix in a friendly way.

So, without further blabbering, let's get into it.

Quick Note:

Before we start, there's two weird things I do in this tutorial. First, I don't use import or alias much to keep it clear where the methods I'm using are coming from. Also, I build out large files additively step-by-step, explaining the reasoning behind the lines we add, but I don't trim or abbreviate the repeated lines from previous steps, this way the entire file can be copy and pasted into a project by those who want/need to.

What you'll need

  1. Erlang, Elixir and Phoenix Installed
  2. A Phoenix App
  3. A Stripe Account with Webhooks set up
  4. The Stripity Stripe package for Elixir Installed

Prologue: Routes and Configuration

First thing we need to do to get this show on the road is to set up a webhook route where we will receive Stripe Webhook Events. I put it in its own scope outside of the browser and other pipelines to keep things simple.

lib/my_app_web/router.ex

scope "/stripe/webhooks" do
  post "/", WebhooksController, :webhooks
end

Later on, we'll create the WebhooksController that this route references, so don't worry about it for now.

Now, we need to add our Webhook Signing Secret to our config. You get this value from your Webhook Dashboard page. I like to keep this alongside the other Stripity Stripe config. Both this secret and the API key should not be committed to GitHub, and you should use your preferred best practices for configuring, handling and storing application secrets.

config/config.exs

config :stripity_stripe,
  api_key: "SECRET KEY",
  signing_secret: "WEBHOOK SIGNING SECRET"

Chapter One: Plug It Up

The first pitfall one encounters with Phoenix has to do with the body_parser used by Plug.Parsers in your endpoint.ex file. The default body parser deletes the raw request body. Problem is, we need that raw request body alongside the signing secret to verify the authenticity of the Stripe Webhook Event; otherwise anyone could call our Webhook endpoint and cause all sorts of payment mayhem.

I like to work around this by adding a custom Plug before the Plug.Parsers run.

lib/my_app_web/endpoint.ex

plug MyAppWeb.StripeWebhooksPlug

plug Plug.Parsers,
  parsers: [:urlencoded, :multipart, :json],
  ...

Then, we create a StripeWebhooksPlug which will do a few useful things for us:

  1. Read the body of the request
  2. Read the Stripe-Signature header of the request
  3. Verify the authenticity of the Stripe Webhook Event
  4. Attach the verified %StripeEvent{} object to the conn

BUT only on requests to our webhooks route.

Let's handle the but condition first: we only want this to do things to requests to our webhooks route (defined as /stripe/webhooks earlier). Let's use pattern matching to accomplish this:

lib/my_app_web/plugs/stripe_webhooks_plug.ex

defmodule MyAppWeb.StripeWebhooksPlug do
  @behaviour Plug

  def init(config), do: config

  def call(%{request_path: "/stripe/webhooks"} = conn, _) do
    conn
  end

  def call(conn, _), do: conn
end

With pattern matching, the top method will handle all requests whose path matches our webhook route. The second one will handle everything else by just returning the conn unchanged.

Now, let's make the method read the body of the request. We're also going to get our signing secret from our config file.

stripe_webhooks_plug.ex (CONTINUED)

defmodule MyAppWeb.StripeWebhooksPlug do
  @behaviour Plug

  def init(config), do: config

  def call(%{request_path: "/stripe/webhooks"} = conn, _) do
    signing_secret = Application.get_env(:stripity_stripe, :signing_secret)
    {:ok, body, _} = Plug.Conn.read_body(conn)
    conn
  end

  def call(conn, _), do: conn
end

Now we have the body of the request stored as body and the signing secret stored as signing_secret for later use.

Next, let's do the same for the Stripe-Signature header, which we also need so we can verify the authenticity of the request.

stripe_webhooks_plug.ex (CONTINUED)

defmodule MyAppWeb.StripeWebhooksPlug do
  @behaviour Plug

  def init(config), do: config

  def call(%{request_path: "/stripe/webhooks"} = conn, _) do
    signing_secret = Application.get_env(:stripity_stripe, :signing_secret)
    {:ok, body, _} = Plug.Conn.read_body(conn)
    [stripe_signature] = Plug.Conn.get_req_header(conn, "stripe-signature")
    conn
  end

  def call(conn, _), do: conn
end

Now we have the signature stored as stripe_signature for later use.

Now we have everything we need to verify the webhook request, so let's do that using Stripe.Webhook.construct_event/3 from Stripity Stripe.

stripe_webhooks_plug.ex (CONTINUED)

defmodule MyAppWeb.StripeWebhooksPlug do
  @behaviour Plug

  def init(config), do: config

  def call(%{request_path: "/stripe/webhooks"} = conn, _) do
    signing_secret = Application.get_env(:stripity_stripe, :signing_secret)
    {:ok, body, _} = Plug.Conn.read_body(conn)
    [stripe_signature] = Plug.Conn.get_req_header(conn, "stripe-signature")

    {:ok, stripe_event} = Stripe.Webhook.construct_event(body, stripe_signature, signing_secret)

    conn
  end

  def call(conn, _), do: conn
end

Stripe.Webhook.construct_event/3 verifies the request is legitimate and then returns a constructed %Stripe.Event{}, which we capture as stripe_event.

The last thing to do is to assign that to the conn with Plug.Conn.assign/3.

stripe_webhooks_plug.ex (CONTINUED)

defmodule MyAppWeb.StripeWebhooksPlug do
  @behaviour Plug

  def init(config), do: config

  def call(%{request_path: "/stripe/webhooks"} = conn, _) do
    signing_secret = Application.get_env(:stripity_stripe, :signing_secret)
    {:ok, body, _} = Plug.Conn.read_body(conn)
    [stripe_signature] = Plug.Conn.get_req_header(conn, "stripe-signature")

    {:ok, stripe_event} = Stripe.Webhook.construct_event(body, stripe_signature, signing_secret)
    
    Plug.Conn.assign(conn, :stripe_event, stripe_event)
  end

  def call(conn, _), do: conn
end

You'll probably want to add some logic to handle error cases in your preferred way, but here's an example of what you could do:

stripe_webhooks_plug.ex (CONTINUED)

defmodule MyAppWeb.StripeWebhooksPlug do
  @behaviour Plug

  import Plug.Conn

  def init(config), do: config

  def call(%{request_path: "/stripe/webhooks"} = conn, _) do
    signing_secret = Application.get_env(:stripity_stripe, :signing_secret)
    [stripe_signature] = Plug.Conn.get_req_header(conn, "stripe-signature")

    with {:ok, body, _} = Plug.Conn.read_body(conn),
         {:ok, stripe_event} = Stripe.Webhook.construct_event(body, stripe_signature, signing_secret)
    do
      Plug.Conn.assign(conn, :stripe_event, stripe_event)
    else
      {:error, error} ->
        conn
        |> send_resp(:bad_request, error.message)
        |> halt()
    end
  end

  def call(conn, _), do: conn
end

Chapter Two: Controlling It

Now, let's go about creating our WebhooksController that we referenced at the beginning of the tutorial in the router.ex file. At its core, it should look like this:

lib/my_app_web/controllers/webhooks_controller.ex

defmodule WebhooksController do
  use MyAppWeb, :controller

  def webhooks(conn, _params) do
    # handle webhook
  end
end

Now, let's use some pattern matching to grab the stripe_event we assigned to the conn in the StripeWebhooksPlug.

webhooks_controller.ex (CONTINUED)

defmodule WebhooksController do
  use MyAppWeb, :controller

  def webhooks(%Plug.Conn{assigns: %{stripe_event: stripe_event}} = conn, _params) do
    # handle webhook
  end
end

Then we'll add the handle_webhook methods using pattern matching and call them from the webhooks method.

webhooks_controller.ex (CONTINUED)

defmodule WebhooksController do
  use MyAppWeb, :controller

  def webhooks(%Plug.Conn{assigns: %{stripe_event: stripe_event}} = conn, _params) do
    handle_webhook(stripe_event)
  end

  defp handle_webhook(%{type: "invoice.created"} = stripe_event) do
    # handle invoice created webhook
  end

  defp handle_webhook(%{type: "invoice.payment_succeeded"} = stripe_event) do
    # handle invoice payment_succeeded webhook
  end

  defp handle_webhook(%{type: "invoice.payment_failed"} = stripe_event) do
    # handle invoice payment_failed webhook
  end 
end

What's happening here? Well, we've got our webhooks/1 method that is called by the router. It then passes the stripe_event it receives to handle_webhook/1. Each definition of handle_webhook/1 only responds to different versions of the stripe_event argument, depending on the value of type. A %Stripe.Event{} object has a different type depending on the Stripe Webhook Event it received, so by pattern matching against that field, we can define different versions of the handle_webhook/1 method for each event.

Finally, let's make sure we're responding correctly to Stripe to let them know we received and successfully handled their event.

webhooks_controller.ex (CONTINUED)

defmodule WebhooksController do
  use MyAppWeb, :controller

  def webhooks(%Plug.Conn{assigns: %{stripe_event: stripe_event}} = conn, _params) do
    case handle_webhook(stripe_event) do
      {:ok, _}        -> handle_success(conn)
      {:error, error} -> handle_error(conn, error)
      _               -> handle_error(conn, "error")
    end
  end

  defp handle_success(conn) do
    conn
    |> put_resp_content_type("text/plain")
    |> send_resp(200, "ok")
  end

  defp handle_error(conn, error) do
    conn
    |> put_resp_content_type("text/plain")
    |> send_resp(422, error)
  end

  defp handle_webhook(%{type: "invoice.created"} = stripe_event) do
    # handle invoice created webhook
    {:ok, "success"}
  end

  defp handle_webhook(%{type: "invoice.payment_succeeded"} = stripe_event) do
    # handle invoice payment_succeeded webhook
    {:ok, "success"}
  end

  defp handle_webhook(%{type: "invoice.payment_failed"} = stripe_event) do
    # handle invoice payment_failed webhook
    {:ok, "success"}
  end  
end

And that's it.

If you liked this you can also follow me on Twitter, check out my GitHub, and take a look at my other blog posts.

Conner Fritz

Hey, I'm Conner, and I'm a Software Developer turned Engineering Leader based in Toronto. I fell in love with coding as a child, teaching myself how to use the power of code to solve problems and bring my ideas to reality. It's been an important part of my life ever since.