Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions examples/05_webhooks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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 ───────────────────────────────────────────────────
Expand Down
39 changes: 26 additions & 13 deletions lib/rail0/resources/webhooks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,6 +25,7 @@ class Webhooks
payments.voided
payments.released
payments.refunded
payments.expired
payments.failed
payments.disputed
payments.dispute_closed
Expand All @@ -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.
Expand All @@ -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<String>] 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.
Expand All @@ -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<String>, 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

Expand Down
2 changes: 1 addition & 1 deletion lib/rail0/types.rb
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ module Types
:id, # String
:name, # String
:callback_url, # String
:topic, # WebhookTopic
:topics, # Array<WebhookTopic> — every event this subscription delivers
:active, # Boolean
:circuit_state, # String
:circuit_failure_count, # Integer
Expand Down
7 changes: 4 additions & 3 deletions spec/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading