Repository: lovelaze/nebula-sync
Pull request #115: Implement webhooks for sync success and failure
Author: login: vondruska
Created: 2025-04-06T22:29:59Z | Merged: 2025-04-15T19:11:45Z

Taking a stab at #16 by implementing simple HTTP webhook(s). This implements two separate webhooks. One for a successful sync (could be used as a heartbeat). And an explicit failure webhook (could be use to notify on failure only).

If the webhook(s) have some sort an issue executing, the issue will be logged but not actual change to the operation of the sync. The webhook(s) do use the default http client so TLS validation will be enforced. Additionally, the webhook uses HTTP POST without any body. I'm happy to revisit these choices and expose some configuration options as well. But as illustrated in the issue, there are so many varying ways to handle this, I thought to try and keep it simple for this first pass. 

I put the trigger for the webhook inside of `doSync` and not apart of the cron trigger purposefully. That allows someone to use the webhook functionality while using a triggering system outside of the cron feature implemented within nebula-sync.

An example of it sending successes and failure from my environment using healthchecks.io:
![image](https://github.com/user-attachments/assets/30adf985-6eb7-4250-b9ca-23ad72ab8046)



Diff:
diff --git a/README.md b/README.md
index 2ab617c..75706a0 100644
--- a/README.md
+++ b/README.md
@@ -88,6 +88,7 @@ The following environment variables can be specified:
 | `CLIENT_RETRY_DELAY_SECONDS`       | 1       | 5               | Seconds to delay between connection attempts       |
 | `CLIENT_TIMEOUT_SECONDS`           | 20      | 60              | Http client timeout in seconds                     |
 
+
 > **Note:** The following optional settings apply only if `FULL_SYNC=false`. They allow for granular control of synchronization if a full sync is not wanted.
 
 | Name                              | Default | Description                            |
@@ -132,6 +133,47 @@ Config keys are relative to the section and are **case sensitive**. For example,
 | `SYNC_CONFIG_DEBUG_EXCLUDE`       | database,networking        | Debug config keys to exclude                   |
 
 
+### Webhooks
+
+Nebula Sync can invoke webhooks depeneding if a sync succeeded or failed. URL is required for the webhook to trigger. Both success and failure webhooks use the same enviroment variable pattern. Webhooks have a timeout of 10 seconds.
+
+| Name                                    | Default | Example                           | Description                                        |
+|-----------------------------------------|---------|-----------------------------------|----------------------------------------------------|
+| `SYNC_WEBHOOK_(SUCCESS\|FAILURE)_URL`    | n/a     | `https://www.example.com/webhook` | URL to invoke for the webhook    |
+| `SYNC_WEBHOOK_(SUCCESS\|FAILURE)_METHOD` | `POST`  | `GET`                             | The HTTP method for the webhook     |
+| `SYNC_WEBHOOK_(SUCCESS\|FAILURE)_BODY`   | n/a     | `this is my webhook body`         | The body of the webhook request |
+| `SYNC_WEBHOOK_(SUCCESS\|FAILURE)_HEADERS` | n/a    | `header1:foo,header2:bar`         | HTTP headers to set for the webhook request in the format `key:value` separated by comma. Any whitespace will be used verbatim, no string trimming. | 
+
+Additionally, you can skip TLS verification for all webhooks if necessary:
+
+| Name                                            | Default | Example         | Description                                        |
+|-------------------------------------------------|---------|-----------------|----------------------------------------------------|
+| `SYNC_WEBHOOK_CLIENT_SKIP_TLS_VERIFICATION`     | false   | true            | Skips TLS certificate verification                 |
+
+#### Examples
+
+##### healthcheck.io:
+
+```
+SYNC_WEBHOOK_SUCCESS_URL=https://hc-ping.com/{your-slug-or-guid-here}
+SYNC_WEBHOOK_FAILURE_URL=https://hc-ping.com/{your-slug-or-guid-here}/fail
+```
+
+##### Apprise:
+
+```
+SYNC_WEBHOOK_FAILURE_URL=http://localhost:8080/notify
+SYNC_WEBHOOK_FAILURE_BODY=urls=mailto://user:pass@gmail.com&body=test message
+```
+
+##### A service that needs JSON:
+
+```
+SYNC_WEBHOOK_FAILURE_URL=https://www.example.com/notify.json
+SYNC_WEBHOOK_FAILURE_BODY={"hello":"world"}
+SYNC_WEBHOOK_FAILURE_HEADERS=Content-Type:application/json
+```
+
 
 ## Disclaimer
 
diff --git a/internal/config/config.go b/internal/config/config.go
index e48fb73..cf623e5 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -2,6 +2,7 @@ package config
 
 import (
 	"fmt"
+
 	"github.com/kelseyhightower/envconfig"
 	"github.com/lovelaze/nebula-sync/internal/pihole/model"
 	"github.com/lovelaze/nebula-sync/internal/sync/filter"
@@ -19,7 +20,8 @@ type Sync struct {
 	Cron            *string `envconfig:"CRON"`
 	RunGravity      bool    `default:"false" envconfig:"RUN_GRAVITY"`
 	GravitySettings *GravitySettings
-	ConfigSettings  *ConfigSettings `ignored:"true"`
+	ConfigSettings  *ConfigSettings  `ignored:"true"`
+	WebhookSettings *WebhookSettings `ignored:"true"`
 }
 
 type GravitySettings struct {
@@ -169,6 +171,10 @@ func (c *Config) Load() error {
 		return err
 	}
 
+	if err := c.loadWebhookSettings(); err != nil {
+		return err
+	}
+
 	return nil
 }
 
@@ -225,3 +231,11 @@ func (cs *ConfigSetting) String() string {
 func (cs *ConfigFilter) String() string {
 	return fmt.Sprintf("%+v", *cs)
 }
+
+func (wes *WebhookEventSetting) String() string {
+	return fmt.Sprintf("%+v", *wes)
+}
+
+func (wes *WebhookSettings) String() string {
+	return fmt.Sprintf("%+v", *wes)
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 9c2577c..cdfb9cd 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -1,10 +1,11 @@
 package config
 
 import (
+	"testing"
+
 	"github.com/lovelaze/nebula-sync/internal/sync/filter"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
-	"testing"
 )
 
 func TestConfig_Load(t *testing.T) {
@@ -23,6 +24,8 @@ func TestConfig_Load(t *testing.T) {
 	assert.Equal(t, "http://localhost:1338", conf.Replicas[0].Url.String())
 	assert.Equal(t, "qwerty", conf.Replicas[0].Password)
 	assert.Equal(t, false, conf.Sync.FullSync)
+	assert.Equal(t, "POST", conf.Sync.WebhookSettings.Success.Method)
+	assert.Equal(t, "POST", conf.Sync.WebhookSettings.Failure.Method)
 }
 
 func TestConfig_loadSync(t *testing.T) {
diff --git a/internal/config/webhook.go b/internal/config/webhook.go
new file mode 100644
index 0000000..2477bb1
--- /dev/null
+++ b/internal/config/webhook.go
@@ -0,0 +1,49 @@
+package config
+
+import (
+	"fmt"
+
+	"github.com/kelseyhightower/envconfig"
+)
+
+type WebhookSettings struct {
+	Failure WebhookEventSetting `ignored:"true"`
+	Success WebhookEventSetting `ignored:"true"`
+	Client  WebhookClient       `ignored:"true"`
+}
+
+type WebhookClient struct {
+	SkipTLSVerification bool `default:"false" envconfig:"SKIP_TLS_VERIFICATION"`
+}
+
+type WebhookEventSetting struct {
+	Body    string            `default:"" envconfig:"BODY"`
+	Headers map[string]string `default:"" envconfig:"HEADERS"`
+	Method  string            `default:"POST" envconfig:"METHOD"`
+	Url     string            `default:"" envconfig:"URL"`
+}
+
+const envPrefix = "SYNC_WEBHOOK_"
+
+func (c *Config) loadWebhookSettings() error {
+	webhookSettings := WebhookSettings{
+		Failure: WebhookEventSetting{},
+		Success: WebhookEventSetting{},
+		Client:  WebhookClient{},
+	}
+
+	if err := envconfig.Process(envPrefix+"FAILURE", &webhookSettings.Failure); err != nil {
+		return fmt.Errorf("process webhook env vars for failure: %w", err)
+	}
+	if err := envconfig.Process(envPrefix+"SUCCESS", &webhookSettings.Success); err != nil {
+		return fmt.Errorf("process webhook env vars for success: %w", err)
+	}
+
+	if err := envconfig.Process(envPrefix+"CLIENT", &webhookSettings.Client); err != nil {
+		return fmt.Errorf("process webhook env vars for client: %w", err)
+	}
+
+	c.Sync.WebhookSettings = &webhookSettings
+
+	return nil
+}
diff --git a/internal/config/webhook_test.go b/internal/config/webhook_test.go
new file mode 100644
index 0000000..a648877
--- /dev/null
+++ b/internal/config/webhook_test.go
@@ -0,0 +1,108 @@
+package config
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestWebhookSettings_Load_Success(t *testing.T) {
+	t.Setenv("SYNC_WEBHOOK_SUCCESS_URL", "http://success.example.com")
+	t.Setenv("SYNC_WEBHOOK_SUCCESS_METHOD", "POST")
+	t.Setenv("SYNC_WEBHOOK_SUCCESS_BODY", "{\"status\":\"ok\"}")
+	t.Setenv("SYNC_WEBHOOK_SUCCESS_HEADERS", "Content-Type:application/json,Authorization:Bearer token,X-Custom-Header: CustomValue")
+
+	conf := Config{
+		Sync: &Sync{},
+	}
+	err := conf.loadWebhookSettings()
+	require.NoError(t, err)
+
+	success := conf.Sync.WebhookSettings.Success
+	assert.Equal(t, "http://success.example.com", success.Url)
+	assert.Equal(t, "POST", success.Method)
+	assert.Equal(t, "{\"status\":\"ok\"}", success.Body)
+	assert.Equal(t, map[string]string{
+		"Content-Type":    "application/json",
+		"Authorization":   "Bearer token",
+		"X-Custom-Header": " CustomValue",
+	}, success.Headers)
+}
+
+func TestWebhookSettings_Load_Failure(t *testing.T) {
+	t.Setenv("SYNC_WEBHOOK_FAILURE_URL", "http://failure.example.com")
+	t.Setenv("SYNC_WEBHOOK_FAILURE_METHOD", "PUT")
+	t.Setenv("SYNC_WEBHOOK_FAILURE_BODY", "{\"status\":\"error\"}")
+	t.Setenv("SYNC_WEBHOOK_FAILURE_HEADERS", "Content-Type:application/json")
+
+	conf := Config{
+		Sync: &Sync{},
+	}
+	err := conf.loadWebhookSettings()
+	require.NoError(t, err)
+
+	failure := conf.Sync.WebhookSettings.Failure
+	assert.Equal(t, "http://failure.example.com", failure.Url)
+	assert.Equal(t, "PUT", failure.Method)
+	assert.Equal(t, "{\"status\":\"error\"}", failure.Body)
+	assert.Equal(t, map[string]string{
+		"Content-Type": "application/json",
+	}, failure.Headers)
+}
+
+func TestWebhookSettings_DefaultValues(t *testing.T) {
+	t.Setenv("SYNC_WEBHOOK_SUCCESS_URL", "http://success.example.com")
+	t.Setenv("SYNC_WEBHOOK_FAILURE_URL", "http://failure.example.com")
+
+	conf := Config{
+		Sync: &Sync{},
+	}
+	err := conf.loadWebhookSettings()
+	require.NoError(t, err)
+
+	// Test default values
+	assert.Equal(t, "POST", conf.Sync.WebhookSettings.Success.Method)
+	assert.Equal(t, "POST", conf.Sync.WebhookSettings.Failure.Method)
+	assert.Empty(t, conf.Sync.WebhookSettings.Success.Body)
+	assert.Empty(t, conf.Sync.WebhookSettings.Failure.Body)
+	assert.Empty(t, conf.Sync.WebhookSettings.Success.Headers)
+	assert.Empty(t, conf.Sync.WebhookSettings.Failure.Headers)
+}
+
+func TestWebhookSettings_InvalidHeaders(t *testing.T) {
+	t.Setenv("SYNC_WEBHOOK_SUCCESS_URL", "http://success.example.com")
+	t.Setenv("SYNC_WEBHOOK_SUCCESS_HEADERS", "InvalidHeader")
+
+	conf := Config{
+		Sync: &Sync{},
+	}
+	err := conf.loadWebhookSettings()
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "process webhook env vars")
+}
+
+func TestWebhookSettings_EmptyURLs(t *testing.T) {
+	conf := Config{
+		Sync: &Sync{},
+	}
+	err := conf.loadWebhookSettings()
+	require.NoError(t, err)
+
+	assert.Empty(t, conf.Sync.WebhookSettings.Success.Url)
+	assert.Empty(t, conf.Sync.WebhookSettings.Failure.Url)
+}
+
+func TestWebhookSettings_ClientConfiguration(t *testing.T) {
+	t.Setenv("SYNC_WEBHOOK_SUCCESS_URL", "http://success.example.com")
+	t.Setenv("SYNC_WEBHOOK_CLIENT_SKIP_TLS_VERIFICATION", "true")
+
+	conf := Config{
+		Sync: &Sync{},
+	}
+	err := conf.loadWebhookSettings()
+	require.NoError(t, err)
+
+	require.NotNil(t, conf.Sync.WebhookSettings.Client)
+	assert.True(t, conf.Sync.WebhookSettings.Client.SkipTLSVerification)
+}
diff --git a/internal/mocks/pihole/Client.go b/internal/mocks/pihole/Client.go
index 784ccde..0a16ac7 100644
--- a/internal/mocks/pihole/Client.go
+++ b/internal/mocks/pihole/Client.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.45.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 package pihole
 
@@ -66,7 +66,7 @@ func (_c *Client_ApiPath_Call) RunAndReturn(run func(string) string) *Client_Api
 	return _c
 }
 
-// DeleteSession provides a mock function with given fields:
+// DeleteSession provides a mock function with no fields
 func (_m *Client) DeleteSession() error {
 	ret := _m.Called()
 
@@ -111,7 +111,7 @@ func (_c *Client_DeleteSession_Call) RunAndReturn(run func() error) *Client_Dele
 	return _c
 }
 
-// GetConfig provides a mock function with given fields:
+// GetConfig provides a mock function with no fields
 func (_m *Client) GetConfig() (*model.ConfigResponse, error) {
 	ret := _m.Called()
 
@@ -168,7 +168,7 @@ func (_c *Client_GetConfig_Call) RunAndReturn(run func() (*model.ConfigResponse,
 	return _c
 }
 
-// GetTeleporter provides a mock function with given fields:
+// GetTeleporter provides a mock function with no fields
 func (_m *Client) GetTeleporter() ([]byte, error) {
 	ret := _m.Called()
 
@@ -225,7 +225,7 @@ func (_c *Client_GetTeleporter_Call) RunAndReturn(run func() ([]byte, error)) *C
 	return _c
 }
 
-// GetVersion provides a mock function with given fields:
+// GetVersion provides a mock function with no fields
 func (_m *Client) GetVersion() (*model.VersionResponse, error) {
 	ret := _m.Called()
 
@@ -328,7 +328,7 @@ func (_c *Client_PatchConfig_Call) RunAndReturn(run func(*model.PatchConfigReque
 	return _c
 }
 
-// PostAuth provides a mock function with given fields:
+// PostAuth provides a mock function with no fields
 func (_m *Client) PostAuth() error {
 	ret := _m.Called()
 
@@ -373,7 +373,7 @@ func (_c *Client_PostAuth_Call) RunAndReturn(run func() error) *Client_PostAuth_
 	return _c
 }
 
-// PostRunGravity provides a mock function with given fields:
+// PostRunGravity provides a mock function with no fields
 func (_m *Client) PostRunGravity() error {
 	ret := _m.Called()
 
@@ -465,7 +465,7 @@ func (_c *Client_PostTeleporter_Call) RunAndReturn(run func([]byte, *model.PostT
 	return _c
 }
 
-// String provides a mock function with given fields:
+// String provides a mock function with no fields
 func (_m *Client) String() string {
 	ret := _m.Called()
 
diff --git a/internal/mocks/sync/Target.go b/internal/mocks/sync/Target.go
index d9d240c..a3ba3fe 100644
--- a/internal/mocks/sync/Target.go
+++ b/internal/mocks/sync/Target.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.45.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 package sync
 
diff --git a/internal/mocks/webhook/WebhookClient.go b/internal/mocks/webhook/WebhookClient.go
new file mode 100644
index 0000000..1a972b1
--- /dev/null
+++ b/internal/mocks/webhook/WebhookClient.go
@@ -0,0 +1,122 @@
+// Code generated by mockery v2.53.3. DO NOT EDIT.
+
+package webhook
+
+import mock "github.com/stretchr/testify/mock"
+
+// WebhookClient is an autogenerated mock type for the WebhookClient type
+type WebhookClient struct {
+	mock.Mock
+}
+
+type WebhookClient_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *WebhookClient) EXPECT() *WebhookClient_Expecter {
+	return &WebhookClient_Expecter{mock: &_m.Mock}
+}
+
+// Failure provides a mock function with no fields
+func (_m *WebhookClient) Failure() error {
+	ret := _m.Called()
+
+	if len(ret) == 0 {
+		panic("no return value specified for Failure")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func() error); ok {
+		r0 = rf()
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// WebhookClient_Failure_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Failure'
+type WebhookClient_Failure_Call struct {
+	*mock.Call
+}
+
+// Failure is a helper method to define mock.On call
+func (_e *WebhookClient_Expecter) Failure() *WebhookClient_Failure_Call {
+	return &WebhookClient_Failure_Call{Call: _e.mock.On("Failure")}
+}
+
+func (_c *WebhookClient_Failure_Call) Run(run func()) *WebhookClient_Failure_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run()
+	})
+	return _c
+}
+
+func (_c *WebhookClient_Failure_Call) Return(_a0 error) *WebhookClient_Failure_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *WebhookClient_Failure_Call) RunAndReturn(run func() error) *WebhookClient_Failure_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// Success provides a mock function with no fields
+func (_m *WebhookClient) Success() error {
+	ret := _m.Called()
+
+	if len(ret) == 0 {
+		panic("no return value specified for Success")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func() error); ok {
+		r0 = rf()
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// WebhookClient_Success_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Success'
+type WebhookClient_Success_Call struct {
+	*mock.Call
+}
+
+// Success is a helper method to define mock.On call
+func (_e *WebhookClient_Expecter) Success() *WebhookClient_Success_Call {
+	return &WebhookClient_Success_Call{Call: _e.mock.On("Success")}
+}
+
+func (_c *WebhookClient_Success_Call) Run(run func()) *WebhookClient_Success_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run()
+	})
+	return _c
+}
+
+func (_c *WebhookClient_Success_Call) Return(_a0 error) *WebhookClient_Success_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *WebhookClient_Success_Call) RunAndReturn(run func() error) *WebhookClient_Success_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// NewWebhookClient creates a new instance of WebhookClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func NewWebhookClient(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *WebhookClient {
+	mock := &WebhookClient{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}
diff --git a/internal/service/service.go b/internal/service/service.go
index b19217d..37789e0 100644
--- a/internal/service/service.go
+++ b/internal/service/service.go
@@ -2,19 +2,22 @@ package service
 
 import (
 	"fmt"
+
 	"github.com/lovelaze/nebula-sync/internal/sync/retry"
 
 	"github.com/lovelaze/nebula-sync/internal/config"
 	"github.com/lovelaze/nebula-sync/internal/pihole"
 	"github.com/lovelaze/nebula-sync/internal/sync"
+	"github.com/lovelaze/nebula-sync/internal/webhook"
 	"github.com/lovelaze/nebula-sync/version"
 	"github.com/robfig/cron/v3"
 	"github.com/rs/zerolog/log"
 )
 
 type Service struct {
-	target sync.Target
-	conf   config.Config
+	target  sync.Target
+	conf    config.Config
+	webhook webhook.WebhookClient
 }
 
 func Init() (*Service, error) {
@@ -33,8 +36,9 @@ func Init() (*Service, error) {
 	}
 
 	return &Service{
-		target: sync.NewTarget(primary, replicas),
-		conf:   conf,
+		target:  sync.NewTarget(primary, replicas),
+		conf:    conf,
+		webhook: webhook.NewWebhookClient(conf.Sync.WebhookSettings),
 	}, nil
 }
 
@@ -65,10 +69,17 @@ func (service *Service) doSync(t sync.Target) (err error) {
 	}
 
 	if err != nil {
-		return err
+		if err := service.webhook.Failure(); err != nil {
+			log.Error().Err(err).Msg("Failed to send failure webhook")
+		}
+	} else {
+		log.Info().Msg("Sync completed")
+
+		if err := service.webhook.Success(); err != nil {
+			log.Error().Err(err).Msg("Failed to send success webhook")
+		}
 	}
 
-	log.Info().Msg("Sync complete")
 	return err
 }
 
diff --git a/internal/service/service_test.go b/internal/service/service_test.go
index d63a351..4a5819d 100644
--- a/internal/service/service_test.go
+++ b/internal/service/service_test.go
@@ -1,11 +1,14 @@
 package service
 
 import (
+	"errors"
+	"testing"
+
 	"github.com/lovelaze/nebula-sync/internal/config"
 	syncmock "github.com/lovelaze/nebula-sync/internal/mocks/sync"
+	webhookmock "github.com/lovelaze/nebula-sync/internal/mocks/webhook"
 	"github.com/lovelaze/nebula-sync/internal/pihole/model"
 	"github.com/stretchr/testify/require"
-	"testing"
 )
 
 func TestRun_full(t *testing.T) {
@@ -19,11 +22,14 @@ func TestRun_full(t *testing.T) {
 	}
 
 	target := syncmock.NewTarget(t)
+	webhook := webhookmock.NewWebhookClient(t)
 	target.On("FullSync", conf.Sync).Return(nil)
+	webhook.On("Success").Return(nil)
 
 	service := Service{
-		target: target,
-		conf:   conf,
+		target:  target,
+		conf:    conf,
+		webhook: webhook,
 	}
 
 	err := service.Run()
@@ -43,15 +49,108 @@ func TestRun_selective(t *testing.T) {
 	}
 
 	target := syncmock.NewTarget(t)
+	webhook := webhookmock.NewWebhookClient(t)
+	target.On("SelectiveSync", conf.Sync).Return(nil)
+	webhook.On("Success").Return(nil)
+
+	service := Service{
+		target:  target,
+		conf:    conf,
+		webhook: webhook,
+	}
+
+	err := service.Run()
+	require.NoError(t, err)
+
+	target.AssertCalled(t, "SelectiveSync", conf.Sync)
+}
+
+func TestRun_webhook_success(t *testing.T) {
+	conf := config.Config{
+		Primary:  model.PiHole{},
+		Replicas: []model.PiHole{},
+		Sync: &config.Sync{
+			FullSync: false,
+			Cron:     nil,
+		},
+	}
+
+	target := syncmock.NewTarget(t)
+	webhook := webhookmock.NewWebhookClient(t)
+
 	target.On("SelectiveSync", conf.Sync).Return(nil)
+	webhook.On("Success").Return(nil)
 
 	service := Service{
-		target: target,
-		conf:   conf,
+		target:  target,
+		conf:    conf,
+		webhook: webhook,
 	}
 
 	err := service.Run()
 	require.NoError(t, err)
 
 	target.AssertCalled(t, "SelectiveSync", conf.Sync)
+	webhook.AssertCalled(t, "Success")
+	webhook.AssertNotCalled(t, "Failure")
+}
+
+func TestRun_webhook_failure(t *testing.T) {
+	conf := config.Config{
+		Primary:  model.PiHole{},
+		Replicas: []model.PiHole{},
+		Sync: &config.Sync{
+			FullSync: false,
+			Cron:     nil,
+		},
+	}
+
+	syncErr := errors.New("sync failed")
+	target := syncmock.NewTarget(t)
+	webhook := webhookmock.NewWebhookClient(t)
+
+	target.On("SelectiveSync", conf.Sync).Return(syncErr)
+	webhook.On("Failure").Return(nil)
+
+	service := Service{
+		target:  target,
+		conf:    conf,
+		webhook: webhook,
+	}
+
+	err := service.Run()
+	require.ErrorIs(t, err, syncErr)
+
+	target.AssertCalled(t, "SelectiveSync", conf.Sync)
+	webhook.AssertCalled(t, "Failure")
+	webhook.AssertNotCalled(t, "Success")
+}
+
+func TestRun_webhook_error_does_not_affect_result(t *testing.T) {
+	conf := config.Config{
+		Primary:  model.PiHole{},
+		Replicas: []model.PiHole{},
+		Sync: &config.Sync{
+			FullSync: true,
+			Cron:     nil,
+		},
+	}
+
+	target := syncmock.NewTarget(t)
+	webhook := webhookmock.NewWebhookClient(t)
+
+	target.On("FullSync", conf.Sync).Return(nil)
+	webhook.On("Success").Return(errors.New("webhook failed"))
+
+	service := Service{
+		target:  target,
+		conf:    conf,
+		webhook: webhook,
+	}
+
+	err := service.Run()
+	require.NoError(t, err)
+
+	target.AssertCalled(t, "FullSync", conf.Sync)
+	webhook.AssertCalled(t, "Success")
 }
diff --git a/internal/webhook/webhook.go b/internal/webhook/webhook.go
new file mode 100644
index 0000000..adc7f90
--- /dev/null
+++ b/internal/webhook/webhook.go
@@ -0,0 +1,80 @@
+package webhook
+
+import (
+	"crypto/tls"
+	"fmt"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/lovelaze/nebula-sync/internal/config"
+	"github.com/lovelaze/nebula-sync/version"
+	"github.com/rs/zerolog/log"
+)
+
+type WebhookClient interface {
+	Success() error
+	Failure() error
+}
+
+type webhookClient struct {
+	successConfig config.WebhookEventSetting
+	failureConfig config.WebhookEventSetting
+	client        *http.Client
+}
+
+func NewWebhookClient(c *config.WebhookSettings) WebhookClient {
+	return &webhookClient{
+		successConfig: c.Success,
+		failureConfig: c.Failure,
+		client: &http.Client{
+			Timeout: 10 * time.Second,
+			Transport: &http.Transport{
+				TLSClientConfig: &tls.Config{InsecureSkipVerify: c.Client.SkipTLSVerification},
+			}},
+	}
+}
+
+func (webhookClient *webhookClient) Success() error {
+	return invokeWebhook(webhookClient.client, webhookClient.successConfig)
+}
+
+func (webhookClient *webhookClient) Failure() error {
+	return invokeWebhook(webhookClient.client, webhookClient.failureConfig)
+}
+
+func invokeWebhook(client *http.Client, settings config.WebhookEventSetting) error {
+	if settings.Url == "" {
+		return nil
+	}
+
+	log.Debug().
+		Str("url", settings.Url).
+		Str("method", settings.Method).
+		Str("body", settings.Body).
+		Interface("headers", settings.Headers).
+		Msg("Invoking webhook")
+
+	req, err := http.NewRequest(settings.Method, settings.Url, strings.NewReader(settings.Body))
+	if err != nil {
+		return fmt.Errorf("create webhook request: %w", err)
+	}
+
+	req.Header.Set("User-Agent", fmt.Sprintf("nebula-sync/%s", version.Version))
+
+	for key, value := range settings.Headers {
+		req.Header.Set(key, value)
+	}
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return fmt.Errorf("send webhook request: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode >= 400 {
+		return fmt.Errorf("webhook returned status %d", resp.StatusCode)
+	}
+
+	return nil
+}
diff --git a/internal/webhook/webhook_test.go b/internal/webhook/webhook_test.go
new file mode 100644
index 0000000..f58c46d
--- /dev/null
+++ b/internal/webhook/webhook_test.go
@@ -0,0 +1,108 @@
+package webhook
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/lovelaze/nebula-sync/internal/config"
+	"github.com/lovelaze/nebula-sync/version"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestWebhookClient(t *testing.T) {
+	t.Run("success webhook uses success configuration", func(t *testing.T) {
+		// Setup test server to verify request
+		var receivedHeaders http.Header
+		var receivedBody string
+		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			receivedHeaders = r.Header
+			buf := make([]byte, 1024)
+			n, _ := r.Body.Read(buf)
+			receivedBody = string(buf[:n])
+			w.WriteHeader(http.StatusOK)
+		}))
+		defer ts.Close()
+
+		// Create webhook settings
+		settings := &config.WebhookSettings{
+			Success: config.WebhookEventSetting{
+				Url:     ts.URL,
+				Method:  "POST",
+				Body:    "success-body",
+				Headers: map[string]string{"X-Test": "success"},
+			},
+			Client: config.WebhookClient{},
+		}
+
+		client := NewWebhookClient(settings)
+		err := client.Success()
+		require.NoError(t, err)
+
+		// Verify request
+		assert.Equal(t, "success-body", receivedBody)
+		assert.Equal(t, "success", receivedHeaders.Get("X-Test"))
+		assert.Equal(t, "nebula-sync/"+version.Version, receivedHeaders.Get("User-Agent"))
+	})
+
+	t.Run("failure webhook uses failure configuration", func(t *testing.T) {
+		var receivedHeaders http.Header
+		var receivedBody string
+		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			receivedHeaders = r.Header
+			buf := make([]byte, 1024)
+			n, _ := r.Body.Read(buf)
+			receivedBody = string(buf[:n])
+			w.WriteHeader(http.StatusOK)
+		}))
+		defer ts.Close()
+
+		settings := &config.WebhookSettings{
+			Failure: config.WebhookEventSetting{
+				Url:     ts.URL,
+				Method:  "PUT",
+				Body:    "failure-body",
+				Headers: map[string]string{"X-Test": "failure"},
+			},
+			Client: config.WebhookClient{},
+		}
+
+		client := NewWebhookClient(settings)
+		err := client.Failure()
+		require.NoError(t, err)
+
+		assert.Equal(t, "failure-body", receivedBody)
+		assert.Equal(t, "failure", receivedHeaders.Get("X-Test"))
+	})
+
+	t.Run("empty url skips webhook", func(t *testing.T) {
+		settings := &config.WebhookSettings{
+			Success: config.WebhookEventSetting{
+				Url: "",
+			},
+		}
+
+		client := NewWebhookClient(settings)
+		err := client.Success()
+		require.NoError(t, err)
+	})
+
+	t.Run("error on non-200 response", func(t *testing.T) {
+		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.WriteHeader(http.StatusBadRequest)
+		}))
+		defer ts.Close()
+
+		settings := &config.WebhookSettings{
+			Success: config.WebhookEventSetting{
+				Url: ts.URL,
+			},
+		}
+
+		client := NewWebhookClient(settings)
+		err := client.Success()
+		require.Error(t, err)
+		assert.Contains(t, err.Error(), "webhook returned status 400")
+	})
+}


Reviews:
user: login: lovelaze
body: I left a few comments, please have a look and see what you think. I will also change the target branch to a feature branch.

Fantastic work, thanks a lot for this! @vondruska 
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-13T20:05:32Z
user: login: vondruska
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T20:15:26Z
user: login: vondruska
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T20:15:59Z
user: login: lovelaze
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-13T20:17:22Z
user: login: vondruska
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T20:19:33Z
user: login: vondruska
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T20:30:12Z
user: login: lovelaze
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-14T19:38:58Z
user: login: lovelaze
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-14T19:40:21Z
user: login: vondruska
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T14:19:55Z
user: login: vondruska
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T14:56:04Z

Review comments:
diff_hunk: @@ -132,6 +133,49 @@ Config keys are relative to the section and are **case sensitive**. For example,
 | `SYNC_CONFIG_DEBUG_EXCLUDE`       | database,networking        | Debug config keys to exclude                   |
 
 
+### Webhooks
+
+Nebula Sync can invoke webhooks depeneding if a sync succeeded or failed. URL is required for the webhook to trigger. Both sucess and failure webhooks use the same enviroment variable pattern.
+
+| Name                                    | Default | Example                           | Description                                        |
+|-----------------------------------------|---------|-----------------------------------|----------------------------------------------------|
+| `SYNC_WEBHOOK_(SUCCESS\|FAILURE)_URL`    | n/a     | `https://www.example.com/webhook` | URL to invoke for the webhook    |
+| `SYNC_WEBHOOK_(SUCCESS\|FAILURE)_METHOD` | `POST`  | `GET`                             | The HTTP method for the webhook     |
path: README.md
user: login: lovelaze
body: I'm not sure we should default the http method, it makes more sense to have it failed when URL is configured without http method I think
created_at: 2025-04-13T19:54:14Z
author_association: OWNER
diff_hunk: @@ -132,6 +133,49 @@ Config keys are relative to the section and are **case sensitive**. For example,
 | `SYNC_CONFIG_DEBUG_EXCLUDE`       | database,networking        | Debug config keys to exclude                   |
 
 
+### Webhooks
+
+Nebula Sync can invoke webhooks depeneding if a sync succeeded or failed. URL is required for the webhook to trigger. Both sucess and failure webhooks use the same enviroment variable pattern.
+
+| Name                                    | Default | Example                           | Description                                        |
+|-----------------------------------------|---------|-----------------------------------|----------------------------------------------------|
+| `SYNC_WEBHOOK_(SUCCESS\|FAILURE)_URL`    | n/a     | `https://www.example.com/webhook` | URL to invoke for the webhook    |
+| `SYNC_WEBHOOK_(SUCCESS\|FAILURE)_METHOD` | `POST`  | `GET`                             | The HTTP method for the webhook     |
+| `SYNC_WEBHOOK_(SUCCESS\|FAILURE)_BODY`   | n/a     | `this is my webhook body`         | The body of the webhook request |
+| `SYNC_WEBHOOK_(SUCCESS\|FAILURE)_HEADERS` | n/a    | `header1:foo,header2:bar`         | HTTP headers to set for the webhook request in the format `key:value` separated by comma. Any whitespace will be used verbatim, no string trimming. | 
+
+Additionally, webhooks have an independent HTTP client configuration. Similar settings as the pihole client but will only be used in the webhook context.
+
+| Name                                            | Default | Example         | Description                                        |
+|-------------------------------------------------|---------|-----------------|----------------------------------------------------|
+| `SYNC_WEBHOOK_CLIENT_SKIP_TLS_VERIFICATION`     | false   | true            | Skips TLS certificate verification                 |
+| `SYNC_WEBHOOK_CLIENT_RETRY_DELAY_SECONDS`       | 1       | 5               | Seconds to delay between connection attempts       |
+| `SYNC_WEBHOOK_CLIENT_TIMEOUT_SECONDS`           | 20      | 60              | Http client timeout in seconds                     |
path: README.md
user: login: lovelaze
body: These are nice, but are they needed? Less features means less complexity. It doesn't look like we use retry on the webhooks now anyways.

Perhaps we should start with only allowing to set `SKIP_TLS`, and then default the webhook to 10s timeout or so. It makes more sense for the sync process because updating gravity etc can take long or short time depending on pihole hardware. Triggering a webhook should be fairly quick :)
created_at: 2025-04-13T19:56:25Z
author_association: OWNER
diff_hunk: @@ -15,6 +19,18 @@ var runCmd = &cobra.Command{
 	Run: func(cmd *cobra.Command, args []string) {
 		readEnvFile()
 
+		if zerolog.GlobalLevel() == zerolog.DebugLevel {
+			dict := zerolog.Dict()
+			for _, env := range os.Environ() {
+				parts := strings.SplitN(env, "=", 2)
+				if len(parts) == 2 {
+					dict = dict.Str(parts[0], parts[1])
+				}
+			}
+
+			log.Info().Dict("env", dict).Msg("Environment variables")
+		}
path: cmd/run.go
user: login: lovelaze
body: I don't think we should print all envs, this should be removed
created_at: 2025-04-13T19:57:18Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,45 @@
+package config
+
+import (
+	"fmt"
+
+	"github.com/kelseyhightower/envconfig"
+)
+
+type WebhookSettings struct {
+	Failure WebhookEventSetting `ignored:"true"`
+	Success WebhookEventSetting `ignored:"true"`
+	Client  Client              `ignored:"true"`
+}
+
+type WebhookEventSetting struct {
+	Body    string            `default:"" envconfig:"BODY"`
+	Headers map[string]string `default:"" envconfig:"HEADERS"`
+	Method  string            `default:"POST" envconfig:"METHOD"`
path: internal/config/webhook.go
user: login: lovelaze
body: Does it make sense to validate the http methd? What happens if an unsupported method is set?
created_at: 2025-04-13T20:00:15Z
author_association: OWNER
diff_hunk: @@ -132,6 +133,49 @@ Config keys are relative to the section and are **case sensitive**. For example,
 | `SYNC_CONFIG_DEBUG_EXCLUDE`       | database,networking        | Debug config keys to exclude                   |
 
 
+### Webhooks
+
+Nebula Sync can invoke webhooks depeneding if a sync succeeded or failed. URL is required for the webhook to trigger. Both sucess and failure webhooks use the same enviroment variable pattern.
+
+| Name                                    | Default | Example                           | Description                                        |
+|-----------------------------------------|---------|-----------------------------------|----------------------------------------------------|
+| `SYNC_WEBHOOK_(SUCCESS\|FAILURE)_URL`    | n/a     | `https://www.example.com/webhook` | URL to invoke for the webhook    |
+| `SYNC_WEBHOOK_(SUCCESS\|FAILURE)_METHOD` | `POST`  | `GET`                             | The HTTP method for the webhook     |
path: README.md
user: login: vondruska
body: I'll update it but respectfully I disagree. It's one more thing to keep track of when most webhook systems are taking input which is more of a POST operation. I think if someone wants different behavior by using GET/PUT/DELETE, they'd know they need to seek that specific behavior. Discord, Aspire, Healthchecks.io, ntfy.sh, Uptime Kuma all either require or support POST by default.
created_at: 2025-04-13T20:15:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -132,6 +133,49 @@ Config keys are relative to the section and are **case sensitive**. For example,
 | `SYNC_CONFIG_DEBUG_EXCLUDE`       | database,networking        | Debug config keys to exclude                   |
 
 
+### Webhooks
+
+Nebula Sync can invoke webhooks depeneding if a sync succeeded or failed. URL is required for the webhook to trigger. Both sucess and failure webhooks use the same enviroment variable pattern.
+
+| Name                                    | Default | Example                           | Description                                        |
+|-----------------------------------------|---------|-----------------------------------|----------------------------------------------------|
+| `SYNC_WEBHOOK_(SUCCESS\|FAILURE)_URL`    | n/a     | `https://www.example.com/webhook` | URL to invoke for the webhook    |
+| `SYNC_WEBHOOK_(SUCCESS\|FAILURE)_METHOD` | `POST`  | `GET`                             | The HTTP method for the webhook     |
+| `SYNC_WEBHOOK_(SUCCESS\|FAILURE)_BODY`   | n/a     | `this is my webhook body`         | The body of the webhook request |
+| `SYNC_WEBHOOK_(SUCCESS\|FAILURE)_HEADERS` | n/a    | `header1:foo,header2:bar`         | HTTP headers to set for the webhook request in the format `key:value` separated by comma. Any whitespace will be used verbatim, no string trimming. | 
+
+Additionally, webhooks have an independent HTTP client configuration. Similar settings as the pihole client but will only be used in the webhook context.
+
+| Name                                            | Default | Example         | Description                                        |
+|-------------------------------------------------|---------|-----------------|----------------------------------------------------|
+| `SYNC_WEBHOOK_CLIENT_SKIP_TLS_VERIFICATION`     | false   | true            | Skips TLS certificate verification                 |
+| `SYNC_WEBHOOK_CLIENT_RETRY_DELAY_SECONDS`       | 1       | 5               | Seconds to delay between connection attempts       |
+| `SYNC_WEBHOOK_CLIENT_TIMEOUT_SECONDS`           | 20      | 60              | Http client timeout in seconds                     |
path: README.md
user: login: vondruska
body: I'm all for it! Less complexity!
created_at: 2025-04-13T20:15:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -132,6 +133,49 @@ Config keys are relative to the section and are **case sensitive**. For example,
 | `SYNC_CONFIG_DEBUG_EXCLUDE`       | database,networking        | Debug config keys to exclude                   |
 
 
+### Webhooks
+
+Nebula Sync can invoke webhooks depeneding if a sync succeeded or failed. URL is required for the webhook to trigger. Both sucess and failure webhooks use the same enviroment variable pattern.
+
+| Name                                    | Default | Example                           | Description                                        |
+|-----------------------------------------|---------|-----------------------------------|----------------------------------------------------|
+| `SYNC_WEBHOOK_(SUCCESS\|FAILURE)_URL`    | n/a     | `https://www.example.com/webhook` | URL to invoke for the webhook    |
+| `SYNC_WEBHOOK_(SUCCESS\|FAILURE)_METHOD` | `POST`  | `GET`                             | The HTTP method for the webhook     |
path: README.md
user: login: lovelaze
body: That makes sense, leave it as POST :)
created_at: 2025-04-13T20:17:22Z
author_association: OWNER
diff_hunk: @@ -15,6 +19,18 @@ var runCmd = &cobra.Command{
 	Run: func(cmd *cobra.Command, args []string) {
 		readEnvFile()
 
+		if zerolog.GlobalLevel() == zerolog.DebugLevel {
+			dict := zerolog.Dict()
+			for _, env := range os.Environ() {
+				parts := strings.SplitN(env, "=", 2)
+				if len(parts) == 2 {
+					dict = dict.Str(parts[0], parts[1])
+				}
+			}
+
+			log.Info().Dict("env", dict).Msg("Environment variables")
+		}
path: cmd/run.go
user: login: vondruska
body: This only prints when the log level is set to debug. This can help users in the future when using JSON payloads in environment variables and escaping the JSON objects and quotes correctly in things like a docker compose file.

I guess the root question for me... Is there enough trust in envconfig to copy the environment variables into the config structs correctly where needing to see the original envvars wouldn't be helpful?
created_at: 2025-04-13T20:19:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,45 @@
+package config
+
+import (
+	"fmt"
+
+	"github.com/kelseyhightower/envconfig"
+)
+
+type WebhookSettings struct {
+	Failure WebhookEventSetting `ignored:"true"`
+	Success WebhookEventSetting `ignored:"true"`
+	Client  Client              `ignored:"true"`
+}
+
+type WebhookEventSetting struct {
+	Body    string            `default:"" envconfig:"BODY"`
+	Headers map[string]string `default:"" envconfig:"HEADERS"`
+	Method  string            `default:"POST" envconfig:"METHOD"`
path: internal/config/webhook.go
user: login: vondruska
body: I'll admit, Go is not my daily use language but I've really come to enjoy it. I was _really_ curious what would happen since I had never tried...

It happily accepts any method here and sends the request using that method. Looking at the standards, [there are more than a few to choose from](https://www.iana.org/assignments/http-methods/http-methods.xhtml). I say we trust the language. The implementation coded in a way that if it can't create the request for use, it will return an error and the webhook will log while not impacting sync operations. If in the future a validation is added, I'd hope the error is descriptive enough for the user to understand what's going on. 
created_at: 2025-04-13T20:30:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -15,6 +19,18 @@ var runCmd = &cobra.Command{
 	Run: func(cmd *cobra.Command, args []string) {
 		readEnvFile()
 
+		if zerolog.GlobalLevel() == zerolog.DebugLevel {
+			dict := zerolog.Dict()
+			for _, env := range os.Environ() {
+				parts := strings.SplitN(env, "=", 2)
+				if len(parts) == 2 {
+					dict = dict.Str(parts[0], parts[1])
+				}
+			}
+
+			log.Info().Dict("env", dict).Msg("Environment variables")
+		}
path: cmd/run.go
user: login: lovelaze
body: I think should trust that the config is loaded correctly by using unit tests. The loaded config should already be printed when debug mode is set, so it makes no sense to print the values twice and a lot of noise from environment variables not related to nebula-sync
created_at: 2025-04-14T19:38:58Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,45 @@
+package config
+
+import (
+	"fmt"
+
+	"github.com/kelseyhightower/envconfig"
+)
+
+type WebhookSettings struct {
+	Failure WebhookEventSetting `ignored:"true"`
+	Success WebhookEventSetting `ignored:"true"`
+	Client  Client              `ignored:"true"`
+}
+
+type WebhookEventSetting struct {
+	Body    string            `default:"" envconfig:"BODY"`
+	Headers map[string]string `default:"" envconfig:"HEADERS"`
+	Method  string            `default:"POST" envconfig:"METHOD"`
path: internal/config/webhook.go
user: login: lovelaze
body: Lets be fair though, in reality only a handful of http methods are used :-) but yeah, most likely the error code is descriptive enough so let's skip this 
created_at: 2025-04-14T19:40:21Z
author_association: OWNER
diff_hunk: @@ -15,6 +19,18 @@ var runCmd = &cobra.Command{
 	Run: func(cmd *cobra.Command, args []string) {
 		readEnvFile()
 
+		if zerolog.GlobalLevel() == zerolog.DebugLevel {
+			dict := zerolog.Dict()
+			for _, env := range os.Environ() {
+				parts := strings.SplitN(env, "=", 2)
+				if len(parts) == 2 {
+					dict = dict.Str(parts[0], parts[1])
+				}
+			}
+
+			log.Info().Dict("env", dict).Msg("Environment variables")
+		}
path: cmd/run.go
user: login: vondruska
body: Very good point. Removed!
created_at: 2025-04-15T14:19:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -132,6 +133,49 @@ Config keys are relative to the section and are **case sensitive**. For example,
 | `SYNC_CONFIG_DEBUG_EXCLUDE`       | database,networking        | Debug config keys to exclude                   |
 
 
+### Webhooks
+
+Nebula Sync can invoke webhooks depeneding if a sync succeeded or failed. URL is required for the webhook to trigger. Both sucess and failure webhooks use the same enviroment variable pattern.
+
+| Name                                    | Default | Example                           | Description                                        |
+|-----------------------------------------|---------|-----------------------------------|----------------------------------------------------|
+| `SYNC_WEBHOOK_(SUCCESS\|FAILURE)_URL`    | n/a     | `https://www.example.com/webhook` | URL to invoke for the webhook    |
+| `SYNC_WEBHOOK_(SUCCESS\|FAILURE)_METHOD` | `POST`  | `GET`                             | The HTTP method for the webhook     |
+| `SYNC_WEBHOOK_(SUCCESS\|FAILURE)_BODY`   | n/a     | `this is my webhook body`         | The body of the webhook request |
+| `SYNC_WEBHOOK_(SUCCESS\|FAILURE)_HEADERS` | n/a    | `header1:foo,header2:bar`         | HTTP headers to set for the webhook request in the format `key:value` separated by comma. Any whitespace will be used verbatim, no string trimming. | 
+
+Additionally, webhooks have an independent HTTP client configuration. Similar settings as the pihole client but will only be used in the webhook context.
+
+| Name                                            | Default | Example         | Description                                        |
+|-------------------------------------------------|---------|-----------------|----------------------------------------------------|
+| `SYNC_WEBHOOK_CLIENT_SKIP_TLS_VERIFICATION`     | false   | true            | Skips TLS certificate verification                 |
+| `SYNC_WEBHOOK_CLIENT_RETRY_DELAY_SECONDS`       | 1       | 5               | Seconds to delay between connection attempts       |
+| `SYNC_WEBHOOK_CLIENT_TIMEOUT_SECONDS`           | 20      | 60              | Http client timeout in seconds                     |
path: README.md
user: login: vondruska
body: Hard coded to 10 second timeout, removed the resulting unused configuration and updated the readme.
created_at: 2025-04-15T14:56:04Z
author_association: CONTRIBUTOR

PR comments:
user: login: lovelaze
created_at: 2025-04-08T19:48:12Z
body: Hi @vondruska!

Thanks a lot for looking into this, much appreciated.

I don't really have a lot of comments on the changes itself (maybe we should just have support for callbacks on sync success/fail that could be used in this case).

However, I'm just not certain what kind of webhooks makes most sense to implement. I haven't spent a lot of time myself looking into this yet, but the [comment](https://github.com/lovelaze/nebula-sync/issues/16#issuecomment-2764216323) from @dreary-ennui makes a lot of sense.

In this case with an empty body POST webhook, do we miss out on anything? What services would be supported besides healthcheck.io etc?
author_association: OWNER
user: login: arpitgupta
created_at: 2025-04-08T20:02:31Z
body: I think folks can use apprise api and achieve integration with any number of services with this. [Example](https://github.com/caronc/apprise-api#stateless-solution) : 
```
curl -X POST -d 'urls=mailto://user:pass@gmail.com&body=test message' \
    http://localhost:8000/notify
```
Will there be ability to specify arbitrary headers? This could give flexibility to users on how to configure the hook. ntfy as example can publish json or set headers for specific fields.

Similarly having support for specifying data for the body would also make it quite flexible.


edit: just realized apprise-api won't work unless we can specify the body.
author_association: NONE
user: login: lovelaze
created_at: 2025-04-08T20:37:58Z
body: > I think folks can use apprise api and achieve integration with any number of services with this. [Example](https://github.com/caronc/apprise-api#stateless-solution) :
> 
> ```
> curl -X POST -d 'urls=mailto://user:pass@gmail.com&body=test message' \
>     http://localhost:8000/notify
> ```
> 
> Will there be ability to specify arbitrary headers? This could give flexibility to users on how to configure the hook. ntfy as example can publish json or set headers for specific fields.
> 
> Similarly having support for specifying data for the body would also make it quite flexible.
> 
> edit: just realized apprise-api won't work unless we can specify the body.

Agree, seems like we should support setting method (get/post), body and headers for webhooks to be flexible enough.


author_association: OWNER
user: login: vondruska
created_at: 2025-04-08T21:23:36Z
body: I'm absolutely on board with changing the functionality and expanding the scope of the PR. I wanted to start _somewhere_ and gather feedback. I can add support for GET/POST and adding a body per webhook.

@lovelaze In healthchecks.io, the post body can be shown in the UI to be able to upload logs or output. But I didn't want to get caught up with a specific upstream service implementation. I think expanding the configuration will solve this and enable users to "choose their own destiny."

@arpitgupta For HTTP headers, would a static list of HTTP headers/values in config handle your use case? Or are you thinking about something else?
author_association: CONTRIBUTOR
user: login: arpitgupta
created_at: 2025-04-08T21:25:56Z
body: @vondruska  my use case is mostly static to specify auth headers, title and message etc :).
author_association: NONE
user: login: badnetmask
created_at: 2025-04-09T12:18:30Z
body: If you add support for POST'ing custom JSON, this can also be used to send notifications via [Pushover](https://pushover.net/api).
author_association: NONE
user: login: dreary-ennui
created_at: 2025-04-10T11:57:39Z
body: Perhaps user-supplied Go templates for the headers and body? Users can pass the template paths in from docker bind mounts if running via docker.
author_association: CONTRIBUTOR
user: login: lovelaze
created_at: 2025-04-12T22:07:47Z
body: @vondruska are you planning to continue this? Otherwise I can create a branch with your commits cherry picked and help out from there. 
author_association: OWNER
user: login: vondruska
created_at: 2025-04-12T22:20:07Z
body: @lovelaze Indeed! I was just working on it actually. I'm planning on pushing up the changes by the end of the weekend for feedback.

In general, it'll support custom HTTP headers, method, URL and body for success and failure independently. Also bring things like timeout and disabling TLS verification in case of a self hosting situation for webhook service.
author_association: CONTRIBUTOR
user: login: vondruska
created_at: 2025-04-13T16:23:50Z
body: Thanks everyone for the awesome feedback! There is now support success and failure HTTP method, body, headers and obviously URL. At this point, body is a static value. The default HTTP method remains POST but you can override it to any HTTP method golang supports. Additionally, this brings the ability to configure the HTTP client to disable TLS verification and set timeout let just for webhooks independent of the pihole client configuration.

---

For full integration testing, I used webhook.site to test the webhook from my own setup. These were the environment variables I used:

```
SYNC_WEBHOOK_SUCCESS_URL=https://webhook.site/2d6ef5e1-436f-40db-b2c1-trimforprivacy
SYNC_WEBHOOK_SUCCESS_BODY={"hello":"world"}
SYNC_WEBHOOK_SUCCESS_HEADERS=Content-Type:application/json
SYNC_WEBHOOK_SUCCESS_METHOD=PUT
```

The debug output log from Nebula Sync:
```
nebula-sync  | 2025-04-13T15:52:56Z DBG github.com/lovelaze/nebula-sync/internal/webhook/webhook.go:50 > Invoking webhook body="{\"hello\":\"world\"}" headers={"Content-Type":"application/json"} method=PUT url=https://webhook.site/2d6ef5e1-436f-40db-b2c1-trimforprivacy
```

And then from webhook.site itself:
![image](https://github.com/user-attachments/assets/210f58de-ff1c-4c58-85fd-01d26c8fa292)

---


author_association: CONTRIBUTOR
user: login: vondruska
created_at: 2025-04-15T14:57:53Z
body: @lovelaze Apologies for the small delay in addressing the feedback. Life got in the way. Everything should be addressed now. 

Thanks for being awesome to work with on this and making this project to begin with!
author_association: CONTRIBUTOR
user: login: lovelaze
created_at: 2025-04-15T19:11:25Z
body: @vondruska No need to stress - time and interest come and go. Same here lately.

We should thank _you_ for implementing this!

I'll merge this onto the feature branch for now!
author_association: OWNER