Stripe Payments webhook handler`

I’m working through this tutorial trying to get payments working with Stripe.

I’ve implemented a Stripe webhook handler:

defmodule Postbox.StripeHandler do
  @behaviour Plug

  alias Plug.Conn

  def init(config), do: config

  def call(%{request_path: "/webhook/payments"} = conn, _params) do
    signing_secret = Application.get_env(:stripity_stripe, :webhook_key)
    [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
      err ->
        conn
        |> Conn.send_resp(:bad_request, err)
        |> Conn.halt()
    end
  end
end

I’ve copied my webhook secret (:webhook_key directly from the signing secret) but I’m still, continuously getting this error from Stripe: ** (MatchError) no match of right hand side value: {:error, "No signatures found matching the expected signature for payload"}.

This is a test account and I’m using Ngrok for the Webhook address.


Initial question aside I’m wondering if I’m just using Stripe.Checkout.Session do I need to even bother with Webhooks? Willl Stripe returning a successful URL be sufficient?

I don’t believe it’s directly related to your issue, but note that there’s a specific callout in the docs for Plug.Conn.read_body saying NOT to discard the conn:

Like all functions in this module, the conn returned by read_body must be passed to the next stage of your pipeline and should not be ignored.

1 Like

Never ignore a returned conn, that’s the new state you must use from then on! Start with that.

…Oh, I see @al2o3cr beat me to it.

Thanks for pointing that out, I will be sure to use the new conn!

That said I don’t think this is causing the issue, I should have been more clear I believe the with chain is failing at Stripe.Webhook.construct_event(body, stripe_signature, signing_secret). It seems like a simple authentication error but I have checked the :webhook_key/:signing_secret and it’s the one from stripe.

When is your plug called?

I think it has to run before all other parsers (plug Plug.Parsers in endpoint.ex) that may change the body, to verify the body as received from stripe.

1 Like

The code you posted matches what’s in the stripity_stripe docs, so the next thing to check is all the inputs. At least one of signing_secret, stripe_signature, or body isn’t getting the value that construct_event is expecting.

One thing I’d particularly watch out for is “test mode” vs “production mode”; IIRC the test mode uses a different signing secret.

You need to access the body before it has been modified by a subsequent Plug.

Here is an example in Plug.Parsers docs that exposes this very use case :slight_smile:

https://hexdocs.pm/plug/Plug.Parsers.html#module-custom-body-reader

Edit : note that you can also do that with a custom Parser that runs before other Plug.Parsers. You can pattern match on the request path if you wish to avoid copying the raw body for other kind of requests.

@behaviour Plug.Parsers
  alias Plug.Conn

  def parse(%{request_path: "/specific_path"} = conn, _type, _subtype, _headers, opts) do
    case Conn.read_body(conn, opts) do
      {:ok, body, conn} ->
        {:ok, %{raw_body: body}, conn}
      {:more, _data, conn} ->
        {:error, :too_large, conn}
    end
  end

  def parse(conn, _type, _subtype, _headers, _opts), do: {:next, conn}

Check for exhaustiveness with the docs, because I cannot verify it today.

After that, the %{raw_body: r} map gets merged into conn.params. You can access it by pattern matching on conn.params in your controller.

def handle_webhook(%{params: %{raw_body: raw_body}} = conn, params) do

There are many ways to get to the desired result, but the goal is the same : preserve the raw request body.

2 Likes

I saw that but if you have plug App.StripeHandler before plug Plug.Parsers wouldn’t that resolve the issue without writing a custom parser?


For what it’s worth: the payload from Plug.Conn.read_body(conn) looks like:

"{\n  \"id\": \"evt_3POga12ilwm20D11GsbWweX\",\n  \"object\": \"event\",\n  \"api_version\": \"2020-08-27\",\n  \"created\": 1717681345,\n  \"data\": {\n    \"object\": {\n      \"id\"....

Essentially just stringifyed JSON. Should it be raw JSON?

This blog post might be helpful: How we verify webhooks - Dashbit Blog.

5 Likes

Look at the tutorial for stripity_stripe: https://tolc.io/blog/stripe-with-elixir-and-phoenix

Maybe you don’t need to implement a plug at all because it’s already implemented in the package? Just use the behaviours in your handler @behaviour Stripe.WebhookHandler.

Initial question aside I’m wondering if I’m just using Stripe.Checkout.Session do I need to even bother with Webhooks? Willl Stripe returning a successful URL be sufficient?

Yes, you need to handle the event after the checkout like I did for setup payment methods:

defmodule MyApp.Endpoint do
  plug(Stripe.WebhookPlug,
    at: "/webhook/stripe",
    handler: MyApp.PaymentService.API.Stripe.EventHandler,
    secret: {MyApp.Config, :cfg, [[:stripity_stripe, :signing_secret]]}
  )
...
defmodule MyApp.PaymentService.API.Stripe.EventHandler do
  @behaviour Stripe.WebhookHandler

  @impl Stripe.WebhookHandler
  def handle_event(%Stripe.Event{type: "checkout.session.completed", data: %{object: %Stripe.Checkout.Session{mode: "setup"}}} = event) do
    ...
  end
...

It’s required to handle all checkout session events. Look at the Stripe API and handle your events according to entity statuses and event types.

In my case I needed to handle the “checkout.session.completed” and then update some payment method fields in the DB to make it active in my system.

Session object API

You should look at the events you need Types of events | Stripe API Reference and handle them as you want, e.g. update a Payment schema entity status.

I would not rely on synchronous API responses only and strongly advice to handle events in a webhook.

P.S.: some useful docs and recommendations about using webhooks - Stripe-Ereignisse in Ihrem Webhook-Endpoint empfangen | Stripe-Dokumentation

2 Likes

Thanks! The Tolc tutorail was one of several I worked off of. I’ve gutted everything I’ve done on this and copied from their tutorial directly and this is the result whenever I call the webhook in Stripe:

[info] POST /webhook/payments
[debug] ** (Phoenix.Router.NoRouteError) no route found for POST /webhook/payments (AppWeb.Router)
    (app 0.1.0) deps/phoenix/lib/phoenix/router.ex:541: AppWeb.Router.call/2
    (app 0.1.0) lib/app_web/endpoint.ex:1: AppWeb.Endpoint.plug_builder_call/2
    (app 0.1.0) deps/plug/lib/plug/debugger.ex:136: AppWeb.Endpoint."call (overridable 3)"/2
    (app 0.1.0) lib/app_web/endpoint.ex:1: AppWeb.Endpoint.call/2
    (phoenix 1.7.11) lib/phoenix/endpoint/sync_code_reload_plug.ex:22: Phoenix.Endpoint.SyncCodeReloadPlug.do_call/4
    (bandit 1.2.1) lib/bandit/pipeline.ex:101: Bandit.Pipeline.call_plug/2
    (bandit 1.2.1) lib/bandit/pipeline.ex:22: Bandit.Pipeline.run/6
    (bandit 1.2.1) lib/bandit/http1/handler.ex:33: Bandit.HTTP1.Handler.handle_data/3
    (bandit 1.2.1) lib/bandit/delegating_handler.ex:18: Bandit.DelegatingHandler.handle_data/3
    (bandit 1.2.1) /Documents/app/deps/thousand_island/lib/thousand_island/handler.ex:379: Bandit.DelegatingHandler.handle_info/2
    (stdlib 5.2) gen_server.erl:1095: :gen_server.try_handle_info/3
    (stdlib 5.2) gen_server.erl:1183: :gen_server.handle_msg/6
    (stdlib 5.2) proc_lib.erl:241: :proc_lib.init_p_do_apply/3

[error] GenServer #PID<0.825.0> terminating
** (stop) "request line read error: \"sequence\\\": 1,\\n\""
Last message: {:tcp, #Port<0.44>, "sequence\": 1,\n      \"phone\": null,\n      \"preferred_locales\": [\n\n      ],\n      \"shipping\": null,\n      \"tax_exempt\": \"none\",\n      \"test_clock\": null\n    }\n  },\n  \"livemode\": false,\n  \"pending_webhooks\": 2,\n  \"request\": {\n    \"id\": \"req_gdapqDtGAqcTo4\",\n    \"idempotency_key\": \"99ba4fe5-8b3d-4a32-a543-157769480bb2\"\n  },\n  \"type\": \"customer.created\"\n}"}
State: {%ThousandIsland.Socket{socket: #Port<0.44>, transport_module: ThousandIsland.Transports.TCP, read_timeout: 60000, silent_terminate_on_error: false, span: %ThousandIsland.Telemetry{span_name: :connection, telemetry_span_context: #Reference<0.2255192960.2853437445.179267>, start_time: -576460639900190737, start_metadata: %{remote_port: 52323, remote_address: {127, 0, 0, 1}, telemetry_span_context: #Reference<0.2255192960.2853437445.179267>, parent_telemetry_span_context: #Reference<0.2255192960.2853437444.179063>}}}, %{opts: %{websocket: [], http_1: [], http_2: []}, plug: {Phoenix.Endpoint.SyncCodeReloadPlug, {AppWeb.Endpoint, []}}, handler_module: Bandit.HTTP1.Handler, http_1_enabled: true, http_2_enabled: true, websocket_enabled: true, requests_processed: 3}}

There seems to be two problems here first is that there is no route for /webhook/payments which is not specified in the tutorial.
The second (related?) problem is with parsing the response, I assume if it was reaching the correct endpoint this wouldn’t be a problem (assuming everything else works correctly)?

Sorry for my slow response, I’ve been moving and this is a side project.

I finally got to the bottom of this, I had, occasionally, used an Ngrok tunnel as an endpoint for the webhook but mostly I was using localhost I didn’t realize that when you ran the Stripe CLI locally it gave you a specific signing_secret when the session started:

> Ready! You are using Stripe API Version [2020-08-27]. Your webhook signing secret is whsec_..... (^C to quit)

I was using the signing secret from another endpoint I had setup for Ngrok.

For anyone else who runs into this, the problem for me had to do with this setting in Bandit.

I was able to get around the problem by changing my config to the following.

config :my_app_web, MyAppWeb.Endpoint,
  # ...
  http_1_options: [max_request_line_length: 50_000]

I haven’t thought deeply about the size to use, but this got around the error I was experiencing.

1 Like

Never mind. I thought it fixed it but it was intermittent. Back to the drawing board.

Was your post deleted? It’s returning 404.

I noticed that the post appears to be deleted earlier this week. Here’s a brief, working example of how to verify stripe webhooks in phoenix.

First, parsed JSON will strip some whitespace and alphabetize keys. To calculate the correct HMAC you need to stash the original unparsed request body. A small body_reader module will do it:

# body_reader.ex
defmodule MyApp.BodyReader do
  def read_body(conn, _opts) do
    # You may want to only do this for certain paths
    with {:ok, raw_body, conn} <- Plug.Conn.read_body(conn) do
      {:ok, raw_body, Plug.Conn.put_private(conn, :raw_body, raw_body)}
    end
  end
end

Configure Plug.Parsers to use the new body_reader module:

  plug Plug.Parsers,
    parsers: [:urlencoded, :multipart, :json],
    pass: ["*/*"],
    json_decoder: Phoenix.json_library(),
    body_reader: {MyApp.BodyReader, :read_body, []}

Then define a private function to verify the signature in your controller:

defmodule MyAppWeb.StripeController do
  use MyAppWeb, :controller

  plug :verify_signature

  # actions go here

  defp verify_signature(conn, _opts) do
    case get_req_header(conn, "stripe-signature") do
      [header] ->
        ["t=" <> time, "v1=" <> sigv | _] = String.split(header, ",")
        signing_secret = Application.fetch_env!(:my_app, :stripe_signing_secret)

        hmac =
          :hmac
          |> :crypto.mac(:sha256, signing_secret, [time, ".", conn.private.raw_body])
          |> Base.encode16(case: :lower)

        if Plug.Crypto.secure_compare(hmac, sigv) do
          conn
        else
          conn
          |> send_resp(400, "Invalid Signature")
          |> halt()
        end

      _ ->
        conn
        |> send_resp(400, "Missing Signature")
        |> halt()
    end
  end
end

That will do it! If you test your webhook controller, which you certainly should, you’ll need to inject a signature as well. The signature below is fake and computed using a test signing key (whsec_test), a fake timestamp, and :raw_body:

  @signature "t=123456789,v1=34e0846d2ae20d2fcde8c391d069223f72d0518eaf511de69f785470938e1505"

  setup %{conn: conn} do
    conn =
      conn
      |> put_req_header("stripe-signature", @signature)
      |> put_private(:raw_body, ~s({"fake":"body"}))

    {:ok, conn: conn}
  end
3 Likes