This walkthrough takes a new application from install to a verified inbound webhook and a delivered outbound webhook. Both halves are independently consumable: inbound-only applications need no Oban.
Requirements: Elixir ~> 1.15, Ash ~> 3.0. Optional components: Oban (~> 2.20) for outbound delivery, Plug/Phoenix for inbound receipt.
Installation
def deps do
[
{:ash_hooks, "~> 1.0"},
# for outbound delivery only:
{:oban, "~> 2.20"}
]
endOr mix igniter.install ash_hooks, which also ATTEMPTS to patch your
endpoint's Plug.Parsers with a body_reader (see below) — review the
generated diff; if the patch could not be applied, add it by hand.
The database migrations
ash_hooks injects fields and identities onto YOUR resources; your migrations create the tables and — critically — the two UNIQUE INDEXES that make the dedup guarantees real. Minimal shapes:
# inbound ledger — payload is the DECODED body (a :map / JSONB column);
# payload_digest binds it to the signed raw bytes
create table(:webhook_ledgers, primary_key: false) do
add :id, :uuid, primary_key: true
add :provider, :text, null: false
add :external_event_id, :text, null: false
add :external_event_type, :text
add :payload, :map, null: false
add :payload_digest, :text, null: false
add :status, :text, null: false, default: "received"
add :fencing_token, :integer, null: false, default: 0
add :lease_expires_at, :utc_datetime_usec
add :error_class, :text
add :attempts, :integer, null: false, default: 0
# one column per scope_identity slot:
add :account_id, :text, null: false
end
create unique_index(:webhook_ledgers, [:provider, :external_event_id, :account_id])
# outbound ledger — payload here is the exact bytes to sign
create table(:outbound_deliveries, primary_key: false) do
add :id, :uuid, primary_key: true
add :event_uuid, :text, null: false
add :event_type, :text, null: false
add :payload, :binary, null: false
add :endpoint_id, :uuid, null: false
add :subscription_id, :uuid
add :signing_mode, :text
add :status, :text, null: false, default: "pending"
add :attempts, :integer, null: false, default: 0
add :response_status, :integer
add :response_snippet, :text
add :last_error, :text
add :next_attempt_at, :utc_datetime_usec
end
create unique_index(:outbound_deliveries, [:endpoint_id, :event_uuid])
create table(:webhook_endpoints, primary_key: false) do
add :id, :uuid, primary_key: true
add :url, :text, null: false
add :status, :text, null: false, default: "enabled"
add :secret_ref, :text, null: false
add :previous_secret_ref, :text
add :legacy_secret_ref, :text
add :legacy_previous_secret_ref, :text
end
create table(:webhook_subscriptions, primary_key: false) do
add :id, :uuid, primary_key: true
add :event_types, {:array, :text}, null: false
add :endpoint_id, :uuid, null: false
add :signing_mode, :text
end(On sqlite, use :jsonb-capable equivalents — the test suite's DDL in
this repo's test files shows the sqlite shapes verbatim.)
Inbound: receive, verify, dedup
Configure the raw-body reader FIRST — signature schemes sign the exact wire bytes, and a router plug cannot recover pre-parser bytes:
# in your Phoenix Endpoint:
plug Plug.Parsers,
parsers: [:json],
pass: ["*/*"],
body_reader: {AshHooks.BodyReader, :read_body, []},
# optional: scope the raw-body memory cost to webhook routes only
# body_reader: {AshHooks.BodyReader, :read_body, [only: ["/webhooks"]]},
json_decoder: Phoenix.json_library()The ledger resource (one resource per inbound surface):
defmodule MyApp.WebhookLedger do
use Ash.Resource,
domain: MyApp.Domain,
data_layer: AshPostgres.DataLayer, # or AshSqlite.DataLayer
extensions: [AshHooks, AshHooks.InboundDelivery]
postgres do
table("webhook_ledgers")
repo(MyApp.Repo)
end
inbound_delivery do
# provider event ids are NOT globally unique across accounts — your
# scope slots extend the unique-ingest identity (each slot must be a
# non-nullable attribute, supplied on every ingest)
scope_identity([:account_id])
end
attributes do
attribute(:account_id, :string, allow_nil?: false)
end
actions do
defaults([:read])
end
webhooks do
inbound :comply_cube do
# convention-resolves the provider to AshHooks.Provider.ComplyCube;
# a SECRET SOURCE, never a literal
secret {:app_env, [:my_app, :complycube_secret]}
end
end
endAshHooks.Ingress.ingest/4 runs the whole sync pipeline — verify the
signature over the RAW bytes, persist the decoded payload plus a digest
binding it to those bytes, dedup on the unique index, claim under a
fenced lease, invoke the provider handler, and mark the outcome. From
your controller:
raw = conn.private[:ash_hooks_raw_body]
case AshHooks.Ingress.ingest(MyApp.WebhookLedger, :comply_cube, raw, %{
signature: List.first(get_req_header(conn, "complycube-signature")),
headers: Map.new(conn.req_headers),
scope: %{"account_id" => conn.params["account_id"]}
}) do
{:ok, :created, %{status: status}} ->
# the handler ran; status is :processed, :failed_retryable, or
# :failed_permanent — map to the response the provider expects
code = if status == :processed, do: 200, else: 500
send_resp(conn, code, "")
{:ok, :duplicate, _delivery} ->
# already seen — respond however the provider expects a replay
send_resp(conn, 200, "")
{:error, _invalid_signature_or_payload} ->
send_resp(conn, 400, "")
endDedup semantics: durable deduplication with at-least-once HANDLER
INVOCATION — a delivery whose row is terminal (:processed /
:failed_permanent) is never processed again, but a crash after your
handler's side effects and before the ledger mark will re-invoke it on
redelivery. Write handlers idempotent, keyed on the external event
identity. claim_delivery/2, mark_processed/3,
mark_failed/5, renew/3 and reap/1 are public if you need to drive
the lease machine yourself (e.g. from your own async pipeline) — the
sync ingest/4 above is the default.
Crash safety: once the durable row exists, a crash between any two steps re-drives on redelivery instead of silently dropping.
Outbound: sign, deliver, retry
The three resources:
defmodule MyApp.WebhookEndpoint do
use Ash.Resource,
domain: MyApp.Domain,
data_layer: AshPostgres.DataLayer,
extensions: [AshHooks.Endpoint]
postgres do
table("webhook_endpoints")
repo(MyApp.Repo)
end
actions do
defaults([:read, :create, :update])
default_accept(:*)
end
end
defmodule MyApp.WebhookSubscription do
use Ash.Resource,
domain: MyApp.Domain,
data_layer: AshPostgres.DataLayer,
extensions: [AshHooks.Subscription]
postgres do
table("webhook_subscriptions")
repo(MyApp.Repo)
end
actions do
defaults([:read, :create])
default_accept(:*)
end
subscription do
endpoint_resource(MyApp.WebhookEndpoint)
end
end
defmodule MyApp.OutboundDelivery do
use Ash.Resource,
domain: MyApp.Domain,
data_layer: AshPostgres.DataLayer,
extensions: [AshHooks.OutboundDelivery]
postgres do
table("outbound_deliveries")
repo(MyApp.Repo)
end
actions do
defaults([:read])
end
endThe emitting resource declares the event:
defmodule MyApp.Order do
use Ash.Resource,
domain: MyApp.Domain,
data_layer: AshPostgres.DataLayer,
extensions: [AshHooks]
# ... your attributes/actions ...
webhooks do
outbound :order_paid do
subscriptions(MyApp.WebhookSubscription)
deliveries(MyApp.OutboundDelivery)
end
end
endThe worker (ONE module, in your app — Oban must be in your deps):
defmodule MyApp.WebhookDeliveryWorker do
use AshHooks.Worker,
deliveries: MyApp.OutboundDelivery,
endpoints: MyApp.WebhookEndpoint,
secret_resolver: {MyApp.Secrets, :webhook_secret},
queue: :webhooks,
oban: MyApp.Oban
endThe secret resolver maps an endpoint's secret REFERENCE to its value — endpoints store references only, never secrets:
defmodule MyApp.Secrets do
# ref is whatever string you stored on the endpoint's secret_ref.
# The value is a COMPLETE generated secret — create it once with
# AshHooks.Signing.generate_secret/0 (returns a "whsec_"-prefixed,
# correctly-encoded binary), store it whole in your secret store,
# and return it unchanged:
def webhook_secret("acme-main") do
case System.fetch_env("ACME_WEBHOOK_SECRET") do
{:ok, secret} -> {:ok, secret}
:error -> {:error, :missing_secret}
end
end
def webhook_secret(_unknown), do: {:error, :unknown_ref}
endOban itself (dependency, migration, and a supervised instance with a
:webhooks queue) is the consumer's to set up — see Oban's install
guide; the worker above plugs into it.
Register an endpoint and a subscription, then dispatch:
# a PUBLIC, DNS-resolvable https URL — the send-time SSRF check
# re-resolves DNS and refuses private/loopback/literal-IP targets
{:ok, endpoint} =
Ash.create(MyApp.WebhookEndpoint, %{
url: System.fetch_env!("PUBLIC_WEBHOOK_TEST_URL"),
secret_ref: "acme-main"
}, authorize?: false)
{:ok, _sub} =
Ash.create(MyApp.WebhookSubscription, %{
endpoint_id: endpoint.id,
event_types: ["order_paid"]
}, authorize?: false)
{:ok, event} = AshHooks.Event.new(type: :order_paid, payload: Jason.encode!(%{"id" => 1}))
{:ok, results} =
AshHooks.dispatch(MyApp.Order, :order_paid, event,
enqueue: {MyApp.WebhookDeliveryWorker, :enqueue}
)With no enqueue: the rows persist :pending (durable ledger only,
nothing sends). With the worker seam wired, each row is delivered by
AshHooks.Delivery: Standard-Webhooks signed, retried with bounded
Retry-After and jittered backoff, dead-lettered at the ceiling, the
endpoint durably disabled on 410.
Signing modes: :standard (default) needs only secret_ref. Both
:legacy and :dual REQUIRE the endpoint to carry a
legacy_secret_ref — signing fails (and the row retries as
signing_failed) without one; :dual additionally emits the legacy
envelope alongside the Standard Webhooks one during receiver
migration.
Response snippets store NO body bytes by default (a status +
content-type summary). For a one-row diagnostic capture, re-drive the
row with AshHooks.Delivery.run(row, snippet_capture: true) — the
captured body persists only after passing the package's redaction
floor, marked [captured].
Observing: telemetry
:telemetry.attach_many("my-ash-hooks", [
[:ash_hooks, :ingress, :verify],
[:ash_hooks, :ingress, :dedup],
[:ash_hooks, :ingress, :claim],
[:ash_hooks, :dispatch, :enqueue_failed],
[:ash_hooks, :delivery, :attempt],
[:ash_hooks, :delivery, :result],
[:ash_hooks, :delivery, :backoff],
[:ash_hooks, :delivery, :dead_letter],
[:ash_hooks, :delivery, :disable]
], fn event, _measurements, metadata, _config ->
require Logger
Logger.debug("ash_hooks #{inspect(event)} #{inspect(metadata)}")
end, nil)attach_many with the exact names is required — :telemetry.execute/3
matches exact names only. Events carry ids, integers, fixed atoms, and
classified reasons — never secrets, bodies, or payloads (ADR-0005).