Phoenix: cache the raw body with Plug.Parsers body_reader for Sume

Phoenix parses JSON before your controller runs. Cache the raw bytes with a Plug.Parsers body_reader, then verify the Sume webhook HMAC signature.

5 min readSume
All posts

In Phoenix, Plug.Parsers decodes the JSON body and discards the raw bytes before your controller runs, so you cannot verify a Sume webhook there. Fix it with a custom :body_reader on Plug.Parsers that saves the bytes in conn.assigns, then recompute HMAC SHA-256 over <timestamp>.<raw_body> in the controller and compare each sume-v1= entry with Plug.Crypto.secure_compare/2.

This is the Elixir counterpart of the raw-body trap in other frameworks, and it has a one-time setup cost in endpoint.ex instead of a per-route flag.

What does body_reader do?

The Plug.Parsers documentation describes :body_reader as an optional replacement or wrapper for Plug.Conn.read_body/2 that gives access to the raw body before it is parsed and discarded, given as a {Module, :function, [args]} tuple. Its own example caches the body this way. The wrapper below accumulates chunks in a list, because read_body can return the payload in more than one call.

Add the module, then point the parser in endpoint.ex at it. The caching module is the part you are likely to reuse for other signed webhooks.

defmodule MyAppWeb.CacheBodyReader do
  def read_body(conn, opts) do
    with {:ok, body, conn} <- Plug.Conn.read_body(conn, opts) do
      conn = update_in(conn.assigns[:raw_body], &[body | &1 || []])
      {:ok, body, conn}
    end
  end
end

# lib/my_app_web/endpoint.ex
plug Plug.Parsers,
  parsers: [:urlencoded, :multipart, :json],
  pass: ["*/*"],
  body_reader: {MyAppWeb.CacheBodyReader, :read_body, []},
  json_decoder: Phoenix.json_library()

How does the controller verify it?

The controller joins the cached chunks, refuses an empty or missing secret, checks a five-minute timestamp window, and compares every sume-v1= entry so a rotation header cannot slip through on a short-circuit.

defmodule MyAppWeb.SumeHookController do
  use MyAppWeb, :controller

  def create(conn, _params) do
    raw = (conn.assigns[:raw_body] || []) |> Enum.reverse() |> IO.iodata_to_binary()
    secret = System.get_env("SUME_COM_WEBHOOK_SIGNING_SECRET") || ""
    [ts | _] = get_req_header(conn, "x-sume-webhook-timestamp") ++ [""]
    [sigs | _] = get_req_header(conn, "x-sume-webhook-signature") ++ [""]

    if valid?(secret, ts, sigs, raw), do: send_resp(conn, 204, ""), else: send_resp(conn, 401, "")
  end

  defp valid?(secret, ts, sigs, raw) when secret != "" and ts != "" and sigs != "" do
    case Integer.parse(ts) do
      {t, ""} when abs(t - System.os_time(:second)) <= 300 ->
        mac = :crypto.mac(:hmac, :sha256, secret, "#{t}." <> raw)
        want = "sume-v1=" <> Base.encode16(mac, case: :lower)
        sigs |> String.split(",") |> Enum.map(&Plug.Crypto.secure_compare(String.trim(&1), want)) |> Enum.any?()
      _ -> false
    end
  end
  defp valid?(_, _, _, _), do: false
end

Where does the route go?

Put the route in a pipeline that accepts JSON and skips any session or CSRF plugs, since Sume is a server, not a browser. Route post "/hooks/sume", SumeHookController, :create inside scope "/", MyAppWeb with pipe_through :api.

Where the raw body is lost, read 2026-10-11 from Plug.Parsers on HexDocs and Webhooks on docs.sume.com.
LayerWhat it does to the bodyFix
Plug.Parsers JSON parserReads the body, decodes it, discards the bytes:body_reader caching module
Plug.Parsers.MULTIPARTDoes not use :body_readerNot relevant: Sume sends JSON
Reverse proxyMay re-encode or re-chunkSign what your app receives; compare the secret fingerprint if it fails
Controller param mapDecoded copy onlyNever sign the decoded map

What breaks most often in Phoenix?

The usual failure is ordering. If your own plug reads the body before Plug.Parsers, the parser finds nothing, and if the parser runs first without a body_reader, the bytes are gone. Keep the single Plug.Parsers call in endpoint.ex and let the reader module be the only place that touches the raw body.

A second trap is an assigns key collision. If another plug already writes :raw_body, the cache fills with its chunks. Pick a key that is unambiguous, such as :sume_raw_body, if you cache for more than one provider.

  • Keep the body_reader option on the one Plug.Parsers call.
  • Reverse the accumulated chunks before joining them.
  • Use Plug.Crypto.secure_compare/2 and visit every entry.
  • Return send_resp(conn, 204, "") after storing the event.

What should I do after verification?

Insert the event keyed on job_id or request_id, answer 204, and hand the heavy work to a background worker. Sume's attempt timeout is 10 seconds, retries stop after 10 attempts, and a polling job against status_url closes any gap that remains.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume