diff --git a/README.md b/README.md index 9749e3c..2849bd6 100644 --- a/README.md +++ b/README.md @@ -308,17 +308,24 @@ client.payments.submit_by_hash(id, "capture", { transaction_hash: "0x…" }) ## Webhooks (JWT) -A webhook subscribes to exactly one topic (see `Rail0::Resources::Webhooks::TOPICS`). +One subscription carries a **set** of topics (see `Rail0::Resources::Webhooks::TOPICS`): +one shared secret and one circuit breaker for all of them, with each delivery naming the +event that fired in `X-Rail0-Topic`. Two subscriptions for the same `callback_url` must not +overlap — the gateway answers 409 and names the topic that collided. ```ruby hook = client.webhooks.create( - name: "orders", callback_url: "https://merchant.example/hook", topic: "payments.captured" + name: "order-lifecycle", callback_url: "https://merchant.example/hook", + topics: ["payments.authorized", "payments.captured", "payments.voided", "payments.refunded"] ) hook[:shared_secret] # shown once — verify delivery signatures with it +# `topic:` is singular here on purpose: which subscriptions deliver THIS event. client.webhooks.list(topic: "payments.captured", active: true) client.webhooks.get(id) client.webhooks.update(id, callback_url: "https://new.example/hook") +# topics REPLACES the set, so this is also how a topic is removed. The secret is untouched. +client.webhooks.update(id, topics: ["payments.captured", "payments.refunded"]) client.webhooks.enable(id) client.webhooks.disable(id) client.webhooks.rotate_secret(id) # returns a fresh shared_secret diff --git a/examples/05_webhooks.rb b/examples/05_webhooks.rb index 85c62e7..0a3a242 100644 --- a/examples/05_webhooks.rb +++ b/examples/05_webhooks.rb @@ -2,7 +2,7 @@ # Manage webhook subscriptions (requires a JWT). # -# A webhook subscribes to exactly one topic and delivers a signed POST to your +# A subscription carries a set of topics and delivers a signed POST to your # callback URL when that event fires. The shared secret used to verify delivery # signatures is shown only once, on create and rotate. @@ -23,14 +23,16 @@ hook = api.webhooks.create( name: "captured-orders", callback_url: "https://merchant.example/rail0/webhook", - topic: "payments.captured" # see Rail0::Resources::Webhooks::TOPICS + # One subscription for the whole order lifecycle: one secret to verify against, one + # circuit breaker. See Rail0::Resources::Webhooks::TOPICS. + topics: %w[payments.authorized payments.captured payments.voided payments.refunded] ) puts "Created webhook #{hook[:id]}" puts "Shared secret (store it now — shown only once): #{hook[:shared_secret]}" # ── List / inspect ──────────────────────────────────────────────────────────── api.webhooks.list(active: true)[:data].each do |w| - puts " #{w[:id]} #{w[:topic]} circuit=#{w[:circuit_state]}" + puts " #{w[:id]} #{Array(w[:topics]).join(',')} circuit=#{w[:circuit_state]}" end # ── Update the callback URL ─────────────────────────────────────────────────── diff --git a/lib/rail0/resources/webhooks.rb b/lib/rail0/resources/webhooks.rb index 25bfcc0..44ad5a1 100644 --- a/lib/rail0/resources/webhooks.rb +++ b/lib/rail0/resources/webhooks.rb @@ -4,12 +4,18 @@ module Rail0 module Resources - # Webhook subscription management (requires JWT). A webhook subscribes to - # exactly one topic; see {TOPICS} for the accepted values. + # Webhook subscription management (requires JWT). + # + # A subscription covers a SET of topics — one shared secret and one circuit breaker + # for all of them — and each delivery names the event that fired in `X-Rail0-Topic`. + # Two subscriptions for the same callback_url must not overlap: one event delivered + # twice under two different secrets is indistinguishable from a duplicate at the + # receiving end, so the gateway answers 409 and names the topic that collided. + # See {TOPICS} for the accepted values. class Webhooks include Query - # Event topics a webhook can subscribe to. A webhook subscribes to one. + # Event topics a subscription can carry. It may carry any non-empty subset. TOPICS = %w[ payments.created payments.signed @@ -19,6 +25,7 @@ class Webhooks payments.voided payments.released payments.refunded + payments.expired payments.failed payments.disputed payments.dispute_closed @@ -32,7 +39,9 @@ def initialize(http) end # List the account's webhooks. - # @param topic [String, nil] Filter by topic (see {TOPICS}). + # @param topic [String, nil] Narrow to subscriptions that INCLUDE this event + # (see {TOPICS}). Singular on purpose: the question is which subscriptions + # deliver one event, whatever else they also deliver. # @param active [Boolean, nil] Filter by active flag. # @param circuit_state [String, nil] Filter by circuit state ("closed" or "open"). # @param sort [String, nil] Comma-separated sort fields; prefix with - for desc. @@ -49,10 +58,12 @@ def list(topic: nil, active: nil, circuit_state: nil, sort: nil, page: nil, per_ # used to verify delivery signatures — it is shown only on create and rotate. # @param name [String] Human-readable name. # @param callback_url [String] HTTPS URL the gateway POSTs events to. - # @param topic [String] One of {TOPICS}. + # @param topics [Array] One or more of {TOPICS}. Repeats are collapsed by + # the gateway; overlapping another subscription on the same callback_url is a 409 + # naming the topic that collided. # @return [Hash] webhook record including shared_secret - def create(name:, callback_url:, topic:) - http.post("/webhooks", { name: name, callback_url: callback_url, topic: topic }) + def create(name:, callback_url:, topics:) + http.post("/webhooks", { name: name, callback_url: callback_url, topics: Array(topics) }) end # Fetch a single webhook. @@ -62,17 +73,19 @@ def get(id) http.get("/webhooks/#{id}") end - # Update a webhook's name, callback_url, and/or topic. + # Update a webhook's name, callback_url, and/or topics. # @param id [String] Webhook UUID. # @param name [String, nil] # @param callback_url [String, nil] - # @param topic [String, nil] One of {TOPICS}. + # @param topics [Array, nil] REPLACES the whole set, which is also how a + # topic is removed: send the union to add one, the remainder to drop one. The + # shared secret is untouched. # @return [Hash] - def update(id, name: nil, callback_url: nil, topic: nil) + def update(id, name: nil, callback_url: nil, topics: nil) body = {} - body[:name] = name unless name.nil? - body[:callback_url] = callback_url unless callback_url.nil? - body[:topic] = topic unless topic.nil? + body[:name] = name unless name.nil? + body[:callback_url] = callback_url unless callback_url.nil? + body[:topics] = Array(topics) unless topics.nil? http.patch("/webhooks/#{id}", body) end diff --git a/lib/rail0/types.rb b/lib/rail0/types.rb index 8480ec8..43abb1a 100644 --- a/lib/rail0/types.rb +++ b/lib/rail0/types.rb @@ -234,7 +234,7 @@ module Types :id, # String :name, # String :callback_url, # String - :topic, # WebhookTopic + :topics, # Array — every event this subscription delivers :active, # Boolean :circuit_state, # String :circuit_failure_count, # Integer diff --git a/spec/client_spec.rb b/spec/client_spec.rb index 1be8893..10341cf 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -580,15 +580,16 @@ def stub_authed_get(token) it "list returns a paginated envelope" do stub_list("/webhooks?topic=payments.captured", [WEBHOOK]) result = client.webhooks.list(topic: "payments.captured") - expect(result[:data].first[:topic]).to eq("payments.captured") + expect(result[:data].first[:topics]).to include("payments.captured") end it "create returns the one-time shared_secret" do stub = stub_request(:post, "#{BASE_URL}/webhooks") - .with(body: { name: "orders", callback_url: "https://merchant.example/hook", topic: "payments.captured" }) + .with(body: { name: "orders", callback_url: "https://merchant.example/hook", + topics: ["payments.captured", "payments.refunded"] }) .to_return(status: 201, body: WEBHOOK_WITH_SECRET.to_json, headers: json_headers) result = client.webhooks.create(name: "orders", callback_url: "https://merchant.example/hook", - topic: "payments.captured") + topics: ["payments.captured", "payments.refunded"]) expect(stub).to have_been_requested expect(result[:shared_secret]).to eq("whsec_test_abc123") end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 5510122..04bdc44 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -132,7 +132,7 @@ # Webhook::Restricted / Webhook::WithSecret WEBHOOK = { id: WEBHOOK_ID, name: "orders", callback_url: "https://merchant.example/hook", - topic: "payments.captured", active: true, circuit_state: "closed", + topics: ["payments.captured", "payments.refunded"], active: true, circuit_state: "closed", circuit_failure_count: 0, created_at: "2026-07-01T00:00:00Z", updated_at: "2026-07-01T00:00:00Z" }.freeze WEBHOOK_WITH_SECRET = WEBHOOK.merge(shared_secret: "whsec_test_abc123").freeze