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.

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
endWhere 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.
| Layer | What it does to the body | Fix |
|---|---|---|
Plug.Parsers JSON parser | Reads the body, decodes it, discards the bytes | :body_reader caching module |
Plug.Parsers.MULTIPART | Does not use :body_reader | Not relevant: Sume sends JSON |
| Reverse proxy | May re-encode or re-chunk | Sign what your app receives; compare the secret fingerprint if it fails |
| Controller param map | Decoded copy only | Never 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_readeroption on the onePlug.Parserscall. - Reverse the accumulated chunks before joining them.
- Use
Plug.Crypto.secure_compare/2and 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
- PowerShell Invoke-RestMethod: a Sume video job to replace Sora
Replace a PowerShell script that called the Sora Videos API: submit to Sume /v1/videos with Invoke-RestMethod, poll polling_url, download the mp4.
- pplx-embed-v2-late for storyboard frames: Sume has no embeddings route
Perplexity's pplx-embed-v2-late embeds images and pages. Sume lists no embeddings route; use video_frames_create for stills and index them on your side.
- Quarkus REST endpoint for Sume webhooks: @HeaderParam and byte[]
A Quarkus REST resource that takes the Sume webhook body as byte[], reads the signature headers with @HeaderParam, and answers 204 or 401.
- Sanic webhook route for Sume: request.body, HMAC, empty() 204
Verify a Sume webhook in Sanic with request.body bytes and hmac.compare_digest, then return an empty 204 so the delivery counts as done.
Written by Sume