Current section
Files
Jump to
Current section
Files
lib/phoenix_kit_billing/web/webhook_controller.ex
defmodule PhoenixKitBilling.Web.WebhookController do
@moduledoc """
Handles webhooks from payment providers (Stripe, PayPal, Razorpay).
This controller receives webhook events from payment providers,
verifies their signatures, and processes them through the WebhookProcessor.
## Webhook URLs
Configure these URLs in your payment provider dashboards:
- Stripe: `https://yourdomain.com/phoenix_kit/webhooks/billing/stripe`
- PayPal: `https://yourdomain.com/phoenix_kit/webhooks/billing/paypal`
- Razorpay: `https://yourdomain.com/phoenix_kit/webhooks/billing/razorpay`
## Security
All webhooks verify signatures to ensure they come from legitimate sources.
Invalid signatures result in 401 Unauthorized responses.
## Idempotency
Events are logged in the `phoenix_kit_webhook_events` table with their
event IDs. Duplicate events are detected and ignored to prevent
double-processing.
"""
use Phoenix.Controller,
formats: [:json]
alias PhoenixKit.Settings
alias PhoenixKitBilling.Providers
alias PhoenixKitBilling.WebhookProcessor
require Logger
@doc """
Handles Stripe webhooks.
Expects the raw body in `conn.assigns.raw_body` (set by a custom Plug).
Signature is read from the `stripe-signature` header.
"""
def stripe(conn, _params) do
handle_webhook(conn, :stripe, "stripe-signature")
end
@doc """
Handles PayPal webhooks.
PayPal verification requires multiple headers for signature verification.
"""
def paypal(conn, _params) do
handle_webhook(conn, :paypal, "paypal-transmission-sig")
end
@doc """
Handles Razorpay webhooks.
Signature is read from the `x-razorpay-signature` header.
"""
def razorpay(conn, _params) do
handle_webhook(conn, :razorpay, "x-razorpay-signature")
end
# ===========================================
# Private Implementation
# ===========================================
defp handle_webhook(conn, provider, signature_header) do
with {:ok, raw_body} <- get_raw_body(conn),
{:ok, signature} <- get_signature(conn, signature_header),
{:ok, secret} <- get_webhook_secret(provider),
:ok <- verify_signature(provider, raw_body, signature, secret),
{:ok, payload} <- decode_payload(raw_body),
{:ok, event} <- Providers.handle_webhook_event(provider, payload),
{:ok, _result} <- WebhookProcessor.process(event) do
Logger.info("Webhook processed successfully: #{provider} - #{event.type}")
conn
|> put_status(200)
|> json(%{status: "ok"})
else
{:error, :invalid_signature} ->
Logger.warning("Invalid webhook signature from #{provider}")
conn
|> put_status(401)
|> json(%{error: "Invalid signature"})
{:error, :duplicate_event} ->
# Duplicate events are OK - return 200 to prevent retries
Logger.debug("Duplicate webhook event from #{provider}")
conn
|> put_status(200)
|> json(%{status: "duplicate"})
{:error, :unknown_event} ->
# Unknown events are OK - return 200 to prevent retries
Logger.debug("Unknown webhook event type from #{provider}")
conn
|> put_status(200)
|> json(%{status: "ignored"})
{:error, :not_configured} ->
Logger.warning("Webhook received for unconfigured provider: #{provider}")
conn
|> put_status(400)
|> json(%{error: "Provider not configured"})
{:error, reason} ->
Logger.error("Webhook processing failed for #{provider}: #{inspect(reason)}")
conn
|> put_status(400)
|> json(%{error: "Processing failed"})
end
end
defp get_raw_body(conn) do
case conn.assigns[:raw_body] do
nil ->
# Try to read from body_params if raw_body not set
# This is a fallback - ideally raw_body should be set by a Plug
{:error, :no_raw_body}
raw_body when is_binary(raw_body) ->
{:ok, raw_body}
end
end
defp get_signature(conn, header_name) do
case get_req_header(conn, header_name) do
[signature | _] -> {:ok, signature}
[] -> {:error, :no_signature}
end
end
defp get_webhook_secret(provider) do
key = "billing_#{provider}_webhook_secret"
case Settings.get_setting(key, "") do
"" -> {:error, :not_configured}
secret -> {:ok, secret}
end
end
defp verify_signature(provider, raw_body, signature, secret) do
Providers.verify_webhook_signature(provider, raw_body, signature, secret)
end
defp decode_payload(raw_body) do
case Jason.decode(raw_body) do
{:ok, payload} -> {:ok, payload}
{:error, _} -> {:error, :invalid_json}
end
end
end