webhook_inbox 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,61 @@
1
+ <div class="wi-stats">
2
+ <% total = @status_counts.values.sum %>
3
+ <div class="wi-stat">
4
+ <div class="wi-stat-label">Total</div>
5
+ <div class="wi-stat-value"><%= total %></div>
6
+ </div>
7
+ <% %w[pending processing processed failed].each do |s| %>
8
+ <div class="wi-stat">
9
+ <div class="wi-stat-label"><%= s.capitalize %></div>
10
+ <div class="wi-stat-value"><%= @status_counts[s] || 0 %></div>
11
+ </div>
12
+ <% end %>
13
+ </div>
14
+
15
+ <%= form_with url: webhook_inbox.dashboard_path, method: :get, local: true, class: "wi-filters" do |f| %>
16
+ <label>Status</label>
17
+ <%= f.select :status,
18
+ [["All", ""], *%w[pending processing processed failed].map { |s| [s.capitalize, s] }],
19
+ { selected: params[:status] },
20
+ { onChange: "this.form.submit()" } %>
21
+ <label>Provider</label>
22
+ <%= f.select :provider,
23
+ [["All", ""], *@providers.map { |p| [p.capitalize, p] }],
24
+ { selected: params[:provider] },
25
+ { onChange: "this.form.submit()" } %>
26
+ <% end %>
27
+
28
+ <table>
29
+ <thead>
30
+ <tr>
31
+ <th>Status</th>
32
+ <th>Provider</th>
33
+ <th>Event Type</th>
34
+ <th>Event ID</th>
35
+ <th>Attempts</th>
36
+ <th>Received</th>
37
+ <th></th>
38
+ </tr>
39
+ </thead>
40
+ <tbody>
41
+ <% if @events.empty? %>
42
+ <tr>
43
+ <td colspan="7" style="text-align:center; color:#94a3b8; padding:32px;">No events yet.</td>
44
+ </tr>
45
+ <% else %>
46
+ <% @events.each do |event| %>
47
+ <tr>
48
+ <td><%= status_badge(event.status) %></td>
49
+ <td><span class="wi-mono"><%= event.provider %></span></td>
50
+ <td><span class="wi-mono"><%= event.event_type %></span></td>
51
+ <td><span class="wi-mono" title="<%= event.event_id %>"><%= event.event_id&.truncate(24) %></span></td>
52
+ <td><%= event.attempts %></td>
53
+ <td><span title="<%= event.created_at %>"><%= event.created_at.strftime("%b %d %H:%M:%S") %></span></td>
54
+ <td>
55
+ <%= link_to "View", webhook_inbox.event_path(event), class: "wi-btn wi-btn-back" %>
56
+ </td>
57
+ </tr>
58
+ <% end %>
59
+ <% end %>
60
+ </tbody>
61
+ </table>
@@ -0,0 +1,54 @@
1
+ <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
2
+ <%= link_to "← All Events", webhook_inbox.dashboard_path, class: "wi-btn wi-btn-back" %>
3
+ <%= button_to "Replay", webhook_inbox.replay_event_path(@event), method: :post, class: "wi-btn wi-btn-replay" %>
4
+ </div>
5
+
6
+ <div class="wi-detail-card">
7
+ <div class="wi-detail-row">
8
+ <span class="wi-detail-label">Status</span>
9
+ <%= status_badge(@event.status) %>
10
+ </div>
11
+ <div class="wi-detail-row">
12
+ <span class="wi-detail-label">Provider</span>
13
+ <span class="wi-mono"><%= @event.provider %></span>
14
+ </div>
15
+ <div class="wi-detail-row">
16
+ <span class="wi-detail-label">Event Type</span>
17
+ <span class="wi-mono"><%= @event.event_type %></span>
18
+ </div>
19
+ <div class="wi-detail-row">
20
+ <span class="wi-detail-label">Event ID</span>
21
+ <span class="wi-mono"><%= @event.event_id %></span>
22
+ </div>
23
+ <div class="wi-detail-row">
24
+ <span class="wi-detail-label">Attempts</span>
25
+ <span><%= @event.attempts %></span>
26
+ </div>
27
+ <div class="wi-detail-row">
28
+ <span class="wi-detail-label">Received</span>
29
+ <span><%= @event.created_at.strftime("%Y-%m-%d %H:%M:%S UTC") %></span>
30
+ </div>
31
+ <% if @event.processed_at %>
32
+ <div class="wi-detail-row">
33
+ <span class="wi-detail-label">Processed At</span>
34
+ <span><%= @event.processed_at.strftime("%Y-%m-%d %H:%M:%S UTC") %></span>
35
+ </div>
36
+ <% end %>
37
+
38
+ <% if @event.error_message.present? %>
39
+ <div style="margin-top:16px;">
40
+ <span class="wi-detail-label">Error</span>
41
+ <div class="wi-error"><%= @event.error_message %></div>
42
+ </div>
43
+ <% end %>
44
+
45
+ <div style="margin-top:20px;">
46
+ <div class="wi-detail-label" style="margin-bottom:8px;">Payload</div>
47
+ <% begin %>
48
+ <% pretty = JSON.pretty_generate(@event.parsed_payload) %>
49
+ <% rescue %>
50
+ <% pretty = @event.payload.to_s %>
51
+ <% end %>
52
+ <pre><%= pretty %></pre>
53
+ </div>
54
+ </div>
data/config/routes.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ WebhookInbox::Engine.routes.draw do
4
+ root to: "dashboard#index", as: :dashboard
5
+ resources :events, only: [:show], controller: "dashboard" do
6
+ member do
7
+ post :replay
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module WebhookInbox
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Creates a WebhookInbox migration and initializer in your application."
14
+
15
+ def create_migration
16
+ migration_template "create_webhook_inbox_events.rb.erb",
17
+ "db/migrate/create_webhook_inbox_events.rb"
18
+ end
19
+
20
+ def create_initializer
21
+ template "initializer.rb", "config/initializers/webhook_inbox.rb"
22
+ end
23
+
24
+ def show_readme
25
+ readme "README" if behavior == :invoke
26
+ end
27
+
28
+ private
29
+
30
+ def migration_version
31
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,33 @@
1
+ ===============================================================================
2
+
3
+ WebhookInbox installed!
4
+
5
+ Next steps:
6
+
7
+ 1. Run the migration:
8
+ rails db:migrate
9
+
10
+ 2. Configure your handlers in config/initializers/webhook_inbox.rb
11
+
12
+ 3. Add a controller for each provider:
13
+
14
+ class StripeWebhooksController < ApplicationController
15
+ include WebhookInbox::Receiver
16
+ receive_from :stripe, secret: -> { ENV["STRIPE_WEBHOOK_SECRET"] }
17
+
18
+ def create
19
+ receive_webhook!
20
+ end
21
+ end
22
+
23
+ 4. Add the route:
24
+
25
+ # config/routes.rb
26
+ post "/webhooks/stripe", to: "stripe_webhooks#create"
27
+
28
+ # Optional dashboard:
29
+ mount WebhookInbox::Engine => "/webhook_inbox"
30
+
31
+ 5. Set STRIPE_WEBHOOK_SECRET in your environment.
32
+
33
+ ===============================================================================
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateWebhookInboxEvents < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ create_table :webhook_inbox_events do |t|
6
+ t.string :provider, null: false
7
+ t.string :event_id, null: false
8
+ t.string :event_type
9
+ t.text :payload, null: false, default: "{}"
10
+ t.string :status, null: false, default: "pending"
11
+ t.integer :attempts, null: false, default: 0
12
+ t.text :error_message
13
+ t.datetime :processed_at
14
+ t.timestamps
15
+ end
16
+
17
+ add_index :webhook_inbox_events, [:provider, :event_id], unique: true
18
+ add_index :webhook_inbox_events, :status
19
+ add_index :webhook_inbox_events, :created_at
20
+ end
21
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ WebhookInbox.configure do |config|
4
+ # Register handlers for each provider + event type you want to handle.
5
+ # The block receives a WebhookInbox::Event object.
6
+ #
7
+ # config.on(:stripe, "customer.subscription.created") do |event|
8
+ # CreateSubscriptionJob.perform_later(event.parsed_payload)
9
+ # end
10
+ #
11
+ # config.on(:stripe, "invoice.payment_failed") do |event|
12
+ # NotifyPaymentFailedJob.perform_later(event.parsed_payload)
13
+ # end
14
+ #
15
+ # Catch-all for all Stripe events:
16
+ # config.on(:stripe, "*") do |event|
17
+ # Rails.logger.info "[WebhookInbox] Received #{event.event_type}"
18
+ # end
19
+
20
+ # Queue name for ProcessJob (default: "webhooks")
21
+ # config.queue_name = "webhooks"
22
+
23
+ # Dashboard auth lambda — return truthy to allow access.
24
+ # Required in production. Uncomment and customize:
25
+ # config.dashboard_auth = ->(controller) { controller.current_user&.admin? }
26
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebhookInbox
4
+ class Configuration
5
+ attr_accessor :queue_name, :dashboard_auth
6
+
7
+ def initialize
8
+ @queue_name = "webhooks"
9
+ @dashboard_auth = nil
10
+ @handlers = {}
11
+ end
12
+
13
+ # Register a handler block for a given provider + event type.
14
+ # Use "*" as event_type to match any event from that provider.
15
+ #
16
+ # config.on(:stripe, "customer.subscription.created") { |event| ... }
17
+ # config.on(:stripe, "*") { |event| ... }
18
+ def on(provider, event_type, &block)
19
+ raise ArgumentError, "Handler block required" unless block
20
+
21
+ key = handler_key(provider, event_type)
22
+ @handlers[key] ||= []
23
+ @handlers[key] << block
24
+ end
25
+
26
+ # Returns all matching handler blocks for [provider, event_type].
27
+ # Includes exact matches and wildcard "*" handlers for the provider.
28
+ def handlers_for(provider, event_type)
29
+ exact = @handlers[handler_key(provider, event_type)] || []
30
+ wildcard = @handlers[handler_key(provider, "*")] || []
31
+ exact + wildcard
32
+ end
33
+
34
+ private
35
+
36
+ def handler_key(provider, event_type)
37
+ "#{provider}:#{event_type}"
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+
5
+ module WebhookInbox
6
+ class Engine < Rails::Engine
7
+ isolate_namespace WebhookInbox
8
+
9
+ initializer "webhook_inbox.initialize" do
10
+ WebhookInbox.configuration ||= WebhookInbox::Configuration.new
11
+ end
12
+
13
+ initializer "webhook_inbox.autoload_receiver" do
14
+ ActiveSupport.on_load(:action_controller) do
15
+ require "webhook_inbox/receiver"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebhookInbox
4
+ module Providers
5
+ class Base
6
+ # Extract the unique event ID from the raw body and request.
7
+ # @param raw_body [String] the raw request body string
8
+ # @param request [ActionDispatch::Request]
9
+ # @return [String]
10
+ def event_id(raw_body, request)
11
+ raise NotImplementedError, "#{self.class}#event_id not implemented"
12
+ end
13
+
14
+ # Extract the event type string (e.g. "customer.subscription.created").
15
+ # @param raw_body [String]
16
+ # @param request [ActionDispatch::Request]
17
+ # @return [String]
18
+ def event_type(raw_body, request)
19
+ raise NotImplementedError, "#{self.class}#event_type not implemented"
20
+ end
21
+
22
+ # Verify the provider signature. Raise WebhookInbox::SignatureError on failure.
23
+ # @param raw_body [String]
24
+ # @param request [ActionDispatch::Request]
25
+ # @param secret [String]
26
+ def verify!(raw_body, request, secret:)
27
+ raise NotImplementedError, "#{self.class}#verify! not implemented"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "json"
5
+ require "active_support/security_utils"
6
+
7
+ module WebhookInbox
8
+ module Providers
9
+ class Stripe < Base
10
+ SIG_HEADER = "HTTP_STRIPE_SIGNATURE"
11
+
12
+ def event_id(raw_body, _request)
13
+ JSON.parse(raw_body)["id"]
14
+ rescue JSON::ParserError
15
+ nil
16
+ end
17
+
18
+ def event_type(raw_body, _request)
19
+ JSON.parse(raw_body)["type"] || "unknown"
20
+ rescue JSON::ParserError
21
+ "unknown"
22
+ end
23
+
24
+ # Stripe signature format: "t=<timestamp>,v1=<hmac>,v0=<hmac>"
25
+ # We verify v1 (HMAC-SHA256 of "timestamp.body" signed with the webhook secret).
26
+ # Raises WebhookInbox::SignatureError if invalid.
27
+ def verify!(raw_body, request, secret:)
28
+ sig_header = request.env[SIG_HEADER]
29
+ raise WebhookInbox::SignatureError, "Missing Stripe-Signature header" if sig_header.blank?
30
+
31
+ parts = sig_header.split(",").each_with_object({}) do |part, hash|
32
+ k, v = part.split("=", 2)
33
+ hash[k] = v if k && v
34
+ end
35
+ timestamp = parts["t"]
36
+ received = parts["v1"]
37
+
38
+ raise WebhookInbox::SignatureError, "Malformed Stripe-Signature header" unless timestamp && received
39
+
40
+ signed_payload = "#{timestamp}.#{raw_body}"
41
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, signed_payload)
42
+
43
+ return if ActiveSupport::SecurityUtils.secure_compare(expected, received)
44
+
45
+ raise WebhookInbox::SignatureError, "Stripe signature verification failed"
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebhookInbox
4
+ module Receiver
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ class_attribute :_webhook_provider, instance_accessor: false
9
+ class_attribute :_webhook_secret_resolver, instance_accessor: false
10
+ end
11
+
12
+ class_methods do
13
+ # Declare the provider and secret for this controller.
14
+ #
15
+ # receive_from :stripe, secret: -> { ENV["STRIPE_WEBHOOK_SECRET"] }
16
+ def receive_from(provider, secret:)
17
+ self._webhook_provider = provider.to_sym
18
+ self._webhook_secret_resolver = secret
19
+ end
20
+ end
21
+
22
+ # Run the full receive pipeline:
23
+ # 1. Verify provider signature (401 on failure)
24
+ # 2. Store event in DB (200 silent on duplicate)
25
+ # 3. Enqueue ProcessJob
26
+ # 4. Respond 200 OK
27
+ def receive_webhook!
28
+ provider_name = self.class._webhook_provider
29
+ raise "No provider declared. Call receive_from :stripe, secret: -> { ... }" unless provider_name
30
+
31
+ adapter = WebhookInbox.provider_for(provider_name)
32
+ secret = self.class._webhook_secret_resolver.call
33
+ raw_body = read_request_body
34
+
35
+ verify_signature!(adapter, raw_body, secret) || return
36
+ store_and_process(adapter, raw_body, provider_name) || return
37
+
38
+ head :ok
39
+ end
40
+
41
+ private
42
+
43
+ def read_request_body
44
+ request.body.rewind # Rewind first — middleware may have consumed the body
45
+ request.body.read
46
+ end
47
+
48
+ def verify_signature!(adapter, raw_body, secret)
49
+ adapter.verify!(raw_body, request, secret: secret)
50
+ true
51
+ rescue WebhookInbox::SignatureError
52
+ head :unauthorized
53
+ false
54
+ end
55
+
56
+ def store_and_process(adapter, raw_body, provider_name)
57
+ event = WebhookInbox::Event.create!(
58
+ provider: provider_name.to_s,
59
+ event_id: adapter.event_id(raw_body, request),
60
+ event_type: adapter.event_type(raw_body, request),
61
+ payload: raw_body,
62
+ status: "pending"
63
+ )
64
+ WebhookInbox::ProcessJob.set(queue: WebhookInbox.configuration.queue_name)
65
+ .perform_later(event.id)
66
+ true
67
+ rescue ActiveRecord::RecordNotUnique
68
+ head :ok # Duplicate delivery — idempotent 200
69
+ false
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "json"
5
+
6
+ module WebhookInbox
7
+ module RSpecHelpers
8
+ # Simulate a signed webhook delivery to the given path.
9
+ # Signs the payload using the provider's scheme so signature verification passes.
10
+ #
11
+ # @param provider [Symbol] e.g. :stripe
12
+ # @param event_type [String] e.g. "customer.subscription.created"
13
+ # @param payload [Hash] the event payload (will be JSON-encoded)
14
+ # @param path [String] the route to POST to (default: "/webhooks/#{provider}")
15
+ # @param event_id [String] override the generated event ID
16
+ # @param secret [String] the webhook secret (default: "test_secret")
17
+ def deliver_webhook(provider, event_type, payload: {}, path: nil, event_id: nil, secret: "test_secret")
18
+ path ||= "/webhooks/#{provider}"
19
+ headers = build_webhook_headers(provider, event_type, payload, event_id: event_id, secret: secret)
20
+ post path, params: headers[:body], headers: headers[:headers]
21
+ end
22
+
23
+ private
24
+
25
+ def build_webhook_headers(provider, event_type, payload, event_id:, secret:)
26
+ case provider.to_sym
27
+ when :stripe
28
+ build_stripe_headers(event_type, payload, event_id: event_id, secret: secret)
29
+ else
30
+ raise ArgumentError, "No RSpec helper for provider: #{provider}"
31
+ end
32
+ end
33
+
34
+ def build_stripe_headers(event_type, payload, event_id:, secret:)
35
+ id = event_id || "evt_test_#{SecureRandom.hex(8)}"
36
+ body = JSON.generate({ id: id, type: event_type, data: payload })
37
+ ts = Time.now.to_i.to_s
38
+ sig = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{ts}.#{body}")
39
+
40
+ {
41
+ body: body,
42
+ headers: {
43
+ "CONTENT_TYPE" => "application/json",
44
+ "HTTP_STRIPE_SIGNATURE" => "t=#{ts},v1=#{sig}"
45
+ }
46
+ }
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webhook_inbox/rspec/helpers"
4
+
5
+ RSpec.configure do |config|
6
+ config.include WebhookInbox::RSpecHelpers, type: :request
7
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebhookInbox
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/core_ext/module/attribute_accessors"
5
+
6
+ require_relative "webhook_inbox/version"
7
+ require_relative "webhook_inbox/configuration"
8
+ require_relative "webhook_inbox/providers/base"
9
+ require_relative "webhook_inbox/providers/stripe"
10
+
11
+ module WebhookInbox
12
+ mattr_accessor :configuration
13
+
14
+ class SignatureError < StandardError; end
15
+ class UnknownProviderError < StandardError; end
16
+
17
+ PROVIDERS = {
18
+ stripe: WebhookInbox::Providers::Stripe
19
+ }.freeze
20
+
21
+ class << self
22
+ def configure
23
+ self.configuration ||= Configuration.new
24
+ yield configuration
25
+ end
26
+
27
+ def provider_for(name)
28
+ klass = PROVIDERS[name.to_sym]
29
+ raise UnknownProviderError, "Unknown provider: #{name}. Available: #{PROVIDERS.keys.join(', ')}" unless klass
30
+
31
+ klass.new
32
+ end
33
+ end
34
+ end
35
+
36
+ require_relative "webhook_inbox/engine" if defined?(Rails::Engine)
@@ -0,0 +1,4 @@
1
+ module WebhookInbox
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end