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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +83 -0
- data/CHANGELOG.md +25 -0
- data/README.md +315 -0
- data/Rakefile +10 -0
- data/app/controllers/webhook_inbox/application_controller.rb +28 -0
- data/app/controllers/webhook_inbox/dashboard_controller.rb +26 -0
- data/app/helpers/webhook_inbox/dashboard_helper.rb +17 -0
- data/app/jobs/webhook_inbox/process_job.rb +37 -0
- data/app/models/webhook_inbox/event.rb +38 -0
- data/app/views/layouts/webhook_inbox/application.html.erb +69 -0
- data/app/views/webhook_inbox/dashboard/index.html.erb +61 -0
- data/app/views/webhook_inbox/dashboard/show.html.erb +54 -0
- data/config/routes.rb +10 -0
- data/lib/generators/webhook_inbox/install/install_generator.rb +35 -0
- data/lib/generators/webhook_inbox/install/templates/README +33 -0
- data/lib/generators/webhook_inbox/install/templates/create_webhook_inbox_events.rb.erb +21 -0
- data/lib/generators/webhook_inbox/install/templates/initializer.rb +26 -0
- data/lib/webhook_inbox/configuration.rb +40 -0
- data/lib/webhook_inbox/engine.rb +19 -0
- data/lib/webhook_inbox/providers/base.rb +31 -0
- data/lib/webhook_inbox/providers/stripe.rb +49 -0
- data/lib/webhook_inbox/receiver.rb +72 -0
- data/lib/webhook_inbox/rspec/helpers.rb +49 -0
- data/lib/webhook_inbox/rspec.rb +7 -0
- data/lib/webhook_inbox/version.rb +5 -0
- data/lib/webhook_inbox.rb +36 -0
- data/sig/webhook_inbox.rbs +4 -0
- metadata +134 -0
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
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
|
+
[](https://github.com/jibranusman95/webhook_inbox/actions)
|
|
6
|
+
[](https://badge.fury.io/rb/webhook_inbox)
|
|
7
|
+
[](https://rubygems.org/gems/webhook_inbox)
|
|
8
|
+
[](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,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>
|