diff --git a/.env.example b/.env.example
index e8a55a4..ba3f2b7 100644
--- a/.env.example
+++ b/.env.example
@@ -19,3 +19,6 @@ BUGCROWD_OTP=your_totp_secret
# API Authentication
API_KEY=your_api_key_here
+
+# Webhook Notifications (optional, referenced from config/settings.yml as ${WEBHOOK_TOKEN})
+WEBHOOK_TOKEN=your_webhook_token
diff --git a/.rubocop.yml b/.rubocop.yml
index 935dfd3..8af4f7b 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -26,7 +26,7 @@ Metrics/ClassLength:
Max: 200
Metrics/ModuleLength:
- Max: 120
+ Max: 140
Metrics/MethodLength:
Max: 30
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1a3477b..1fd6916 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,43 @@
# Changelog
+## Version 2.1.0 - Generic HTTP Webhook Notifications
+
+### ✨ Features
+
+#### Generic HTTP Webhook Notifications
+
+Notifications are no longer Discord-only: a generic HTTP webhook notifier can be enabled alongside (or instead of) Discord.
+
+**Changes:**
+- **New `Notifiers::Webhook`**: POSTs every event as JSON to a configurable endpoint
+- **New `Notifiers::Multi`**: fans notifications out to all configured notifiers, a failing one never blocks the others
+- **Custom headers**: optional headers (typically for authentication) with `${VAR}` environment variable interpolation, so secrets stay in `.env`
+- **Same filtering as Discord**: `events` and `new_scope_types` filters, plus a dedicated `error` event
+
+**Configuration** (`config/settings.yml`):
+
+```yaml
+webhook:
+ enabled: true
+ url: "https://example.com/hooks/scopes"
+ headers:
+ Authorization: "Bearer ${WEBHOOK_TOKEN}"
+ events: ["new_program", "removed_program", "new_scope", "removed_scope", "ignored_asset", "error"]
+ new_scope_types: ["web"]
+```
+
+**Payload:**
+
+```json
+{
+ "event": "new_scope",
+ "timestamp": "2025-07-30T10:17:00Z",
+ "data": { "platform": "yeswehack", "program": "Example Program", "value": "*.example.com", "type": "web" }
+}
+```
+
+---
+
## Version 2.0.1 - Bugcrowd Auth Fix
### 🐛 Bug Fixes
diff --git a/README.md b/README.md
index 2e37942..51e9908 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@

-A tool to automatically synchronize and track bug bounty program scopes from multiple platforms. Monitor new programs, scope changes, and receive Discord notifications for updates.
+A tool to automatically synchronize and track bug bounty program scopes from multiple platforms. Monitor new programs, scope changes, and receive Discord or HTTP webhook notifications for updates.
[](https://www.ruby-lang.org/en/)
[](https://www.docker.com/)
@@ -17,7 +17,7 @@ A tool to automatically synchronize and track bug bounty program scopes from mul
- 🔄 **Multi-Platform Support**: YesWeHack, HackerOne, Intigriti, Bugcrowd
- 📊 **Automatic Synchronization**: Continuously monitor programs and detect scope changes
-- 🔔 **Discord Notifications**: Get notified about new programs, scope changes, and removals
+- 🔔 **Notifications**: Get notified about new programs, scope changes, and removals via Discord or a generic HTTP webhook (JSON)
- 🗄️ **SQLite Database**: Persistent storage with historical change tracking
- 🌐 **REST API**: Query scopes and changes programmatically
- 🎯 **Smart Scope Processing**: Automatic validation and normalization with platform-specific rules
@@ -39,7 +39,7 @@ A tool to automatically synchronize and track bug bounty program scopes from mul
2. **Configure platform credentials** in `.env` (email, password, API tokens, TOTP secrets)
-3. **Configure application settings** in `config/settings.yml` (enable/disable platforms, Discord webhooks, etc.)
+3. **Configure application settings** in `config/settings.yml` (enable/disable platforms, notification webhooks, etc.)
### Using Docker (Recommended)
@@ -383,9 +383,10 @@ curl -H "X-API-KEY: your_api_key" "http://localhost:4567/exclusions"
-## Discord Notifications
+## Notifications
-Configure Discord webhooks to receive real-time notifications:
+Two notifiers are available and can be enabled independently (both at once if needed):
+Discord webhooks and a generic HTTP webhook receiving JSON payloads.
### Notification Types
@@ -394,10 +395,11 @@ Configure Discord webhooks to receive real-time notifications:
- **new_scope**: New scope added to a program
- **removed_scope**: Scope removed from a program
- **ignored_asset**: Asset failed validation and was ignored
+- **error**: Synchronization error (HTTP webhook only, Discord uses a dedicated `errors` webhook)
### Scope Type Filtering
-Use `new_scope_types` to filter which scope types trigger notifications:
+Use `new_scope_types` to filter which scope types trigger `new_scope` notifications:
```yaml
discord:
@@ -407,6 +409,83 @@ discord:
# Or leave empty/null to notify for all types
```
+### Discord Notifications
+
+```yaml
+discord:
+ enabled: true
+ webhooks:
+ main:
+ url: "https://discord.com/api/webhooks/.../xxx"
+ events: ["new_program", "removed_program", "new_scope", "removed_scope", "ignored_asset"]
+ new_scope_types: ["web"]
+ errors:
+ url: "https://discord.com/api/webhooks/.../yyy"
+```
+
+### HTTP Webhook Notifications
+
+Send every event as a JSON `POST` to your own endpoint:
+
+```yaml
+webhook:
+ enabled: true
+ url: "https://example.com/hooks/scopes"
+ headers:
+ Authorization: "Bearer ${WEBHOOK_TOKEN}" # ${VAR} is replaced by the environment variable
+ events: ["new_program", "removed_program", "new_scope", "removed_scope", "ignored_asset", "error"]
+ new_scope_types: ["web"]
+```
+
+Custom headers are optional and mainly meant for authentication. Any `${VAR}` inside a header
+value (or inside the URL) is replaced by the matching environment variable, so secrets stay in
+`.env` instead of `config/settings.yml`.
+
+#### Payload Format
+
+Every request shares the same envelope, `data` depends on the event:
+
+```json
+{
+ "event": "new_scope",
+ "timestamp": "2025-07-30T10:17:00Z",
+ "data": {
+ "platform": "yeswehack",
+ "program": "Example Program",
+ "value": "*.example.com",
+ "type": "web"
+ }
+}
+```
+
+
+Payload per event
+
+| Event | `data` fields |
+|-------|---------------|
+| `new_program` | `platform`, `program`, `slug`, `scopes_count`, `scopes` (count per type) |
+| `removed_program` | `platform`, `program`, `slug` |
+| `new_scope` | `platform`, `program`, `value`, `type` |
+| `removed_scope` | `platform`, `program`, `value` |
+| `ignored_asset` | `platform`, `program`, `value`, `reason` |
+| `error` | `title`, `message` |
+
+```json
+{
+ "event": "new_program",
+ "timestamp": "2025-07-30T10:17:00Z",
+ "data": {
+ "platform": "yeswehack",
+ "program": "Example Program",
+ "slug": "example-program",
+ "scopes_count": 3,
+ "scopes": { "web": 2, "mobile": 1 }
+ }
+}
+```
+
+
+
## Scope Processing
ScopesExtractor includes intelligent scope processing with automatic normalization and validation.
@@ -531,7 +610,7 @@ scopes_refactor/
│ ├── sync_manager.rb # Platform synchronization orchestration
│ ├── validator.rb # Scope validation logic
│ ├── models/ # Dry-Struct models
-│ ├── notifiers/ # Discord notifications
+│ ├── notifiers/ # Discord & HTTP webhook notifications
│ └── platforms/ # Platform-specific implementations
│ ├── base_platform.rb
│ ├── yeswehack/
diff --git a/config/settings.yml b/config/settings.yml
index 6a74a1e..c6e58cc 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -52,6 +52,16 @@ discord:
errors:
url: "" # https://discord.com/api/webhooks/.../yyy
+# Generic HTTP webhook notifications (JSON payloads)
+webhook:
+ enabled: false
+ url: "" # https://example.com/hooks/scopes
+ headers: {} # Optional custom headers, "${VAR}" is replaced by the environment variable
+ # Example: { "Authorization": "Bearer ${WEBHOOK_TOKEN}" }
+ events: ["new_program", "removed_program", "new_scope", "removed_scope", "ignored_asset", "error"]
+ new_scope_types: ["web"] # Filter new_scope notifications by type (web, mobile, android, ios, etc.)
+ # Empty or null = notify all types
+
platform_exclusions:
yeswehack: []
hackerone: []
diff --git a/lib/scopes_extractor.rb b/lib/scopes_extractor.rb
index 5cf1504..7185877 100644
--- a/lib/scopes_extractor.rb
+++ b/lib/scopes_extractor.rb
@@ -66,6 +66,8 @@ def colorize_severity(severity)
# Require notifiers
require_relative 'scopes_extractor/notifiers/discord'
+require_relative 'scopes_extractor/notifiers/webhook'
+require_relative 'scopes_extractor/notifiers/multi'
# Require models
require_relative 'scopes_extractor/models/scope'
diff --git a/lib/scopes_extractor/config.rb b/lib/scopes_extractor/config.rb
index 1f1ccc1..c0f5ac6 100644
--- a/lib/scopes_extractor/config.rb
+++ b/lib/scopes_extractor/config.rb
@@ -131,6 +131,32 @@ def discord_new_scope_types
discord_main_webhook[:new_scope_types] || []
end
+ def webhook
+ load[:webhook] || {}
+ end
+
+ def webhook_enabled?
+ webhook[:enabled] == true
+ end
+
+ def webhook_url
+ resolve_env(webhook[:url])
+ end
+
+ # Custom headers sent with every webhook call (e.g. authentication).
+ # Values may reference environment variables using the "${VAR}" syntax.
+ def webhook_headers
+ (webhook[:headers] || {}).transform_keys(&:to_s).transform_values { |value| resolve_env(value) }
+ end
+
+ def webhook_events
+ webhook[:events] || []
+ end
+
+ def webhook_new_scope_types
+ webhook[:new_scope_types] || []
+ end
+
def platform_exclusions
load[:platform_exclusions] || {}
end
@@ -143,6 +169,10 @@ def excluded?(platform_name, program_slug)
private
+ def resolve_env(value)
+ value.to_s.gsub(/\$\{(\w+)\}/) { ENV.fetch(Regexp.last_match(1), '') }
+ end
+
def config_path
File.join(ScopesExtractor.root, 'config', 'settings.yml')
end
diff --git a/lib/scopes_extractor/diff_engine.rb b/lib/scopes_extractor/diff_engine.rb
index 633aeaa..0a2876f 100644
--- a/lib/scopes_extractor/diff_engine.rb
+++ b/lib/scopes_extractor/diff_engine.rb
@@ -5,7 +5,7 @@ module ScopesExtractor
class DiffEngine
def initialize(notifier: nil)
@db = ScopesExtractor.db
- @notifier = notifier || Notifiers::Discord.new
+ @notifier = notifier || Notifiers::Multi.default
end
def process_program(platform_name, fetched_program, skip_notifications: false)
@@ -45,7 +45,7 @@ def process_program(platform_name, fetched_program, skip_notifications: false)
details: 'Brand new program discovered'
)
- # Only send Discord notification if not skipping notifications
+ # Only send notifications if not skipping them
return if skip_notifications
@notifier.notify_new_program(
diff --git a/lib/scopes_extractor/notifiers/multi.rb b/lib/scopes_extractor/notifiers/multi.rb
new file mode 100644
index 0000000..88af707
--- /dev/null
+++ b/lib/scopes_extractor/notifiers/multi.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module ScopesExtractor
+ module Notifiers
+ # Fans out every notification to all configured notifiers.
+ # A failing notifier never prevents the others from being notified.
+ class Multi
+ NOTIFY_METHODS = %i[
+ notify_new_program notify_removed_program notify_new_scope
+ notify_removed_scope notify_ignored_asset notify_error
+ ].freeze
+
+ # Default set of notifiers, each one self-disables when not configured.
+ def self.default
+ new([Discord.new, Webhook.new])
+ end
+
+ def initialize(notifiers)
+ @notifiers = notifiers
+ end
+
+ NOTIFY_METHODS.each do |method_name|
+ define_method(method_name) do |*args, **kwargs|
+ @notifiers.each do |notifier|
+ notifier.public_send(method_name, *args, **kwargs)
+ rescue StandardError => e
+ ScopesExtractor.logger.error "#{notifier.class} ##{method_name} failed: #{e.message}"
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/scopes_extractor/notifiers/webhook.rb b/lib/scopes_extractor/notifiers/webhook.rb
new file mode 100644
index 0000000..43c75c7
--- /dev/null
+++ b/lib/scopes_extractor/notifiers/webhook.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require 'json'
+require 'time'
+
+module ScopesExtractor
+ module Notifiers
+ # Sends notifications as JSON payloads to a generic HTTP endpoint.
+ #
+ # Every payload shares the same envelope:
+ # { "event": "", "timestamp": "", "data": { ... } }
+ #
+ # Custom headers (e.g. an Authorization header) can be declared in the
+ # configuration and may reference environment variables as "${VAR}".
+ class Webhook
+ DEFAULT_HEADERS = { 'Content-Type' => 'application/json' }.freeze
+
+ def initialize
+ @url = Config.webhook_url
+ @headers = DEFAULT_HEADERS.merge(Config.webhook_headers)
+ @events = Config.webhook_events
+ @new_scope_types = Config.webhook_new_scope_types
+ end
+
+ def notify_new_program(platform, program_name, slug, scope_stats: {})
+ publish('new_program', platform: platform, program: program_name, slug: slug,
+ scopes_count: scope_stats.values.sum, scopes: stringify_stats(scope_stats))
+ end
+
+ def notify_removed_program(platform, program_name, slug)
+ publish('removed_program', platform: platform, program: program_name, slug: slug)
+ end
+
+ def notify_new_scope(platform, program_name, value, type)
+ return unless scope_type_enabled?(type)
+
+ publish('new_scope', platform: platform, program: program_name, value: value, type: type)
+ end
+
+ def notify_removed_scope(platform, program_name, value)
+ publish('removed_scope', platform: platform, program: program_name, value: value)
+ end
+
+ def notify_ignored_asset(platform, program_name, value, reason)
+ publish('ignored_asset', platform: platform, program: program_name, value: value, reason: reason)
+ end
+
+ def notify_error(title, message)
+ publish('error', title: title, message: message)
+ end
+
+ private
+
+ def enabled?
+ Config.webhook_enabled? && !@url.to_s.empty?
+ end
+
+ def event_enabled?(event_name)
+ @events.empty? || @events.include?(event_name)
+ end
+
+ def scope_type_enabled?(type)
+ @new_scope_types.empty? || @new_scope_types.include?(type.to_s)
+ end
+
+ def stringify_stats(scope_stats)
+ scope_stats.transform_keys(&:to_s)
+ end
+
+ def publish(event, **data)
+ return unless enabled? && event_enabled?(event)
+
+ payload = { event: event, timestamp: Time.now.utc.iso8601, data: data }
+ response = HTTP.post(@url, body: payload.to_json, headers: @headers)
+
+ return if (200..299).cover?(response.code)
+
+ ScopesExtractor.logger.error "Webhook notification failed: #{response.code} - #{response.body}"
+ rescue StandardError => e
+ ScopesExtractor.logger.error "Webhook notification error: #{e.message}"
+ end
+ end
+ end
+end
diff --git a/lib/scopes_extractor/sync_manager.rb b/lib/scopes_extractor/sync_manager.rb
index 9297dc7..9245b3d 100644
--- a/lib/scopes_extractor/sync_manager.rb
+++ b/lib/scopes_extractor/sync_manager.rb
@@ -6,7 +6,7 @@ module ScopesExtractor
class SyncManager
def initialize(diff_engine: nil, notifier: nil)
@diff_engine = diff_engine || DiffEngine.new(notifier: notifier)
- @notifier = notifier || Notifiers::Discord.new
+ @notifier = notifier || Notifiers::Multi.default
@platforms = []
@db = ScopesExtractor.db
setup_platforms
diff --git a/spec/scopes_extractor/config_spec.rb b/spec/scopes_extractor/config_spec.rb
index 19c4923..11a76b4 100644
--- a/spec/scopes_extractor/config_spec.rb
+++ b/spec/scopes_extractor/config_spec.rb
@@ -161,6 +161,44 @@
end
end
+ describe 'webhook configuration' do
+ describe '.webhook_enabled?' do
+ it 'is disabled by default' do
+ expect(described_class.webhook_enabled?).to be false
+ end
+ end
+
+ describe '.webhook_url' do
+ it 'resolves environment variables in the url' do
+ allow(described_class).to receive(:webhook).and_return({ url: 'https://example.com/${WEBHOOK_PATH}' })
+ allow(ENV).to receive(:fetch).with('WEBHOOK_PATH', '').and_return('hooks')
+
+ expect(described_class.webhook_url).to eq('https://example.com/hooks')
+ end
+ end
+
+ describe '.webhook_headers' do
+ it 'returns an empty hash when none are configured' do
+ allow(described_class).to receive(:webhook).and_return({})
+
+ expect(described_class.webhook_headers).to eq({})
+ end
+
+ it 'stringifies keys and resolves environment variables in values' do
+ allow(described_class).to receive(:webhook).and_return({ headers: { Authorization: 'Bearer ${WEBHOOK_TOKEN}' } })
+ allow(ENV).to receive(:fetch).with('WEBHOOK_TOKEN', '').and_return('s3cret')
+
+ expect(described_class.webhook_headers).to eq('Authorization' => 'Bearer s3cret')
+ end
+
+ it 'resolves unknown environment variables to an empty string' do
+ allow(described_class).to receive(:webhook).and_return({ headers: { 'X-Token' => '${MISSING_VAR}' } })
+
+ expect(described_class.webhook_headers).to eq('X-Token' => '')
+ end
+ end
+ end
+
describe 'platform exclusions' do
describe '.excluded?' do
it 'returns false when program is not in exclusion list' do
diff --git a/spec/scopes_extractor/notifiers/multi_spec.rb b/spec/scopes_extractor/notifiers/multi_spec.rb
new file mode 100644
index 0000000..9d82fe3
--- /dev/null
+++ b/spec/scopes_extractor/notifiers/multi_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ScopesExtractor::Notifiers::Multi do
+ subject(:notifier) { described_class.new([first, second]) }
+
+ let(:first) { double('Notifier') }
+ let(:second) { double('Notifier') }
+
+ it 'forwards notifications to every notifier' do
+ expect(first).to receive(:notify_new_scope).with('yeswehack', 'Program', 'example.com', 'web')
+ expect(second).to receive(:notify_new_scope).with('yeswehack', 'Program', 'example.com', 'web')
+
+ notifier.notify_new_scope('yeswehack', 'Program', 'example.com', 'web')
+ end
+
+ it 'forwards keyword arguments' do
+ expect(first).to receive(:notify_new_program).with('yeswehack', 'Program', 'slug', scope_stats: { 'web' => 1 })
+ expect(second).to receive(:notify_new_program).with('yeswehack', 'Program', 'slug', scope_stats: { 'web' => 1 })
+
+ notifier.notify_new_program('yeswehack', 'Program', 'slug', scope_stats: { 'web' => 1 })
+ end
+
+ it 'keeps notifying the others when one fails' do
+ allow(first).to receive(:notify_error).and_raise(StandardError, 'boom')
+ expect(second).to receive(:notify_error).with('Sync Error', 'boom')
+ expect(ScopesExtractor.logger).to receive(:error).with(/notify_error failed: boom/)
+
+ expect { notifier.notify_error('Sync Error', 'boom') }.not_to raise_error
+ end
+
+ describe '.default' do
+ it 'builds the Discord and Webhook notifiers' do
+ expect(described_class.default).to be_a(described_class)
+ end
+ end
+end
diff --git a/spec/scopes_extractor/notifiers/webhook_spec.rb b/spec/scopes_extractor/notifiers/webhook_spec.rb
new file mode 100644
index 0000000..e991f06
--- /dev/null
+++ b/spec/scopes_extractor/notifiers/webhook_spec.rb
@@ -0,0 +1,160 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ScopesExtractor::Notifiers::Webhook do
+ subject(:notifier) { described_class.new }
+
+ let(:url) { 'https://example.com/hooks/scopes' }
+ let(:response) { double(code: 200, body: '') }
+
+ before do
+ allow(ScopesExtractor::Config).to receive_messages(
+ webhook_enabled?: true,
+ webhook_url: url,
+ webhook_headers: {},
+ webhook_events: [],
+ webhook_new_scope_types: []
+ )
+ allow(ScopesExtractor::HTTP).to receive(:post).and_return(response)
+ end
+
+ # Captures the JSON body sent to the endpoint
+ def sent_payload
+ body = nil
+ allow(ScopesExtractor::HTTP).to receive(:post) do |_url, options|
+ body = options[:body]
+ response
+ end
+ yield
+ body && JSON.parse(body)
+ end
+
+ describe '#notify_new_program' do
+ it 'posts a JSON payload describing the event' do
+ payload = sent_payload do
+ notifier.notify_new_program('yeswehack', 'Test Program', 'test-slug', scope_stats: { 'web' => 2 })
+ end
+
+ expect(payload).to include('event' => 'new_program')
+ expect(payload['timestamp']).to match(/\A\d{4}-\d{2}-\d{2}T/)
+ expect(payload['data']).to eq(
+ 'platform' => 'yeswehack',
+ 'program' => 'Test Program',
+ 'slug' => 'test-slug',
+ 'scopes_count' => 2,
+ 'scopes' => { 'web' => 2 }
+ )
+ end
+
+ it 'sends the configured custom headers' do
+ allow(ScopesExtractor::Config).to receive(:webhook_headers).and_return('Authorization' => 'Bearer secret')
+
+ expect(ScopesExtractor::HTTP).to receive(:post).with(
+ url,
+ hash_including(headers: { 'Content-Type' => 'application/json', 'Authorization' => 'Bearer secret' })
+ )
+
+ notifier.notify_new_program('yeswehack', 'Test Program', 'test-slug')
+ end
+ end
+
+ describe '#notify_removed_program' do
+ it 'posts a removed_program event' do
+ payload = sent_payload { notifier.notify_removed_program('hackerone', 'Test Program', 'test-slug') }
+
+ expect(payload['event']).to eq('removed_program')
+ expect(payload['data']).to eq('platform' => 'hackerone', 'program' => 'Test Program', 'slug' => 'test-slug')
+ end
+ end
+
+ describe '#notify_new_scope' do
+ it 'posts a new_scope event' do
+ payload = sent_payload { notifier.notify_new_scope('intigriti', 'Test Program', 'example.com', 'web') }
+
+ expect(payload['event']).to eq('new_scope')
+ expect(payload['data']).to eq(
+ 'platform' => 'intigriti', 'program' => 'Test Program', 'value' => 'example.com', 'type' => 'web'
+ )
+ end
+
+ it 'skips scope types that are filtered out' do
+ allow(ScopesExtractor::Config).to receive(:webhook_new_scope_types).and_return(['web'])
+
+ expect(ScopesExtractor::HTTP).not_to receive(:post)
+ notifier.notify_new_scope('intigriti', 'Test Program', 'com.example.app', 'mobile')
+ end
+ end
+
+ describe '#notify_removed_scope' do
+ it 'posts a removed_scope event' do
+ payload = sent_payload { notifier.notify_removed_scope('bugcrowd', 'Test Program', 'example.com') }
+
+ expect(payload['event']).to eq('removed_scope')
+ expect(payload['data']).to eq('platform' => 'bugcrowd', 'program' => 'Test Program', 'value' => 'example.com')
+ end
+ end
+
+ describe '#notify_ignored_asset' do
+ it 'posts an ignored_asset event with its reason' do
+ payload = sent_payload { notifier.notify_ignored_asset('bugcrowd', 'Test Program', 'foo', 'Invalid format') }
+
+ expect(payload['event']).to eq('ignored_asset')
+ expect(payload['data']['reason']).to eq('Invalid format')
+ end
+ end
+
+ describe '#notify_error' do
+ it 'posts an error event' do
+ payload = sent_payload { notifier.notify_error('Sync Error', 'boom') }
+
+ expect(payload['event']).to eq('error')
+ expect(payload['data']).to eq('title' => 'Sync Error', 'message' => 'boom')
+ end
+ end
+
+ context 'when the webhook is disabled' do
+ before { allow(ScopesExtractor::Config).to receive(:webhook_enabled?).and_return(false) }
+
+ it 'does not send anything' do
+ expect(ScopesExtractor::HTTP).not_to receive(:post)
+ notifier.notify_new_program('yeswehack', 'Test Program', 'test-slug')
+ end
+ end
+
+ context 'when no url is configured' do
+ before { allow(ScopesExtractor::Config).to receive(:webhook_url).and_return('') }
+
+ it 'does not send anything' do
+ expect(ScopesExtractor::HTTP).not_to receive(:post)
+ notifier.notify_new_program('yeswehack', 'Test Program', 'test-slug')
+ end
+ end
+
+ context 'when the event is not in the configured list' do
+ before { allow(ScopesExtractor::Config).to receive(:webhook_events).and_return(['new_scope']) }
+
+ it 'does not send anything' do
+ expect(ScopesExtractor::HTTP).not_to receive(:post)
+ notifier.notify_new_program('yeswehack', 'Test Program', 'test-slug')
+ end
+ end
+
+ context 'when the endpoint returns an error' do
+ let(:response) { double(code: 500, body: 'boom') }
+
+ it 'logs the failure without raising' do
+ expect(ScopesExtractor.logger).to receive(:error).with(/Webhook notification failed: 500/)
+ expect { notifier.notify_error('Sync Error', 'boom') }.not_to raise_error
+ end
+ end
+
+ context 'when the request raises' do
+ it 'logs the error without raising' do
+ allow(ScopesExtractor::HTTP).to receive(:post).and_raise(StandardError, 'network down')
+
+ expect(ScopesExtractor.logger).to receive(:error).with(/Webhook notification error: network down/)
+ expect { notifier.notify_error('Sync Error', 'boom') }.not_to raise_error
+ end
+ end
+end