Current section
Files
Jump to
Current section
Files
lib/teac/plug/event_sub.ex
defmodule Teac.Plug.EventSub do
@moduledoc """
A Plug for receiving Twitch EventSub events via webhook.
Verifies the `Twitch-Eventsub-Message-Signature` HMAC-SHA256 header,
handles the one-time challenge verification handshake, and dispatches
`notification` and `revocation` messages to your handler module —
the same `Teac.WssClient.Handler` behaviour used for WebSocket delivery.
## Phoenix setup
Add a route before your main pipeline so body parsers don't consume the
raw body before signature verification:
# router.ex
scope "/webhooks" do
forward "/twitch", Teac.Plug.EventSub,
secret: "your-webhook-secret",
handler: MyApp.TwitchHandler,
handler_state: %{}
end
Because Phoenix's `Plug.Parsers` reads the body, you **must** configure a
raw body cache for the webhook path. Add this to your `Endpoint`:
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
body_reader: {Teac.Plug.CacheBodyReader, :read_body, []},
json_decoder: JSON
Then use `Teac.Plug.CacheBodyReader` (included in this library) so the raw
bytes remain available for HMAC verification after parsing.
## Handler
Your handler receives the raw string-keyed subscription and event maps,
exactly as with `Teac.WssClient`. The return value of `handle_event/3` is
ignored in this context — the `handler_state` passed at startup is static.
Use side effects (PubSub, GenServer messages, database writes) for state.
defmodule MyApp.TwitchHandler do
@behaviour Teac.WssClient.Handler
@impl true
def handle_event(%{"type" => "channel.follow"}, event, _state) do
IO.puts("\#{event["user_name"]} followed!")
{:ok, nil}
end
def handle_event(_sub, _event, state), do: {:ok, state}
@impl true
def handle_revocation(%{"type" => type}, _state) do
Logger.warning("Subscription revoked: \#{type}")
end
end
## Webhook secrets
Generate a secret per subscription and store it. Pass it to the Plug via
`:secret`. If you use a single webhook endpoint for all subscriptions, use
one shared secret. If you route multiple secrets, wrap this Plug in your own
Plug that selects the correct secret per request path.
## Deduplication (idempotency)
Twitch retries failed webhook deliveries up to **three times**, each carrying
the **same** `Twitch-Eventsub-Message-Id` header. This Plug does **not**
deduplicate retries — it is stateless by design.
If your handler performs non-idempotent operations (e.g. inserting a row,
sending a message), you are responsible for deduplication. A common approach:
1. Extract the message ID from the conn before calling the handler:
msg_id = List.first(get_req_header(conn, "twitch-eventsub-message-id"))
2. Check whether you have already processed that ID (Redis, ETS, database).
3. If seen, skip processing and respond 204 anyway — Twitch will stop retrying
once it receives a 2xx response.
The message ID is a stable UUID per notification; Twitch considers any 2xx
response a successful delivery.
"""
@behaviour Plug
import Plug.Conn
require Logger
@impl true
def init(opts) do
%{
secret: Keyword.fetch!(opts, :secret),
handler: Keyword.fetch!(opts, :handler),
handler_state: Keyword.get(opts, :handler_state)
}
end
@impl true
def call(conn, opts) do
with {:ok, body, conn} <- read_raw_body(conn),
:ok <- verify_signature(conn, body, opts.secret) do
message_type =
conn
|> get_req_header("twitch-eventsub-message-type")
|> List.first()
handle_message(conn, body, message_type, opts)
else
{:error, :invalid_signature} ->
conn
|> send_resp(403, "Forbidden")
|> halt()
{:error, reason} ->
Logger.error("Teac.Plug.EventSub failed to read body: #{inspect(reason)}")
conn
|> send_resp(500, "Internal Server Error")
|> halt()
end
end
# ── Message handlers ──────────────────────────────────────────────────────
defp handle_message(conn, body, "webhook_callback_verification", _opts) do
%{"challenge" => challenge} = JSON.decode!(body)
conn
|> put_resp_content_type("text/plain")
|> send_resp(200, challenge)
end
defp handle_message(conn, body, "notification", opts) do
data = JSON.decode!(body)
subscription = data["subscription"]
event = data["event"]
case opts.handler.handle_event(subscription, event, opts.handler_state) do
{:ok, _} -> :ok
{:error, reason} -> Logger.error("Handler error: #{inspect(reason)}")
end
send_resp(conn, 204, "")
end
defp handle_message(conn, body, "revocation", opts) do
subscription = JSON.decode!(body)["subscription"]
opts.handler.handle_revocation(subscription, opts.handler_state)
send_resp(conn, 204, "")
end
defp handle_message(conn, _body, type, _opts) do
Logger.debug("Teac.Plug.EventSub: unhandled message type #{inspect(type)}")
send_resp(conn, 204, "")
end
# ── Signature verification ────────────────────────────────────────────────
defp verify_signature(conn, body, secret) do
msg_id = conn |> get_req_header("twitch-eventsub-message-id") |> List.first()
timestamp = conn |> get_req_header("twitch-eventsub-message-timestamp") |> List.first()
signature = conn |> get_req_header("twitch-eventsub-message-signature") |> List.first()
hmac_input = (msg_id || "") <> (timestamp || "") <> body
expected =
"sha256=" <>
Base.encode16(
:crypto.mac(:hmac, :sha256, secret, hmac_input),
case: :lower
)
if Plug.Crypto.secure_compare(expected, signature || "") do
:ok
else
{:error, :invalid_signature}
end
end
# ── Body reading ──────────────────────────────────────────────────────────
# Tries the cached raw body first (set by CacheBodyReader in Phoenix apps),
# then falls back to reading directly from the connection.
defp read_raw_body(conn) do
case conn.assigns[:raw_body] do
nil -> read_body(conn)
cached -> {:ok, cached, conn}
end
end
end