axn-webhooks 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +176 -0
  3. data/DESIGN-NOTES.md +241 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +1042 -0
  6. data/lib/axn/webhooks/dispatch.rb +129 -0
  7. data/lib/axn/webhooks/errors.rb +48 -0
  8. data/lib/axn/webhooks/handler.rb +15 -0
  9. data/lib/axn/webhooks/header_value.rb +29 -0
  10. data/lib/axn/webhooks/inbound/build_request.rb +23 -0
  11. data/lib/axn/webhooks/inbound/challenge.rb +37 -0
  12. data/lib/axn/webhooks/inbound/challenge_required.rb +35 -0
  13. data/lib/axn/webhooks/inbound/dsl.rb +240 -0
  14. data/lib/axn/webhooks/inbound/endpoint.rb +221 -0
  15. data/lib/axn/webhooks/inbound/parsers.rb +20 -0
  16. data/lib/axn/webhooks/inbound/respond_context.rb +17 -0
  17. data/lib/axn/webhooks/inbound/router.rb +104 -0
  18. data/lib/axn/webhooks/inbound.rb +124 -0
  19. data/lib/axn/webhooks/outbound/callable_arity.rb +99 -0
  20. data/lib/axn/webhooks/outbound/config.rb +442 -0
  21. data/lib/axn/webhooks/outbound/deliver.rb +425 -0
  22. data/lib/axn/webhooks/outbound/dsl.rb +121 -0
  23. data/lib/axn/webhooks/outbound/emit.rb +181 -0
  24. data/lib/axn/webhooks/outbound/envelope.rb +23 -0
  25. data/lib/axn/webhooks/outbound/signer.rb +376 -0
  26. data/lib/axn/webhooks/outbound/subscriber.rb +152 -0
  27. data/lib/axn/webhooks/outbound/target_policy.rb +135 -0
  28. data/lib/axn/webhooks/outbound/transport.rb +59 -0
  29. data/lib/axn/webhooks/outbound.rb +73 -0
  30. data/lib/axn/webhooks/request.rb +230 -0
  31. data/lib/axn/webhooks/resolvers.rb +43 -0
  32. data/lib/axn/webhooks/respond.rb +26 -0
  33. data/lib/axn/webhooks/response.rb +116 -0
  34. data/lib/axn/webhooks/signature.rb +268 -0
  35. data/lib/axn/webhooks/static_respond.rb +22 -0
  36. data/lib/axn/webhooks/vendor_facet.rb +25 -0
  37. data/lib/axn/webhooks/verifiers/basic_auth.rb +128 -0
  38. data/lib/axn/webhooks/verifiers/hmac.rb +58 -0
  39. data/lib/axn/webhooks/verifiers/standard_webhooks.rb +129 -0
  40. data/lib/axn/webhooks/verifiers.rb +50 -0
  41. data/lib/axn/webhooks/verify.rb +106 -0
  42. data/lib/axn/webhooks/version.rb +7 -0
  43. data/lib/axn/webhooks.rb +61 -0
  44. data/lib/axn-webhooks.rb +3 -0
  45. metadata +128 -0
data/README.md ADDED
@@ -0,0 +1,1042 @@
1
+ # axn-webhooks
2
+
3
+ Webhook handling for [axn](https://github.com/teamshares/axn), both directions, on one signature
4
+ primitive. Works in or out of Rails.
5
+
6
+ * **[Inbound](#inbound)** — verify a vendor's signature, dispatch the event to a handler action, and
7
+ acknowledge. Declared per vendor; mounts as a Rack app, no controller needed.
8
+ * **[Outbound](#outbound)** — declare your own events and subscribers, and emit signed,
9
+ self-retrying deliveries. Declared once per sending app.
10
+
11
+ **Contents:** [Installation](#installation) · [Quick start](#quick-start) · [Inbound](#inbound) ·
12
+ [Outbound](#outbound) · [Signature primitive](#signature-primitive) · [Testing](#testing)
13
+
14
+ The *why* behind the surprising parts — and the traps worth naming — lives in
15
+ [DESIGN-NOTES.md](DESIGN-NOTES.md).
16
+
17
+ ## Installation
18
+
19
+ ```ruby
20
+ gem "axn-webhooks"
21
+ ```
22
+
23
+ Requires Ruby 3.2.1+ and Rack 3. Under Rails, `axn`'s own ActiveSupport 7.2 floor makes **Rails 7.2+**
24
+ the effective minimum.
25
+
26
+ ## Quick start
27
+
28
+ ### Receiving a webhook
29
+
30
+ Declare the endpoint once (e.g. `config/initializers/webhooks.rb`):
31
+
32
+ ```ruby
33
+ Axn::Webhooks.inbound :codat do
34
+ verify :standard_webhooks, secret: ENV.fetch("CODAT_WEBHOOK_SECRET")
35
+ dispatch on: ->(e) { e["eventType"] },
36
+ to: { "connection.updated" => "Actions::Codat::ConnectionUpdated" }
37
+ end
38
+ ```
39
+
40
+ Mount it:
41
+
42
+ ```ruby
43
+ # config/routes.rb
44
+ mount Axn::Webhooks::Inbound[:codat], at: "/webhooks/codat"
45
+ ```
46
+
47
+ Write the handler as an ordinary Axn:
48
+
49
+ ```ruby
50
+ module Actions
51
+ module Codat
52
+ class ConnectionUpdated
53
+ include Axn::Webhooks::Handler # includes Axn; makes `retry_later!` a quiet failure
54
+
55
+ expects :event
56
+
57
+ def call
58
+ Connection.find_by(external_id: event.dig("data", "connectionId"))&.refresh!
59
+ end
60
+ end
61
+ end
62
+ end
63
+ ```
64
+
65
+ That's the whole loop: a signed POST is verified, parsed, routed, and acked with a bare 200.
66
+
67
+ ### Sending a webhook
68
+
69
+ ```ruby
70
+ Axn::Webhooks.outbound do
71
+ sign :standard_webhooks, secret: -> { ENV.fetch("WEBHOOK_SIGNING_SECRET") } # "whsec_<base64>"
72
+
73
+ event :lead_signed, to: ["https://partner.example/hooks/leads"]
74
+ end
75
+
76
+ Axn::Webhooks.emit(:lead_signed, data: { lead_id: 42 })
77
+ ```
78
+
79
+ The receiver gets a signed Standard Webhooks envelope.
80
+
81
+ **Retries need an async adapter.** With an `async :sidekiq` / `async :active_job` default configured
82
+ for axn, each delivery retries itself on a retryable failure, up to `max_attempts`. Without one,
83
+ `emit` falls back to a best-effort inline send that logs a warning and does **not** retry — the first
84
+ retryable failure is treated as exhausted, so a transient receiver outage drops the delivery. See
85
+ [Async posture](DESIGN-NOTES.md#async-posture-auto-vs-explicit).
86
+
87
+ ---
88
+
89
+ # Inbound
90
+
91
+ ## Declaring an endpoint
92
+
93
+ `Axn::Webhooks.inbound(:name) { … }` registers an endpoint; `Axn::Webhooks::Inbound[:name]` looks it
94
+ up. The symbol is the vendor's name — whatever you'll reference it by.
95
+
96
+ ```ruby
97
+ # Standard Webhooks (Svix) preset
98
+ Axn::Webhooks.inbound :codat do
99
+ verify :standard_webhooks, secret: ENV.fetch("CODAT_WEBHOOK_SECRET")
100
+ end
101
+
102
+ # Parametric HMAC
103
+ Axn::Webhooks.inbound :merge_dev do
104
+ verify :hmac,
105
+ secret: ENV.fetch("MERGE_WEBHOOK_SIGNATURE_KEY"),
106
+ signature: header("X-Merge-Webhook-Signature"),
107
+ encoding: :base64_urlsafe
108
+ end
109
+
110
+ # HTTP Basic auth rather than a signature
111
+ Axn::Webhooks.inbound :legacy_vendor do
112
+ verify :basic_auth,
113
+ username: -> { ENV.fetch("WEBHOOKS_AUTH_USERNAME") },
114
+ password: -> { ENV.fetch("WEBHOOKS_AUTH_PASSWORD") }
115
+ end
116
+
117
+ # Custom verifier delegating to a vendor SDK
118
+ Axn::Webhooks.inbound :twilio do
119
+ verify do |req|
120
+ path, query = req.url.split("?", 2) # see URL-signing verifiers in DESIGN-NOTES.md
121
+
122
+ Twilio::Security::RequestValidator.new(ENV.fetch("TWILIO_AUTH_TOKEN"))
123
+ .validate([path.chomp("/"), query].compact.join("?"), req.params, req.header("X-Twilio-Signature"))
124
+ end
125
+ end
126
+ ```
127
+
128
+ Blocks are evaluated with `instance_exec` against an internal DSL, so `self` is **not** the
129
+ surrounding object. `ENV`, constants and local variables are fine; the surrounding object's helper
130
+ methods and ivars are not.
131
+
132
+ ### DSL reference
133
+
134
+ | Declaration | Purpose |
135
+ | -- | -- |
136
+ | `verify :strategy, **opts` / `verify { \|req\| … }` | How to authenticate the request. Required whenever `dispatch` is declared. |
137
+ | `dispatch …` | Route the parsed event to a handler. See [Dispatching](#dispatching-to-a-handler). |
138
+ | `respond { \|result\| … }` | Render a body from the handler's result. See [Responding](#responding). |
139
+ | `static_respond { … }` | Render a fixed body that doesn't read the result. |
140
+ | `challenge resolver, if: nil` | Answer a vendor's `GET` verification handshake. |
141
+ | `challenge_required { \|req\| … }` | Mark a request as the bare first leg of a challenge-response auth handshake. |
142
+ | `unauthorized_headers "H" => "v"` | Extra headers on the 401 (e.g. `WWW-Authenticate`). |
143
+ | `endpoint(:child) { … }` | [Nested endpoints](#nested-endpoints) sharing this block's declarations. |
144
+
145
+ Inside a block, `header(name)`, `raw_body`, `params` and `url` build deferred lookups against the
146
+ request, and `async(target, **)` / `sync(target, **)` build dispatch-map entries.
147
+
148
+ ## Verifying
149
+
150
+ | Strategy | For | Key options |
151
+ | -- | -- | -- |
152
+ | `verify :standard_webhooks` | Standard Webhooks / Svix (Codat, Lob, …) | `secret:`, `tolerance:` (300) |
153
+ | `verify :hmac` | Anything signing the body with an HMAC | `secret:`, `signature:`, `signing_string:`, `digest:`, `encoding:`, `prefix:`, `replay:` |
154
+ | `verify :basic_auth` | Vendors gated by HTTP Basic auth | `username:`, `password:`, `realm:` (`"Webhook"`) |
155
+ | `verify { \|req\| … }` | Anything else (vendor SDKs, URL signing) | — |
156
+
157
+ Every `secret:`/`username:`/`password:` accepts a plain value, or one of the **deferred shapes**
158
+ re-resolved per request so a rotation needs no reboot: a **lambda/proc** (`-> { ENV.fetch("SECRET") }`,
159
+ or 1-arity to receive the request), a **`header(…)`/`params`/`raw_body`/`url` resolver**, or a
160
+ **Symbol** naming a `Request` method. Anything else — including a provider object that merely
161
+ responds to `#call`, or a `Method` — is treated as a literal value, and rejected at declaration if
162
+ it isn't a usable secret.
163
+
164
+ A blank or missing secret is always rejected, never used: `""` is a legal HMAC key, so signing with
165
+ one would make the expected signature something any stranger could compute. Literals fail at boot;
166
+ deferred shapes are checked on every request, since they can go missing long after boot.
167
+
168
+ ### `verify :hmac`
169
+
170
+ | Option | Default | Notes |
171
+ | -- | -- | -- |
172
+ | `secret:` | required | Plain value or callable. |
173
+ | `signature:` | required | Usually `header("X-…-Signature")`. There is no universal header name. |
174
+ | `signing_string:` | `:raw_body` | `:raw_body`, or a lambda building the exact signed string. |
175
+ | `digest:` | `:sha256` | `:sha256` / `:sha1` / `:md5` |
176
+ | `encoding:` | `:hex` | `:hex` / `:base64` / `:base64_urlsafe` |
177
+ | `prefix:` | `nil` | Stripped before comparison, e.g. `"v0="` for Slack. |
178
+ | `replay:` | `nil` | `{ timestamp:, within:, unit: }` — see [Replay protection](#replay-protection). |
179
+
180
+ ```ruby
181
+ Axn::Webhooks.inbound :slack do
182
+ verify :hmac, secret: ENV.fetch("SLACK_SIGNING_SECRET"),
183
+ signature: header("X-Slack-Signature"),
184
+ prefix: "v0=",
185
+ signing_string: ->(r) { "v0:#{r.header('X-Slack-Request-Timestamp')}:#{r.raw_body}" },
186
+ replay: { timestamp: header("X-Slack-Request-Timestamp"), within: 300 }
187
+ end
188
+ ```
189
+
190
+ ### `verify :standard_webhooks`
191
+
192
+ Implements [Standard Webhooks](https://www.standardwebhooks.com/) — the cross-vendor spec (Zapier,
193
+ Twilio, Svix and others) for signed webhooks. It's what Codat, Lob and any Svix-backed sender emit,
194
+ and it's the same scheme this gem's own [`sign :standard_webhooks`](#sign-standard_webhooks) sends,
195
+ so the two halves round-trip. Full details in the
196
+ [specification](https://github.com/standard-webhooks/standard-webhooks).
197
+
198
+ Per that spec the secret is **`whsec_<base64>`** — the prefix is stripped and the rest Base64-decoded
199
+ to the raw HMAC key. Pass the vendor's value verbatim, prefix included. `id:`, `timestamp:` and
200
+ `signature:` default to the spec's `webhook-*` headers and rarely need overriding; `tolerance:`
201
+ defaults to 300 seconds.
202
+
203
+ A secret missing the prefix is rejected, and the check happens as early as it possibly can:
204
+
205
+ | Secret form | Checked | On failure |
206
+ | -- | -- | -- |
207
+ | a literal (`"whsec_…"`, or anything else) | at declaration | `ArgumentError` — your boot fails, not your traffic |
208
+ | a callable or `header(…)` resolver | on **every request** | `Axn::Webhooks::Error` — reported to `Axn.config.on_exception`, 401 |
209
+
210
+ A callable can't be settled at boot (it may read a secret store, or an env var set after boot), so
211
+ it's validated each time it resolves. Either way the error names the value's *shape*, never its
212
+ bytes.
213
+
214
+ > **Why this is checked so aggressively:** the decode used to coerce with `to_s`, so a secret that
215
+ > resolved to `nil` — an unset env var, or a `header(…)` on an absent header — became an **empty
216
+ > HMAC key**. Anyone who knew the credential was missing could sign with that empty key and verify.
217
+ > A missing secret now fails loudly instead of authenticating strangers, and it can never degrade
218
+ > into a quiet `:signature_mismatch` that reads like a rotated key.
219
+
220
+ ### `verify :basic_auth`
221
+
222
+ Handles the full two-legged handshake for you, including the `WWW-Authenticate` challenge that
223
+ clients like Twilio require before they will send credentials at all — see
224
+ [Basic auth is two-legged](DESIGN-NOTES.md#basic-auth-is-two-legged) for why that matters and what a custom block
225
+ has to do instead. Prefer signature verification wherever the vendor offers it.
226
+
227
+ ### Custom `verify` blocks
228
+
229
+ The contract is `->(request) { Boolean }`. A return value is read as:
230
+
231
+ | Return | Read as |
232
+ | -- | -- |
233
+ | an object responding to `ok?` (a `Signature::Check`, an `Axn::Result`, …) | whatever its `ok?` says |
234
+ | any other truthy value | verified |
235
+ | `nil` / `false` | rejected |
236
+
237
+ So returning an `Axn::Result` works — a failed one rejects the request rather than silently
238
+ verifying it. To name your own failure *cause*, return an `Axn::Webhooks::Signature::Check`;
239
+ `Signature` exports ready-made verdicts (`OK`, `MISMATCH`, `SIGNATURE_MISSING`,
240
+ `CREDENTIALS_MISSING`, `CREDENTIALS_MISMATCH`), so you rarely have to build one:
241
+
242
+ ```ruby
243
+ verify do |req|
244
+ MyCheck.call(request: req).ok? ? Axn::Webhooks::Signature::OK : Axn::Webhooks::Signature::MISMATCH
245
+ end
246
+ ```
247
+
248
+ Without a `Check`, a rejection is reported as `:signature_mismatch`.
249
+
250
+ > **Gotcha: `ok?` on an `Axn::Result` means the action SUCCEEDED, not that the signature was
251
+ > valid.** The two coincide only if your action `fail!`s on a bad signature. An action that
252
+ > succeeds while carrying its verdict in an exposure (`expose(valid: false)`) still reads as
253
+ > verified — so `fail!` on rejection, or translate to a `Check` as above. See
254
+ > [Don't return an Axn::Result](DESIGN-NOTES.md#dont-return-an-axnresult-from-a-verify-block).
255
+
256
+ ### Why verification failed
257
+
258
+ A rejection is always a bare 401 on the wire, but the cause is on the result and stamped as a
259
+ bounded `reason` metrics dimension, so failures can be grouped and alerted on separately:
260
+
261
+ ```ruby
262
+ result = Axn::Webhooks::Inbound[:codat].verify(request)
263
+ result.reason # => :replay_window
264
+ result.skew # => 10_000 (seconds, signed: positive = the timestamp is in the past)
265
+ result.error # => "Webhook verification failed: replay window exceeded (timestamp skew 10000s)"
266
+ ```
267
+
268
+ | `reason` | What it means | Usually caused by |
269
+ | -- | -- | -- |
270
+ | `:replay_window` | Valid timestamp, outside the window. Carries `skew`. | A genuine replay or real clock drift |
271
+ | `:replay_timestamp_invalid` | Timestamp absent or unparseable | A typo'd `replay: { timestamp: … }`, or a vendor that stopped sending it |
272
+ | `:signature_missing` | No signature header at all | A typo'd `signature:` header name, or an unsigned sender |
273
+ | `:signature_mismatch` | The HMAC genuinely didn't match | Wrong/rotated secret, or the wrong `signing_string` |
274
+ | `:credentials_missing` | (`:basic_auth`) An `Authorization` header that isn't a Basic credential | A client using the wrong scheme, or a scanner |
275
+ | `:credentials_mismatch` | (`:basic_auth`) Credentials offered and rejected | Wrong/rotated credentials, or a scanner guessing |
276
+
277
+ A `:replay_window` rejection also carries **`suggested_unit`** — the scale that *would* have fit
278
+ (`nil` for a genuine replay). Since `unit:` [infers the scale](#replay-protection) by default, it is
279
+ only ever set when a `unit:` was explicitly pinned and is wrong, which cleanly splits misconfiguration
280
+ from attack.
281
+
282
+ ## Mounting
283
+
284
+ An endpoint is itself a Rack app.
285
+
286
+ ```ruby
287
+ # config/routes.rb (Rails)
288
+ Rails.application.routes.draw do
289
+ mount Axn::Webhooks::Inbound[:codat], at: "/webhooks/codat"
290
+ end
291
+ ```
292
+
293
+ ```ruby
294
+ # config.ru (no Rails)
295
+ require "axn-webhooks"
296
+ map("/webhooks/codat") { run Axn::Webhooks::Inbound[:codat] }
297
+ ```
298
+
299
+ The mount owns the whole path and every verb: `POST` runs verify → dispatch → respond; `GET` runs a
300
+ declared `challenge`, or 405s; anything else is a 405 — including `HEAD` on a bare `Rack::Builder`
301
+ mount with no `Rack::Head` upstream. (Rails inserts `Rack::Head`, so `HEAD` becomes `GET` there.)
302
+
303
+ Or drive it yourself from a controller — `#verify`, `#handle` and `#to_response` all take an
304
+ `Axn::Webhooks::Request`.
305
+
306
+ ## Dispatching to a handler
307
+
308
+ `dispatch` routes the verified, parsed event to a handler Axn.
309
+
310
+ | Option | Default | Notes |
311
+ | -- | -- | -- |
312
+ | `to:` | — | A handler, a map, or a namespace. See [Routing forms](#routing-forms). |
313
+ | `on:` | `nil` | `->(event) { key }` — makes `to:` a map or namespace. |
314
+ | `otherwise:` | `nil` | `:ack` or a callable for unmatched keys. Omit to raise loudly. |
315
+ | `via:` | `nil` | Custom key → constant-name transform (namespace routing only). |
316
+ | `parse:` | `:json` | `:json`, or `->(request) { … }` for other bodies. |
317
+ | `mode:` | `:auto` | `:auto` / `:async` / `:sync`. See [Sync vs async](#sync-vs-async). |
318
+ | `unparseable_status:` | global | Per-endpoint override of [`unparseable_status`](#unparseable-bodies). |
319
+
320
+ Handler targets may be a class-name **String** or the **class itself** — both resolve the constant
321
+ lazily per request, so either stays reload-safe under Zeitwerk. Strings are the safe default in an
322
+ initializer, since they never force the handler to be autoloadable at boot.
323
+
324
+ ```ruby
325
+ result = Axn::Webhooks::Inbound[:codat].handle(request) # verify + dispatch => Axn::Result
326
+ result.handler_result # the handler's own Axn::Result (nil on ack / failure)
327
+ ```
328
+
329
+ A missing handler class, or an unmatched event with no `otherwise:`, is reported to
330
+ `Axn.config.on_exception` and returned as a failed result — never an unhandled exception.
331
+
332
+ ### Routing forms
333
+
334
+ | Declaration | Resolves to |
335
+ | -- | -- |
336
+ | `to: "Handler"` (no `on:`) | that one handler, for every event |
337
+ | `on: ->(e) { … }, to: { key => target }` | the target the key maps to — an explicit map |
338
+ | `on: ->(e) { … }, to: "Namespace"` | `Namespace::<KeyCamelized>` — by convention, no map to maintain |
339
+
340
+ A String `to:` means different things with and without `on:`: alone it's the handler, with `on:` it's
341
+ the **namespace** keys resolve under. The convention splits the key on `.` and `_` and capitalizes
342
+ each part (`"connection.updated"` → `Actions::Codat::ConnectionUpdated`); `via:` replaces that
343
+ transform:
344
+
345
+ ```ruby
346
+ dispatch on: ->(e) { e["eventType"] },
347
+ to: "Actions::Codat",
348
+ via: ->(key) { "#{key.split('.').map(&:capitalize).join}Handler" }
349
+ ```
350
+
351
+ Namespace routing has no map to miss, so `otherwise:` doesn't apply — an unknown key is a
352
+ constant-resolution failure at request time.
353
+
354
+ ### Handler arguments (`with:`)
355
+
356
+ By default a handler receives the whole parsed event as `event:`. To change that, use the
357
+ **map-entry Hash** form, where `with:` is a Symbol (rename-only) or a callable returning the kwargs:
358
+
359
+ ```ruby
360
+ dispatch on: ->(e) { e["type"] },
361
+ to: {
362
+ # rename only — handler declares `expects :payload`
363
+ "interaction" => { call: "Actions::Slack::HandleInteraction", with: :payload },
364
+ # project to scalars — handler declares `expects :lead_id, :status`
365
+ "lead.updated" => { call: "Actions::Leads::Update", with: ->(e) { { lead_id: e["id"], status: e["status"] } } },
366
+ }
367
+ ```
368
+
369
+ `with:` lives on the entry, not on `dispatch` — `dispatch to: "H", with: :payload` raises
370
+ `ArgumentError: unknown keyword: :with`. The `async(…)`/`sync(…)` helpers build the same Hash and
371
+ pass `with:` through, so `async("H", with: :payload)` composes.
372
+
373
+ ### `otherwise:`
374
+
375
+ `:ack` logs the unmatched key and returns a 2xx. A **callable** is invoked with the event first (its
376
+ return value is ignored) and then acks — the seam for alerting without failing the request:
377
+
378
+ ```ruby
379
+ dispatch on: ->(e) { e["type"] },
380
+ to: { "lead.updated" => "Actions::Leads::Update" },
381
+ otherwise: ->(event) { Honeybadger.notify("unhandled webhook", context: { type: event["type"] }) }
382
+ ```
383
+
384
+ ## Responding
385
+
386
+ By default a successful request gets a bare 2xx ack — most vendors want nothing else.
387
+
388
+ **`respond`** renders a body from what the handler computed. The block receives the handler's
389
+ `Axn::Result` and runs with `ack`/`text`/`xml`/`json` available as bare calls:
390
+
391
+ ```ruby
392
+ Axn::Webhooks.inbound :twilio do
393
+ verify { |req| … }
394
+ dispatch to: "Actions::Twilio::HandleCall", parse: ->(req) { req.params }
395
+ respond { |result| xml(result.twiml) } # handler exposes :twiml
396
+ end
397
+ ```
398
+
399
+ `json` takes a Hash/Array (JSON-encoded) or a pre-serialized String, and all four take
400
+ `status:`/`headers:`.
401
+
402
+ **`static_respond`** renders a fixed body that never reads the result. Its block takes no arguments,
403
+ so it doesn't force sync dispatch:
404
+
405
+ ```ruby
406
+ # DropboxSign requires this exact literal string, and its handler must run async:
407
+ Axn::Webhooks.inbound :dropbox_sign do
408
+ verify { |req| … }
409
+ dispatch to: "Actions::DropboxSign::HandleWebhook"
410
+ static_respond { text("Hello API Event Received") }
411
+ end
412
+ ```
413
+
414
+ Declaring both on one endpoint raises at registration.
415
+
416
+ ### HTTP status reference
417
+
418
+ `Inbound[:vendor].to_response(request)` runs the whole pipeline and maps the outcome:
419
+
420
+ | Stage | Outcome | Status | `static_respond` renders? |
421
+ | -- | -- | -- | -- |
422
+ | Verify | rejected signature, or the verifier crashed | 401 | no |
423
+ | Dispatch | missing/unresolvable handler, unmatched event with no `otherwise:`, handler crash | 500 (reported) | no |
424
+ | Dispatch | body doesn't parse | [`unparseable_status`](#unparseable-bodies) — **200** by default (reported) | yes |
425
+ | Dispatch | unknown-but-expected event (`otherwise: :ack`) | 2xx ack | yes |
426
+ | Handle | handler's own business `fail!` | 2xx ack (logged) | yes |
427
+ | Handle | [`retry_later!`](#asking-for-redelivery) | 503 (+ `Retry-After`) | no |
428
+ | Handle | success | declared `respond` body, or a bare 2xx ack | yes |
429
+
430
+ `respond` runs **only** for a genuine handler success; every other row gets its fixed status
431
+ regardless.
432
+
433
+ ## Sync vs async
434
+
435
+ By default (`mode: :auto`) a handler runs **async when it has an axn async adapter configured** — an
436
+ `async :sidekiq` / `async :active_job` on the handler, or a host-app global default — and **sync
437
+ otherwise**. This gem never references a specific adapter; it only checks whether one is present.
438
+
439
+ | Setting | Behavior |
440
+ | -- | -- |
441
+ | `mode: :auto` (default) | Async if an adapter is configured, else sync |
442
+ | `mode: :async` | Always async; reported as an exception if no adapter is configured |
443
+ | `mode: :sync` | Always inline |
444
+ | a declared `respond` | Forces sync (you can't read a result you enqueued) |
445
+ | a map entry's `async:` | Overrides everything above, for that route only |
446
+
447
+ Precedence, most specific first: the entry's `async:` → an explicit endpoint `mode:` → a declared
448
+ `respond` → `:auto` adapter detection. Declaring both `mode: :async` and a custom `respond` raises at
449
+ registration.
450
+
451
+ ### Per-route sync/async
452
+
453
+ A single fixed URL sometimes needs both disciplines — the interaction-platform pattern (Slack,
454
+ Discord, Telegram) multiplexes a synchronous body and ack-then-async on one Request URL. `async(…)`
455
+ and `sync(…)` build the entry for you:
456
+
457
+ ```ruby
458
+ Axn::Webhooks.inbound :slack do
459
+ verify :hmac, secret: ENV.fetch("SLACK_SIGNING_SECRET"),
460
+ signature: header("X-Slack-Signature"),
461
+ prefix: "v0=",
462
+ signing_string: ->(r) { "v0:#{r.header('X-Slack-Request-Timestamp')}:#{r.raw_body}" }
463
+ dispatch on: ->(e) { e["type"] },
464
+ to: {
465
+ "view_submission" => "Actions::Slack::HandleViewSubmission", # sync: returns a response_action body
466
+ "block_actions" => async("Actions::Slack::HandleBlockActions"), # ack now, run async
467
+ }
468
+ respond { |result| json(result.response_action) } # sync route renders JSON; async route auto-acks
469
+ end
470
+ ```
471
+
472
+ `async("H")` is sugar for `{ call: "H", async: true }`, `sync("H")` for `{ call: "H", async: false }`;
473
+ both pass extra kwargs through.
474
+
475
+ ## Challenge (GET-echo handshake)
476
+
477
+ Some vendors (Nylas, Meta) verify a new endpoint with a `GET` before sending real events. No extra
478
+ route is needed — `challenge` teaches the same mount to answer `GET`:
479
+
480
+ ```ruby
481
+ Axn::Webhooks.inbound :nylas do
482
+ verify { |req| … }
483
+ challenge ->(req) { req.params["challenge"] } # echoed verbatim, 200 text/plain
484
+ end
485
+
486
+ Axn::Webhooks.inbound :meta do
487
+ challenge ->(req) { req.params["hub.challenge"] },
488
+ if: ->(req) { req.params["hub.verify_token"] == ENV.fetch("META_VERIFY_TOKEN") }
489
+ end
490
+ ```
491
+
492
+ An `if:` rejection is a **403**; a missing/nil challenge value is a **400**; a raise is reported and
493
+ mapped to **500**.
494
+
495
+ Slack's in-band `url_verification` handshake is **not** this — Slack sends it as a POST event, so
496
+ it's a normal `dispatch` entry.
497
+
498
+ ## Nested endpoints
499
+
500
+ When several endpoints share a vendor's verification, declare it once and nest what differs:
501
+
502
+ ```ruby
503
+ Axn::Webhooks.inbound :slack do
504
+ verify :hmac, secret: ENV.fetch("SLACK_SIGNING_SECRET"),
505
+ signature: header("X-Slack-Signature"),
506
+ prefix: "v0=",
507
+ signing_string: ->(r) { "v0:#{r.header('X-Slack-Request-Timestamp')}:#{r.raw_body}" },
508
+ replay: { timestamp: header("X-Slack-Request-Timestamp"), within: 300 }
509
+
510
+ endpoint :interactivity do
511
+ dispatch on: ->(e) { e["type"] }, to: { "block_actions" => async("Actions::Slack::HandleBlockActions") }
512
+ respond { |result| json(result.response_action) }
513
+ end
514
+
515
+ endpoint :events do
516
+ dispatch on: ->(e) { e.dig("event", "type") }, to: { "app_mention" => "Actions::Slack::HandleMention" }
517
+ end
518
+ end
519
+ # => registers Inbound[:slack_interactivity] and Inbound[:slack_events]
520
+ ```
521
+
522
+ * **Each child registers as `:"#{parent}_#{child}"`.** The parent is **not** registered — it's a
523
+ container. Declaring a top-level `dispatch` alongside `endpoint` blocks raises at boot.
524
+ * **Children inherit every parent declaration** (`verify`, `challenge`, `challenge_required`,
525
+ `unauthorized_headers`, `respond`, `static_respond`) and override by re-declaring. Siblings are
526
+ independent. `dispatch` is the one thing a parent can't declare, so each child brings its own.
527
+ * A child may swap renderer forms (`static_respond` over an inherited `respond`, or the reverse).
528
+ There is no way to *un*-declare an inherited block — move the parent's `respond` down into the
529
+ siblings that want it instead.
530
+ * **One level only.** An `endpoint` inside an `endpoint` raises.
531
+
532
+ Each child is validated exactly as a standalone endpoint, so a child with `dispatch` and no
533
+ inherited or declared `verify` still fails at boot.
534
+
535
+ Nesting is sugar: a shared options hash splatted with `**`, or a shared lambda passed to
536
+ `verify(&lambda)`, expresses the same thing and remains a fine choice.
537
+
538
+ ## The request object
539
+
540
+ Verifiers, `parse:` and `challenge` blocks all receive an `Axn::Webhooks::Request` — a
541
+ Rails-agnostic view, so the same endpoint works behind a Rack mount, a controller, or a plain test
542
+ constructor.
543
+
544
+ | | |
545
+ |---|---|
546
+ | `raw_body` | the exact bytes the vendor signed (frozen; never re-encoded) |
547
+ | `header(name)` | case-insensitive header lookup |
548
+ | `params` | the request's **primary** param source (see below) |
549
+ | `url` | full URL including scheme, host, mount prefix, and query string |
550
+ | `http_method` | upcased (`"POST"`, `"GET"`, …) |
551
+
552
+ `params` is one source, never a query+form merge:
553
+
554
+ - **POST with a form body** — `application/x-www-form-urlencoded` (Twilio) or `multipart/form-data`
555
+ (Dropbox Sign) → the form fields. A malformed multipart body yields `{}` rather than raising.
556
+ - **Everything else** — JSON POST, and any GET/HEAD → the query string.
557
+
558
+ `inspect`/`pp` redact `raw_body` and headers, since webhook payloads routinely carry bank account
559
+ numbers, credentials and addresses that must not reach logs or exception reports.
560
+
561
+ ## Unparseable bodies
562
+
563
+ A verified request whose body doesn't parse is **terminal, not retryable** — a redelivery of the same
564
+ bytes will never parse either. So the parse step reports and then **acks**:
565
+
566
+ ```ruby
567
+ Axn::Webhooks.configure { |c| c.unparseable_status = 400 } # global; default 200
568
+
569
+ Axn::Webhooks.inbound :lob do
570
+ verify :hmac, secret: ENV.fetch("LOB_WEBHOOK_SECRET"), signature: header("Lob-Signature")
571
+ dispatch to: "Actions::Lob::HandleWebhook", unparseable_status: 200 # per-endpoint override
572
+ end
573
+ ```
574
+
575
+ Whatever `parse:` raises is wrapped in `Axn::Webhooks::UnparseableBody` (the original stays reachable
576
+ as `cause`) and reported to `Axn.config.on_exception` exactly once. Because the whole step is wrapped
577
+ rather than a list of known JSON errors, a custom XML/form/protobuf `parse:` gets the same treatment.
578
+
579
+ The default is 200 rather than the tidier 400 because [2xx is the only answer every vendor reads as
580
+ "stop redelivering"](DESIGN-NOTES.md#why-unparseable-bodies-ack-with-200). A `parse:` proc that does I/O can opt back
581
+ into redelivery by raising [`retry_later!`](#asking-for-redelivery).
582
+
583
+ ## Asking for redelivery
584
+
585
+ A handler can ask the sender to redeliver later without paging:
586
+
587
+ ```ruby
588
+ class HandleWebhook
589
+ include Axn::Webhooks::Handler
590
+
591
+ def call
592
+ Axn::Webhooks.retry_later!(after: 30) unless dependency_ready? # => 503, Retry-After: 30
593
+ end
594
+ end
595
+ ```
596
+
597
+ Raising `Axn::Webhooks::RetryLater` (directly or via the helper) **always** maps to a **503** —
598
+ `after:` only controls whether the `Retry-After` header is present. It's rescued around the whole
599
+ synchronous dispatch, so the handler, a `parse:` proc, a `with:` extractor and an `otherwise:`
600
+ callable can all defer.
601
+
602
+ > **`include Axn::Webhooks::Handler`, not plain `include Axn`.** The concern includes `Axn` and
603
+ > declares `fails_on Axn::Webhooks::RetryLater`, so a deferral settles as a quiet failure. Without it
604
+ > you still get the 503, but you *also* page `Axn.config.on_exception` on every single deferral —
605
+ > the opposite of the promise.
606
+
607
+ Only synchronous dispatch can defer: a `retry_later!` raised inside an async worker is just a worker
608
+ exception, unrelated to the response already sent.
609
+
610
+ ## Per-vendor observability
611
+
612
+ ```ruby
613
+ Axn::Webhooks.configure { |c| c.vendor_facet = :dimension } # or :tag; default false
614
+ ```
615
+
616
+ When set, every `verify`/`dispatch`/`respond`/`challenge` call for a registered endpoint is stamped
617
+ with the endpoint's name as that Datadog/OTel facet — `:dimension` for a bounded, low-cardinality
618
+ grouping; `:tag` for the higher-cardinality path. Ships `false` so a standalone consumer opts in.
619
+
620
+ This governs the **vendor** facet only. The [`reason` dimension](#why-verification-failed) is always
621
+ stamped — it's a closed enum, so there's no cardinality decision to defer. Group by `reason`, filter
622
+ by `vendor`.
623
+
624
+ ---
625
+
626
+ # Outbound
627
+
628
+ ## Declaring events and subscribers
629
+
630
+ Declare once (e.g. a Rails initializer), then emit by symbol from wherever the triggering event
631
+ happens:
632
+
633
+ ```ruby
634
+ Axn::Webhooks.outbound do
635
+ sign :standard_webhooks, secret: -> { ENV.fetch("WEBHOOK_SIGNING_SECRET") }
636
+
637
+ event :lead_signed, to: ["https://partner.example/hooks/leads"] # static list
638
+ event :lead_closed # resolved via `subscribers`
639
+ event :invoice_paid, type: "invoice.paid", to: ["https://partner.example/hooks/invoices"]
640
+
641
+ subscribers ->(event) { Subscription.where(event:).map { |s| { url: s.url, id: s.id.to_s } } }
642
+ end
643
+ ```
644
+
645
+ | Declaration | Default | Purpose |
646
+ | -- | -- | -- |
647
+ | `event :name, to:, type:, vendor:` | — | Declare an emittable event. `type:` overrides the wire type; `vendor:` overrides the facet. |
648
+ | `sign :strategy, **opts` / `sign { … }` | — | How to sign each delivery. See [Signing](#signing). |
649
+ | `subscribers ->(event) { … }` | `nil` | Default resolver for events with no `to:`. |
650
+ | `headers ->(subscriber) { … }` | `nil` | Per-destination extra headers, resolved per attempt. |
651
+ | `allowed_hosts %w[…]` | `nil` (any) | Host allowlist; exact match or a leading `*.` wildcard. |
652
+ | `allow_url ->(uri) { … }` | `nil` (any) | Arbitrary target predicate. |
653
+ | `max_attempts 8` | `8` | Attempts before giving up. |
654
+ | `backoff ->(attempt) { … }` | capped exponential | Seconds until the next attempt. |
655
+ | `transport MyTransport` | stdlib `net/http` | Injectable HTTP seam. |
656
+ | `timeouts open: 5, read: 10` | `5` / `10` | Built-in transport only. |
657
+ | `vendor :name` | `nil` | Block-level observability facet default. |
658
+ | `user_agent value_or_callable` | `nil` | Suffix: `axn-webhooks/<version> (<value>)`. Plain value or zero-arity callable, resolved per attempt. |
659
+
660
+ **Wire `type`** defaults to the symbol as a string (`:lead_signed` → `"lead_signed"`).
661
+ `emit(:unknown_event)` raises immediately, listing the known events — no silent no-op for a typo. A
662
+ statically declared `event :x, to: []` warns at boot. A second `outbound` block replaces the first
663
+ and logs a warning; only one is ever active.
664
+
665
+ ### Subscriber rows
666
+
667
+ `to:` accepts a static Array or a lambda (`->(event) { … }`); `subscribers` is the shared default for
668
+ events with no `to:`. Either may resolve to:
669
+
670
+ - a bare URL **String** — no identity, and
671
+ - a **`{ url:, id: }` Hash** — an identity that `sign`'s `secret:`, the `headers` resolver, and
672
+ `Deliver`'s observability can key off of.
673
+
674
+ An unknown Hash key (e.g. a stray `secret:`) is rejected rather than silently dropped, since it's
675
+ almost certainly a credential the caller thought they were setting.
676
+
677
+ Both resolvers run fresh on **every** `emit`, never memoized at boot, so a DB-backed lambda picks up
678
+ rows added or removed at runtime. Resolution runs inline in whatever process called `emit`, so a
679
+ store that raises (a database outage) raises out of `emit`.
680
+
681
+ ### Per-subscriber secrets and headers
682
+
683
+ `sign`'s `secret:` and the `headers` resolver both accept a **one-arity** callable receiving the
684
+ resolved `Subscriber`, re-resolved per delivery attempt:
685
+
686
+ ```ruby
687
+ Axn::Webhooks.outbound do
688
+ subscribers ->(event) { Subscription.where(event:).map { |s| { url: s.url, id: s.id.to_s } } }
689
+
690
+ sign :standard_webhooks, secret: ->(subscriber) { Subscription.find(subscriber.id).signing_secret }
691
+ headers ->(subscriber) { { "authorization" => "Bearer #{Subscription.find(subscriber.id).token}" } }
692
+
693
+ allowed_hosts %w[hooks.partner.example *.customer.example]
694
+
695
+ event :lead_closed
696
+ end
697
+ ```
698
+
699
+ > **`subscriber.id` is `nil` for a bare URL String row.** If an event mixes static `to:` URLs with
700
+ > `subscribers`-resolved rows, guard for it —
701
+ > `subscriber.id ? Subscription.find(subscriber.id).signing_secret : ENV.fetch("DEFAULT_SECRET")` —
702
+ > rather than letting `find(nil)` raise on the first static delivery.
703
+
704
+ Neither value ever enters the job payload; see
705
+ [Credentials never enter the queue](DESIGN-NOTES.md#credentials-never-enter-the-queue).
706
+
707
+ ### Host policy
708
+
709
+ `allowed_hosts` matches case-insensitively; a `*.suffix` entry matches any subdomain of `suffix` but
710
+ **not** the bare suffix itself. `allow_url` is the general escape hatch — called with the parsed
711
+ `URI`, must return truthy. Both are nil by default (any http(s) URL passes); when both are declared, a
712
+ target must pass both.
713
+
714
+ > **A host policy, not a network one.** Neither resolves DNS, so neither is proof against DNS
715
+ > rebinding or a hostname that resolves to a private IP at request time. `uri.host` is a hostname,
716
+ > not necessarily an IP literal — an `allow_url` doing IP-range math must parse defensively:
717
+
718
+ ```ruby
719
+ allow_url(lambda do |uri|
720
+ ip = begin
721
+ IPAddr.new(uri.host)
722
+ rescue IPAddr::Error
723
+ nil # not a literal IP — nothing to range-check
724
+ end
725
+ ip.nil? || PRIVATE_IP_RANGES.none? { |r| r.include?(ip) }
726
+ end)
727
+ ```
728
+
729
+ A static `to:` Array is validated at **boot** (an `ArgumentError` — a declaration mistake); a
730
+ resolver's rows are validated identically at **every** `emit`, but collected into
731
+ [`rejected`](#the-emit-result) rather than failing the fan-out.
732
+
733
+ ## Signing
734
+
735
+ ### `sign :standard_webhooks`
736
+
737
+ The default, and the symmetric counterpart to a receiver's
738
+ [`verify :standard_webhooks`](#verify-standard_webhooks). The body is the
739
+ [Standard Webhooks](https://www.standardwebhooks.com/) envelope; `id` and `timestamp` are mirrored
740
+ into the signed headers:
741
+
742
+ ```
743
+ POST <subscriber-url>
744
+ webhook-id: msg_<uuid>
745
+ webhook-timestamp: 1721160000
746
+ webhook-signature: v1,<base64 hmac of "id.timestamp.body">
747
+ content-type: application/json
748
+ user-agent: axn-webhooks/<version>
749
+
750
+ {"id":"msg_<uuid>","timestamp":1721160000,"type":"lead_signed","data":{"lead_id":42}}
751
+ ```
752
+
753
+ `secret:` is a **`whsec_<base64>`** value. A literal one is validated at boot; a callable's resolved
754
+ value is checked per attempt.
755
+
756
+ ### `sign :hmac`
757
+
758
+ For a receiver that expects a plain signature header rather than an envelope:
759
+
760
+ ```ruby
761
+ # minimal — one header, signature over the raw body
762
+ sign :hmac, secret: -> { ENV.fetch("PARTNER_SECRET") }, header: "X-Signature"
763
+
764
+ # …or a replay-protectable signature, Slack-style
765
+ sign :hmac,
766
+ secret: -> { ENV.fetch("PARTNER_SECRET") },
767
+ header: "X-Signature",
768
+ timestamp_header: "X-Timestamp",
769
+ signing_string: "v0:{timestamp}:{body}",
770
+ prefix: "v0="
771
+ ```
772
+
773
+ | Option | Default | Notes |
774
+ | -- | -- | -- |
775
+ | `secret:` | required | Plain value, or a 0-/1-arity callable re-resolved per attempt. |
776
+ | `header:` | required | There is no universal signature-header name. |
777
+ | `timestamp_header:` | `nil` | Required if `signing_string:` references `{timestamp}`. |
778
+ | `signing_string:` | `"{body}"` | A **template**. `{timestamp}` and `{body}` are the only placeholders. |
779
+ | `digest:` | `:sha256` | |
780
+ | `encoding:` | `:hex` | |
781
+ | `prefix:` | `nil` | |
782
+
783
+ `digest:`, `encoding:`, both header names and the template are validated at boot rather than inside
784
+ every delivery attempt. Header names must be valid HTTP field tokens, must differ from each other,
785
+ and may not collide with anything the pipeline sets after signing (`content-type`, `user-agent`,
786
+ `content-length`, `transfer-encoding`) — in every one of those cases the later value would replace
787
+ the signature and each delivery would ship unverifiable.
788
+
789
+ There is no id header here: a signature bound to a per-message id is what `:standard_webhooks` is for.
790
+
791
+ ### Custom signer
792
+
793
+ ```ruby
794
+ sign { |id:, timestamp:, body:| { "X-My-Sig" => my_signature(body) } }
795
+ ```
796
+
797
+ Return the header Hash. The block may also declare `subscriber:` to receive the resolved Subscriber;
798
+ a block that doesn't declare it (or `**`) simply isn't passed one.
799
+
800
+ ## Emitting
801
+
802
+ ```ruby
803
+ result = Axn::Webhooks.emit(:lead_signed, data: { lead_id: 42 })
804
+ ```
805
+
806
+ `emit` resolves the event's subscribers and enqueues one independent, self-retrying delivery per
807
+ target, so one slow or failing subscriber can't block another. Each delivery gets its own stable
808
+ `webhook-id`, generated once per (emission × target) and reused across every retry of that delivery,
809
+ so receivers can dedup.
810
+
811
+ ### The emit result
812
+
813
+ | Field | Meaning |
814
+ | -- | -- |
815
+ | `webhook_ids` | One id per **enqueued** target |
816
+ | `target_count` | Rows actually enqueued |
817
+ | `deliveries` | One `{ webhook_id:, url:, subscriber_id: }` per target — the correlation to persist a delivery record without re-resolving and trusting ordering |
818
+ | `rejected` | `{ target:, reason: }` per row the host/shape policy refused |
819
+ | `rejected_count` | How many |
820
+ | `failed_count` | Deliveries that came back failed — **sync path only**, always `0` when enqueued async |
821
+
822
+ A rejected row is neither counted in `target_count` nor delivered to; the rejection is reported once
823
+ per `emit` (not once per bad row). `emit` still reports `ok` — the good rows really were enqueued,
824
+ and a subscriber being down is not an emit failure.
825
+
826
+ `failed_count` is about the *path*, not adapter presence: `emit(…, async: false)` runs inline and
827
+ counts failures even when an adapter is configured. `target_count - failed_count` is the sync-path
828
+ success count.
829
+
830
+ ### Per-call overrides
831
+
832
+ ```ruby
833
+ Axn::Webhooks.emit(:lead_signed, data: { lead_id: 42 },
834
+ to: "https://one-off.example/hook", # String or Array
835
+ async: false)
836
+ ```
837
+
838
+ `to:` **replaces** the event's declared targets for that call — it never merges. The event must still
839
+ be declared (it supplies the wire `type` and `vendor`), and the URL goes through the same validation
840
+ as a declared target, raising rather than being silently rejected.
841
+
842
+ `async: true` **raises** when no adapter is configured rather than running inline; `async: false`
843
+ forces the inline path and suppresses the degraded-mode warning. See
844
+ [Async posture](DESIGN-NOTES.md#async-posture-auto-vs-explicit).
845
+
846
+ There is deliberately **no per-call `headers:`** — it would be serialized into the job payload. Use
847
+ the block-level `headers` resolver.
848
+
849
+ ## Delivery, retries, and failure
850
+
851
+ Each attempt classifies the receiver's response:
852
+
853
+ | Receiver responds | Delivery does |
854
+ | -- | -- |
855
+ | **2xx** | success |
856
+ | **408, 425, 429, 5xx, timeout, connection error** | retryable → self-reschedule the next attempt |
857
+ | **other 4xx** (400, 401/403, 404, 410, 422) | permanent → quiet `fail!`, no retry. Not reported via `on_exception`; the failure message carries a truncated (500-byte) copy of the response body |
858
+ | **unexpected exception** (crash / OOM / network raise mid-flight) | propagates → the adapter retries the un-acked job (at-least-once safety net) |
859
+
860
+ **One self-managed retry engine, adapter-agnostic.** On a retryable response, `Deliver` computes its
861
+ own delay and re-enqueues itself via axn's delayed-enqueue seam rather than inheriting whatever
862
+ backoff the underlying adapter has — identical behavior across every adapter. `Retry-After` is
863
+ honored precisely: `delay = max(backoff(attempt), retry_after_seconds)`, including the HTTP-date
864
+ form. The default curve applies **equal jitter** (half fixed, half random, capped at 6h) so a fan-out
865
+ whose receiver is down doesn't have every target retry in lockstep.
866
+
867
+ After `max_attempts`, exhaustion is reported **once** via `Axn.config.on_exception` and delivery
868
+ stops. It never raises, so the adapter doesn't also retry an already-exhausted job. With no async
869
+ adapter configured at all, a retryable failure is treated the same as an exhausted budget.
870
+
871
+ Because every attempt reuses the same `webhook-id`, a double-delivery from the crash safety net is
872
+ idempotent on the receiver side.
873
+
874
+ ### Transport
875
+
876
+ The HTTP call is an injectable seam:
877
+
878
+ ```ruby
879
+ class MyFaradayTransport
880
+ # Must return an Axn::Webhooks::Outbound::Transport::Response.
881
+ def self.post(url:, body:, headers:)
882
+ res = Faraday.post(url, body, headers)
883
+ Axn::Webhooks::Outbound::Transport::Response.new(status: res.status, headers: res.headers, body: res.body)
884
+ end
885
+ end
886
+ ```
887
+
888
+ The default is stdlib `net/http` — no new runtime dependency. `timeouts open:`/`read:` reach only the
889
+ built-in transport; a custom one owns its own timeout configuration, since the documented seam is
890
+ `.post(url:, body:, headers:)` with no timeout kwargs guaranteed. `Response`'s `body:` is optional
891
+ (defaults to `nil`) — only `status` is read for the retry classification.
892
+
893
+ ### Boot-time validation
894
+
895
+ An `outbound` block fails loudly at declaration — rather than as an unexpected exception mid-delivery,
896
+ which the async adapter would retry as if it were a network failure — for: a non-positive-Integer
897
+ `max_attempts`; a `backoff` that doesn't accept the attempt number; a `to:` that is neither an Array
898
+ nor a callable; a static `to:` entry that fails shape or host policy; a malformed `allowed_hosts`; an
899
+ `allow_url` or `headers` with the wrong arity; any invalid `sign :hmac` option; and a **literal**
900
+ `sign :standard_webhooks` secret that isn't a decodable `whsec_<base64>` value.
901
+
902
+ What can't be checked at boot is anything depending on runtime state: a callable `to:`'s/`subscribers`'
903
+ *return value* (validated at every `emit` instead), and a callable `secret:`'s *resolved value* — only
904
+ its **arity** is settled at declaration (0 ignores the subscriber, 1 receives it).
905
+
906
+ ---
907
+
908
+ # Signature primitive
909
+
910
+ `Axn::Webhooks::Signature` is a standalone, Rails-agnostic HMAC verifier — usable directly, with no
911
+ endpoint involved:
912
+
913
+ ```ruby
914
+ Axn::Webhooks::Signature.hmac(
915
+ secret: ENV["WEBHOOK_SECRET"],
916
+ payload: request.raw_body, # exact bytes the vendor signed
917
+ signature: request.header("X-Signature"),
918
+ digest: :sha256, # :sha256 (default) | :sha1 | :md5
919
+ encoding: :hex, # :hex (default) | :base64 | :base64_urlsafe
920
+ prefix: nil, # e.g. "v0=" for Slack
921
+ timestamp: request.header("X-Timestamp"), # optional replay guard
922
+ tolerance: 300,
923
+ )
924
+ ```
925
+
926
+ Always constant-time, and it supports multi-signature (key-rotation) headers.
927
+
928
+ `hmac` answers *whether* a request verified; `hmac_check` answers *why* it didn't — same check,
929
+ returning a `Signature::Check` instead of a boolean (`hmac` is literally `hmac_check(...).ok?`, so
930
+ there is only ever one replay window and one comparison):
931
+
932
+ ```ruby
933
+ check = Axn::Webhooks::Signature.hmac_check(secret:, payload:, signature:, timestamp:, tolerance: 300)
934
+ check.ok? # => false
935
+ check.reason # => :replay_window
936
+ check.skew # => 10_000 (seconds, signed: positive = in the past)
937
+ check.suggested_unit # => nil (a Symbol only when a pinned `unit:` is what missed the window)
938
+ ```
939
+
940
+ ## Replay protection
941
+
942
+ Pass `timestamp:` and `tolerance:` to guard against replayed requests — verification fails if the
943
+ timestamp is more than `tolerance` seconds from now in either direction. Epoch seconds, milliseconds
944
+ and microseconds are all handled without configuration:
945
+
946
+ ```ruby
947
+ Axn::Webhooks::Signature.hmac(
948
+ secret:, payload:, signature:,
949
+ timestamp: request.header("X-Timestamp"), # epoch s, ms or µs — inferred per timestamp
950
+ tolerance: 300,
951
+ )
952
+ ```
953
+
954
+ `unit:` defaults to `:auto`, reading the scale off each timestamp's magnitude. Pin it explicitly to
955
+ make a change in what a vendor sends fail loudly instead of being absorbed:
956
+
957
+ ```ruby
958
+ Axn::Webhooks::Signature.hmac(
959
+ secret:, payload:, signature:, timestamp:, tolerance: 300,
960
+ unit: :ms, # :auto (default) | :seconds | :ms | :milliseconds | :microseconds
961
+ )
962
+ ```
963
+
964
+ `unit:` describes only the incoming timestamp's resolution — `tolerance:`/`within:` is always in
965
+ seconds. A `Time` timestamp ignores it. An unrecognized value raises `ArgumentError` immediately,
966
+ even when `timestamp:` is a `Time`.
967
+
968
+ The same option is available on `verify :hmac`'s `replay:` hash:
969
+
970
+ ```ruby
971
+ Axn::Webhooks.inbound :lob do
972
+ verify :hmac, secret: ENV.fetch("LOB_WEBHOOK_SECRET"), signature: header("X-Lob-Signature"),
973
+ replay: { timestamp: header("X-Lob-Signature-Timestamp"), within: 300, unit: :auto }
974
+ end
975
+ ```
976
+
977
+ `mismatched_unit` answers "would another scale have fit?" on its own — side-effect-free, so a caller
978
+ decides what to do with it:
979
+
980
+ ```ruby
981
+ Axn::Webhooks::Signature.mismatched_unit(timestamp:, tolerance: 300, unit: :seconds) # => :ms
982
+ ```
983
+
984
+ ---
985
+
986
+ # Testing
987
+
988
+ Both registries are process-global, so reset them between examples that declare their own:
989
+ `Axn::Webhooks::Inbound.reset!` clears registered vendors, `Axn::Webhooks::Outbound.reset!` clears
990
+ the declared `outbound` block.
991
+
992
+ To exercise an inbound endpoint without a Rack stack, build a `Request` directly:
993
+
994
+ ```ruby
995
+ request = Axn::Webhooks::Request.new(
996
+ raw_body: JSON.dump({ "eventType" => "connection.updated" }), # the exact bytes you sign
997
+ headers: { "Content-Type" => "application/json", "X-Signature" => signature },
998
+ url: "https://example.com/webhooks/codat",
999
+ http_method: "POST",
1000
+ params: {}, # form/query params; `raw_body` is the only required kwarg
1001
+ )
1002
+
1003
+ Axn::Webhooks::Inbound[:codat].verify(request) # => Axn::Result (just the signature check)
1004
+ Axn::Webhooks::Inbound[:codat].handle(request) # => Axn::Result (verify + dispatch)
1005
+ Axn::Webhooks::Inbound[:codat].to_response(request) # => Axn::Webhooks::Response (the full mapping)
1006
+ ```
1007
+
1008
+ `to_response` is the one to assert against when you care about the status a vendor actually sees —
1009
+ it's the whole [status mapping](#http-status-reference), which `handle` stops short of. For the Rack
1010
+ layer, `Request.from_rack(env)` and `Inbound[:codat].call(env)` take a Rack env instead.
1011
+
1012
+ On the outbound side, inject a recording double via `transport`:
1013
+
1014
+ ```ruby
1015
+ recorder = Class.new do
1016
+ def self.calls = (@calls ||= [])
1017
+ def self.post(url:, body:, headers:)
1018
+ calls << { url:, body:, headers: }
1019
+ Axn::Webhooks::Outbound::Transport::Response.new(status: 200, headers: {}, body: "")
1020
+ end
1021
+ end
1022
+
1023
+ Axn::Webhooks.outbound do
1024
+ sign :hmac, secret: -> { "test-secret" }, header: "X-Signature"
1025
+ transport recorder
1026
+ event :lead_signed, to: ["https://example.com/hook"]
1027
+ end
1028
+
1029
+ Axn::Webhooks.emit(:lead_signed, data: { lead_id: 42 }, async: false)
1030
+ recorder.calls.first[:headers]["X-Signature"] # => the signature the receiver will verify
1031
+ ```
1032
+
1033
+ Pass `async: false` so the delivery runs inline and the assertion sees it, rather than depending on
1034
+ whether the test environment happens to have an async adapter configured.
1035
+
1036
+ ---
1037
+
1038
+ # Development
1039
+
1040
+ - `bin/refresh` — pull latest and install dependencies (fails on a dirty working tree).
1041
+ - `bundle exec rake` — the default task (Rails-free specs + rubocop).
1042
+ - `bundle exec rake verify` — the full suite (library specs, Rails specs, rubocop).