Teac.Plug.EventSub (teac v1.0.0)

Copy Markdown View Source

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.