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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +65 -0
- data/MIT-LICENSE +22 -0
- data/README.md +910 -0
- data/Rakefile +13 -0
- data/SECURITY.md +51 -0
- data/app/assets/config/angarium_manifest.js +1 -0
- data/app/assets/stylesheets/angarium/application.css +15 -0
- data/app/controllers/angarium/api/attempts_controller.rb +12 -0
- data/app/controllers/angarium/api/base_controller.rb +133 -0
- data/app/controllers/angarium/api/deliveries_controller.rb +29 -0
- data/app/controllers/angarium/api/endpoints_controller.rb +117 -0
- data/app/controllers/angarium/api/not_authorized.rb +7 -0
- data/app/controllers/angarium/api/unpermitted_parameter.rb +15 -0
- data/app/helpers/angarium/application_helper.rb +4 -0
- data/app/jobs/angarium/application_job.rb +5 -0
- data/app/jobs/angarium/deliver_job.rb +11 -0
- data/app/models/angarium/application_record.rb +17 -0
- data/app/models/angarium/delivery.rb +254 -0
- data/app/models/angarium/delivery_attempt.rb +14 -0
- data/app/models/angarium/endpoint.rb +217 -0
- data/app/models/angarium/event.rb +7 -0
- data/app/policies/angarium/api/policy.rb +78 -0
- data/app/validators/angarium/endpoint_url_validator.rb +20 -0
- data/app/views/layouts/angarium/application.html.erb +15 -0
- data/config/routes.rb +22 -0
- data/db/angarium_migrate/20260704000001_create_angarium_endpoints.rb +22 -0
- data/db/angarium_migrate/20260704000002_create_angarium_events.rb +9 -0
- data/db/angarium_migrate/20260704000003_create_angarium_deliveries.rb +13 -0
- data/db/angarium_migrate/20260704000004_create_angarium_delivery_attempts.rb +12 -0
- data/lib/angarium/address_policy.rb +82 -0
- data/lib/angarium/client.rb +53 -0
- data/lib/angarium/configuration.rb +66 -0
- data/lib/angarium/dispatch.rb +23 -0
- data/lib/angarium/engine.rb +5 -0
- data/lib/angarium/event_matcher.rb +17 -0
- data/lib/angarium/signature.rb +57 -0
- data/lib/angarium/version.rb +3 -0
- data/lib/angarium.rb +44 -0
- data/lib/generators/angarium/install/install_generator.rb +57 -0
- data/lib/generators/angarium/install/templates/initializer.rb +77 -0
- data/lib/generators/angarium/migrations/migrations_generator.rb +39 -0
- data/lib/generators/angarium/policy/policy_generator.rb +40 -0
- data/lib/generators/angarium/policy/templates/policy.rb +33 -0
- data/lib/tasks/angarium_tasks.rake +15 -0
- metadata +137 -0
data/README.md
ADDED
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
# Angarium
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/angarium)
|
|
4
|
+
[](https://github.com/radioactive-labs/angarium/actions/workflows/ci.yml)
|
|
5
|
+
[](https://www.standardwebhooks.com)
|
|
6
|
+
[](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).
|