angarium 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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +65 -0
  3. data/MIT-LICENSE +22 -0
  4. data/README.md +910 -0
  5. data/Rakefile +13 -0
  6. data/SECURITY.md +51 -0
  7. data/app/assets/config/angarium_manifest.js +1 -0
  8. data/app/assets/stylesheets/angarium/application.css +15 -0
  9. data/app/controllers/angarium/api/attempts_controller.rb +12 -0
  10. data/app/controllers/angarium/api/base_controller.rb +133 -0
  11. data/app/controllers/angarium/api/deliveries_controller.rb +29 -0
  12. data/app/controllers/angarium/api/endpoints_controller.rb +117 -0
  13. data/app/controllers/angarium/api/not_authorized.rb +7 -0
  14. data/app/controllers/angarium/api/unpermitted_parameter.rb +15 -0
  15. data/app/helpers/angarium/application_helper.rb +4 -0
  16. data/app/jobs/angarium/application_job.rb +5 -0
  17. data/app/jobs/angarium/deliver_job.rb +11 -0
  18. data/app/models/angarium/application_record.rb +17 -0
  19. data/app/models/angarium/delivery.rb +254 -0
  20. data/app/models/angarium/delivery_attempt.rb +14 -0
  21. data/app/models/angarium/endpoint.rb +217 -0
  22. data/app/models/angarium/event.rb +7 -0
  23. data/app/policies/angarium/api/policy.rb +78 -0
  24. data/app/validators/angarium/endpoint_url_validator.rb +20 -0
  25. data/app/views/layouts/angarium/application.html.erb +15 -0
  26. data/config/routes.rb +22 -0
  27. data/db/angarium_migrate/20260704000001_create_angarium_endpoints.rb +22 -0
  28. data/db/angarium_migrate/20260704000002_create_angarium_events.rb +9 -0
  29. data/db/angarium_migrate/20260704000003_create_angarium_deliveries.rb +13 -0
  30. data/db/angarium_migrate/20260704000004_create_angarium_delivery_attempts.rb +12 -0
  31. data/lib/angarium/address_policy.rb +82 -0
  32. data/lib/angarium/client.rb +53 -0
  33. data/lib/angarium/configuration.rb +66 -0
  34. data/lib/angarium/dispatch.rb +23 -0
  35. data/lib/angarium/engine.rb +5 -0
  36. data/lib/angarium/event_matcher.rb +17 -0
  37. data/lib/angarium/signature.rb +57 -0
  38. data/lib/angarium/version.rb +3 -0
  39. data/lib/angarium.rb +44 -0
  40. data/lib/generators/angarium/install/install_generator.rb +57 -0
  41. data/lib/generators/angarium/install/templates/initializer.rb +77 -0
  42. data/lib/generators/angarium/migrations/migrations_generator.rb +39 -0
  43. data/lib/generators/angarium/policy/policy_generator.rb +40 -0
  44. data/lib/generators/angarium/policy/templates/policy.rb +33 -0
  45. data/lib/tasks/angarium_tasks.rake +15 -0
  46. metadata +137 -0
data/README.md ADDED
@@ -0,0 +1,910 @@
1
+ # Angarium
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/angarium)](https://rubygems.org/gems/angarium)
4
+ [![CI](https://github.com/radioactive-labs/angarium/actions/workflows/ci.yml/badge.svg)](https://github.com/radioactive-labs/angarium/actions/workflows/ci.yml)
5
+ [![Standard Webhooks](https://img.shields.io/badge/Standard%20Webhooks-compliant-3068b7)](https://www.standardwebhooks.com)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](MIT-LICENSE)
7
+
8
+ **Everything your hand-rolled webhook job is missing**: HMAC signing, retries
9
+ with backoff, zero-downtime secret rotation, SSRF protection, and a queryable log
10
+ of every delivery attempt.
11
+
12
+ The moment "just POST from a background job" ships to production, the gaps start
13
+ showing: your customers need signatures they can verify, failed deliveries need
14
+ to back off and retry for hours, secrets need to rotate without downtime, an
15
+ endpoint URL shouldn't be able to reach your internal network, and sooner or
16
+ later someone asks "did we actually send it?". Angarium is a Rails engine that
17
+ handles all of it, and signs to the [Standard Webhooks](https://www.standardwebhooks.com)
18
+ spec, so your receivers verify with off-the-shelf libraries in any language and
19
+ you never write verification docs of your own. That conformance is enforced in
20
+ CI: any drift from the spec fails the build.
21
+
22
+ Headless by design: models, jobs, and an optional [JSON API](#http-api). Works
23
+ with any ActiveJob backend on Rails 7.1+.
24
+
25
+ ### 30-second tour
26
+
27
+ Any model can own endpoints (an account, team, or user):
28
+
29
+ ```ruby
30
+ class Account < ApplicationRecord
31
+ has_many :webhook_endpoints, as: :owner, class_name: "Angarium::Endpoint"
32
+ end
33
+
34
+ # Register an endpoint; the signing secret is generated for you
35
+ account.webhook_endpoints.create!(
36
+ name: "Production",
37
+ url: "https://example.com/webhooks",
38
+ subscribed_events: ["invoice.*", "user.created"] # exact, "prefix.*", or "*"
39
+ )
40
+
41
+ # Fan an event out to every subscribed endpoint
42
+ Angarium.dispatch("invoice.paid", { id: 123, total: 4200 }, owner: account)
43
+ ```
44
+
45
+ Angarium handles the rest: signing, retries with backoff, `Retry-After`,
46
+ dedup-friendly delivery IDs, SSRF checks, and a full attempt log. See
47
+ [Delivery guarantees](#delivery-guarantees) for the specifics receivers care about.
48
+
49
+ ## Installation
50
+
51
+ Add to your Gemfile:
52
+
53
+ ```ruby
54
+ gem "angarium"
55
+ ```
56
+
57
+ Then:
58
+
59
+ ```bash
60
+ bundle install
61
+ bin/rails g angarium:install
62
+ bin/rails db:migrate
63
+ ```
64
+
65
+ `angarium:install` writes the initializer and installs Angarium's migrations in
66
+ one step. For a separate database, pass `--database=NAME` (see
67
+ [Multiple databases](#multiple-databases)).
68
+
69
+ ### Active Record Encryption
70
+
71
+ Angarium encrypts each endpoint's `signing_secret` and `custom_headers` at rest,
72
+ so it needs Active Record Encryption keys. If you haven't set them up, it's one
73
+ command:
74
+
75
+ ```bash
76
+ bin/rails db:encryption:init
77
+ ```
78
+
79
+ Add the generated keys to your credentials (`config/credentials.yml.enc`) or
80
+ set `config.active_record.encryption.{primary_key,deterministic_key,key_derivation_salt}`.
81
+ See the [Rails guide on Active Record Encryption](https://guides.rubyonrails.org/active_record_encryption.html).
82
+
83
+ The migration above creates plain columns, so you can run it before or after
84
+ setting up keys: encryption applies when rows are written, not when the table is
85
+ created.
86
+
87
+ ## Dispatching events
88
+
89
+ `Angarium.dispatch` fans a single event out to every enabled, subscribed
90
+ endpoint, creating one delivery each and one ActiveJob per delivery:
91
+
92
+ ```ruby
93
+ Angarium.dispatch("invoice.paid", { id: 123, total: 4200 }, owner: account)
94
+ ```
95
+
96
+ Each request is delivered as a JSON envelope:
97
+
98
+ ```json
99
+ { "id": 42, "event": "invoice.paid", "created_at": "2026-07-04T12:00:00Z", "data": { "id": 123, "total": 4200 } }
100
+ ```
101
+
102
+ ## Verifying signatures (receiver side)
103
+
104
+ **Angarium signs webhooks using the [Standard Webhooks](https://www.standardwebhooks.com)
105
+ specification**, so receivers can verify them with the official
106
+ [`standardwebhooks` libraries](https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries)
107
+ in any language (Ruby, Python, JavaScript, Go, Rust, PHP, Java, and more), with no
108
+ Angarium-specific code required. Conformance is enforced in CI: signed requests
109
+ are verified with the official `standardwebhooks` Ruby library, so any drift from
110
+ the spec fails the build.
111
+
112
+ Every request carries three headers:
113
+
114
+ | Header | Value |
115
+ | --- | --- |
116
+ | `webhook-id` | Unique, retry-stable message id: the delivery's `id`, the **same value** as the envelope's `id`. It is unique per *delivery*, not per event, so the same event delivered to two endpoints has two different ids. |
117
+ | `webhook-timestamp` | Unix seconds when the request was signed. |
118
+ | `webhook-signature` | Space-delimited list of `v1,<base64 HMAC-SHA256>` tokens (one per active signing secret). |
119
+
120
+ The signature is `HMAC-SHA256(secret_key, "{webhook-id}.{webhook-timestamp}.{body}")`,
121
+ base64-encoded, where `secret_key` is the base64-decoded portion of the
122
+ `whsec_`-prefixed `signing_secret`.
123
+
124
+ You can verify with any Standard Webhooks library, or with Angarium's own helper.
125
+ Pass a Rails `request:` and it reads the raw body and `webhook-*` headers for you:
126
+
127
+ ```ruby
128
+ Angarium::Signature.verify(request: request, secret: endpoint.signing_secret)
129
+ # => true / false
130
+ ```
131
+
132
+ Or pass the fields explicitly:
133
+
134
+ ```ruby
135
+ Angarium::Signature.verify(
136
+ payload: request.raw_post,
137
+ id: request.headers["webhook-id"],
138
+ timestamp: request.headers["webhook-timestamp"],
139
+ signature: request.headers["webhook-signature"],
140
+ secret: endpoint.signing_secret
141
+ )
142
+ ```
143
+
144
+ `verify` also enforces a timestamp tolerance (default 300s) to resist replay.
145
+
146
+ The secret (a `whsec_...` string) is stored encrypted at rest and is only
147
+ decrypted in memory when signing; `endpoint.signing_secret` returns the
148
+ plaintext, so deliver it to receivers over a secure channel.
149
+
150
+ ### Rotating a signing secret (zero-downtime)
151
+
152
+ Rotate a secret with `endpoint.rotate_secret!` (returns the new
153
+ plaintext). During a grace window (`config.signing_secret_grace_period`, default
154
+ `24.hours`) every delivery is signed with **both** the new and the previous
155
+ secret. The `webhook-signature` header carries multiple space-delimited `v1,`
156
+ tokens:
157
+
158
+ ```
159
+ webhook-signature: v1,<new_sig> v1,<previous_sig>
160
+ ```
161
+
162
+ Verification succeeds if the payload matches **any** token in the header (the
163
+ Standard Webhooks libraries already do this), so a receiver still holding the
164
+ old secret keeps validating while you roll it over, and one holding the new
165
+ secret validates immediately. Once the grace period elapses, deliveries are
166
+ signed with the new secret only. This lets receivers update their copy of the
167
+ secret with zero downtime and no rejected deliveries.
168
+
169
+ ### Per-endpoint custom headers
170
+
171
+ Attach static headers (e.g. an `Authorization` bearer token the receiver
172
+ expects) to every request from an endpoint:
173
+
174
+ ```ruby
175
+ endpoint.update!(custom_headers: { "Authorization" => "Bearer abc123" })
176
+ ```
177
+
178
+ `custom_headers` must be a hash of string keys and values. Because it commonly
179
+ carries a receiver credential (like the bearer token above), it's **encrypted at
180
+ rest** with Active Record Encryption, same as the signing secret. The
181
+ `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers always win, so
182
+ a custom header can never override or spoof them. In the same spirit, reserved
183
+ and transport headers (`webhook-id`, `webhook-timestamp`, `webhook-signature`,
184
+ `host`, `content-length`, `content-type`, `transfer-encoding`, `connection`)
185
+ are rejected at validation (case-insensitively) and can't be overridden.
186
+
187
+ ## Retries
188
+
189
+ Failed deliveries (non-2xx or connection errors) are retried on the schedule in
190
+ `config.retry_schedule`. The default follows the [Standard Webhooks](https://www.standardwebhooks.com)
191
+ recommendation of a multi-day schedule with exponential backoff and jitter: our
192
+ instantiation is twelve retries spanning ~10 days (`5s, 5m, 30m, 2h, 5h, 10h,
193
+ 14h, 20h, 24h, 36h, 48h, 72h`, after an immediate first delivery). Every attempt
194
+ is recorded as an `Angarium::DeliveryAttempt`. After the schedule is exhausted
195
+ the delivery is marked `exhausted`.
196
+
197
+ Each `DeliveryAttempt` stores the response body, truncated to
198
+ `config.max_response_body_bytes` bytes (default `65_536`; set `nil` to store the
199
+ full body).
200
+
201
+ ### Status-code handling
202
+
203
+ Angarium follows the Standard Webhooks receiver-etiquette guidance:
204
+
205
+ | Response | Handling |
206
+ | --- | --- |
207
+ | `2xx` | Success. |
208
+ | `410 Gone` | The receiver wants no more webhooks. The **endpoint status becomes `gone`** and the delivery is marked `gone`, with no retries. |
209
+ | `429`, `502`, `504` | Retryable failure, retried with backoff, honoring `Retry-After` when present (the recommended way to throttle). |
210
+ | `3xx` and everything else | Retryable failure. Redirects are **not** followed (following them loads both sides); update the endpoint URL instead. |
211
+
212
+ ### Backoff jitter
213
+
214
+ Each retry delay gets a small amount of additive positive jitter
215
+ (`config.retry_jitter`, default `0.15` → up to +15%) so many deliveries failing
216
+ at once don't retry in lockstep and stampede the receiver.
217
+
218
+ ### Retry-After
219
+
220
+ When a failed response carries a `Retry-After` header (seconds or an HTTP-date),
221
+ Angarium honors it, but only when it asks for a **longer** wait than the
222
+ scheduled backoff. It takes the later of the two, so a receiver's `Retry-After`
223
+ can *delay* the next attempt but never pull it *earlier* than our schedule. This
224
+ keeps a malicious or misconfigured receiver from using a tiny `Retry-After` to
225
+ defeat our backoff and make us retry aggressively. The honored value is capped at
226
+ `config.max_retry_after` (default `3600` seconds), and the whole behavior can be
227
+ disabled with `config.respect_retry_after = false`.
228
+
229
+ One interaction to note with the default schedule: because the cap is one hour,
230
+ `Retry-After` can only ever extend the wait during the early steps (up to the
231
+ `30m` step). Once backoff reaches `2h` and beyond, a capped `Retry-After` is
232
+ always shorter than the scheduled delay, so it has no effect. If you need
233
+ receivers to push back harder late in the schedule, raise `config.max_retry_after`.
234
+
235
+ ### Manual redelivery
236
+
237
+ Re-send any delivery, including an exhausted one, with:
238
+
239
+ ```ruby
240
+ delivery.redeliver!
241
+ ```
242
+
243
+ This resets the retry cycle (`state` → `pending`, `attempt_count` → 0) and
244
+ enqueues a fresh `DeliverJob`, while keeping the prior `DeliveryAttempt` history.
245
+
246
+ ### Endpoint status
247
+
248
+ Every endpoint has a lifecycle `status` (only `enabled` endpoints receive
249
+ deliveries):
250
+
251
+ | Status | Meaning | Resumable? |
252
+ | --- | --- | --- |
253
+ | `unverified` | Created but not yet proven (opt-in). Receives no dispatched deliveries. | verified by a successful `ping!`, or `endpoint.verify!` |
254
+ | `enabled` | Delivering normally. | n/a |
255
+ | `paused` | Turned off manually (`endpoint.pause!`). | `endpoint.enable!` |
256
+ | `disabled` | Auto-disabled after too many consecutive failures. | `endpoint.enable!` |
257
+ | `gone` | The receiver returned `410 Gone`. Treat as terminal. | `endpoint.enable!` (explicit override) |
258
+
259
+ Every transition stamps `status_changed_at`. `endpoint.enable!` also clears the
260
+ failure counter. Scope enabled endpoints with `Angarium::Endpoint.enabled`.
261
+
262
+ The `enabled` filter also applies to deliveries **already queued** when the status
263
+ changes (a retry cycle that trips auto-disable, a manual `pause!`, or a sibling
264
+ delivery's `410`). Each delivery re-checks the endpoint before it attempts:
265
+ `paused` **holds** the delivery (it stays `pending`, consumes no attempt, and
266
+ `enable!` re-enqueues it), while `disabled`/`gone` **cancels** it (a terminal
267
+ `canceled` state, logged with the reason). Recover a canceled delivery after
268
+ re-enabling with `delivery.redeliver!`. (Nothing transitions back to
269
+ `unverified`, so dispatched and retried deliveries never meet one; a non-forced
270
+ delivery to an unverified endpoint would `cancel` like `disabled`/`gone`, since
271
+ only `enabled` passes the guard. `ping!` forces past it, which is how
272
+ verification happens.)
273
+
274
+ ### Verifying an endpoint
275
+
276
+ Create an endpoint as `unverified` to make it prove itself before it receives any
277
+ events. An unverified endpoint is excluded from dispatch, so no webhooks are sent
278
+ until it is verified:
279
+
280
+ ```ruby
281
+ endpoint = Angarium::Endpoint.create!(owner: current_user, url: params[:url],
282
+ subscribed_events: ["*"], status: :unverified)
283
+
284
+ endpoint.ping! # forces past the status guard (force: true default), so it sends
285
+ endpoint.reload.enabled? # => true once the ping is delivered (2xx)
286
+ ```
287
+
288
+ A successful delivery to an unverified endpoint (a `ping!` in practice, since
289
+ dispatch skips unverified endpoints) verifies it: the status moves to `enabled`
290
+ and the `on_endpoint_verified` callback fires. You can also verify manually with
291
+ `endpoint.verify!`. Verification only promotes `unverified` endpoints; a
292
+ `disabled` or `gone` endpoint is revived with `enable!`, never silently by a ping.
293
+
294
+ Through the JSON API, set the `create_unverified?` policy predicate to `true` to
295
+ create endpoints `unverified`, then `POST /endpoints/:id/ping` (which verifies on
296
+ a successful delivery) or `POST /endpoints/:id/verify` to promote one manually.
297
+
298
+ ### Auto-disabling failing endpoints
299
+
300
+ Set `config.auto_disable_endpoint_after` to a number of **consecutive** failed
301
+ deliveries after which an endpoint is automatically moved to `disabled`.
302
+ `endpoint.consecutive_failures` tracks the running count and resets to `0` on the
303
+ next successful delivery. Left `nil` (the default), endpoints are never
304
+ auto-disabled. (A `410 Gone` response moves the endpoint to `gone` immediately,
305
+ regardless of this setting.)
306
+
307
+ ### Notification callbacks
308
+
309
+ When delivery fails for good, the Standard Webhooks guidance is to notify the
310
+ consumer out of band (email, Slack, PagerDuty). Angarium is headless, so it hands
311
+ you the events and lets you do the notifying via config callbacks. Two fire when
312
+ delivery fails for good; a third, `on_endpoint_verified`, fires when an
313
+ `unverified` endpoint passes its first delivery (see
314
+ [Verifying an endpoint](#verifying-an-endpoint)):
315
+
316
+ ```ruby
317
+ Angarium.configure do |config|
318
+ # A delivery has exhausted its whole retry schedule.
319
+ config.on_delivery_exhausted = ->(delivery) do
320
+ AdminMailer.webhook_failed(delivery).deliver_later
321
+ end
322
+
323
+ # An endpoint was deactivated. reason is :consecutive_failures (status becomes
324
+ # `disabled`) or :gone (HTTP 410, status becomes `gone`).
325
+ config.on_endpoint_deactivated = ->(endpoint, reason) do
326
+ AdminMailer.endpoint_deactivated(endpoint, reason).deliver_later
327
+ end
328
+ end
329
+ ```
330
+
331
+ All callbacks are optional. A callback that raises is logged and swallowed, so a
332
+ broken notifier never breaks delivery.
333
+
334
+ ### Recovering interrupted deliveries
335
+
336
+ If a worker dies mid-delivery (crash, deploy, OOM) after a delivery is marked
337
+ `delivering` but before the attempt is recorded or rescheduled, that delivery
338
+ would otherwise be stranded (the job only re-runs `pending` deliveries). Requeue
339
+ these with a periodic reaper:
340
+
341
+ ```ruby
342
+ Angarium::Delivery.reap_stalled # requeues deliveries stuck in `delivering`
343
+ # or from cron/scheduler: bin/rails angarium:reap
344
+ ```
345
+
346
+ Anything `delivering` whose last attempt began more than
347
+ `config.delivering_timeout` ago (default `15.minutes`) is presumed abandoned and
348
+ reset to `pending`. Keep the timeout well above a single attempt's worst case
349
+ (`open_timeout + http_timeout`) so a slow-but-alive worker isn't reaped; a
350
+ redelivery is at-least-once-safe either way. Set it to `nil` to disable reaping.
351
+
352
+ ### Pinging an endpoint
353
+
354
+ Verify an endpoint end-to-end by delivering a synthetic `angarium.ping` event
355
+ (subscription matching is bypassed, so a ping is always sent). Returns the
356
+ `Angarium::Delivery`, so you can reload it to inspect the outcome:
357
+
358
+ ```ruby
359
+ delivery = endpoint.ping!
360
+ # optionally: endpoint.ping!(message: "hello")
361
+ delivery.reload.succeeded? # => true once delivered
362
+ ```
363
+
364
+ A ping always sends, even to an `unverified`, `paused`, `disabled`, or `gone`
365
+ endpoint, so you can test one before enabling it. Pass
366
+ `endpoint.ping!(force: false)` to respect the endpoint status guard instead (held
367
+ while `paused`, canceled while `unverified`/`disabled`/`gone`). The lower-level `delivery.deliver!(force: true)` and
368
+ `Delivery#redeliver!(force: true)` override the guard the same way, for a single
369
+ attempt; any retry that attempt schedules follows the normal status rules again.
370
+
371
+ ### At-least-once delivery
372
+
373
+ Delivery is **at-least-once**: a webhook may arrive more than once, from a retry
374
+ after a receiver processed the request but the response was lost, or a rare
375
+ duplicate job enqueue. **Make your receivers idempotent**: dedupe on the
376
+ envelope's `id` (stable across every attempt of the same delivery) and treat a
377
+ repeat as a no-op.
378
+
379
+ ## Data retention
380
+
381
+ Every delivery attempt stores the receiver's response body (capped at
382
+ `config.max_response_body_bytes`, 64KB by default). Because
383
+ `angarium_delivery_attempts` grows with delivery volume × retries, a busy app
384
+ talking to a flapping receiver can accumulate rows quickly. You have three
385
+ options to keep it bounded:
386
+
387
+ ```ruby
388
+ # 1. Set a retention window and prune on a schedule (cron / your scheduler):
389
+ Angarium.config.delivery_attempt_retention = 90.days
390
+ # then run periodically: bin/rails angarium:prune
391
+
392
+ # 2. Or prune inline, wherever you like:
393
+ Angarium::DeliveryAttempt.prune(older_than: 90.days)
394
+
395
+ # 3. Or store less per attempt by lowering the response-body cap:
396
+ Angarium.config.max_response_body_bytes = 4_096
397
+ ```
398
+
399
+ ## HTTP API
400
+
401
+ Angarium ships an optional **JSON API** for managing endpoints and browsing
402
+ deliveries. It has no HTML views or UI of its own. Mount the engine wherever you
403
+ like:
404
+
405
+ ```ruby
406
+ # config/routes.rb
407
+ mount Angarium::Engine => "/webhooks"
408
+ ```
409
+
410
+ ### Authentication
411
+
412
+ The API has no auth of its own; it uses yours. Its controllers inherit from
413
+ `config.parent_controller` (default `"ApplicationController"`), so every
414
+ `before_action` your app already runs (Devise, Rodauth, etc.) applies here too.
415
+ Angarium reads the signed-in user via your current-user convention:
416
+
417
+ ```ruby
418
+ config.parent_controller = "ApplicationController" # or your API base controller
419
+ config.current_user = ->(controller) { controller.current_user }
420
+ ```
421
+
422
+ Requests without a resolved current user get a `401`.
423
+
424
+ ### Authorization
425
+
426
+ Authorization lives in one place: a **policy** class, `config.policy_class`
427
+ (default `Angarium::Api::Policy`). Generate one to start from (it creates the
428
+ class and points `config.policy_class` at it in your initializer):
429
+
430
+ ```bash
431
+ bin/rails g angarium:policy # app/policies/webhook_endpoint_policy.rb
432
+ ```
433
+
434
+ Angarium instantiates the policy per request with the controller and (for member
435
+ actions) the target record, and runs it in the controller's context, so
436
+ `current_user`, `params`, `controller`, and `record` are all available. Its
437
+ methods:
438
+
439
+ | Method | Default | Purpose |
440
+ | --- | --- | --- |
441
+ | `scope(relation)` | `relation.where(owner: current_user)` | Narrows a base relation to the endpoints this user may see and act on. Reads, finds, and delivery/attempt access all go through it. |
442
+ | `owner` | `current_user` | The owner assigned to a newly-created endpoint. Set before `create?` runs, so you can gate the target owner there via `record.owner`. |
443
+ | `create_unverified?` | `false` | Whether endpoints created through the API start `unverified` (no deliveries until a successful ping verifies them) instead of live. |
444
+ | `permit_allow_private_network?` | `false` | Whether `allow_private_network` (relax the private-IP block) is API-writable. Dangerous; trusted operators only. |
445
+ | `permit_allowed_networks?` | `false` | Whether `allowed_networks` (a restrictive CIDR allowlist) is API-writable. |
446
+ | `index?` `show?` `create?` `update?` `destroy?` | `true` | Whether each action is allowed. |
447
+ | `rotate_secret?` `pause?` `enable?` `verify?` `ping?` `redeliver?` | `update?` | Member actions; default to the `update?` capability. |
448
+
449
+ Override only what you need; the defaults are single-owner (you see and manage
450
+ your own endpoints). A denied action returns `403`; anything outside `scope` is a
451
+ `404`.
452
+
453
+ ```ruby
454
+ class WebhookEndpointPolicy < Angarium::Api::Policy
455
+ # Multi-tenant visibility: compose on top of the relation you're given.
456
+ def scope(relation) = relation.where(owner_id: current_user.account.owner_ids)
457
+
458
+ # Admins may create for any owner in their account (via an owner_id param);
459
+ # everyone else creates for themselves.
460
+ def owner
461
+ id = params[:owner_id]
462
+ id && current_user.admin? ? current_user.account.owners.find(id) : current_user
463
+ end
464
+
465
+ # Restrict individual actions (members default to update?, which defaults true).
466
+ def update? = current_user.can?(:manage_webhooks)
467
+ def destroy? = current_user.admin?
468
+ end
469
+
470
+ config.policy_class = "WebhookEndpointPolicy"
471
+ ```
472
+
473
+ ### Objects
474
+
475
+ Responses wrap these objects. `signing_secret` and `custom_headers` are never
476
+ included (see the note below):
477
+
478
+ ```json
479
+ // endpoint
480
+ { "id": 1, "name": "Production", "url": "https://example.com/webhooks",
481
+ "status": "enabled", "subscribed_events": ["invoice.*"], "allow_private_network": false,
482
+ "allowed_networks": [], "consecutive_failures": 0, "status_changed_at": null,
483
+ "created_at": "2026-07-04T12:00:00Z", "updated_at": "2026-07-04T12:00:00Z" }
484
+
485
+ // delivery
486
+ { "id": 42, "endpoint_id": 1, "event": "invoice.paid", "state": "succeeded",
487
+ "attempt_count": 1, "next_attempt_at": null, "last_attempt_at": "2026-07-04T12:00:01Z",
488
+ "created_at": "2026-07-04T12:00:00Z", "updated_at": "2026-07-04T12:00:01Z" }
489
+
490
+ // attempt
491
+ { "id": 7, "delivery_id": 42, "response_code": 200, "response_body": "ok",
492
+ "error": null, "duration": 0.12, "created_at": "2026-07-04T12:00:01Z" }
493
+
494
+ // pagination (on every list response)
495
+ { "limit": 50, "offset": 0, "count": 20, "total": 137 }
496
+ ```
497
+
498
+ ### Routes
499
+
500
+ | Method & path | Request body | Response |
501
+ | --- | --- | --- |
502
+ | `GET /endpoints?limit=&offset=` | none | `200 { "endpoints": [endpoint, ...], "pagination": pagination }` |
503
+ | `POST /endpoints` | `{ "endpoint": { "name", "url", "subscribed_events": [...] } }` | `201 { "endpoint": {...endpoint, "signing_secret": "whsec_..."} }` |
504
+ | `GET /endpoints/:id` | none | `200 { "endpoint": endpoint }` |
505
+ | `PATCH /endpoints/:id` | `{ "endpoint": { "name": "New name" } }` | `200 { "endpoint": endpoint }` |
506
+ | `DELETE /endpoints/:id` | none | `204` (no body) |
507
+ | `POST /endpoints/:id/rotate_secret` | none | `200 { "endpoint": endpoint, "signing_secret": "whsec_..." }` |
508
+ | `POST /endpoints/:id/pause`, `/enable`, `/verify` | none | `200 { "endpoint": endpoint }` |
509
+ | `POST /endpoints/:id/ping` | none | `202 { "delivery": delivery }` |
510
+ | `GET /endpoints/:id/deliveries?limit=&offset=` | none | `200 { "deliveries": [delivery, ...], "pagination": pagination }` |
511
+ | `GET /deliveries/:id` | none | `200 { "delivery": delivery }` |
512
+ | `POST /deliveries/:id/redeliver` | none | `202 { "delivery": delivery }` |
513
+ | `GET /deliveries/:id/attempts?limit=&offset=` | none | `200 { "attempts": [attempt, ...], "pagination": pagination }` |
514
+
515
+ - **Secrets are never echoed.** `signing_secret` is returned only by `create` and
516
+ `rotate_secret`; `custom_headers` (which may hold a credential) is write-only.
517
+ - **Pagination.** List endpoints take `?limit=` (default 50, max 200) and
518
+ `?offset=`, and each list response carries a `pagination` object (`limit`,
519
+ `offset`, `count` in this page, `total` overall); there are more when
520
+ `offset + count < total`.
521
+ - **Errors are JSON.** `422 { "error": "validation failed", "details": [...] }`
522
+ for an invalid body, plus `401` (unauthenticated), `403` (policy denied), and
523
+ `404` (out of scope).
524
+
525
+ ### Permitted attributes
526
+
527
+ `POST`/`PATCH /endpoints` accept these keys under `endpoint`; anything else is
528
+ ignored (strong parameters). The exception is the two privileged controls below:
529
+ when your policy hasn't permitted them, they are **rejected with a `422`, not
530
+ silently ignored**, so an attempt to enable one never looks like it succeeded.
531
+
532
+ | Attribute | Type | Notes |
533
+ | --- | --- | --- |
534
+ | `name` | string | |
535
+ | `url` | string | the receiver URL (SSRF-validated on every change) |
536
+ | `subscribed_events` | array of strings | event patterns: exact, `"prefix.*"`, or `"*"` |
537
+ | `custom_headers` | object | write-only; sent with each delivery, never echoed back |
538
+ | `allow_private_network` | boolean | privileged; **not writable by default**, see below |
539
+ | `allowed_networks` | array of CIDRs | privileged; **not writable by default**, see below |
540
+
541
+ `status` is not writable (use the `pause` / `enable` actions), and the owner of a
542
+ created endpoint comes from the policy's `owner`, not the request.
543
+
544
+ `allow_private_network` and `allowed_networks` are **independent** SSRF controls,
545
+ each gated by its own policy predicate (default off), because they do opposite
546
+ things:
547
+
548
+ - `allow_private_network` **relaxes** protection: it lets an endpoint deliver to
549
+ private and loopback addresses. This is the dangerous one; an end user who can
550
+ set it can point a webhook at your internal network.
551
+ - `allowed_networks` **restricts** delivery to a CIDR allowlist (both it and the
552
+ private-IP denylist must still pass), so it's safe to expose more widely.
553
+
554
+ You can always set them from trusted code, regardless of the API:
555
+
556
+ ```ruby
557
+ endpoint.update!(allow_private_network: true, allowed_networks: ["10.0.5.0/24"])
558
+ ```
559
+
560
+ To permit either through the API, override its predicate. Being independent, you
561
+ can allow the safe one without the dangerous one:
562
+
563
+ ```ruby
564
+ class WebhookEndpointPolicy < Angarium::Api::Policy
565
+ def permit_allow_private_network? = current_user.operator? # dangerous: operators only
566
+ def permit_allowed_networks? = true # restrictive: safe to expose
567
+ end
568
+ ```
569
+
570
+ A request that tries to **change** a control it isn't permitted to set gets a
571
+ `422` naming the attribute, rather than the change being silently dropped, so a
572
+ misconfigured client fails loudly instead of appearing to work. (Sending a
573
+ control's current value is a no-op.)
574
+
575
+ ## Security (SSRF protection)
576
+
577
+ Because endpoint URLs are user-supplied, Angarium guards against Server-Side
578
+ Request Forgery. Three controls, validated when an endpoint is created or when
579
+ its `url`, `allow_private_network`, or `allowed_networks` change, and re-checked
580
+ at delivery time:
581
+
582
+ - **`config.block_private_ips`** (default `true`) blocks delivery to
583
+ private, loopback, and link-local addresses (e.g. `127.0.0.1`, `10.0.0.0/8`,
584
+ `169.254.169.254`), including IPv4-mapped IPv6 forms (e.g. `::ffff:127.0.0.1`)
585
+ and the unspecified address (`0.0.0.0` / `::`).
586
+ - **`endpoint.allow_private_network`** (default `false`) is the per-endpoint opt-in
587
+ required to deliver to a private address. An allowlist entry alone does **not**
588
+ unlock a private address.
589
+ - **`endpoint.allowed_networks`** (CIDR array), when set, restricts this
590
+ endpoint's deliveries to those CIDRs. It only narrows; to allow a private range
591
+ you must also set `allow_private_network`.
592
+
593
+ > **Note:** `allow_private_network` is a privileged control. Expose it only to
594
+ > trusted operators, never to end users; otherwise it becomes an SSRF opt-in.
595
+
596
+ **Connect-time IP pinning:** the delivery-time check re-resolves the host,
597
+ rejects disallowed addresses, and then pins the connection to exactly the
598
+ validated IP(s), so HTTPX does not re-resolve or connect elsewhere, while TLS
599
+ SNI and certificate verification still use the original hostname. This closes
600
+ the DNS-rebinding window between resolution and connection. Angarium's own
601
+ resolver is the single source of truth: if it can't resolve a host, the
602
+ delivery fails (retryable) rather than falling back to an unvalidated HTTPX
603
+ resolution, so there is no unpinned path. The only cost is that hosts
604
+ resolvable *only* via non-DNS mechanisms Angarium's resolver doesn't use
605
+ (e.g. mDNS `.local`) won't be delivered to, which is not a concern for real
606
+ webhook endpoints. HTTPX does not follow redirects, so redirect-based
607
+ bypasses are already closed.
608
+
609
+ Found a gap in any of this? Report it privately: see [SECURITY.md](SECURITY.md)
610
+ for the disclosure process (GitHub's private advisory workflow) and the surfaces
611
+ we hold ourselves to.
612
+
613
+ ## Delivery guarantees
614
+
615
+ What Angarium actually promises about delivery, so a receiver knows what it can
616
+ rely on:
617
+
618
+ - **At-least-once, not exactly-once.** A delivery is retried until it succeeds,
619
+ the schedule is exhausted, or its endpoint is deactivated mid-cycle (the
620
+ delivery is `canceled`, kept, and recoverable with `redeliver!`), so the same
621
+ event can arrive more than once (for example, a retry after your `200` was lost
622
+ in transit). Every request carries a `webhook-id` that stays constant across a
623
+ delivery's retries: dedupe on it and treat repeats as no-ops.
624
+ - **No ordering.** Deliveries are independent jobs and each retries on its own
625
+ schedule, so events can arrive out of order. If order matters, put a sequence
626
+ number or timestamp in the payload and sort on the receiver.
627
+ - **Durable; nothing is silently dropped.** Every attempt is persisted as an
628
+ `Angarium::DeliveryAttempt` (response code, body, error, duration), and a
629
+ delivery that exhausts its retries is kept in the `exhausted` state, not
630
+ deleted. You can always tell whether an event was delivered, and re-send with
631
+ `delivery.redeliver!`.
632
+ - **Authenticated per request.** Every request is signed and timestamped per the
633
+ [Standard Webhooks](https://www.standardwebhooks.com) spec (HMAC-SHA256 over
634
+ `{id}.{timestamp}.{body}`, 5-minute tolerance), so a receiver can confirm it
635
+ came from you and reject replays, independent of transport.
636
+
637
+ Secret rotation, SSRF protection, and encryption harden delivery but aren't
638
+ delivery-semantics guarantees; they have their own sections above.
639
+
640
+ ## Instrumentation
641
+
642
+ Angarium emits `ActiveSupport::Notifications` events so you can feed delivery
643
+ metrics and traces into your own backend (StatsD, Prometheus, OpenTelemetry, or
644
+ structured logs). This is the metrics leg beside the notification callbacks
645
+ (alerting) and the `DeliveryAttempt` rows (audit); it is off unless you subscribe.
646
+
647
+ **`deliver.angarium`** fires once per delivery attempt:
648
+
649
+ | Key | Notes |
650
+ | --- | --- |
651
+ | `delivery_id`, `endpoint_id` | ids |
652
+ | `event` | the event name being delivered |
653
+ | `outcome` | `delivered` \| `failed` \| `gone` \| `held` \| `canceled` \| `blocked` \| `unresolvable` |
654
+ | `attempt` | attempt number (absent for `held`/`canceled`) |
655
+ | `code` | HTTP status, when a response was received |
656
+ | `http_duration` | wire time in seconds, when a request went out |
657
+ | `error` | failure reason string, on `blocked`/`unresolvable` and a transport-error `failed` (a non-2xx `failed` carries its status in `code` instead) |
658
+ | `force` | whether the status guard was bypassed |
659
+
660
+ **`dispatch.angarium`** fires once per `Angarium.dispatch`: `event`, `event_id`,
661
+ and `deliveries` (fan-out count).
662
+
663
+ Payloads carry ids, codes, and timings only, never the signing secret or the
664
+ request/response body, so they are safe to ship to third-party backends.
665
+
666
+ ```ruby
667
+ ActiveSupport::Notifications.subscribe("deliver.angarium") do |*, payload|
668
+ StatsD.increment("webhooks.delivery.#{payload[:outcome]}")
669
+ StatsD.histogram("webhooks.delivery.ms", payload[:http_duration] * 1000) if payload[:http_duration]
670
+ end
671
+ ```
672
+
673
+ ## Configuration
674
+
675
+ Run `bin/rails g angarium:install` to generate `config/initializers/angarium.rb`,
676
+ which documents every option inline. The delivery and retry settings:
677
+
678
+ | Option | Default | What it controls |
679
+ | --- | --- | --- |
680
+ | `job_queue` | `:default` | ActiveJob queue used for deliveries. |
681
+ | `http_timeout` | `10` | HTTP read timeout (seconds) per attempt. |
682
+ | `open_timeout` | `5` | TCP connect timeout (seconds) per attempt. |
683
+ | `user_agent` | `"Angarium/<version>"` | User-Agent header on each delivery. |
684
+ | `retry_schedule` | 12 delays over ~10 days | Backoff between retries; its length is the retry count. |
685
+ | `retry_jitter` | `0.15` | Fraction of additive positive jitter on each backoff delay. |
686
+ | `respect_retry_after` | `true` | Honor a receiver's `Retry-After` header (delay-only). |
687
+ | `max_retry_after` | `3600` | Cap (seconds) on a honored `Retry-After`; `nil` is uncapped. |
688
+ | `auto_disable_endpoint_after` | `nil` | Deactivate an endpoint after N consecutive failures; `nil` never does. |
689
+ | `signing_secret_grace_period` | `24.hours` | How long a rotated endpoint's previous secret stays valid. |
690
+ | `block_private_ips` | `true` | Reject endpoint URLs resolving to private/loopback addresses (SSRF). |
691
+ | `max_response_body_bytes` | `65_536` | Truncate the stored response body; `nil` stores it whole. |
692
+ | `delivery_attempt_retention` | `nil` | Age past which `angarium:prune` deletes attempts; `nil` keeps all. |
693
+ | `delivering_timeout` | `15.minutes` | Age after which `angarium:reap` requeues a stuck `delivering` delivery. |
694
+ | `primary_key_type` | `nil` | Primary key type for Angarium's tables (see below). |
695
+ | `on_delivery_exhausted` | `nil` | Callback `->(delivery)` when a delivery exhausts its retries. |
696
+ | `on_endpoint_deactivated` | `nil` | Callback `->(endpoint, reason)` when an endpoint is disabled or gone. |
697
+ | `on_endpoint_verified` | `nil` | Callback `->(endpoint)` when an `unverified` endpoint is verified. |
698
+
699
+ Mounting the JSON API adds `parent_controller`, `current_user`, and
700
+ `policy_class` (see [Authentication](#authentication) and
701
+ [Authorization](#authorization)). Multi-database setups add `database` and
702
+ `connects_to` (see [Multiple databases](#multiple-databases)).
703
+
704
+ ### Primary keys
705
+
706
+ Angarium's own tables (`angarium_endpoints`, `angarium_events`,
707
+ `angarium_deliveries`, `angarium_delivery_attempts`) follow
708
+ `config.primary_key_type`: leave it `nil` (the default) to inherit your app's
709
+ own default (its `config.generators.active_record.primary_key_type`, or
710
+ bigint if that's unset), or set it explicitly (e.g. `:uuid`) to force a type
711
+ regardless of the app's default.
712
+
713
+ `owner_id` on `angarium_endpoints` is always a string column, since a
714
+ polymorphic owner can point at models with different primary key types (an
715
+ integer-keyed `User` and a UUID-keyed `Account` in the same app). This works
716
+ transparently with any owner primary key (integer, UUID, or a mix) without
717
+ any configuration.
718
+
719
+ ### Multiple databases
720
+
721
+ To keep Angarium's tables out of your primary database, install with a
722
+ `--database` flag. It records `config.database` and puts the migrations in that
723
+ database's own path in one step:
724
+
725
+ ```bash
726
+ bin/rails g angarium:install --database=angarium
727
+ ```
728
+
729
+ That writes `config.database = :angarium` to your initializer and installs the
730
+ engine's migrations into `db/angarium_migrate` (not the primary `db/migrate`), so
731
+ each database keeps its own `schema_migrations`. All four Angarium models inherit
732
+ from one abstract `Angarium::ApplicationRecord`, so `config.database` points the
733
+ whole engine at that connection. Add the database to `database.yml`:
734
+
735
+ ```yaml
736
+ # config/database.yml
737
+ production:
738
+ primary:
739
+ <<: *default
740
+ database: my_app_production
741
+ angarium:
742
+ <<: *default
743
+ database: my_app_angarium
744
+ migrations_paths: db/angarium_migrate
745
+ ```
746
+
747
+ The generator has already written the matching initializer:
748
+
749
+ ```ruby
750
+ # config/initializers/angarium.rb
751
+ Angarium.configure do |c|
752
+ c.database = :angarium
753
+ end
754
+ ```
755
+
756
+ Then run `bin/rails db:migrate:angarium`. **After a gem upgrade**, pull in new
757
+ migrations with `bin/rails g angarium:migrations` (no flag needed): it reads
758
+ `config.database` and installs them into `db/angarium_migrate` for you.
759
+
760
+ For custom roles or shards, set `config.connects_to` to a hash passed straight to
761
+ Rails' [`connects_to`](https://guides.rubyonrails.org/active_record_multiple_databases.html)
762
+ instead; it takes precedence over `config.database` for the connection.
763
+
764
+ Left unset (the default), Angarium stays on the app's primary connection. The
765
+ owner association still works across databases (it's a polymorphic reference, not
766
+ a foreign key), so your `User`/`Account` can live in the primary database while
767
+ Angarium's tables live in their own. This is independent of where your ActiveJob
768
+ backend (Solid Queue, etc.) stores its own tables.
769
+
770
+ ## Why not just POST from a job?
771
+
772
+ `HTTP.post(endpoint.url, body: payload)` in a background job works right up
773
+ until it's in front of customers. Then the edge cases arrive one at a time, and
774
+ each is a small project:
775
+
776
+ - **Signatures.** Receivers won't (and shouldn't) trust an unsigned POST. Roll
777
+ your own and you now own an HMAC scheme, a header format, and the verification
778
+ docs your customers need in every language they use. Angarium signs to
779
+ [Standard Webhooks](https://www.standardwebhooks.com), so they verify with an
780
+ off-the-shelf library and you write none.
781
+ - **Retries that don't stampede.** A receiver has a bad 30 minutes; a naive retry
782
+ either gives up too early or hammers them in lockstep. Angarium retries on a
783
+ backoff schedule (~10 days by default) with jitter, and honors `Retry-After`,
784
+ but only to *delay* a retry, never to pull it earlier than your schedule.
785
+ - **Duplicate suppression.** Retries mean the same event lands more than once.
786
+ Without a stable ID that's invariant across a delivery's retries, receivers
787
+ can't dedupe. Angarium gives every delivery exactly that.
788
+ - **SSRF.** An endpoint URL is user input. POST to it blindly and a customer can
789
+ point it at `169.254.169.254` or `10.0.0.1` and read your internal network.
790
+ Angarium blocks private ranges, pins the connection to the validated IP, and
791
+ fails closed on hosts it can't resolve.
792
+ - **Secret rotation.** Rotating a signing secret through a single POST path means
793
+ a window where the old or new secret gets rejected. Angarium signs with both
794
+ during a grace window, so receivers roll over without dropping a webhook.
795
+ - **Stranded deliveries.** A worker crashes mid-send and the delivery is stuck
796
+ half-done, with no retry and no record. Angarium reaps deliveries stranded in
797
+ `delivering` and re-queues them.
798
+ - **"Did we actually send it?"** When support asks, you need the answer. Angarium
799
+ persists every attempt (response code, body, error, duration) and never
800
+ silently drops a delivery.
801
+
802
+ None of these is hard on its own. Building and maintaining all of them together,
803
+ as Rails and your receivers change, is the work Angarium takes off your plate.
804
+
805
+ ## How Angarium compares
806
+
807
+ There are several ways to send outbound webhooks from a Rails app. Angarium aims
808
+ to be the maintained middle ground between rolling your own delivery system and
809
+ adopting external webhook infrastructure.
810
+
811
+ | | Angarium | ActionHook | bullet_train-outgoing_webhooks | active_webhook | Svix / Hookdeck Outpost |
812
+ |---|---|---|---|---|---|
813
+ | Type | Rails engine (headless + JSON API) | Ruby delivery library | Rails engine (Bullet Train) | Ruby library | Hosted / self-hosted service |
814
+ | [Persisted endpoints & subscriptions](#30-second-tour) | ✅ per-endpoint event subscriptions | ❌ bring your own model | ✅ (tied to BT teams) | ✅ topics | ✅ |
815
+ | [Endpoint-management JSON API](#http-api) | ✅ auth + policy | ❌ | ❌ | ❌ | ✅ |
816
+ | [HMAC request signing](#verifying-signatures-receiver-side) | ✅ | ✅ (SHA256 fingerprint) | ✅ | ✅ | ✅ |
817
+ | [Standard Webhooks](https://www.standardwebhooks.com) compliant | ✅ | ❌ | ❌ | ❌ | ✅ (Svix initiated the spec) |
818
+ | [Automatic retries with backoff](#retries) | ✅ jitter + `Retry-After` | ❌ (delegates to your job runner) | ✅ | ✅ (via queue adapter) | ✅ |
819
+ | [Manual redelivery](#manual-redelivery) | ✅ | ❌ | ✅ `deliver(force:)` | ❌ | ✅ |
820
+ | [Auto-disable failing endpoints](#auto-disabling-failing-endpoints) | ✅ (opt-in) | ❌ | ✅ (opt-in) | ❌ | ✅ |
821
+ | [SSRF protection](#security-ssrf-protection) | ✅ block + pin + fail-closed | ✅ private-IP blocking | ❌ | ❌ | ✅ |
822
+ | [Signing secrets encrypted at rest](#active-record-encryption) | ✅ Active Record Encryption | n/a (you store secrets) | ❌ | ❌ | ✅ |
823
+ | [Zero-downtime secret rotation](#rotating-a-signing-secret-zero-downtime) | ✅ dual-signing grace window | ❌ | ❌ | ❌ | ✅ |
824
+ | Job backend | Any ActiveJob backend | n/a | ActiveJob | Multiple adapters | Managed workers |
825
+ | Runs inside your app | ✅ | ✅ | ✅ | ✅ | ❌ separate service |
826
+ | Framework requirements | Rails 7.1+ | Any Ruby | Bullet Train | Rails 5+ | Any (HTTP API) |
827
+
828
+ <sub>All columns verified by reading each project's source: actionhook 1.0.2,
829
+ active_webhook 1.0.0, bullet_train-outgoing_webhooks 1.45.1, as of July 2026;
830
+ Svix / Hookdeck Outpost cells reflect their published docs. Corrections welcome
831
+ via issue or PR.</sub>
832
+
833
+ ### When to choose Angarium
834
+
835
+ - You want customer-facing webhooks (endpoints, subscriptions, signing, retries)
836
+ without standing up separate infrastructure like Svix or Outpost.
837
+ - You want SSRF protection and encrypted signing secrets out of the box instead
838
+ of remembering to build them.
839
+ - You want receivers to verify with an off-the-shelf library, since Angarium is
840
+ [Standard Webhooks](https://www.standardwebhooks.com) compliant.
841
+ - You already run ActiveJob and don't want a Redis- or Sidekiq-specific dependency.
842
+
843
+ ### When to choose something else
844
+
845
+ - **You need massive multi-tenant scale, a customer-facing delivery portal, or
846
+ fan-out to queues (SQS, Kafka, EventBridge):** use [Svix](https://www.svix.com)
847
+ or [Hookdeck Outpost](https://github.com/hookdeck/outpost). They are dedicated
848
+ infrastructure and will outgrow any in-app gem.
849
+ - **You only need a hardened HTTP delivery primitive** and want to own the data
850
+ model yourself: [ActionHook](https://github.com/smsohan/actionhook) is a solid
851
+ low-level choice.
852
+ - **You're building on Bullet Train:** use
853
+ [bullet_train-outgoing_webhooks](https://rubygems.org/gems/bullet_train-outgoing_webhooks),
854
+ which integrates with its team and account model.
855
+
856
+ ## Development
857
+
858
+ After cloning the repo, install dependencies and generate the per-Rails-version
859
+ gemfiles:
860
+
861
+ ```bash
862
+ bundle install
863
+ bundle exec appraisal install # writes gemfiles/*.gemfile for each Rails version
864
+ ```
865
+
866
+ Tests run through a `test/dummy` app against the supported Rails versions via
867
+ [Appraisal](https://github.com/thoughtbot/appraisal) (there is no `rake test`
868
+ task; the runner is `bin/rails test`):
869
+
870
+ ```bash
871
+ bundle exec appraisal bin/rails test # all Rails versions
872
+ bundle exec appraisal rails-8.1 bin/rails test # a single version
873
+ bin/rails test # just your default bundle
874
+ bin/rails test test/lib/angarium/signature_test.rb # a single file
875
+ ```
876
+
877
+ Available appraisals: `rails-7.1`, `rails-7.2`, `rails-8.0`, `rails-8.1`. CI runs
878
+ the same matrix across Ruby 3.2 and 3.3 (8 jobs). After changing a dependency or
879
+ the `Appraisals` file, re-run `bundle exec appraisal install` and commit the
880
+ updated `gemfiles/`.
881
+
882
+ ### Linting and security
883
+
884
+ CI also runs [Standard](https://github.com/standardrb/standard) and
885
+ [Brakeman](https://brakemanscanner.org):
886
+
887
+ ```bash
888
+ bundle exec rake standard # lint (rake standard:fix to autocorrect)
889
+ bundle exec brakeman -q -z # static security analysis
890
+ ```
891
+
892
+ ### Cutting a release
893
+
894
+ Publishing runs from a laptop; CI only cuts the GitHub Release when the tag
895
+ lands. Version math and the changelog come from [git-cliff](https://git-cliff.org)
896
+ over your [Conventional Commits](https://www.conventionalcommits.org)
897
+ (`brew install git-cliff`):
898
+
899
+ ```bash
900
+ rake release:prepare # bump version.rb + regenerate CHANGELOG.md, then STAGE (nothing committed)
901
+ git diff --cached # review
902
+ rake release:publish # commit, gem build + push, tag + push (idempotent/resumable)
903
+ ```
904
+
905
+ `rake release:prepare[1.2.3]` forces a version instead of computing it. The bare
906
+ `rake release` is disabled in favor of this two-step flow.
907
+
908
+ ## License
909
+
910
+ Angarium is released under the [MIT License](MIT-LICENSE).