towerops/lib/towerops_web/controllers/api/v1/stripe_webhook_controller.ex
Graham McIntire 3b0961dfc0 refactor(webhooks): enqueue Oban jobs immediately, ACK fast
All 4 webhook controllers now verify the signature, insert an Oban job,
and return immediately. Previously, Stripe and PagerDuty did DB queries +
writes inline; Gaiia ran upserts; Agent Release broadcast to all connected
WebSockets — all in the request path.

New workers:
- StripeWebhookWorker — delegates to WebhookProcessor.process/1
- GaiiaWebhookWorker — delegates to Webhooks.process_event/3
- PagerdutyWebhookWorker — resolve/acknowledge alerts by dedup key
- AgentReleaseWebhookWorker — broadcast mass update to agents

Also fix: log error when resolve_alert fails in agent_channel (was silently
discarded, causing "Resolving stuck device_down alert" to repeat in logs).
2026-05-10 17:18:23 -05:00

98 lines
2.7 KiB
Elixir

defmodule ToweropsWeb.Api.V1.StripeWebhookController do
use ToweropsWeb, :controller
alias Towerops.Workers.StripeWebhookWorker
require Logger
def create(conn, _params) do
signature = conn |> get_req_header("stripe-signature") |> List.first()
raw_body = conn.private[:raw_body] || ""
with :ok <- verify_signature(raw_body, signature),
{:ok, event} <- decode_payload(raw_body) do
case Oban.insert(StripeWebhookWorker.new(%{event: event})) do
{:ok, _job} ->
json(conn, %{received: true})
{:error, changeset} ->
Logger.error("Failed to enqueue Stripe webhook: #{inspect(changeset.errors)}")
conn |> put_status(500) |> json(%{error: "Failed to enqueue"})
end
else
{:error, :invalid_signature} ->
conn |> put_status(401) |> json(%{error: "Invalid signature"})
{:error, :invalid_json} ->
conn |> put_status(400) |> json(%{error: "Invalid JSON payload"})
{:error, reason} ->
Logger.error("Stripe webhook verification failed: #{inspect(reason)}")
conn |> put_status(400) |> json(%{error: "Verification failed"})
end
end
defp decode_payload(raw_body) do
case Jason.decode(raw_body) do
{:ok, event} -> {:ok, event}
{:error, _} -> {:error, :invalid_json}
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