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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +176 -0
- data/DESIGN-NOTES.md +241 -0
- data/LICENSE.txt +21 -0
- data/README.md +1042 -0
- data/lib/axn/webhooks/dispatch.rb +129 -0
- data/lib/axn/webhooks/errors.rb +48 -0
- data/lib/axn/webhooks/handler.rb +15 -0
- data/lib/axn/webhooks/header_value.rb +29 -0
- data/lib/axn/webhooks/inbound/build_request.rb +23 -0
- data/lib/axn/webhooks/inbound/challenge.rb +37 -0
- data/lib/axn/webhooks/inbound/challenge_required.rb +35 -0
- data/lib/axn/webhooks/inbound/dsl.rb +240 -0
- data/lib/axn/webhooks/inbound/endpoint.rb +221 -0
- data/lib/axn/webhooks/inbound/parsers.rb +20 -0
- data/lib/axn/webhooks/inbound/respond_context.rb +17 -0
- data/lib/axn/webhooks/inbound/router.rb +104 -0
- data/lib/axn/webhooks/inbound.rb +124 -0
- data/lib/axn/webhooks/outbound/callable_arity.rb +99 -0
- data/lib/axn/webhooks/outbound/config.rb +442 -0
- data/lib/axn/webhooks/outbound/deliver.rb +425 -0
- data/lib/axn/webhooks/outbound/dsl.rb +121 -0
- data/lib/axn/webhooks/outbound/emit.rb +181 -0
- data/lib/axn/webhooks/outbound/envelope.rb +23 -0
- data/lib/axn/webhooks/outbound/signer.rb +376 -0
- data/lib/axn/webhooks/outbound/subscriber.rb +152 -0
- data/lib/axn/webhooks/outbound/target_policy.rb +135 -0
- data/lib/axn/webhooks/outbound/transport.rb +59 -0
- data/lib/axn/webhooks/outbound.rb +73 -0
- data/lib/axn/webhooks/request.rb +230 -0
- data/lib/axn/webhooks/resolvers.rb +43 -0
- data/lib/axn/webhooks/respond.rb +26 -0
- data/lib/axn/webhooks/response.rb +116 -0
- data/lib/axn/webhooks/signature.rb +268 -0
- data/lib/axn/webhooks/static_respond.rb +22 -0
- data/lib/axn/webhooks/vendor_facet.rb +25 -0
- data/lib/axn/webhooks/verifiers/basic_auth.rb +128 -0
- data/lib/axn/webhooks/verifiers/hmac.rb +58 -0
- data/lib/axn/webhooks/verifiers/standard_webhooks.rb +129 -0
- data/lib/axn/webhooks/verifiers.rb +50 -0
- data/lib/axn/webhooks/verify.rb +106 -0
- data/lib/axn/webhooks/version.rb +7 -0
- data/lib/axn/webhooks.rb +61 -0
- data/lib/axn-webhooks.rb +3 -0
- 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).
|