Repository: GopeedLab/gopeed

Issue:
【功能请求】可否添加webhook推送功能
希望添加webhook推送功能，比如可以接入gotify等，方便接收下载完成，下载失败等消息

Hints from issue comments:
@monkeyWie: 可以的，主打一个可扩展性

Solution patch:
diff --git a/go.mod b/go.mod
index 0cdc646..a98d899 100644
--- a/go.mod
+++ b/go.mod
@@ -27,29 +27,23 @@ require (
 
 require (
 	github.com/andybalholm/brotli v1.1.1 // indirect
-	github.com/ebitengine/purego v0.8.1 // indirect
-	github.com/go-ole/go-ole v1.2.6 // indirect
 	github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
 	github.com/hashicorp/errwrap v1.1.0 // indirect
 	github.com/hashicorp/go-multierror v1.1.1 // indirect
 	github.com/icholy/digest v1.1.0 // indirect
 	github.com/klauspost/compress v1.18.0 // indirect
-	github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c // indirect
 	github.com/onsi/ginkgo/v2 v2.23.4 // indirect
-	github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c // indirect
 	github.com/quic-go/qpack v0.5.1 // indirect
 	github.com/quic-go/quic-go v0.51.0 // indirect
 	github.com/refraction-networking/utls v1.6.7 // indirect
-	github.com/tklauser/go-sysconf v0.3.12 // indirect
-	github.com/tklauser/numcpus v0.6.1 // indirect
-	github.com/yusufpapurcu/wmi v1.2.4 // indirect
+	github.com/stretchr/testify v1.10.0 // indirect
 	go.uber.org/automaxprocs v1.6.0 // indirect
 	go.uber.org/mock v0.5.1 // indirect
 )
 
 require (
 	dario.cat/mergo v1.0.0 // indirect
-	github.com/Microsoft/go-winio v0.6.1 // indirect
+	github.com/Microsoft/go-winio v0.6.1
 	github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect
 	github.com/RoaringBitmap/roaring v1.9.4 // indirect
 	github.com/acomagu/bufpipe v1.0.4 // indirect
@@ -126,7 +120,6 @@ require (
 	github.com/protolambda/ctxlock v0.1.0 // indirect
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 	github.com/sergi/go-diff v1.1.0 // indirect
-	github.com/shirou/gopsutil/v4 v4.24.12
 	github.com/skeema/knownhosts v1.2.0 // indirect
 	github.com/spaolacci/murmur3 v1.1.0 // indirect
 	github.com/tidwall/btree v1.7.0 // indirect
diff --git a/go.sum b/go.sum
index f328432..87392db 100644
--- a/go.sum
+++ b/go.sum
@@ -146,8 +146,6 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m
 github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
 github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
 github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
-github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE=
-github.com/ebitengine/purego v0.8.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
 github.com/edsrzf/mmap-go v1.2.0 h1:hXLYlkbaPzt1SaQk+anYwKSRNhufIDCchSPkUD6dD84=
 github.com/edsrzf/mmap-go v1.2.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
 github.com/elazarl/goproxy v0.0.0-20221015165544-a0805db90819 h1:RIB4cRk+lBqKK3Oy0r2gRX4ui7tuhiZq2SuTtTCi0/0=
@@ -192,8 +190,6 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
 github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
-github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
-github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
 github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
 github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
 github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
@@ -308,8 +304,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c h1:VtwQ41oftZwlMnOEbMWQtSEUgU64U4s+GHk7hZK+jtY=
-github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c/go.mod h1:JKx41uQRwqlTZabZc+kILPrO/3jlKnQ2Z8b7YiVw5cE=
 github.com/matoous/go-nanoid v1.5.0/go.mod h1:zyD2a71IubI24efhpvkJz+ZwfwagzgSO6UNiFsZKN7U=
 github.com/matoous/go-nanoid/v2 v2.0.0 h1:d19kur2QuLeHmJBkvYkFdhFBzLoo1XVm2GgTpL+9Tj0=
 github.com/matoous/go-nanoid/v2 v2.0.0/go.mod h1:FtS4aGPVfEkxKxhdWPAspZpZSh1cOjtM7Ej/So3hR0g=
@@ -398,8 +392,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c h1:NRoLoZvkBTKvR5gQLgA3e0hqjkY9u1wm+iOL45VN/qI=
-github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
 github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
 github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
 github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
@@ -450,8 +442,6 @@ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a
 github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
-github.com/shirou/gopsutil/v4 v4.24.12 h1:qvePBOk20e0IKA1QXrIIU+jmk+zEiYVVx06WjBRlZo4=
-github.com/shirou/gopsutil/v4 v4.24.12/go.mod h1:DCtMPAad2XceTeIAbGyVfycbYQNBGk2P8cvDi7/VN9o=
 github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
 github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
 github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
@@ -488,10 +478,6 @@ github.com/tidwall/btree v1.7.0/go.mod h1:twD9XRA5jj9VUQGELzDO4HPQTNJsoWWfYEL+EU
 github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
 github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
 github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
-github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
-github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
-github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
-github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
 github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
 github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
 github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
@@ -505,8 +491,6 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
-github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
 go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0=
 go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I=
 go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
@@ -596,7 +580,6 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -606,7 +589,6 @@ golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -622,8 +604,6 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
 golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go
index 35d5414..573ffac 100644
--- a/pkg/download/downloader.go
+++ b/pkg/download/downloader.go
@@ -857,6 +857,7 @@ func (d *Downloader) watch(task *Task) {
 	d.emit(EventKeyFinally, task, err)
 	d.notifyRunning()
 	d.triggerOnDone(task)
+	d.triggerWebhooks(WebhookEventDownloadDone, task, nil)
 
 	if e, ok := task.Meta.Opts.Extra.(*http.OptsExtra); ok {
 		downloadFilePath := task.Meta.SingleFilepath()
@@ -887,6 +888,7 @@ func (d *Downloader) doOnError(task *Task, err error) {
 		d.emit(EventKeyError, task, err)
 		d.emit(EventKeyFinally, task, err)
 		d.notifyRunning()
+		d.triggerWebhooks(WebhookEventDownloadError, task, err)
 	}
 }
 
diff --git a/pkg/download/webhook.go b/pkg/download/webhook.go
new file mode 100644
index 0000000..d095b06
--- /dev/null
+++ b/pkg/download/webhook.go
@@ -0,0 +1,210 @@
+package download
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"time"
+
+	"github.com/GopeedLab/gopeed/internal/fetcher"
+	"github.com/GopeedLab/gopeed/pkg/base"
+)
+
+const (
+	webhookTimeout = 10 * time.Second
+)
+
+// WebhookEvent represents the type of webhook event
+type WebhookEvent string
+
+const (
+	WebhookEventDownloadDone  WebhookEvent = "DOWNLOAD_DONE"
+	WebhookEventDownloadError WebhookEvent = "DOWNLOAD_ERROR"
+)
+
+// WebhookData is the data sent to webhook URLs
+type WebhookData struct {
+	Event   WebhookEvent    `json:"event"`
+	Time    int64           `json:"time"` // Unix timestamp in milliseconds
+	Payload *WebhookPayload `json:"payload"`
+}
+
+// WebhookPayload contains the task data
+type WebhookPayload struct {
+	Task *Task `json:"task"`
+}
+
+// getWebhookUrls extracts and converts webhook URLs from config
+func (d *Downloader) getWebhookUrls() []string {
+	cfg := d.cfg.DownloaderStoreConfig
+	if cfg == nil || cfg.Extra == nil {
+		return nil
+	}
+
+	webhookUrls, ok := cfg.Extra["webhookUrls"]
+	if !ok {
+		return nil
+	}
+
+	// Try direct string slice first
+	if urlsStr, ok := webhookUrls.([]string); ok {
+		if len(urlsStr) == 0 {
+			return nil
+		}
+		return urlsStr
+	}
+
+	// Convert []interface{} to []string
+	if urlsInterface, ok := webhookUrls.([]any); ok {
+		if len(urlsInterface) == 0 {
+			return nil
+		}
+		urls := make([]string, 0, len(urlsInterface))
+		for _, urlInterface := range urlsInterface {
+			if url, ok := urlInterface.(string); ok && url != "" {
+				urls = append(urls, url)
+			}
+		}
+		if len(urls) == 0 {
+			return nil
+		}
+		return urls
+	}
+
+	return nil
+}
+
+// sendWebhookToUrl sends webhook data to a single URL
+// Returns the HTTP status code and any error that occurred
+func (d *Downloader) sendWebhookToUrl(url string, data *WebhookData) (int, error) {
+	if url == "" {
+		return 0, fmt.Errorf("webhook URL is empty")
+	}
+
+	jsonData, err := json.Marshal(data)
+	if err != nil {
+		return 0, err
+	}
+
+	client := &http.Client{
+		Timeout: webhookTimeout,
+	}
+
+	req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData))
+	if err != nil {
+		return 0, err
+	}
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("User-Agent", "Gopeed-Webhook/1.0")
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return 0, err
+	}
+	defer resp.Body.Close()
+
+	return resp.StatusCode, nil
+}
+
+// triggerWebhooks sends webhook notifications to all configured URLs
+func (d *Downloader) triggerWebhooks(event WebhookEvent, task *Task, err error) {
+	urls := d.getWebhookUrls()
+	if len(urls) == 0 {
+		return
+	}
+
+	data := &WebhookData{
+		Event: event,
+		Time:  time.Now().UnixMilli(),
+		Payload: &WebhookPayload{
+			Task: task.clone(),
+		},
+	}
+
+	go d.sendWebhooks(urls, data)
+}
+
+func (d *Downloader) sendWebhooks(urls []string, data *WebhookData) {
+	for _, url := range urls {
+		if url == "" {
+			continue
+		}
+		go func(webhookUrl string) {
+			statusCode, err := d.sendWebhookToUrl(webhookUrl, data)
+			if err != nil {
+				d.Logger.Warn().Err(err).Str("url", webhookUrl).Msg("webhook: failed to send request")
+				return
+			}
+			if statusCode >= 200 && statusCode < 300 {
+				d.Logger.Debug().Str("url", webhookUrl).Int("status", statusCode).Msg("webhook: sent successfully")
+			} else {
+				d.Logger.Warn().Str("url", webhookUrl).Int("status", statusCode).Msg("webhook: received non-success status")
+			}
+		}(url)
+	}
+}
+
+// SendTestWebhook sends a test webhook with a simulated payload
+// Returns error if any webhook URL does not respond with HTTP 200
+func (d *Downloader) SendTestWebhook() error {
+	urls := d.getWebhookUrls()
+	if len(urls) == 0 {
+		return nil
+	}
+
+	for _, url := range urls {
+		if url == "" {
+			continue
+		}
+		if err := d.TestWebhookUrl(url); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// TestWebhookUrl tests a single webhook URL with a simulated payload
+// Returns error if the URL does not respond with HTTP 200
+func (d *Downloader) TestWebhookUrl(url string) error {
+	// Create a simulated test task with minimal required fields
+	testTask := NewTask()
+	testTask.Protocol = "http"
+	testTask.Status = base.DownloadStatusDone
+	testTask.Meta = &fetcher.FetcherMeta{
+		Req: &base.Request{
+			URL: "https://example.com/test-file.zip",
+		},
+		Opts: &base.Options{
+			Name: "test-file.zip",
+			Path: "/downloads",
+		},
+		Res: &base.Resource{
+			Size: 1024 * 1024 * 100, // 100MB
+			Files: []*base.FileInfo{
+				{Name: "test-file.zip", Size: 1024 * 1024 * 100},
+			},
+		},
+	}
+
+	// Create test data
+	testData := &WebhookData{
+		Event: WebhookEventDownloadDone,
+		Time:  time.Now().UnixMilli(),
+		Payload: &WebhookPayload{
+			Task: testTask,
+		},
+	}
+
+	statusCode, err := d.sendWebhookToUrl(url, testData)
+	if err != nil {
+		return err
+	}
+
+	if statusCode != http.StatusOK {
+		return fmt.Errorf("webhook test failed: %s returned status %d", url, statusCode)
+	}
+
+	return nil
+}
diff --git a/pkg/rest/api.go b/pkg/rest/api.go
index 7c42f9e..6c388fc 100644
--- a/pkg/rest/api.go
+++ b/pkg/rest/api.go
@@ -389,3 +389,14 @@ func getServerConfig() *base.DownloaderStoreConfig {
 	cfg, _ := Downloader.GetConfig()
 	return cfg
 }
+
+func TestWebhook(w http.ResponseWriter, r *http.Request) {
+	var req model.TestWebhookReq
+	if ReadJson(r, w, &req) {
+		if err := Downloader.TestWebhookUrl(req.URL); err != nil {
+			WriteJson(w, model.NewErrorResult(err.Error()))
+			return
+		}
+		WriteJson(w, model.NewNilResult())
+	}
+}
diff --git a/pkg/rest/model/webhook.go b/pkg/rest/model/webhook.go
new file mode 100644
index 0000000..e82bfc8
--- /dev/null
+++ b/pkg/rest/model/webhook.go
@@ -0,0 +1,6 @@
+package model
+
+// TestWebhookReq is the request body for testing a single webhook URL
+type TestWebhookReq struct {
+	URL string `json:"url"`
+}
diff --git a/pkg/rest/server.go b/pkg/rest/server.go
index b931258..3c431e6 100644
--- a/pkg/rest/server.go
+++ b/pkg/rest/server.go
@@ -141,6 +141,7 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
 	r.Methods(http.MethodDelete).Path("/api/v1/extensions/{identity}").HandlerFunc(DeleteExtension)
 	r.Methods(http.MethodGet).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateCheckExtension)
 	r.Methods(http.MethodPost).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateExtension)
+	r.Methods(http.MethodPost).Path("/api/v1/webhook/test").HandlerFunc(TestWebhook)
 	r.Path("/api/v1/proxy").HandlerFunc(DoProxy)
 
 	enableApiToken := startCfg.ApiToken != ""
diff --git a/ui/flutter/lib/api/api.dart b/ui/flutter/lib/api/api.dart
index e8686da..e7056b0 100644
--- a/ui/flutter/lib/api/api.dart
+++ b/ui/flutter/lib/api/api.dart
@@ -236,6 +236,11 @@ Future<void> updateExtension(String identity) async {
       () => _client.dio.post("api/v1/extensions/$identity/update"), null);
 }
 
+Future<void> testWebhook(String url) async {
+  return _parse(
+      () => _client.dio.post("api/v1/webhook/test", data: {"url": url}), null);
+}
+
 Future<String> login(LoginReq loginReq) async {
   return _parse(() => _client.dio.post("api/web/login", data: loginReq),
       (data) => data as String);
diff --git a/ui/flutter/lib/api/model/downloader_config.dart b/ui/flutter/lib/api/model/downloader_config.dart
index 4685db0..e62c7dd 100644
--- a/ui/flutter/lib/api/model/downloader_config.dart
+++ b/ui/flutter/lib/api/model/downloader_config.dart
@@ -82,6 +82,7 @@ class ExtraConfig {
   bool defaultDirectDownload;
   bool defaultBtClient;
   bool notifyWhenNewVersion;
+  List<String> webhookUrls;
 
   ExtraConfigBt bt = ExtraConfigBt();
 
@@ -92,6 +93,7 @@ class ExtraConfig {
     this.defaultDirectDownload = false,
     this.defaultBtClient = true,
     this.notifyWhenNewVersion = true,
+    this.webhookUrls = const [],
   });
 
   factory ExtraConfig.fromJson(Map<String, dynamic>? json) =>
diff --git a/ui/flutter/lib/api/model/downloader_config.g.dart b/ui/flutter/lib/api/model/downloader_config.g.dart
index 3603b0e..f5a3485 100644
--- a/ui/flutter/lib/api/model/downloader_config.g.dart
+++ b/ui/flutter/lib/api/model/downloader_config.g.dart
@@ -75,6 +75,10 @@ ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) => ExtraConfig(
       defaultDirectDownload: json['defaultDirectDownload'] as bool? ?? false,
       defaultBtClient: json['defaultBtClient'] as bool? ?? true,
       notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true,
+      webhookUrls: (json['webhookUrls'] as List<dynamic>?)
+              ?.map((e) => e as String)
+              .toList() ??
+          const [],
     )..bt = ExtraConfigBt.fromJson(json['bt'] as Map<String, dynamic>);
 
 Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) =>
@@ -85,6 +89,7 @@ Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) =>
       'defaultDirectDownload': instance.defaultDirectDownload,
       'defaultBtClient': instance.defaultBtClient,
       'notifyWhenNewVersion': instance.notifyWhenNewVersion,
+      'webhookUrls': instance.webhookUrls,
       'bt': instance.bt.toJson(),
     };
 
diff --git a/ui/flutter/lib/app/modules/setting/views/setting_view.dart b/ui/flutter/lib/app/modules/setting/views/setting_view.dart
index 4e8bfe0..f0d2abf 100644
--- a/ui/flutter/lib/app/modules/setting/views/setting_view.dart
+++ b/ui/flutter/lib/app/modules/setting/views/setting_view.dart
@@ -10,6 +10,7 @@ import 'package:intl/intl.dart';
 import 'package:launch_at_startup/launch_at_startup.dart';
 import 'package:url_launcher/url_launcher.dart';
 
+import '../../../../api/api.dart' as api;
 import '../../../../api/model/downloader_config.dart';
 import '../../../../i18n/message.dart';
 import '../../../../util/input_formatter.dart';
@@ -24,6 +25,7 @@ import '../../../views/check_list_view.dart';
 import '../../../views/directory_selector.dart';
 import '../../../views/open_in_new.dart';
 import '../../../views/outlined_button_loading.dart';
+import '../../../views/text_button_loading.dart';
 import '../../app/controllers/app_controller.dart';
 import '../controllers/setting_controller.dart';
 
@@ -891,6 +893,72 @@ class SettingView extends GetView<SettingController> {
       );
     });
 
+    // advanced config webhook items
+    final buildWebhook = _buildConfigItem(
+      'webhook',
+      () => 'items'.trParams(
+          {'count': downloaderCfg.value.extra.webhookUrls.length.toString()}),
+      (Key key) {
+        return Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            Text(
+              'webhookDesc'.tr,
+              style: Theme.of(Get.context!).textTheme.bodySmall,
+            ),
+            _padding,
+            // List of existing webhook URLs
+            ...downloaderCfg.value.extra.webhookUrls
+                .asMap()
+                .entries
+                .map((entry) {
+              final index = entry.key;
+              final url = entry.value;
+              return Padding(
+                padding: const EdgeInsets.only(bottom: 8.0),
+                child: Row(
+                  children: [
+                    Expanded(
+                      child: Text(
+                        url,
+                        style: Theme.of(Get.context!).textTheme.bodyMedium,
+                        overflow: TextOverflow.ellipsis,
+                      ),
+                    ),
+                    IconButton(
+                      icon: const Icon(Icons.edit, size: 20),
+                      tooltip: 'edit'.tr,
+                      onPressed: () {
+                        _showWebhookDialog(index: index, initialUrl: url);
+                      },
+                    ),
+                    IconButton(
+                      icon: const Icon(Icons.delete, size: 20),
+                      tooltip: 'delete'.tr,
+                      onPressed: () async {
+                        downloaderCfg.value.extra.webhookUrls.removeAt(index);
+                        downloaderCfg.refresh();
+                        await debounceSave();
+                      },
+                    ),
+                  ],
+                ),
+              );
+            }),
+            _padding,
+            // Add button
+            OutlinedButton.icon(
+              onPressed: () {
+                _showWebhookDialog();
+              },
+              icon: const Icon(Icons.add),
+              label: Text('add'.tr),
+            ),
+          ],
+        );
+      },
+    );
+
     // advanced config log items start
     buildLogsDir() {
       return ListTile(
@@ -1035,6 +1103,7 @@ class SettingView extends GetView<SettingController> {
                       Card(
                           child: Column(
                         children: _addDivider([
+                          buildWebhook(),
                           buildLogsDir(),
                         ]),
                       )),
@@ -1047,6 +1116,120 @@ class SettingView extends GetView<SettingController> {
     });
   }
 
+  void _showWebhookDialog({int? index, String? initialUrl}) {
+    final urlController = TextEditingController(text: initialUrl ?? '');
+    final testController = OutlinedButtonLoadingController();
+    final saveController = TextButtonLoadingController();
+    final appController = Get.find<AppController>();
+    final downloaderCfg = appController.downloaderConfig;
+    final isEdit = index != null;
+    final formKey = GlobalKey<FormState>();
+
+    showDialog(
+      context: Get.context!,
+      builder: (dialogContext) => AlertDialog(
+        title: Text(isEdit ? 'edit'.tr : 'add'.tr),
+        content: Form(
+          key: formKey,
+          child: Column(
+            mainAxisSize: MainAxisSize.min,
+            children: [
+              TextFormField(
+                controller: urlController,
+                decoration: InputDecoration(
+                  hintText: 'webhookUrlHint'.tr,
+                ),
+                keyboardType: TextInputType.url,
+                validator: (value) {
+                  if (value == null || value.trim().isEmpty) {
+                    return 'required'.tr;
+                  }
+                  final url = value.trim().toLowerCase();
+                  if (!url.startsWith('http://') &&
+                      !url.startsWith('https://')) {
+                    return 'webhookUrlInvalid'.tr;
+                  }
+                  try {
+                    Uri.parse(value.trim());
+                  } catch (e) {
+                    return 'webhookUrlInvalid'.tr;
+                  }
+                  return null;
+                },
+              ),
+            ],
+          ),
+        ),
+        actionsAlignment: MainAxisAlignment.spaceBetween,
+        actions: [
+          // Test button on the left
+          Padding(
+            padding: const EdgeInsets.only(left: 16.0),
+            child: OutlinedButtonLoading(
+              controller: testController,
+              onPressed: () async {
+                if (!formKey.currentState!.validate()) {
+                  return;
+                }
+                final url = urlController.text.trim();
+                if (url.isEmpty) return;
+                testController.start();
+                try {
+                  await api.testWebhook(url);
+                  showMessage('tip'.tr, 'webhookTestSuccess'.tr);
+                } catch (e) {
+                  showErrorMessage('webhookTestFail'.tr);
+                } finally {
+                  testController.stop();
+                }
+              },
+              child: Text('webhookTest'.tr),
+            ),
+          ),
+          // Cancel and Confirm on the right
+          Row(
+            mainAxisSize: MainAxisSize.min,
+            children: [
+              TextButton(
+                onPressed: () => Navigator.of(dialogContext).pop(),
+                child: Text('cancel'.tr),
+              ),
+              TextButtonLoading(
+                controller: saveController,
+                onPressed: () async {
+                  if (!formKey.currentState!.validate()) {
+                    return;
+                  }
+                  final url = urlController.text.trim();
+                  if (url.isEmpty) return;
+
+                  saveController.start();
+                  try {
+                    if (isEdit) {
+                      downloaderCfg.value.extra.webhookUrls[index] = url;
+                    } else {
+                      downloaderCfg.value.extra.webhookUrls.add(url);
+                    }
+                    downloaderCfg.refresh();
+                    await appController.saveConfig();
+                    if (dialogContext.mounted) {
+                      Navigator.of(dialogContext).pop();
+                    }
+                  } catch (e) {
+                    showErrorMessage(e);
+                  } finally {
+                    saveController.stop();
+                  }
+                },
+                child: Text('confirm'.tr),
+              ),
+            ],
+          ),
+        ],
+      ),
+    );
+  }
+
   void _tapInputWidget(GlobalKey key) {
     if (key.currentContext == null) {
       return;
diff --git a/ui/flutter/lib/i18n/langs/en_us.dart b/ui/flutter/lib/i18n/langs/en_us.dart
index a40bcc9..f18a6e8 100644
--- a/ui/flutter/lib/i18n/langs/en_us.dart
+++ b/ui/flutter/lib/i18n/langs/en_us.dart
@@ -65,6 +65,14 @@ const enUS = {
     'effectAfterRestart': 'Effect after restart',
     'developer': 'Developer',
     'logDirectory': 'Log Directory',
+    'webhook': 'Webhook',
+    'webhookDesc': 'Send HTTP POST notifications when tasks complete or fail',
+    'webhookUrlHint': 'Enter webhook URL',
+    'webhookTest': 'Test',
+    'webhookTestSuccess': 'Webhook test successful',
+    'webhookTestFail': 'Webhook test failed',
+    'webhookUrlInvalid': 'Please enter a valid HTTP or HTTPS URL',
+    'required': 'This field is required',
     'show': 'Show',
     'continue': 'Continue',
     'pause': 'Pause',
@@ -73,6 +81,8 @@ const enUS = {
     'deleteTask': 'Delete @count tasks',
     'deleteTaskTip': 'Keep downloaded files',
     'delete': 'Delete',
+    'add': 'Add',
+    'edit': 'Edit',
     'newVersionTitle': 'Discover new version @version',
     'newVersionUpdate': 'Update Now',
     'newVersionLater': 'Later',
diff --git a/ui/flutter/lib/i18n/langs/zh_cn.dart b/ui/flutter/lib/i18n/langs/zh_cn.dart
index b0debcd..252ddc0 100644
--- a/ui/flutter/lib/i18n/langs/zh_cn.dart
+++ b/ui/flutter/lib/i18n/langs/zh_cn.dart
@@ -64,6 +64,14 @@ const zhCN = {
     'effectAfterRestart': '此配置项将在重启应用后生效',
     'developer': '开发者',
     'logDirectory': '日志目录',
+    'webhook': 'Webhook 推送',
+    'webhookDesc': '在任务完成或失败时发送 HTTP POST 通知',
+    'webhookUrlHint': '请输入 Webhook URL',
+    'webhookTest': '测试',
+    'webhookTestSuccess': 'Webhook 测试成功',
+    'webhookTestFail': 'Webhook 测试失败',
+    'webhookUrlInvalid': '请输入有效的 HTTP 或 HTTPS 链接',
+    'required': '此项为必填项',
     'show': '显示',
     'continue': '继续',
     'pause': '暂停',
@@ -72,6 +80,8 @@ const zhCN = {
     'deleteTask': '删除 @count 个任务',
     'deleteTaskTip': '保留已下载的文件',
     'delete': '删除',
+    'add': '添加',
+    'edit': '编辑',
     'newVersionTitle': '发现新版本 @version',
     'newVersionUpdate': '立即更新',
     'newVersionLater': '稍后再说',



Test patch:
diff --git a/pkg/download/webhook_test.go b/pkg/download/webhook_test.go
new file mode 100644
index 0000000..521bb21
--- /dev/null
+++ b/pkg/download/webhook_test.go
@@ -0,0 +1,799 @@
+package download
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"testing"
+	"time"
+
+	"github.com/GopeedLab/gopeed/internal/fetcher"
+	"github.com/GopeedLab/gopeed/pkg/base"
+)
+
+var mockFetcherMeta = fetcher.FetcherMeta{
+	Req: &base.Request{
+		URL: "https://example.com/test.zip",
+	},
+	Opts: &base.Options{
+		Path: "/downloads",
+	},
+	Res: &base.Resource{
+		Size: 1024 * 1024,
+		Files: []*base.FileInfo{
+			{Name: "test.zip", Size: 1024 * 1024},
+		},
+	},
+}
+
+func TestWebhook_TriggerOnDone(t *testing.T) {
+	receivedData := make(chan *WebhookData, 1)
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != http.MethodPost {
+			t.Errorf("Expected POST request, got %s", r.Method)
+		}
+		if r.Header.Get("Content-Type") != "application/json" {
+			t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type"))
+		}
+		var data WebhookData
+		if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+			t.Errorf("Failed to decode data: %v", err)
+			return
+		}
+		receivedData <- &data
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+
+	setupWebhookTest(t, func(downloader *Downloader) {
+		// Configure webhook URLs
+		cfg, _ := downloader.GetConfig()
+		if cfg.Extra == nil {
+			cfg.Extra = make(map[string]any)
+		}
+		cfg.Extra["webhookUrls"] = []string{server.URL}
+		downloader.PutConfig(cfg)
+
+		// Create a mock task
+		task := NewTask()
+		task.Protocol = "http"
+		task.Meta = &mockFetcherMeta
+
+		// Trigger webhook
+		downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
+
+		select {
+		case data := <-receivedData:
+			if data.Event != WebhookEventDownloadDone {
+				t.Errorf("Expected event 'DOWNLOAD_DONE', got '%s'", data.Event)
+			}
+			if data.Payload == nil || data.Payload.Task == nil {
+				t.Error("Expected payload.task to be present")
+			} else if data.Payload.Task.ID != task.ID {
+				t.Errorf("Expected task ID '%s', got '%s'", task.ID, data.Payload.Task.ID)
+			}
+			if data.Time == 0 {
+				t.Error("Expected time to be set")
+			}
+		case <-time.After(2 * time.Second):
+			t.Error("Timeout waiting for webhook")
+		}
+	})
+}
+
+func TestWebhook_TriggerOnError(t *testing.T) {
+	receivedData := make(chan *WebhookData, 1)
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		var data WebhookData
+		if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+			t.Errorf("Failed to decode data: %v", err)
+			return
+		}
+		receivedData <- &data
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+
+	setupWebhookTest(t, func(downloader *Downloader) {
+		// Configure webhook URLs
+		cfg, _ := downloader.GetConfig()
+		if cfg.Extra == nil {
+			cfg.Extra = make(map[string]any)
+		}
+		cfg.Extra["webhookUrls"] = []string{server.URL}
+		downloader.PutConfig(cfg)
+
+		// Create a mock task
+		task := NewTask()
+		task.Protocol = "http"
+		task.Meta = &mockFetcherMeta
+
+		// Trigger webhook with error
+		testError := http.ErrServerClosed
+		downloader.triggerWebhooks(WebhookEventDownloadError, task, testError)
+
+		select {
+		case data := <-receivedData:
+			if data.Event != WebhookEventDownloadError {
+				t.Errorf("Expected event 'DOWNLOAD_ERROR', got '%s'", data.Event)
+			}
+			if data.Payload == nil || data.Payload.Task == nil {
+				t.Error("Expected payload.task to be present")
+			}
+		case <-time.After(2 * time.Second):
+			t.Error("Timeout waiting for webhook")
+		}
+	})
+}
+
+func TestWebhook_SendTestWebhook(t *testing.T) {
+	receivedData := make(chan *WebhookData, 1)
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		var data WebhookData
+		if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+			t.Errorf("Failed to decode data: %v", err)
+			return
+		}
+		receivedData <- &data
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+
+	setupWebhookTest(t, func(downloader *Downloader) {
+		// Configure webhook URLs
+		cfg, _ := downloader.GetConfig()
+		if cfg.Extra == nil {
+			cfg.Extra = make(map[string]any)
+		}
+		cfg.Extra["webhookUrls"] = []string{server.URL}
+		downloader.PutConfig(cfg)
+
+		// Send test webhook
+		err := downloader.SendTestWebhook()
+		if err != nil {
+			t.Errorf("SendTestWebhook failed: %v", err)
+		}
+
+		select {
+		case data := <-receivedData:
+			if data.Event != WebhookEventDownloadDone {
+				t.Errorf("Expected event 'DOWNLOAD_DONE', got '%s'", data.Event)
+			}
+			if data.Payload == nil || data.Payload.Task == nil {
+				t.Error("Expected payload.task to be present")
+			}
+			if data.Time == 0 {
+				t.Error("Expected time to be set")
+			}
+		case <-time.After(2 * time.Second):
+			t.Error("Timeout waiting for webhook")
+		}
+	})
+}
+
+func TestWebhook_NoWebhookConfigured(t *testing.T) {
+	setupWebhookTest(t, func(downloader *Downloader) {
+		// Create a mock task
+		task := NewTask()
+		task.Protocol = "http"
+		task.Meta = &mockFetcherMeta
+
+		// Trigger webhook (should not panic with no webhooks configured)
+		downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
+
+		// Send test webhook (should not panic)
+		err := downloader.SendTestWebhook()
+		if err != nil {
+			t.Errorf("SendTestWebhook failed: %v", err)
+		}
+	})
+}
+
+func TestWebhook_MultipleUrls(t *testing.T) {
+	count := 0
+	server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		count++
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server1.Close()
+	server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		count++
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server2.Close()
+
+	setupWebhookTest(t, func(downloader *Downloader) {
+		// Configure multiple webhook URLs
+		cfg, _ := downloader.GetConfig()
+		if cfg.Extra == nil {
+			cfg.Extra = make(map[string]any)
+		}
+		cfg.Extra["webhookUrls"] = []string{server1.URL, server2.URL}
+		downloader.PutConfig(cfg)
+
+		// Create a mock task
+		task := NewTask()
+		task.Protocol = "http"
+		task.Meta = &mockFetcherMeta
+
+		// Trigger webhook
+		downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
+
+		// Wait for webhooks
+		time.Sleep(500 * time.Millisecond)
+
+		if count != 2 {
+			t.Errorf("Expected 2 webhook calls, got %d", count)
+		}
+	})
+}
+
+func TestWebhook_TestWebhookFailsOnNon200(t *testing.T) {
+	// Test that SendTestWebhook returns error for non-200 status codes
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusInternalServerError) // 500
+	}))
+	defer server.Close()
+
+	setupWebhookTest(t, func(downloader *Downloader) {
+		// Configure webhook URLs
+		cfg, _ := downloader.GetConfig()
+		if cfg.Extra == nil {
+			cfg.Extra = make(map[string]any)
+		}
+		cfg.Extra["webhookUrls"] = []string{server.URL}
+		downloader.PutConfig(cfg)
+
+		// Send test webhook - should fail with non-200 status
+		err := downloader.SendTestWebhook()
+		if err == nil {
+			t.Error("Expected SendTestWebhook to return error for non-200 status")
+		}
+	})
+}
+
+func TestWebhook_TestWebhookFailsOn201(t *testing.T) {
+	// Test that SendTestWebhook returns error for 201 (only 200 is success)
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusCreated) // 201
+	}))
+	defer server.Close()
+
+	setupWebhookTest(t, func(downloader *Downloader) {
+		// Configure webhook URLs
+		cfg, _ := downloader.GetConfig()
+		if cfg.Extra == nil {
+			cfg.Extra = make(map[string]any)
+		}
+		cfg.Extra["webhookUrls"] = []string{server.URL}
+		downloader.PutConfig(cfg)
+
+		// Send test webhook - should fail with 201 status (only 200 is success)
+		err := downloader.SendTestWebhook()
+		if err == nil {
+			t.Error("Expected SendTestWebhook to return error for 201 status")
+		}
+	})
+}
+
+func TestWebhook_TestWebhookUrl(t *testing.T) {
+	receivedData := make(chan *WebhookData, 1)
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		var data WebhookData
+		if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+			t.Errorf("Failed to decode data: %v", err)
+			return
+		}
+		receivedData <- &data
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+
+	setupWebhookTest(t, func(downloader *Downloader) {
+		// Test single URL
+		err := downloader.TestWebhookUrl(server.URL)
+		if err != nil {
+			t.Errorf("TestWebhookUrl failed: %v", err)
+		}
+
+		select {
+		case data := <-receivedData:
+			if data.Event != WebhookEventDownloadDone {
+				t.Errorf("Expected event 'DOWNLOAD_DONE', got '%s'", data.Event)
+			}
+			if data.Payload == nil || data.Payload.Task == nil {
+				t.Error("Expected payload.task to be present")
+			}
+		case <-time.After(2 * time.Second):
+			t.Error("Timeout waiting for webhook")
+		}
+	})
+}
+
+func TestWebhook_TestWebhookUrlEmpty(t *testing.T) {
+	setupWebhookTest(t, func(downloader *Downloader) {
+		// Test with empty URL - should return error
+		err := downloader.TestWebhookUrl("")
+		if err == nil {
+			t.Error("Expected TestWebhookUrl to return error for empty URL")
+		}
+	})
+}
+
+func TestWebhook_GetWebhookUrls_EmptyConfig(t *testing.T) {
+	setupWebhookTest(t, func(downloader *Downloader) {
+		urls := downloader.getWebhookUrls()
+		if urls != nil {
+			t.Errorf("Expected nil, got %v", urls)
+		}
+	})
+}
+
+func TestWebhook_GetWebhookUrls_NoExtraField(t *testing.T) {
+	setupWebhookTest(t, func(downloader *Downloader) {
+		cfg, _ := downloader.GetConfig()
+		cfg.Extra = nil
+		downloader.PutConfig(cfg)
+
+		urls := downloader.getWebhookUrls()
+		if urls != nil {
+			t.Errorf("Expected nil, got %v", urls)
+		}
+	})
+}
+
+func TestWebhook_GetWebhookUrls_NoWebhookUrlsKey(t *testing.T) {
+	setupWebhookTest(t, func(downloader *Downloader) {
+		cfg, _ := downloader.GetConfig()
+		cfg.Extra = make(map[string]any)
+		cfg.Extra["otherKey"] = "value"
+		downloader.PutConfig(cfg)
+
+		urls := downloader.getWebhookUrls()
+		if urls != nil {
+			t.Errorf("Expected nil, got %v", urls)
+		}
+	})
+}
+
+func TestWebhook_GetWebhookUrls_StringSlice(t *testing.T) {
+	setupWebhookTest(t, func(downloader *Downloader) {
+		cfg, _ := downloader.GetConfig()
+		cfg.Extra = make(map[string]any)
+		cfg.Extra["webhookUrls"] = []string{"http://example.com", "http://example2.com"}
+		downloader.PutConfig(cfg)
+
+		urls := downloader.getWebhookUrls()
+		if len(urls) != 2 {
+			t.Errorf("Expected 2 URLs, got %d", len(urls))
+		}
+		if urls[0] != "http://example.com" || urls[1] != "http://example2.com" {
+			t.Errorf("URLs don't match expected values: %v", urls)
+		}
+	})
+}
+
+func TestWebhook_GetWebhookUrls_InterfaceSlice(t *testing.T) {
+	setupWebhookTest(t, func(downloader *Downloader) {
+		cfg, _ := downloader.GetConfig()
+		cfg.Extra = make(map[string]any)
+		cfg.Extra["webhookUrls"] = []interface{}{"http://example.com", "http://example2.com"}
+		downloader.PutConfig(cfg)
+
+		urls := downloader.getWebhookUrls()
+		if len(urls) != 2 {
+			t.Errorf("Expected 2 URLs, got %d", len(urls))
+		}
+		if urls[0] != "http://example.com" || urls[1] != "http://example2.com" {
+			t.Errorf("URLs don't match expected values: %v", urls)
+		}
+	})
+}
+
+func TestWebhook_GetWebhookUrls_EmptyStringSlice(t *testing.T) {
+	setupWebhookTest(t, func(downloader *Downloader) {
+		cfg, _ := downloader.GetConfig()
+		cfg.Extra = make(map[string]any)
+		cfg.Extra["webhookUrls"] = []string{}
+		downloader.PutConfig(cfg)
+
+		urls := downloader.getWebhookUrls()
+		if urls != nil {
+			t.Errorf("Expected nil, got %v", urls)
+		}
+	})
+}
+
+func TestWebhook_GetWebhookUrls_InterfaceSliceWithEmptyStrings(t *testing.T) {
+	setupWebhookTest(t, func(downloader *Downloader) {
+		cfg, _ := downloader.GetConfig()
+		cfg.Extra = make(map[string]any)
+		cfg.Extra["webhookUrls"] = []interface{}{"", "", ""}
+		downloader.PutConfig(cfg)
+
+		urls := downloader.getWebhookUrls()
+		if urls != nil {
+			t.Errorf("Expected nil for all empty strings, got %v", urls)
+		}
+	})
+}
+
+func TestWebhook_GetWebhookUrls_InterfaceSliceMixedTypes(t *testing.T) {
+	setupWebhookTest(t, func(downloader *Downloader) {
+		cfg, _ := downloader.GetConfig()
+		cfg.Extra = make(map[string]any)
+		cfg.Extra["webhookUrls"] = []interface{}{"http://example.com", 123, "http://example2.com", nil, ""}
+		downloader.PutConfig(cfg)
+
+		urls := downloader.getWebhookUrls()
+		if len(urls) != 2 {
+			t.Errorf("Expected 2 valid URLs (ignoring non-strings and empty), got %d: %v", len(urls), urls)
+		}
+		if urls[0] != "http://example.com" || urls[1] != "http://example2.com" {
+			t.Errorf("URLs don't match expected values: %v", urls)
+		}
+	})
+}
+
+func TestWebhook_GetWebhookUrls_InvalidType(t *testing.T) {
+	setupWebhookTest(t, func(downloader *Downloader) {
+		cfg, _ := downloader.GetConfig()
+		cfg.Extra = make(map[string]any)
+		cfg.Extra["webhookUrls"] = "not-a-slice"
+		downloader.PutConfig(cfg)
+
+		urls := downloader.getWebhookUrls()
+		if urls != nil {
+			t.Errorf("Expected nil for invalid type, got %v", urls)
+		}
+	})
+}
+
+func TestWebhook_SendWebhookToUrl_Success(t *testing.T) {
+	receivedData := make(chan *WebhookData, 1)
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		// Verify headers
+		if r.Header.Get("Content-Type") != "application/json" {
+			t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type"))
+		}
+		if r.Header.Get("User-Agent") != "Gopeed-Webhook/1.0" {
+			t.Errorf("Expected User-Agent Gopeed-Webhook/1.0, got %s", r.Header.Get("User-Agent"))
+		}
+
+		var data WebhookData
+		if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+			t.Errorf("Failed to decode data: %v", err)
+			return
+		}
+		receivedData <- &data
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+
+	setupWebhookTest(t, func(downloader *Downloader) {
+		task := NewTask()
+		task.Protocol = "http"
+		task.Meta = &mockFetcherMeta
+
+		data := &WebhookData{
+			Event: WebhookEventDownloadDone,
+			Time:  time.Now().UnixMilli(),
+			Payload: &WebhookPayload{
+				Task: task,
+			},
+		}
+
+		statusCode, err := downloader.sendWebhookToUrl(server.URL, data)
+		if err != nil {
+			t.Errorf("sendWebhookToUrl failed: %v", err)
+		}
+		if statusCode != http.StatusOK {
+			t.Errorf("Expected status 200, got %d", statusCode)
+		}
+
+		select {
+		case received := <-receivedData:
+			if received.Event != WebhookEventDownloadDone {
+				t.Errorf("Expected event DOWNLOAD_DONE, got %s", received.Event)
+			}
+		case <-time.After(1 * time.Second):
+			t.Error("Timeout waiting for webhook")
+		}
+	})
+}
+
+func TestWebhook_SendWebhookToUrl_EmptyUrl(t *testing.T) {
+	setupWebhookTest(t, func(downloader *Downloader) {
+		data := &WebhookData{
+			Event: WebhookEventDownloadDone,
+			Time:  time.Now().UnixMilli(),
+		}
+
+		_, err := downloader.sendWebhookToUrl("", data)
+		if err == nil {
+			t.Error("Expected error for empty URL")
+		}
+		if err.Error() != "webhook URL is empty" {
+			t.Errorf("Expected 'webhook URL is empty' error, got: %v", err)
+		}
+	})
+}
+
+func TestWebhook_SendWebhookToUrl_InvalidUrl(t *testing.T) {
+	setupWebhookTest(t, func(downloader *Downloader) {
+		data := &WebhookData{
+			Event: WebhookEventDownloadDone,
+			Time:  time.Now().UnixMilli(),
+		}
+
+		_, err := downloader.sendWebhookToUrl("://invalid-url", data)
+		if err == nil {
+			t.Error("Expected error for invalid URL")
+		}
+	})
+}
+
+func TestWebhook_SendWebhookToUrl_NonExistentHost(t *testing.T) {
+	setupWebhookTest(t, func(downloader *Downloader) {
+		data := &WebhookData{
+			Event: WebhookEventDownloadDone,
+			Time:  time.Now().UnixMilli(),
+		}
+
+		_, err := downloader.sendWebhookToUrl("http://non-existent-host-12345.example", data)
+		if err == nil {
+			t.Error("Expected error for non-existent host")
+		}
+	})
+}
+
+func TestWebhook_SendWebhookToUrl_Timeout(t *testing.T) {
+	// Create a server that delays response beyond webhook timeout
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		time.Sleep(15 * time.Second) // Longer than webhookTimeout (10s)
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+
+	setupWebhookTest(t, func(downloader *Downloader) {
+		data := &WebhookData{
+			Event: WebhookEventDownloadDone,
+			Time:  time.Now().UnixMilli(),
+		}
+
+		_, err := downloader.sendWebhookToUrl(server.URL, data)
+		if err == nil {
+			t.Error("Expected timeout error")
+		}
+	})
+}
+
+func TestWebhook_SendWebhookToUrl_VariousStatusCodes(t *testing.T) {
+	testCases := []struct {
+		name       string
+		statusCode int
+	}{
+		{"200 OK", http.StatusOK},
+		{"201 Created", http.StatusCreated},
+		{"204 No Content", http.StatusNoContent},
+		{"400 Bad Request", http.StatusBadRequest},
+		{"404 Not Found", http.StatusNotFound},
+		{"500 Internal Server Error", http.StatusInternalServerError},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(tc.statusCode)
+			}))
+			defer server.Close()
+
+			setupWebhookTest(t, func(downloader *Downloader) {
+				data := &WebhookData{
+					Event: WebhookEventDownloadDone,
+					Time:  time.Now().UnixMilli(),
+				}
+
+				statusCode, err := downloader.sendWebhookToUrl(server.URL, data)
+				if err != nil {
+					t.Errorf("sendWebhookToUrl failed: %v", err)
+				}
+				if statusCode != tc.statusCode {
+					t.Errorf("Expected status %d, got %d", tc.statusCode, statusCode)
+				}
+			})
+		})
+	}
+}
+
+func TestWebhook_TriggerWebhooks_EmptyUrlSkipped(t *testing.T) {
+	requestCount := 0
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		requestCount++
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+
+	setupWebhookTest(t, func(downloader *Downloader) {
+		cfg, _ := downloader.GetConfig()
+		cfg.Extra = make(map[string]any)
+		cfg.Extra["webhookUrls"] = []string{server.URL, "", server.URL}
+		downloader.PutConfig(cfg)
+
+		task := NewTask()
+		task.Protocol = "http"
+		task.Meta = &mockFetcherMeta
+
+		downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
+
+		time.Sleep(500 * time.Millisecond)
+
+		if requestCount != 2 {
+			t.Errorf("Expected 2 requests (empty URL should be skipped), got %d", requestCount)
+		}
+	})
+}
+
+func TestWebhook_WebhookDataStructure(t *testing.T) {
+	receivedData := make(chan *WebhookData, 1)
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		var data WebhookData
+		if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+			t.Errorf("Failed to decode data: %v", err)
+			return
+		}
+		receivedData <- &data
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+
+	setupWebhookTest(t, func(downloader *Downloader) {
+		cfg, _ := downloader.GetConfig()
+		cfg.Extra = make(map[string]any)
+		cfg.Extra["webhookUrls"] = []string{server.URL}
+		downloader.PutConfig(cfg)
+
+		task := NewTask()
+		task.Protocol = "http"
+		task.Status = base.DownloadStatusDone
+		task.Meta = &mockFetcherMeta
+
+		downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
+
+		select {
+		case data := <-receivedData:
+			// Verify event
+			if data.Event != WebhookEventDownloadDone {
+				t.Errorf("Expected event DOWNLOAD_DONE, got %s", data.Event)
+			}
+			// Verify time is set
+			if data.Time == 0 {
+				t.Error("Expected time to be set")
+			}
+			// Verify payload
+			if data.Payload == nil {
+				t.Error("Expected payload to be present")
+			}
+			if data.Payload.Task == nil {
+				t.Error("Expected task in payload to be present")
+			}
+			// Verify task is cloned (has same ID but different pointer)
+			if data.Payload.Task.ID != task.ID {
+				t.Errorf("Expected task ID %s, got %s", task.ID, data.Payload.Task.ID)
+			}
+		case <-time.After(2 * time.Second):
+			t.Error("Timeout waiting for webhook")
+		}
+	})
+}
+
+func TestWebhook_SendTestWebhook_EmptyUrls(t *testing.T) {
+	setupWebhookTest(t, func(downloader *Downloader) {
+		cfg, _ := downloader.GetConfig()
+		cfg.Extra = make(map[string]any)
+		cfg.Extra["webhookUrls"] = []string{}
+		downloader.PutConfig(cfg)
+
+		err := downloader.SendTestWebhook()
+		if err != nil {
+			t.Errorf("Expected no error for empty URLs, got: %v", err)
+		}
+	})
+}
+
+func TestWebhook_SendTestWebhook_MixedResults(t *testing.T) {
+	// First server returns 200
+	server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server1.Close()
+
+	// Second server returns 500
+	server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusInternalServerError)
+	}))
+	defer server2.Close()
+
+	setupWebhookTest(t, func(downloader *Downloader) {
+		cfg, _ := downloader.GetConfig()
+		cfg.Extra = make(map[string]any)
+		cfg.Extra["webhookUrls"] = []string{server1.URL, server2.URL}
+		downloader.PutConfig(cfg)
+
+		// Should fail because server2 returns 500
+		err := downloader.SendTestWebhook()
+		if err == nil {
+			t.Error("Expected error when one server returns non-200 status")
+		}
+	})
+}
+
+func TestWebhook_TestWebhookUrl_InvalidUrl(t *testing.T) {
+	setupWebhookTest(t, func(downloader *Downloader) {
+		err := downloader.TestWebhookUrl("://invalid")
+		if err == nil {
+			t.Error("Expected error for invalid URL")
+		}
+	})
+}
+
+func TestWebhook_TestWebhookUrl_VerifyTestPayload(t *testing.T) {
+	receivedData := make(chan *WebhookData, 1)
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		var data WebhookData
+		if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+			t.Errorf("Failed to decode data: %v", err)
+			return
+		}
+		receivedData <- &data
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+
+	setupWebhookTest(t, func(downloader *Downloader) {
+		err := downloader.TestWebhookUrl(server.URL)
+		if err != nil {
+			t.Errorf("TestWebhookUrl failed: %v", err)
+		}
+
+		select {
+		case data := <-receivedData:
+			// Verify it's a test webhook
+			if data.Event != WebhookEventDownloadDone {
+				t.Errorf("Expected event DOWNLOAD_DONE, got %s", data.Event)
+			}
+			if data.Payload == nil || data.Payload.Task == nil {
+				t.Error("Expected payload with task")
+			}
+			// Verify test task properties
+			task := data.Payload.Task
+			if task.Protocol != "http" {
+				t.Errorf("Expected protocol 'http', got '%s'", task.Protocol)
+			}
+			if task.Status != base.DownloadStatusDone {
+				t.Errorf("Expected status Done, got %s", task.Status)
+			}
+			if task.Meta == nil || task.Meta.Req == nil {
+				t.Error("Expected meta and request in test task")
+			}
+			if task.Meta.Req.URL != "https://example.com/test-file.zip" {
+				t.Errorf("Expected test URL, got %s", task.Meta.Req.URL)
+			}
+		case <-time.After(2 * time.Second):
+			t.Error("Timeout waiting for webhook")
+		}
+	})
+}
+
+func setupWebhookTest(t *testing.T, fn func(downloader *Downloader)) {
+	defaultDownloader.Setup()
+	defaultDownloader.cfg.StorageDir = ".test_storage"
+	defaultDownloader.cfg.DownloadDir = ".test_download"
+	defer func() {
+		defaultDownloader.Clear()
+		os.RemoveAll(defaultDownloader.cfg.StorageDir)
+		os.RemoveAll(defaultDownloader.cfg.DownloadDir)
+	}()
+	fn(defaultDownloader)
+}
diff --git a/pkg/rest/server_test.go b/pkg/rest/server_test.go
index 4ad6724..dc57a94 100644
--- a/pkg/rest/server_test.go
+++ b/pkg/rest/server_test.go
@@ -6,10 +6,6 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
-	"github.com/GopeedLab/gopeed/internal/test"
-	"github.com/GopeedLab/gopeed/pkg/base"
-	"github.com/GopeedLab/gopeed/pkg/download"
-	"github.com/GopeedLab/gopeed/pkg/rest/model"
 	"io"
 	"net"
 	"net/http"
@@ -20,6 +16,11 @@ import (
 	"sync"
 	"testing"
 	"time"
+
+	"github.com/GopeedLab/gopeed/internal/test"
+	"github.com/GopeedLab/gopeed/pkg/base"
+	"github.com/GopeedLab/gopeed/pkg/download"
+	"github.com/GopeedLab/gopeed/pkg/rest/model"
 )
 
 var (
@@ -606,6 +607,116 @@ func TestDoProxy(t *testing.T) {
 	})
 }
 
+func TestTestWebhook(t *testing.T) {
+	doTest(func() {
+		// Set up a mock webhook server
+		webhookReceived := false
+		var receivedData map[string]interface{}
+		var wg sync.WaitGroup
+		wg.Add(1)
+
+		webhookServer := http.Server{
+			Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				if r.Method != http.MethodPost {
+					w.WriteHeader(http.StatusMethodNotAllowed)
+					return
+				}
+
+				body, err := io.ReadAll(r.Body)
+				if err != nil {
+					w.WriteHeader(http.StatusBadRequest)
+					return
+				}
+
+				if err := json.Unmarshal(body, &receivedData); err != nil {
+					w.WriteHeader(http.StatusBadRequest)
+					return
+				}
+
+				// Check Content-Type
+				if r.Header.Get("Content-Type") != "application/json" {
+					t.Errorf("TestWebhook() Content-Type got = %v, want %v", r.Header.Get("Content-Type"), "application/json")
+				}
+
+				webhookReceived = true
+				w.WriteHeader(http.StatusOK)
+				wg.Done()
+			}),
+		}
+
+		// Start webhook server
+		listener, err := net.Listen("tcp", "127.0.0.1:0")
+		if err != nil {
+			t.Fatal(err)
+		}
+		webhookPort := listener.Addr().(*net.TCPAddr).Port
+		webhookURL := fmt.Sprintf("http://127.0.0.1:%d/webhook", webhookPort)
+
+		go webhookServer.Serve(listener)
+		defer webhookServer.Close()
+
+		// Test with valid webhook URL
+		httpRequestCheckOk[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
+			URL: webhookURL,
+		})
+
+		// Wait for webhook to be received
+		wg.Wait()
+
+		if !webhookReceived {
+			t.Error("TestWebhook() webhook was not received")
+		}
+
+		// Verify webhook data structure
+		if receivedData["event"] == nil {
+			t.Error("TestWebhook() missing 'event' field")
+		}
+		if receivedData["time"] == nil {
+			t.Error("TestWebhook() missing 'time' field")
+		}
+		if receivedData["payload"] == nil {
+			t.Error("TestWebhook() missing 'payload' field")
+		}
+
+		// Test with invalid webhook URL
+		code, _ := httpRequest[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
+			URL: "http://invalid-webhook-url-that-does-not-exist.local:99999/webhook",
+		})
+		checkCode(code, model.CodeError)
+
+		// Test with empty URL
+		code, _ = httpRequest[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
+			URL: "",
+		})
+		checkCode(code, model.CodeError)
+
+		// Test with webhook server returning non-200 status
+		wg.Add(1)
+		badWebhookServer := http.Server{
+			Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusBadRequest)
+				wg.Done()
+			}),
+		}
+		badListener, err := net.Listen("tcp", "127.0.0.1:0")
+		if err != nil {
+			t.Fatal(err)
+		}
+		badWebhookPort := badListener.Addr().(*net.TCPAddr).Port
+		badWebhookURL := fmt.Sprintf("http://127.0.0.1:%d/webhook", badWebhookPort)
+
+		go badWebhookServer.Serve(badListener)
+		defer badWebhookServer.Close()
+
+		code, _ = httpRequest[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
+			URL: badWebhookURL,
+		})
+		checkCode(code, model.CodeError)
+
+		wg.Wait()
+	})
+}
+
 func TestApiToken(t *testing.T) {
 	var cfg = &model.StartConfig{}
 	cfg.Init()