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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3e8996a95485a31adad6b0ad9be8b2c24bd40ce375f66242db772c0cf61fc3be
4
+ data.tar.gz: cda5dd9e10dbe34183a2e3526d4661c07aca104139899effb24b7a993499b08a
5
+ SHA512:
6
+ metadata.gz: 6b379d2cb53ea7635367ecd47ca935928e65b778ee3c24b3605999167da68df616f868bc074e702b7bdff4f63ad9d10d94a64e0658bdf7c77e278f5381f33780
7
+ data.tar.gz: fba1b1086546871e9d7324c650dc8779781c896d70f84a6ddef4c4a1543ac5a10e64e189c1e41a4052b32501d0810b504099953d95498ef409ef852e730681a1
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --require spec_helper
2
+ --format documentation
3
+ --color
data/.rubocop.yml ADDED
@@ -0,0 +1,83 @@
1
+ plugins:
2
+ - rubocop-rspec
3
+
4
+ AllCops:
5
+ NewCops: enable
6
+ TargetRubyVersion: 3.1
7
+ Exclude:
8
+ - "bin/**/*"
9
+ - "vendor/**/*"
10
+ - "pkg/**/*"
11
+
12
+ Style/StringLiterals:
13
+ EnforcedStyle: double_quotes
14
+
15
+ Style/FrozenStringLiteralComment:
16
+ Enabled: true
17
+
18
+ Metrics/MethodLength:
19
+ Max: 20
20
+
21
+ Metrics/BlockLength:
22
+ Max: 40
23
+ Exclude:
24
+ - "spec/**/*"
25
+
26
+ Metrics/ClassLength:
27
+ Max: 150
28
+
29
+ Style/Documentation:
30
+ Enabled: false
31
+
32
+ Naming/MethodParameterName:
33
+ MinNameLength: 1
34
+
35
+ Naming/PredicateMethod:
36
+ Enabled: false
37
+
38
+ RSpec/VerifiedDoubles:
39
+ Enabled: false
40
+
41
+ RSpec/MessageSpies:
42
+ Enabled: false
43
+
44
+ RSpec/IdenticalEqualityAssertion:
45
+ Enabled: false
46
+
47
+ RSpec/StubbedMock:
48
+ Enabled: false
49
+
50
+ Style/OpenStructUse:
51
+ Exclude:
52
+ - "spec/**/*"
53
+
54
+ Lint/FloatComparison:
55
+ Enabled: false
56
+
57
+ Metrics/AbcSize:
58
+ Max: 35
59
+
60
+ Metrics/CyclomaticComplexity:
61
+ Max: 10
62
+
63
+ Metrics/PerceivedComplexity:
64
+ Max: 12
65
+
66
+ Metrics/ParameterLists:
67
+ Max: 7
68
+
69
+ RSpec/SpecFilePathFormat:
70
+ Enabled: false
71
+
72
+ RSpec/MultipleExpectations:
73
+ Max: 5
74
+
75
+ RSpec/ExampleLength:
76
+ Max: 15
77
+
78
+ RSpec/MultipleMemoizedHelpers:
79
+ Max: 10
80
+
81
+ Lint/EmptyBlock:
82
+ Exclude:
83
+ - "spec/**/*"
data/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] — 2026-06-09
11
+
12
+ ### Added
13
+
14
+ - `WebhookInbox::Receiver` controller concern — `receive_from :stripe, secret: -> { ENV["..."] }` + `receive_webhook!` runs the full receive pipeline (verify → store → enqueue → respond).
15
+ - `WebhookInbox::Event` ActiveRecord model — `text` payload column (cross-DB compatible), status enum (`pending` / `processing` / `processed` / `failed`), scopes (`.pending`, `.failed`, `.for_provider`), and `retry!` for replay.
16
+ - `WebhookInbox::ProcessJob` ActiveJob — looks up registered handlers for `[provider, event_type]`, marks event status through its lifecycle, stores error messages on failure, re-raises for ActiveJob retry backoff.
17
+ - Handler registry via `WebhookInbox.configure { |c| c.on(:stripe, "event.type") { |e| ... } }`. Supports multiple handlers per event type, wildcard `"*"` catch-all per provider, and independent handlers per provider.
18
+ - `WebhookInbox::Providers::Stripe` — HMAC-SHA256 signature verification (manual implementation, no stripe gem required), event ID and event type extraction from raw body. Raises `WebhookInbox::SignatureError` on failure.
19
+ - `WebhookInbox::Providers::Base` — interface class for building additional provider adapters.
20
+ - `rails generate webhook_inbox:install` — generates the migration (with unique index on `[provider, event_id]`) and a commented initializer stub.
21
+ - Dashboard Rails Engine at `/webhook_inbox` — event list with status/provider filters, event detail with full JSON payload, replay button, error message display. Plain HTML + inline CSS, no JavaScript framework.
22
+ - Dashboard auth lambda `config.dashboard_auth = ->(controller) { ... }` — passthrough in development, enforced in production.
23
+ - `WebhookInbox::RSpecHelpers` — `deliver_webhook(:stripe, "event.type", payload: {})` posts a correctly HMAC-signed request, matching Stripe's live webhook format. Auto-included in request specs via `require "webhook_inbox/rspec"`.
24
+ - Race-condition-safe deduplication: relies exclusively on the DB unique constraint (not application-level `exists?` checks). Rescues `ActiveRecord::RecordNotUnique`, returns `200` silently.
25
+ - Request body rewind: reads and rewinds `request.body` before passing to the adapter, so middleware that consumes the body before the controller does not break signature verification.
data/README.md ADDED
@@ -0,0 +1,315 @@
1
+ # webhook_inbox
2
+
3
+ **Production-ready transactional inbox for Rails webhooks. Two lines.**
4
+
5
+ [![CI](https://github.com/jibranusman95/webhook_inbox/actions/workflows/ci.yml/badge.svg)](https://github.com/jibranusman95/webhook_inbox/actions)
6
+ [![Gem Version](https://badge.fury.io/rb/webhook_inbox.svg)](https://badge.fury.io/rb/webhook_inbox)
7
+ [![Downloads](https://img.shields.io/gem/dt/webhook_inbox)](https://rubygems.org/gems/webhook_inbox)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
9
+
10
+ ---
11
+
12
+ > **[Screenshot: /webhook_inbox dashboard — table of Stripe events with green "processed", red "failed", yellow "pending" badges. One row expanded to show full JSON payload and a "Replay" button.]**
13
+
14
+ ---
15
+
16
+ Every Rails app that accepts webhooks has written this controller:
17
+
18
+ ```ruby
19
+ def create
20
+ payload = request.body.read
21
+ sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
22
+ event = Stripe::Webhook.construct_event(payload, sig_header, ENV["STRIPE_SECRET"])
23
+
24
+ return head :ok if StripeEvent.exists?(stripe_id: event.id)
25
+
26
+ StripeEvent.create!(stripe_id: event.id, payload: payload)
27
+ HandleStripeEventJob.perform_later(event.id)
28
+ head :ok
29
+ rescue Stripe::SignatureVerificationError
30
+ head :unauthorized
31
+ rescue ActiveRecord::RecordNotUnique
32
+ head :ok
33
+ end
34
+ ```
35
+
36
+ And every Rails app has had a production incident when it broke.
37
+
38
+ Here's the same thing with webhook_inbox:
39
+
40
+ ```ruby
41
+ class StripeWebhooksController < ApplicationController
42
+ include WebhookInbox::Receiver
43
+ receive_from :stripe, secret: -> { ENV["STRIPE_WEBHOOK_SECRET"] }
44
+
45
+ def create
46
+ receive_webhook!
47
+ end
48
+ end
49
+ ```
50
+
51
+ Signature verification, deduplication, async processing, replay, and a dashboard. All included. No Redis, no extra services, no copy-paste.
52
+
53
+ ---
54
+
55
+ ## What you get
56
+
57
+ **Deduplication** — DB unique constraint on `[provider, event_id]`. Two identical deliveries arriving simultaneously? Both pass the `exists?` check? Doesn't matter. The constraint is enforced at the database level. Duplicates silently return 200.
58
+
59
+ **Async processing** — events are stored first, then processed via your existing job queue. Stripe gets its 200 immediately. Your handler can take as long as it needs.
60
+
61
+ **Replay** — any event can be re-run from the dashboard or via `event.retry!`. Debug handlers, recover from bugs, reprocess failed deliveries.
62
+
63
+ **Dashboard** — `/webhook_inbox` shows every event, its status, full JSON payload, error details, and a replay button. Protected by a configurable auth lambda.
64
+
65
+ **RSpec helpers** — `deliver_webhook(:stripe, "event.type", payload: {})` posts a correctly-signed request in your tests. No mocking, no fixtures.
66
+
67
+ ---
68
+
69
+ ## Install
70
+
71
+ ```ruby
72
+ # Gemfile
73
+ gem "webhook_inbox"
74
+ ```
75
+
76
+ ```bash
77
+ bundle install
78
+ rails generate webhook_inbox:install
79
+ rails db:migrate
80
+ ```
81
+
82
+ Mount the dashboard:
83
+
84
+ ```ruby
85
+ # config/routes.rb
86
+ post "/webhooks/stripe", to: "stripe_webhooks#create"
87
+ mount WebhookInbox::Engine => "/webhook_inbox"
88
+ ```
89
+
90
+ ---
91
+
92
+ ## Configuration
93
+
94
+ ```ruby
95
+ # config/initializers/webhook_inbox.rb
96
+ WebhookInbox.configure do |config|
97
+ # Register handlers — block receives a WebhookInbox::Event object
98
+ config.on(:stripe, "customer.subscription.created") do |event|
99
+ CreateSubscriptionJob.perform_later(event.parsed_payload)
100
+ end
101
+
102
+ config.on(:stripe, "invoice.payment_failed") do |event|
103
+ NotifyPaymentFailedJob.perform_later(event.parsed_payload)
104
+ end
105
+
106
+ # Catch-all — runs for every Stripe event with no exact match
107
+ config.on(:stripe, "*") do |event|
108
+ Rails.logger.info "[webhook] Unhandled #{event.event_type}"
109
+ end
110
+
111
+ # Queue name (default: "webhooks")
112
+ config.queue_name = "webhooks"
113
+
114
+ # Dashboard auth — return truthy to allow access. Required in production.
115
+ config.dashboard_auth = ->(controller) { controller.current_user&.admin? }
116
+ end
117
+ ```
118
+
119
+ ---
120
+
121
+ ## Controller
122
+
123
+ ```ruby
124
+ class StripeWebhooksController < ApplicationController
125
+ include WebhookInbox::Receiver
126
+ receive_from :stripe, secret: -> { ENV["STRIPE_WEBHOOK_SECRET"] }
127
+
128
+ def create
129
+ receive_webhook!
130
+ # receive_webhook! always responds — nothing needed after it
131
+ end
132
+ end
133
+ ```
134
+
135
+ `receive_webhook!` runs the full pipeline:
136
+
137
+ 1. Verify Stripe signature → `401` on failure
138
+ 2. Insert event into DB → silent `200` on duplicate (race-condition-safe)
139
+ 3. Enqueue `WebhookInbox::ProcessJob`
140
+ 4. Respond `200 OK`
141
+
142
+ ---
143
+
144
+ ## Working with events
145
+
146
+ ```ruby
147
+ # Query
148
+ WebhookInbox::Event.pending.count
149
+ WebhookInbox::Event.failed.each(&:retry!)
150
+ WebhookInbox::Event.for_provider(:stripe).where(event_type: "invoice.payment_failed")
151
+
152
+ # Event object passed to handlers
153
+ event.provider # => "stripe"
154
+ event.event_id # => "evt_1ABC..."
155
+ event.event_type # => "customer.subscription.created"
156
+ event.parsed_payload # => Hash (parsed from stored JSON)
157
+ event.attempts # => 1
158
+ event.status # => "pending" | "processing" | "processed" | "failed"
159
+ event.error_message # => "RuntimeError: handler exploded\n..." (on failure)
160
+
161
+ # Replay a specific event
162
+ WebhookInbox::Event.find_by(event_id: "evt_1ABC...").retry!
163
+ ```
164
+
165
+ ---
166
+
167
+ ## Processing flow
168
+
169
+ ```
170
+ POST /webhooks/stripe
171
+
172
+
173
+ WebhookInbox::Receiver
174
+
175
+ ├── 1. Verify signature (401 on failure)
176
+ ├── 2. INSERT webhook_inbox_events (200 silent on duplicate)
177
+ ├── 3. Enqueue ProcessJob
178
+ └── 4. Respond 200 OK
179
+
180
+ ▼ (async, via your queue)
181
+ WebhookInbox::ProcessJob
182
+
183
+ ├── 1. Find event, mark: processing
184
+ ├── 2. Look up handlers for [provider, event_type]
185
+ ├── 3. Call each handler block
186
+ ├── 4a. Success → status: processed, processed_at: now
187
+ └── 4b. Failure → status: failed, error_message stored, re-raise for retry
188
+ ```
189
+
190
+ ---
191
+
192
+ ## RSpec helpers
193
+
194
+ ```ruby
195
+ # spec/rails_helper.rb
196
+ require "webhook_inbox/rspec"
197
+
198
+ # In request specs (auto-included)
199
+ RSpec.describe "Stripe billing", type: :request do
200
+ it "creates a subscription on webhook" do
201
+ deliver_webhook(:stripe, "customer.subscription.created", payload: {
202
+ data: { object: { id: "sub_123", customer: "cus_456" } }
203
+ })
204
+
205
+ perform_enqueued_jobs
206
+
207
+ expect(Subscription.find_by(stripe_id: "sub_123")).to be_active
208
+ end
209
+
210
+ it "ignores duplicate deliveries" do
211
+ 2.times do
212
+ deliver_webhook(:stripe, "customer.subscription.created",
213
+ event_id: "evt_fixed_id", payload: {})
214
+ end
215
+ expect(WebhookInbox::Event.count).to eq(1)
216
+ end
217
+ end
218
+ ```
219
+
220
+ `deliver_webhook` signs the request using the same HMAC-SHA256 scheme as Stripe's live webhooks. The signature will pass `receive_webhook!` verification without any mocking. Pass `secret:` to match your test initializer (default: `"test_secret"`).
221
+
222
+ ---
223
+
224
+ ## Dashboard
225
+
226
+ Mount in `config/routes.rb`:
227
+
228
+ ```ruby
229
+ mount WebhookInbox::Engine => "/webhook_inbox"
230
+ ```
231
+
232
+ Configure auth in the initializer:
233
+
234
+ ```ruby
235
+ config.dashboard_auth = ->(controller) { controller.current_user&.admin? }
236
+ ```
237
+
238
+ In development, the dashboard is open when `dashboard_auth` is not set. In production, it blocks with a clear error if auth is not configured.
239
+
240
+ The dashboard shows:
241
+ - All events with status badges (pending / processing / processed / failed)
242
+ - Filter by status or provider
243
+ - Full JSON payload for each event
244
+ - Error message and stack trace on failures
245
+ - Replay button — re-enqueues the handler job
246
+
247
+ ---
248
+
249
+ ## Why not stripe_event?
250
+
251
+ [stripe_event](https://github.com/integrallis/stripe_event) is a great event router — 14.5M downloads. It dispatches to handlers. That's all it does.
252
+
253
+ It has no storage, no deduplication, no replay, no dashboard. You still write the controller, the dedup migration, the job, and the retry logic.
254
+
255
+ webhook_inbox handles the layer below: receive, store, deduplicate, process, replay. They're complementary. If you already use stripe_event for routing, webhook_inbox can sit underneath it as the storage and dedup layer.
256
+
257
+ ---
258
+
259
+ ## Database schema
260
+
261
+ ```ruby
262
+ create_table :webhook_inbox_events do |t|
263
+ t.string :provider, null: false
264
+ t.string :event_id, null: false
265
+ t.string :event_type
266
+ t.text :payload, null: false, default: "{}"
267
+ t.string :status, null: false, default: "pending"
268
+ t.integer :attempts, null: false, default: 0
269
+ t.text :error_message
270
+ t.datetime :processed_at
271
+ t.timestamps
272
+ end
273
+
274
+ add_index :webhook_inbox_events, [:provider, :event_id], unique: true
275
+ add_index :webhook_inbox_events, :status
276
+ add_index :webhook_inbox_events, :created_at
277
+ ```
278
+
279
+ `payload` is `text` (not `jsonb`) for cross-database compatibility — identical behavior on SQLite, MySQL, and PostgreSQL.
280
+
281
+ ---
282
+
283
+ ## Requirements
284
+
285
+ - Ruby >= 3.1
286
+ - Rails >= 7.0
287
+ - ActiveRecord, ActiveJob, ActionController
288
+
289
+ ---
290
+
291
+ ## Contributing
292
+
293
+ ```bash
294
+ git clone https://github.com/jibranusman95/webhook_inbox
295
+ cd webhook_inbox
296
+ bundle install
297
+ bundle exec rspec
298
+ bundle exec rubocop
299
+ ```
300
+
301
+ ---
302
+
303
+ ## From the same author
304
+
305
+ | Gem | What it does |
306
+ |-----|-------------|
307
+ | [turbo_presence](https://github.com/jibranusman95/turbo_presence) | Figma-style live cursors, avatar stacks, and typing indicators for Rails — one line |
308
+ | [http_decoy](https://github.com/jibranusman95/http_decoy) | A real Rack server that runs inside your RSpec tests — test HTTP contracts, not stubs |
309
+ | [promptscrub](https://github.com/jibranusman95/promptscrub) | PII redaction middleware for LLM calls |
310
+
311
+ ---
312
+
313
+ ## License
314
+
315
+ MIT. See [LICENSE](LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require "rubocop/rake_task"
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+ RuboCop::RakeTask.new
9
+
10
+ task default: %i[spec rubocop]
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebhookInbox
4
+ class ApplicationController < ActionController::Base
5
+ before_action :authenticate_dashboard!
6
+
7
+ private
8
+
9
+ def authenticate_dashboard!
10
+ auth = WebhookInbox.configuration&.dashboard_auth
11
+
12
+ # In production, require dashboard_auth to be configured.
13
+ if auth.nil?
14
+ if Rails.env.production?
15
+ render plain: "WebhookInbox dashboard requires authentication. " \
16
+ "Set config.dashboard_auth in your initializer.",
17
+ status: :forbidden
18
+ end
19
+ # In development/test, allow through.
20
+ return
21
+ end
22
+
23
+ return if instance_exec(self, &auth)
24
+
25
+ render plain: "Unauthorized", status: :unauthorized
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebhookInbox
4
+ class DashboardController < ApplicationController
5
+ def index
6
+ @events = WebhookInbox::Event.order(created_at: :desc)
7
+ @events = @events.where(status: params[:status]) if params[:status].present?
8
+ @events = @events.where(provider: params[:provider]) if params[:provider].present?
9
+ @events = @events.page(params[:page]).per(50) if @events.respond_to?(:page)
10
+ @events = @events.limit(200) unless @events.respond_to?(:page)
11
+
12
+ @status_counts = WebhookInbox::Event.group(:status).count
13
+ @providers = WebhookInbox::Event.distinct.pluck(:provider).sort
14
+ end
15
+
16
+ def show
17
+ @event = WebhookInbox::Event.find(params[:id])
18
+ end
19
+
20
+ def replay
21
+ @event = WebhookInbox::Event.find(params[:id])
22
+ @event.retry!
23
+ redirect_to webhook_inbox.dashboard_path, notice: "Event #{@event.event_id} queued for reprocessing."
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebhookInbox
4
+ module DashboardHelper
5
+ STATUS_CLASSES = {
6
+ "pending" => "badge-pending",
7
+ "processing" => "badge-processing",
8
+ "processed" => "badge-processed",
9
+ "failed" => "badge-failed"
10
+ }.freeze
11
+
12
+ def status_badge(status)
13
+ css = STATUS_CLASSES.fetch(status, "badge-pending")
14
+ content_tag(:span, status, class: "wi-badge #{css}")
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebhookInbox
4
+ class ProcessJob < ActiveJob::Base
5
+ queue_as { WebhookInbox.configuration.queue_name }
6
+
7
+ retry_on StandardError, wait: :polynomially_longer, attempts: 5
8
+
9
+ def perform(event_id)
10
+ event = WebhookInbox::Event.find_by(id: event_id)
11
+ unless event
12
+ logger.warn "[WebhookInbox] ProcessJob: event #{event_id} not found — skipping"
13
+ return
14
+ end
15
+
16
+ return unless event.status.in?(%w[pending])
17
+
18
+ event.update!(status: "processing", attempts: event.attempts + 1)
19
+
20
+ handlers = WebhookInbox.configuration.handlers_for(event.provider, event.event_type)
21
+
22
+ if handlers.empty?
23
+ logger.info "[WebhookInbox] No handlers registered for #{event.provider}:#{event.event_type}"
24
+ else
25
+ handlers.each { |handler| handler.call(event) }
26
+ end
27
+
28
+ event.update!(status: "processed", processed_at: Time.current, error_message: nil)
29
+ rescue StandardError => e
30
+ event&.update!(
31
+ status: "failed",
32
+ error_message: "#{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}"
33
+ )
34
+ raise # let ActiveJob retry_on handle re-enqueueing
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebhookInbox
4
+ class Event < ActiveRecord::Base
5
+ self.table_name = "webhook_inbox_events"
6
+
7
+ # payload is stored as text, deserialized as Hash on read
8
+ attribute :payload, :string
9
+
10
+ STATUSES = %w[pending processing processed failed].freeze
11
+
12
+ validates :provider, presence: true
13
+ validates :event_id, presence: true
14
+ validates :status, inclusion: { in: STATUSES }
15
+
16
+ scope :pending, -> { where(status: "pending") }
17
+ scope :processing, -> { where(status: "processing") }
18
+ scope :processed, -> { where(status: "processed") }
19
+ scope :failed, -> { where(status: "failed") }
20
+ scope :for_provider, ->(name) { where(provider: name.to_s) }
21
+
22
+ # parsed_payload returns the payload as a Hash regardless of how it was stored
23
+ def parsed_payload
24
+ return payload if payload.is_a?(Hash)
25
+
26
+ JSON.parse(payload)
27
+ rescue JSON::ParserError
28
+ {}
29
+ end
30
+
31
+ # Enqueue for reprocessing. Resets status to pending.
32
+ def retry!
33
+ update!(status: "pending", error_message: nil)
34
+ WebhookInbox::ProcessJob.set(queue: WebhookInbox.configuration.queue_name)
35
+ .perform_later(id)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,69 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>WebhookInbox Dashboard</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; color: #111; background: #f5f5f5; }
10
+ a { color: #2563eb; text-decoration: none; }
11
+ a:hover { text-decoration: underline; }
12
+
13
+ .wi-header { background: #1e293b; color: #f8fafc; padding: 12px 24px; display: flex; align-items: center; gap: 12px; }
14
+ .wi-header h1 { font-size: 16px; font-weight: 600; letter-spacing: 0.02em; }
15
+ .wi-header a { color: #94a3b8; font-size: 13px; }
16
+
17
+ .wi-container { max-width: 1200px; margin: 0 auto; padding: 24px; }
18
+
19
+ .wi-notice { background: #dcfce7; border: 1px solid #86efac; color: #15803d; padding: 10px 16px; border-radius: 6px; margin-bottom: 20px; }
20
+
21
+ .wi-stats { display: flex; gap: 12px; margin-bottom: 24px; flex-wrap: wrap; }
22
+ .wi-stat { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px 20px; min-width: 120px; }
23
+ .wi-stat-label { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
24
+ .wi-stat-value { font-size: 24px; font-weight: 700; margin-top: 2px; }
25
+
26
+ .wi-filters { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; align-items: center; }
27
+ .wi-filters label { font-size: 12px; color: #64748b; font-weight: 500; }
28
+ .wi-filters select, .wi-filters input { font-size: 13px; border: 1px solid #d1d5db; border-radius: 6px; padding: 6px 10px; background: #fff; }
29
+ .wi-filters button { font-size: 13px; background: #2563eb; color: #fff; border: none; border-radius: 6px; padding: 6px 14px; cursor: pointer; }
30
+
31
+ table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; }
32
+ thead th { background: #f8fafc; padding: 10px 14px; text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; border-bottom: 1px solid #e2e8f0; }
33
+ tbody td { padding: 10px 14px; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
34
+ tbody tr:last-child td { border-bottom: none; }
35
+ tbody tr:hover { background: #f8fafc; }
36
+
37
+ .wi-badge { display: inline-block; padding: 2px 8px; border-radius: 9999px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
38
+ .badge-pending { background: #fef9c3; color: #854d0e; }
39
+ .badge-processing { background: #dbeafe; color: #1e40af; }
40
+ .badge-processed { background: #dcfce7; color: #15803d; }
41
+ .badge-failed { background: #fee2e2; color: #991b1b; }
42
+
43
+ .wi-mono { font-family: "SF Mono", "Fira Mono", monospace; font-size: 12px; color: #475569; }
44
+
45
+ .wi-btn { display: inline-block; padding: 6px 14px; border-radius: 6px; font-size: 12px; font-weight: 500; cursor: pointer; border: none; }
46
+ .wi-btn-replay { background: #f0f9ff; color: #0369a1; border: 1px solid #bae6fd; }
47
+ .wi-btn-replay:hover { background: #e0f2fe; }
48
+ .wi-btn-back { background: #f1f5f9; color: #475569; border: 1px solid #e2e8f0; }
49
+
50
+ .wi-detail-card { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 24px; }
51
+ .wi-detail-row { display: flex; gap: 16px; margin-bottom: 12px; align-items: baseline; }
52
+ .wi-detail-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; width: 120px; flex-shrink: 0; }
53
+ pre { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; overflow-x: auto; font-size: 12px; font-family: "SF Mono", "Fira Mono", monospace; white-space: pre; }
54
+ .wi-error { background: #fff1f2; border: 1px solid #fecdd3; border-radius: 6px; padding: 12px; color: #be123c; font-size: 12px; font-family: monospace; white-space: pre-wrap; margin-top: 8px; }
55
+ </style>
56
+ </head>
57
+ <body>
58
+ <div class="wi-header">
59
+ <h1>WebhookInbox</h1>
60
+ <%= link_to "All Events", webhook_inbox.dashboard_path %>
61
+ </div>
62
+ <div class="wi-container">
63
+ <% if flash[:notice] %>
64
+ <div class="wi-notice"><%= flash[:notice] %></div>
65
+ <% end %>
66
+ <%= yield %>
67
+ </div>
68
+ </body>
69
+ </html>