Angarium
Everything your hand-rolled webhook job is missing: HMAC signing, retries with backoff, zero-downtime secret rotation, SSRF protection, and a queryable log of every delivery attempt.
The moment "just POST from a background job" ships to production, the gaps start showing: your customers need signatures they can verify, failed deliveries need to back off and retry for hours, secrets need to rotate without downtime, an endpoint URL shouldn't be able to reach your internal network, and sooner or later someone asks "did we actually send it?". Angarium is a Rails engine that handles all of it, and signs to the Standard Webhooks spec, so your receivers verify with off-the-shelf libraries in any language and you never write verification docs of your own. That conformance is enforced in CI: any drift from the spec fails the build.
Headless by design: models, jobs, and an optional JSON API. Works with any ActiveJob backend on Rails 7.1+.
30-second tour
Any model can own endpoints (an account, team, or user):
class Account < ApplicationRecord
has_many :webhook_endpoints, as: :owner, class_name: "Angarium::Endpoint"
end
# Register an endpoint; the signing secret is generated for you
account.webhook_endpoints.create!(
name: "Production",
url: "https://example.com/webhooks",
subscribed_events: ["invoice.*", "user.created"] # exact, "prefix.*", or "*"
)
# Fan an event out to every subscribed endpoint
Angarium.dispatch("invoice.paid", { id: 123, total: 4200 }, owner: account)
Angarium handles the rest: signing, retries with backoff, Retry-After,
dedup-friendly delivery IDs, SSRF checks, and a full attempt log. See
Delivery guarantees for the specifics receivers care about.
Installation
Add to your Gemfile:
gem "angarium"
Then:
bundle install
bin/rails g angarium:install
bin/rails db:migrate
angarium:install writes the initializer and installs Angarium's migrations in
one step. For a separate database, pass --database=NAME (see
Multiple databases).
Active Record Encryption
Angarium encrypts each endpoint's signing_secret and custom_headers at rest,
so it needs Active Record Encryption keys. If you haven't set them up, it's one
command:
bin/rails db:encryption:init
Add the generated keys to your credentials (config/credentials.yml.enc) or
set config.active_record.encryption.{primary_key,deterministic_key,key_derivation_salt}.
See the Rails guide on Active Record Encryption.
The migration above creates plain columns, so you can run it before or after setting up keys: encryption applies when rows are written, not when the table is created.
Dispatching events
Angarium.dispatch fans a single event out to every enabled, subscribed
endpoint, creating one delivery each and one ActiveJob per delivery:
Angarium.dispatch("invoice.paid", { id: 123, total: 4200 }, owner: account)
Each request is delivered as a JSON envelope:
{ "id": 42, "event": "invoice.paid", "created_at": "2026-07-04T12:00:00Z", "data": { "id": 123, "total": 4200 } }
Verifying signatures (receiver side)
Angarium signs webhooks using the Standard Webhooks
specification, so receivers can verify them with the official
standardwebhooks libraries
in any language (Ruby, Python, JavaScript, Go, Rust, PHP, Java, and more), with no
Angarium-specific code required. Conformance is enforced in CI: signed requests
are verified with the official standardwebhooks Ruby library, so any drift from
the spec fails the build.
Every request carries three headers:
| Header | Value |
|---|---|
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. |
webhook-timestamp |
Unix seconds when the request was signed. |
webhook-signature |
Space-delimited list of v1,<base64 HMAC-SHA256> tokens (one per active signing secret). |
The signature is HMAC-SHA256(secret_key, "{webhook-id}.{webhook-timestamp}.{body}"),
base64-encoded, where secret_key is the base64-decoded portion of the
whsec_-prefixed signing_secret.
You can verify with any Standard Webhooks library, or with Angarium's own helper.
Pass a Rails request: and it reads the raw body and webhook-* headers for you:
Angarium::Signature.verify(request: request, secret: endpoint.signing_secret)
# => true / false
Or pass the fields explicitly:
Angarium::Signature.verify(
payload: request.raw_post,
id: request.headers["webhook-id"],
timestamp: request.headers["webhook-timestamp"],
signature: request.headers["webhook-signature"],
secret: endpoint.signing_secret
)
verify also enforces a timestamp tolerance (default 300s) to resist replay.
The secret (a whsec_... string) is stored encrypted at rest and is only
decrypted in memory when signing; endpoint.signing_secret returns the
plaintext, so deliver it to receivers over a secure channel.
Rotating a signing secret (zero-downtime)
Rotate a secret with endpoint.rotate_secret! (returns the new
plaintext). During a grace window (config.signing_secret_grace_period, default
24.hours) every delivery is signed with both the new and the previous
secret. The webhook-signature header carries multiple space-delimited v1,
tokens:
webhook-signature: v1,<new_sig> v1,<previous_sig>
Verification succeeds if the payload matches any token in the header (the Standard Webhooks libraries already do this), so a receiver still holding the old secret keeps validating while you roll it over, and one holding the new secret validates immediately. Once the grace period elapses, deliveries are signed with the new secret only. This lets receivers update their copy of the secret with zero downtime and no rejected deliveries.
Per-endpoint custom headers
Attach static headers (e.g. an Authorization bearer token the receiver
expects) to every request from an endpoint:
endpoint.update!(custom_headers: { "Authorization" => "Bearer abc123" })
custom_headers must be a hash of string keys and values. Because it commonly
carries a receiver credential (like the bearer token above), it's encrypted at
rest with Active Record Encryption, same as the signing secret. The
webhook-id, webhook-timestamp, and webhook-signature headers always win, so
a custom header can never override or spoof them. In the same spirit, reserved
and transport headers (webhook-id, webhook-timestamp, webhook-signature,
host, content-length, content-type, transfer-encoding, connection)
are rejected at validation (case-insensitively) and can't be overridden.
Retries
Failed deliveries (non-2xx or connection errors) are retried on the schedule in
config.retry_schedule. The default follows the Standard Webhooks
recommendation of a multi-day schedule with exponential backoff and jitter: our
instantiation is twelve retries spanning ~10 days (5s, 5m, 30m, 2h, 5h, 10h, 14h, 20h, 24h, 36h, 48h, 72h, after an immediate first delivery). Every attempt
is recorded as an Angarium::DeliveryAttempt. After the schedule is exhausted
the delivery is marked exhausted.
Each DeliveryAttempt stores the response body, truncated to
config.max_response_body_bytes bytes (default 65_536; set nil to store the
full body).
Status-code handling
Angarium follows the Standard Webhooks receiver-etiquette guidance:
| Response | Handling |
|---|---|
2xx |
Success. |
410 Gone |
The receiver wants no more webhooks. The endpoint status becomes gone and the delivery is marked gone, with no retries. |
429, 502, 504 |
Retryable failure, retried with backoff, honoring Retry-After when present (the recommended way to throttle). |
3xx and everything else |
Retryable failure. Redirects are not followed (following them loads both sides); update the endpoint URL instead. |
Backoff jitter
Each retry delay gets a small amount of additive positive jitter
(config.retry_jitter, default 0.15 → up to +15%) so many deliveries failing
at once don't retry in lockstep and stampede the receiver.
Retry-After
When a failed response carries a Retry-After header (seconds or an HTTP-date),
Angarium honors it, but only when it asks for a longer wait than the
scheduled backoff. It takes the later of the two, so a receiver's Retry-After
can delay the next attempt but never pull it earlier than our schedule. This
keeps a malicious or misconfigured receiver from using a tiny Retry-After to
defeat our backoff and make us retry aggressively. The honored value is capped at
config.max_retry_after (default 3600 seconds), and the whole behavior can be
disabled with config.respect_retry_after = false.
One interaction to note with the default schedule: because the cap is one hour,
Retry-After can only ever extend the wait during the early steps (up to the
30m step). Once backoff reaches 2h and beyond, a capped Retry-After is
always shorter than the scheduled delay, so it has no effect. If you need
receivers to push back harder late in the schedule, raise config.max_retry_after.
Manual redelivery
Re-send any delivery, including an exhausted one, with:
delivery.redeliver!
This resets the retry cycle (state → pending, attempt_count → 0) and
enqueues a fresh DeliverJob, while keeping the prior DeliveryAttempt history.
Endpoint status
Every endpoint has a lifecycle status (only enabled endpoints receive
deliveries):
| Status | Meaning | Resumable? |
|---|---|---|
unverified |
Created but not yet proven (opt-in). Receives no dispatched deliveries. | verified by a successful ping!, or endpoint.verify! |
enabled |
Delivering normally. | n/a |
paused |
Turned off manually (endpoint.pause!). |
endpoint.enable! |
disabled |
Auto-disabled after too many consecutive failures. | endpoint.enable! |
gone |
The receiver returned 410 Gone. Treat as terminal. |
endpoint.enable! (explicit override) |
Every transition stamps status_changed_at. endpoint.enable! also clears the
failure counter. Scope enabled endpoints with Angarium::Endpoint.enabled.
The enabled filter also applies to deliveries already queued when the status
changes (a retry cycle that trips auto-disable, a manual pause!, or a sibling
delivery's 410). Each delivery re-checks the endpoint before it attempts:
paused holds the delivery (it stays pending, consumes no attempt, and
enable! re-enqueues it), while disabled/gone cancels it (a terminal
canceled state, logged with the reason). Recover a canceled delivery after
re-enabling with delivery.redeliver!. (Nothing transitions back to
unverified, so dispatched and retried deliveries never meet one; a non-forced
delivery to an unverified endpoint would cancel like disabled/gone, since
only enabled passes the guard. ping! forces past it, which is how
verification happens.)
Verifying an endpoint
Create an endpoint as unverified to make it prove itself before it receives any
events. An unverified endpoint is excluded from dispatch, so no webhooks are sent
until it is verified:
endpoint = Angarium::Endpoint.create!(owner: current_user, url: params[:url],
subscribed_events: ["*"], status: :unverified)
endpoint.ping! # forces past the status guard (force: true default), so it sends
endpoint.reload.enabled? # => true once the ping is delivered (2xx)
A successful delivery to an unverified endpoint (a ping! in practice, since
dispatch skips unverified endpoints) verifies it: the status moves to enabled
and the on_endpoint_verified callback fires. You can also verify manually with
endpoint.verify!. Verification only promotes unverified endpoints; a
disabled or gone endpoint is revived with enable!, never silently by a ping.
Through the JSON API, set the create_unverified? policy predicate to true to
create endpoints unverified, then POST /endpoints/:id/ping (which verifies on
a successful delivery) or POST /endpoints/:id/verify to promote one manually.
Auto-disabling failing endpoints
Set config.auto_disable_endpoint_after to a number of consecutive failed
deliveries after which an endpoint is automatically moved to disabled.
endpoint.consecutive_failures tracks the running count and resets to 0 on the
next successful delivery. Left nil (the default), endpoints are never
auto-disabled. (A 410 Gone response moves the endpoint to gone immediately,
regardless of this setting.)
Notification callbacks
When delivery fails for good, the Standard Webhooks guidance is to notify the
consumer out of band (email, Slack, PagerDuty). Angarium is headless, so it hands
you the events and lets you do the notifying via config callbacks. Two fire when
delivery fails for good; a third, on_endpoint_verified, fires when an
unverified endpoint passes its first delivery (see
Verifying an endpoint):
Angarium.configure do |config|
# A delivery has exhausted its whole retry schedule.
config.on_delivery_exhausted = ->(delivery) do
AdminMailer.webhook_failed(delivery).deliver_later
end
# An endpoint was deactivated. reason is :consecutive_failures (status becomes
# `disabled`) or :gone (HTTP 410, status becomes `gone`).
config.on_endpoint_deactivated = ->(endpoint, reason) do
AdminMailer.endpoint_deactivated(endpoint, reason).deliver_later
end
end
All callbacks are optional. A callback that raises is logged and swallowed, so a broken notifier never breaks delivery.
Recovering interrupted deliveries
If a worker dies mid-delivery (crash, deploy, OOM) after a delivery is marked
delivering but before the attempt is recorded or rescheduled, that delivery
would otherwise be stranded (the job only re-runs pending deliveries). Requeue
these with a periodic reaper:
Angarium::Delivery.reap_stalled # requeues deliveries stuck in `delivering`
# or from cron/scheduler: bin/rails angarium:reap
Anything delivering whose last attempt began more than
config.delivering_timeout ago (default 15.minutes) is presumed abandoned and
reset to pending. Keep the timeout well above a single attempt's worst case
(open_timeout + http_timeout) so a slow-but-alive worker isn't reaped; a
redelivery is at-least-once-safe either way. Set it to nil to disable reaping.
Pinging an endpoint
Verify an endpoint end-to-end by delivering a synthetic angarium.ping event
(subscription matching is bypassed, so a ping is always sent). Returns the
Angarium::Delivery, so you can reload it to inspect the outcome:
delivery = endpoint.ping!
# optionally: endpoint.ping!(message: "hello")
delivery.reload.succeeded? # => true once delivered
A ping always sends, even to an unverified, paused, disabled, or gone
endpoint, so you can test one before enabling it. Pass
endpoint.ping!(force: false) to respect the endpoint status guard instead (held
while paused, canceled while unverified/disabled/gone). The lower-level delivery.deliver!(force: true) and
Delivery#redeliver!(force: true) override the guard the same way, for a single
attempt; any retry that attempt schedules follows the normal status rules again.
At-least-once delivery
Delivery is at-least-once: a webhook may arrive more than once, from a retry
after a receiver processed the request but the response was lost, or a rare
duplicate job enqueue. Make your receivers idempotent: dedupe on the
envelope's id (stable across every attempt of the same delivery) and treat a
repeat as a no-op.
Data retention
Every delivery attempt stores the receiver's response body (capped at
config.max_response_body_bytes, 64KB by default). Because
angarium_delivery_attempts grows with delivery volume × retries, a busy app
talking to a flapping receiver can accumulate rows quickly. You have three
options to keep it bounded:
# 1. Set a retention window and prune on a schedule (cron / your scheduler):
Angarium.config.delivery_attempt_retention = 90.days
# then run periodically: bin/rails angarium:prune
# 2. Or prune inline, wherever you like:
Angarium::DeliveryAttempt.prune(older_than: 90.days)
# 3. Or store less per attempt by lowering the response-body cap:
Angarium.config.max_response_body_bytes = 4_096
HTTP API
Angarium ships an optional JSON API for managing endpoints and browsing deliveries. It has no HTML views or UI of its own. Mount the engine wherever you like:
# config/routes.rb
mount Angarium::Engine => "/webhooks"
Authentication
The API has no auth of its own; it uses yours. Its controllers inherit from
config.parent_controller (default "ApplicationController"), so every
before_action your app already runs (Devise, Rodauth, etc.) applies here too.
Angarium reads the signed-in user via your current-user convention:
config.parent_controller = "ApplicationController" # or your API base controller
config.current_user = ->(controller) { controller.current_user }
Requests without a resolved current user get a 401.
Authorization
Authorization lives in one place: a policy class, config.policy_class
(default Angarium::Api::Policy). Generate one to start from (it creates the
class and points config.policy_class at it in your initializer):
bin/rails g angarium:policy # app/policies/webhook_endpoint_policy.rb
Angarium instantiates the policy per request with the controller and (for member
actions) the target record, and runs it in the controller's context, so
current_user, params, controller, and record are all available. Its
methods:
| Method | Default | Purpose |
|---|---|---|
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. |
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. |
create_unverified? |
false |
Whether endpoints created through the API start unverified (no deliveries until a successful ping verifies them) instead of live. |
permit_allow_private_network? |
false |
Whether allow_private_network (relax the private-IP block) is API-writable. Dangerous; trusted operators only. |
permit_allowed_networks? |
false |
Whether allowed_networks (a restrictive CIDR allowlist) is API-writable. |
index? show? create? update? destroy? |
true |
Whether each action is allowed. |
rotate_secret? pause? enable? verify? ping? redeliver? |
update? |
Member actions; default to the update? capability. |
Override only what you need; the defaults are single-owner (you see and manage
your own endpoints). A denied action returns 403; anything outside scope is a
404.
class WebhookEndpointPolicy < Angarium::Api::Policy
# Multi-tenant visibility: compose on top of the relation you're given.
def scope(relation) = relation.where(owner_id: current_user.account.owner_ids)
# Admins may create for any owner in their account (via an owner_id param);
# everyone else creates for themselves.
def owner
id = params[:owner_id]
id && current_user.admin? ? current_user.account.owners.find(id) : current_user
end
# Restrict individual actions (members default to update?, which defaults true).
def update? = current_user.can?(:manage_webhooks)
def destroy? = current_user.admin?
end
config.policy_class = "WebhookEndpointPolicy"
Objects
Responses wrap these objects. signing_secret and custom_headers are never
included (see the note below):
// endpoint
{ "id": 1, "name": "Production", "url": "https://example.com/webhooks",
"status": "enabled", "subscribed_events": ["invoice.*"], "allow_private_network": false,
"allowed_networks": [], "consecutive_failures": 0, "status_changed_at": null,
"created_at": "2026-07-04T12:00:00Z", "updated_at": "2026-07-04T12:00:00Z" }
// delivery
{ "id": 42, "endpoint_id": 1, "event": "invoice.paid", "state": "succeeded",
"attempt_count": 1, "next_attempt_at": null, "last_attempt_at": "2026-07-04T12:00:01Z",
"created_at": "2026-07-04T12:00:00Z", "updated_at": "2026-07-04T12:00:01Z" }
// attempt
{ "id": 7, "delivery_id": 42, "response_code": 200, "response_body": "ok",
"error": null, "duration": 0.12, "created_at": "2026-07-04T12:00:01Z" }
// pagination (on every list response)
{ "limit": 50, "offset": 0, "count": 20, "total": 137 }
Routes
| Method & path | Request body | Response |
|---|---|---|
GET /endpoints?limit=&offset= |
none | 200 { "endpoints": [endpoint, ...], "pagination": pagination } |
POST /endpoints |
{ "endpoint": { "name", "url", "subscribed_events": [...] } } |
201 { "endpoint": {...endpoint, "signing_secret": "whsec_..."} } |
GET /endpoints/:id |
none | 200 { "endpoint": endpoint } |
PATCH /endpoints/:id |
{ "endpoint": { "name": "New name" } } |
200 { "endpoint": endpoint } |
DELETE /endpoints/:id |
none | 204 (no body) |
POST /endpoints/:id/rotate_secret |
none | 200 { "endpoint": endpoint, "signing_secret": "whsec_..." } |
POST /endpoints/:id/pause, /enable, /verify |
none | 200 { "endpoint": endpoint } |
POST /endpoints/:id/ping |
none | 202 { "delivery": delivery } |
GET /endpoints/:id/deliveries?limit=&offset= |
none | 200 { "deliveries": [delivery, ...], "pagination": pagination } |
GET /deliveries/:id |
none | 200 { "delivery": delivery } |
POST /deliveries/:id/redeliver |
none | 202 { "delivery": delivery } |
GET /deliveries/:id/attempts?limit=&offset= |
none | 200 { "attempts": [attempt, ...], "pagination": pagination } |
- Secrets are never echoed.
signing_secretis returned only bycreateandrotate_secret;custom_headers(which may hold a credential) is write-only. - Pagination. List endpoints take
?limit=(default 50, max 200) and?offset=, and each list response carries apaginationobject (limit,offset,countin this page,totaloverall); there are more whenoffset + count < total. - Errors are JSON.
422 { "error": "validation failed", "details": [...] }for an invalid body, plus401(unauthenticated),403(policy denied), and404(out of scope).
Permitted attributes
POST/PATCH /endpoints accept these keys under endpoint; anything else is
ignored (strong parameters). The exception is the two privileged controls below:
when your policy hasn't permitted them, they are rejected with a 422, not
silently ignored, so an attempt to enable one never looks like it succeeded.
| Attribute | Type | Notes |
|---|---|---|
name |
string | |
url |
string | the receiver URL (SSRF-validated on every change) |
subscribed_events |
array of strings | event patterns: exact, "prefix.*", or "*" |
custom_headers |
object | write-only; sent with each delivery, never echoed back |
allow_private_network |
boolean | privileged; not writable by default, see below |
allowed_networks |
array of CIDRs | privileged; not writable by default, see below |
status is not writable (use the pause / enable actions), and the owner of a
created endpoint comes from the policy's owner, not the request.
allow_private_network and allowed_networks are independent SSRF controls,
each gated by its own policy predicate (default off), because they do opposite
things:
allow_private_networkrelaxes protection: it lets an endpoint deliver to private and loopback addresses. This is the dangerous one; an end user who can set it can point a webhook at your internal network.allowed_networksrestricts delivery to a CIDR allowlist (both it and the private-IP denylist must still pass), so it's safe to expose more widely.
You can always set them from trusted code, regardless of the API:
endpoint.update!(allow_private_network: true, allowed_networks: ["10.0.5.0/24"])
To permit either through the API, override its predicate. Being independent, you can allow the safe one without the dangerous one:
class WebhookEndpointPolicy < Angarium::Api::Policy
def permit_allow_private_network? = current_user.operator? # dangerous: operators only
def permit_allowed_networks? = true # restrictive: safe to expose
end
A request that tries to change a control it isn't permitted to set gets a
422 naming the attribute, rather than the change being silently dropped, so a
misconfigured client fails loudly instead of appearing to work. (Sending a
control's current value is a no-op.)
Security (SSRF protection)
Because endpoint URLs are user-supplied, Angarium guards against Server-Side
Request Forgery. Three controls, validated when an endpoint is created or when
its url, allow_private_network, or allowed_networks change, and re-checked
at delivery time:
config.block_private_ips(defaulttrue) blocks delivery to private, loopback, and link-local addresses (e.g.127.0.0.1,10.0.0.0/8,169.254.169.254), including IPv4-mapped IPv6 forms (e.g.::ffff:127.0.0.1) and the unspecified address (0.0.0.0/::).endpoint.allow_private_network(defaultfalse) is the per-endpoint opt-in required to deliver to a private address. An allowlist entry alone does not unlock a private address.endpoint.allowed_networks(CIDR array), when set, restricts this endpoint's deliveries to those CIDRs. It only narrows; to allow a private range you must also setallow_private_network.
Note:
allow_private_networkis a privileged control. Expose it only to trusted operators, never to end users; otherwise it becomes an SSRF opt-in.
Connect-time IP pinning: the delivery-time check re-resolves the host,
rejects disallowed addresses, and then pins the connection to exactly the
validated IP(s), so HTTPX does not re-resolve or connect elsewhere, while TLS
SNI and certificate verification still use the original hostname. This closes
the DNS-rebinding window between resolution and connection. Angarium's own
resolver is the single source of truth: if it can't resolve a host, the
delivery fails (retryable) rather than falling back to an unvalidated HTTPX
resolution, so there is no unpinned path. The only cost is that hosts
resolvable only via non-DNS mechanisms Angarium's resolver doesn't use
(e.g. mDNS .local) won't be delivered to, which is not a concern for real
webhook endpoints. HTTPX does not follow redirects, so redirect-based
bypasses are already closed.
Found a gap in any of this? Report it privately: see SECURITY.md for the disclosure process (GitHub's private advisory workflow) and the surfaces we hold ourselves to.
Delivery guarantees
What Angarium actually promises about delivery, so a receiver knows what it can rely on:
- At-least-once, not exactly-once. A delivery is retried until it succeeds,
the schedule is exhausted, or its endpoint is deactivated mid-cycle (the
delivery is
canceled, kept, and recoverable withredeliver!), so the same event can arrive more than once (for example, a retry after your200was lost in transit). Every request carries awebhook-idthat stays constant across a delivery's retries: dedupe on it and treat repeats as no-ops. - No ordering. Deliveries are independent jobs and each retries on its own schedule, so events can arrive out of order. If order matters, put a sequence number or timestamp in the payload and sort on the receiver.
- Durable; nothing is silently dropped. Every attempt is persisted as an
Angarium::DeliveryAttempt(response code, body, error, duration), and a delivery that exhausts its retries is kept in theexhaustedstate, not deleted. You can always tell whether an event was delivered, and re-send withdelivery.redeliver!. - Authenticated per request. Every request is signed and timestamped per the
Standard Webhooks spec (HMAC-SHA256 over
{id}.{timestamp}.{body}, 5-minute tolerance), so a receiver can confirm it came from you and reject replays, independent of transport.
Secret rotation, SSRF protection, and encryption harden delivery but aren't delivery-semantics guarantees; they have their own sections above.
Instrumentation
Angarium emits ActiveSupport::Notifications events so you can feed delivery
metrics and traces into your own backend (StatsD, Prometheus, OpenTelemetry, or
structured logs). This is the metrics leg beside the notification callbacks
(alerting) and the DeliveryAttempt rows (audit); it is off unless you subscribe.
deliver.angarium fires once per delivery attempt:
| Key | Notes | ||||||
|---|---|---|---|---|---|---|---|
delivery_id, endpoint_id |
ids | ||||||
event |
the event name being delivered | ||||||
outcome |
delivered \ |
failed \ |
gone \ |
held \ |
canceled \ |
blocked \ |
unresolvable |
attempt |
attempt number (absent for held/canceled) |
||||||
code |
HTTP status, when a response was received | ||||||
http_duration |
wire time in seconds, when a request went out | ||||||
error |
failure reason string, on blocked/unresolvable and a transport-error failed (a non-2xx failed carries its status in code instead) |
||||||
force |
whether the status guard was bypassed |
dispatch.angarium fires once per Angarium.dispatch: event, event_id,
and deliveries (fan-out count).
Payloads carry ids, codes, and timings only, never the signing secret or the request/response body, so they are safe to ship to third-party backends.
ActiveSupport::Notifications.subscribe("deliver.angarium") do |*, payload|
StatsD.increment("webhooks.delivery.#{payload[:outcome]}")
StatsD.histogram("webhooks.delivery.ms", payload[:http_duration] * 1000) if payload[:http_duration]
end
Configuration
Run bin/rails g angarium:install to generate config/initializers/angarium.rb,
which documents every option inline. The delivery and retry settings:
| Option | Default | What it controls |
|---|---|---|
job_queue |
:default |
ActiveJob queue used for deliveries. |
http_timeout |
10 |
HTTP read timeout (seconds) per attempt. |
open_timeout |
5 |
TCP connect timeout (seconds) per attempt. |
user_agent |
"Angarium/<version>" |
User-Agent header on each delivery. |
retry_schedule |
12 delays over ~10 days | Backoff between retries; its length is the retry count. |
retry_jitter |
0.15 |
Fraction of additive positive jitter on each backoff delay. |
respect_retry_after |
true |
Honor a receiver's Retry-After header (delay-only). |
max_retry_after |
3600 |
Cap (seconds) on a honored Retry-After; nil is uncapped. |
auto_disable_endpoint_after |
nil |
Deactivate an endpoint after N consecutive failures; nil never does. |
signing_secret_grace_period |
24.hours |
How long a rotated endpoint's previous secret stays valid. |
block_private_ips |
true |
Reject endpoint URLs resolving to private/loopback addresses (SSRF). |
max_response_body_bytes |
65_536 |
Truncate the stored response body; nil stores it whole. |
delivery_attempt_retention |
nil |
Age past which angarium:prune deletes attempts; nil keeps all. |
delivering_timeout |
15.minutes |
Age after which angarium:reap requeues a stuck delivering delivery. |
primary_key_type |
nil |
Primary key type for Angarium's tables (see below). |
on_delivery_exhausted |
nil |
Callback ->(delivery) when a delivery exhausts its retries. |
on_endpoint_deactivated |
nil |
Callback ->(endpoint, reason) when an endpoint is disabled or gone. |
on_endpoint_verified |
nil |
Callback ->(endpoint) when an unverified endpoint is verified. |
Mounting the JSON API adds parent_controller, current_user, and
policy_class (see Authentication and
Authorization). Multi-database setups add database and
connects_to (see Multiple databases).
Primary keys
Angarium's own tables (angarium_endpoints, angarium_events,
angarium_deliveries, angarium_delivery_attempts) follow
config.primary_key_type: leave it nil (the default) to inherit your app's
own default (its config.generators.active_record.primary_key_type, or
bigint if that's unset), or set it explicitly (e.g. :uuid) to force a type
regardless of the app's default.
owner_id on angarium_endpoints is always a string column, since a
polymorphic owner can point at models with different primary key types (an
integer-keyed User and a UUID-keyed Account in the same app). This works
transparently with any owner primary key (integer, UUID, or a mix) without
any configuration.
Multiple databases
To keep Angarium's tables out of your primary database, install with a
--database flag. It records config.database and puts the migrations in that
database's own path in one step:
bin/rails g angarium:install --database=angarium
That writes config.database = :angarium to your initializer and installs the
engine's migrations into db/angarium_migrate (not the primary db/migrate), so
each database keeps its own schema_migrations. All four Angarium models inherit
from one abstract Angarium::ApplicationRecord, so config.database points the
whole engine at that connection. Add the database to database.yml:
# config/database.yml
production:
primary:
<<: *default
database: my_app_production
angarium:
<<: *default
database: my_app_angarium
migrations_paths: db/angarium_migrate
The generator has already written the matching initializer:
# config/initializers/angarium.rb
Angarium.configure do |c|
c.database = :angarium
end
Then run bin/rails db:migrate:angarium. After a gem upgrade, pull in new
migrations with bin/rails g angarium:migrations (no flag needed): it reads
config.database and installs them into db/angarium_migrate for you.
For custom roles or shards, set config.connects_to to a hash passed straight to
Rails' connects_to
instead; it takes precedence over config.database for the connection.
Left unset (the default), Angarium stays on the app's primary connection. The
owner association still works across databases (it's a polymorphic reference, not
a foreign key), so your User/Account can live in the primary database while
Angarium's tables live in their own. This is independent of where your ActiveJob
backend (Solid Queue, etc.) stores its own tables.
Why not just POST from a job?
HTTP.post(endpoint.url, body: payload) in a background job works right up
until it's in front of customers. Then the edge cases arrive one at a time, and
each is a small project:
- Signatures. Receivers won't (and shouldn't) trust an unsigned POST. Roll your own and you now own an HMAC scheme, a header format, and the verification docs your customers need in every language they use. Angarium signs to Standard Webhooks, so they verify with an off-the-shelf library and you write none.
- Retries that don't stampede. A receiver has a bad 30 minutes; a naive retry
either gives up too early or hammers them in lockstep. Angarium retries on a
backoff schedule (~10 days by default) with jitter, and honors
Retry-After, but only to delay a retry, never to pull it earlier than your schedule. - Duplicate suppression. Retries mean the same event lands more than once. Without a stable ID that's invariant across a delivery's retries, receivers can't dedupe. Angarium gives every delivery exactly that.
- SSRF. An endpoint URL is user input. POST to it blindly and a customer can
point it at
169.254.169.254or10.0.0.1and read your internal network. Angarium blocks private ranges, pins the connection to the validated IP, and fails closed on hosts it can't resolve. - Secret rotation. Rotating a signing secret through a single POST path means a window where the old or new secret gets rejected. Angarium signs with both during a grace window, so receivers roll over without dropping a webhook.
- Stranded deliveries. A worker crashes mid-send and the delivery is stuck
half-done, with no retry and no record. Angarium reaps deliveries stranded in
deliveringand re-queues them. - "Did we actually send it?" When support asks, you need the answer. Angarium persists every attempt (response code, body, error, duration) and never silently drops a delivery.
None of these is hard on its own. Building and maintaining all of them together, as Rails and your receivers change, is the work Angarium takes off your plate.
How Angarium compares
There are several ways to send outbound webhooks from a Rails app. Angarium aims to be the maintained middle ground between rolling your own delivery system and adopting external webhook infrastructure.
| Angarium | ActionHook | bullet_train-outgoing_webhooks | active_webhook | Svix / Hookdeck Outpost | |
|---|---|---|---|---|---|
| Type | Rails engine (headless + JSON API) | Ruby delivery library | Rails engine (Bullet Train) | Ruby library | Hosted / self-hosted service |
| Persisted endpoints & subscriptions | ✅ per-endpoint event subscriptions | ❌ bring your own model | ✅ (tied to BT teams) | ✅ topics | ✅ |
| Endpoint-management JSON API | ✅ auth + policy | ❌ | ❌ | ❌ | ✅ |
| HMAC request signing | ✅ | ✅ (SHA256 fingerprint) | ✅ | ✅ | ✅ |
| Standard Webhooks compliant | ✅ | ❌ | ❌ | ❌ | ✅ (Svix initiated the spec) |
| Automatic retries with backoff | ✅ jitter + Retry-After |
❌ (delegates to your job runner) | ✅ | ✅ (via queue adapter) | ✅ |
| Manual redelivery | ✅ | ❌ | ✅ deliver(force:) |
❌ | ✅ |
| Auto-disable failing endpoints | ✅ (opt-in) | ❌ | ✅ (opt-in) | ❌ | ✅ |
| SSRF protection | ✅ block + pin + fail-closed | ✅ private-IP blocking | ❌ | ❌ | ✅ |
| Signing secrets encrypted at rest | ✅ Active Record Encryption | n/a (you store secrets) | ❌ | ❌ | ✅ |
| Zero-downtime secret rotation | ✅ dual-signing grace window | ❌ | ❌ | ❌ | ✅ |
| Job backend | Any ActiveJob backend | n/a | ActiveJob | Multiple adapters | Managed workers |
| Runs inside your app | ✅ | ✅ | ✅ | ✅ | ❌ separate service |
| Framework requirements | Rails 7.1+ | Any Ruby | Bullet Train | Rails 5+ | Any (HTTP API) |
All columns verified by reading each project's source: actionhook 1.0.2, active_webhook 1.0.0, bullet_train-outgoing_webhooks 1.45.1, as of July 2026; Svix / Hookdeck Outpost cells reflect their published docs. Corrections welcome via issue or PR.
When to choose Angarium
- You want customer-facing webhooks (endpoints, subscriptions, signing, retries) without standing up separate infrastructure like Svix or Outpost.
- You want SSRF protection and encrypted signing secrets out of the box instead of remembering to build them.
- You want receivers to verify with an off-the-shelf library, since Angarium is Standard Webhooks compliant.
- You already run ActiveJob and don't want a Redis- or Sidekiq-specific dependency.
When to choose something else
- You need massive multi-tenant scale, a customer-facing delivery portal, or fan-out to queues (SQS, Kafka, EventBridge): use Svix or Hookdeck Outpost. They are dedicated infrastructure and will outgrow any in-app gem.
- You only need a hardened HTTP delivery primitive and want to own the data model yourself: ActionHook is a solid low-level choice.
- You're building on Bullet Train: use bullet_train-outgoing_webhooks, which integrates with its team and account model.
Development
After cloning the repo, install dependencies and generate the per-Rails-version gemfiles:
bundle install
bundle exec appraisal install # writes gemfiles/*.gemfile for each Rails version
Tests run through a test/dummy app against the supported Rails versions via
Appraisal (there is no rake test
task; the runner is bin/rails test):
bundle exec appraisal bin/rails test # all Rails versions
bundle exec appraisal rails-8.1 bin/rails test # a single version
bin/rails test # just your default bundle
bin/rails test test/lib/angarium/signature_test.rb # a single file
Available appraisals: rails-7.1, rails-7.2, rails-8.0, rails-8.1. CI runs
the same matrix across Ruby 3.2 and 3.3 (8 jobs). After changing a dependency or
the Appraisals file, re-run bundle exec appraisal install and commit the
updated gemfiles/.
Linting and security
CI also runs Standard and Brakeman:
bundle exec rake standard # lint (rake standard:fix to autocorrect)
bundle exec brakeman -q -z # static security analysis
Cutting a release
Publishing runs from a laptop; CI only cuts the GitHub Release when the tag
lands. Version math and the changelog come from git-cliff
over your Conventional Commits
(brew install git-cliff):
rake release:prepare # bump version.rb + regenerate CHANGELOG.md, then STAGE (nothing committed)
git diff --cached # review
rake release:publish # commit, gem build + push, tag + push (idempotent/resumable)
rake release:prepare[1.2.3] forces a version instead of computing it. The bare
rake release is disabled in favor of this two-step flow.
License
Angarium is released under the MIT License.