defmodule ToweropsWeb.Api.V1.StripeWebhookController do use ToweropsWeb, :controller alias Towerops.Billing.WebhookProcessor require Logger @doc """ Stripe webhook endpoint. Receives events like payment_intent.succeeded, customer.subscription.updated. Verifies signature before processing. """ def create(conn, _params) do signature = conn |> get_req_header("stripe-signature") |> List.first() raw_body = conn.private[:raw_body] || "" case verify_signature(raw_body, signature) do :ok -> event = Jason.decode!(raw_body) case WebhookProcessor.process(event) do :ok -> json(conn, %{received: true}) {:error, reason} -> Logger.error("Webhook processing failed: #{inspect(reason)}") json(conn, %{received: true}) end {:error, :invalid_signature} -> Logger.warning("Invalid webhook signature") conn |> put_status(401) |> json(%{error: "Invalid signature"}) {:error, reason} -> Logger.error("Webhook verification failed: #{inspect(reason)}") conn |> put_status(400) |> json(%{error: "Verification failed"}) end end defp verify_signature(payload, signature_header) do webhook_secret = Application.fetch_env!(:towerops, :stripe_webhook_secret) with {:ok, timestamp, signature} <- parse_signature_header(signature_header), :ok <- check_timestamp_tolerance(timestamp, 300) do signed_payload = "#{timestamp}.#{payload}" expected_signature = :hmac |> :crypto.mac(:sha256, webhook_secret, signed_payload) |> Base.encode16(case: :lower) if Plug.Crypto.secure_compare(signature, expected_signature) do :ok else {:error, :invalid_signature} end end end defp parse_signature_header(header) do parts = String.split(header, ",") timestamp = Enum.find_value(parts, fn part -> case String.split(part, "=") do ["t", t] -> t _ -> nil end end) signature = Enum.find_value(parts, fn part -> case String.split(part, "=") do ["v1", sig] -> sig _ -> nil end end) if timestamp && signature do {:ok, timestamp, signature} else {:error, :malformed_header} end end defp check_timestamp_tolerance(timestamp_str, max_age_seconds) do timestamp = String.to_integer(timestamp_str) now = System.system_time(:second) if abs(now - timestamp) <= max_age_seconds do :ok else {:error, :timestamp_too_old} end end end