Angarium

Gem Version CI Standard Webhooks License: MIT

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
.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: )

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: )

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 (statepending, 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..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..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_secret is returned only by create and rotate_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 a pagination object (limit, offset, count in this page, total overall); there are more when offset + count < total.
  • Errors are JSON. 422 { "error": "validation failed", "details": [...] } for an invalid body, plus 401 (unauthenticated), 403 (policy denied), and 404 (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_network relaxes 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_networks restricts 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 (default true) 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 (default false) 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 set allow_private_network.

Note: allow_private_network is 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 with redeliver!), so the same event can arrive more than once (for example, a retry after your 200 was lost in transit). Every request carries a webhook-id that 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 the exhausted state, not deleted. You can always tell whether an event was delivered, and re-send with delivery.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.254 or 10.0.0.1 and 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 delivering and 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.