From 3eef75dab8a83f7babf041ce6ea344a45019970e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20Pl=C3=A1cido=20da=20Costa?= Date: Tue, 28 Jul 2026 14:23:23 -0300 Subject: [PATCH] feat(webhook): allow multiple webhook URLs per instance (fan-out) --- pkg/events/webhook/webhook_producer.go | 47 ++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/pkg/events/webhook/webhook_producer.go b/pkg/events/webhook/webhook_producer.go index c78bbf5d..653c8a35 100644 --- a/pkg/events/webhook/webhook_producer.go +++ b/pkg/events/webhook/webhook_producer.go @@ -2,6 +2,7 @@ package webhook_producer import ( "bytes" + "encoding/json" "errors" "fmt" "io" @@ -43,13 +44,55 @@ func (p *webhookProducer) Produce( if p.url != "" { go p.sendWebhookWithRetry(p.url, payload, 5, 30*time.Second, userID) } - if webhookUrl != "" { - go p.sendWebhookWithRetry(webhookUrl, payload, 5, 30*time.Second, userID) + + // Support multiple webhook URLs per instance. The instance Webhook field + // may contain several URLs (separated by newline, comma or semicolon, or a + // JSON array). The SAME request is delivered to each address. Fully + // backward compatible with a single URL. + for _, url := range splitWebhookURLs(webhookUrl) { + u := url + go p.sendWebhookWithRetry(u, payload, 5, 30*time.Second, userID) } return nil } +// splitWebhookURLs splits the instance webhook string into one or more URLs. +// It accepts a JSON array (["https://a","https://b"]) OR a list separated by +// newline / comma / semicolon. It drops duplicates, empty entries and the +// "disabled" marker. +func splitWebhookURLs(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "disabled" { + return nil + } + + var parts []string + if strings.HasPrefix(raw, "[") { + var arr []string + if err := json.Unmarshal([]byte(raw), &arr); err == nil { + parts = arr + } + } + if parts == nil { + parts = strings.FieldsFunc(raw, func(r rune) bool { + return r == '\n' || r == '\r' || r == ',' || r == ';' + }) + } + + out := make([]string, 0, len(parts)) + seen := make(map[string]bool, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" || p == "disabled" || seen[p] { + continue + } + seen[p] = true + out = append(out, p) + } + return out +} + func (p *webhookProducer) sendWebhookWithRetry(url string, body []byte, maxRetries int, retryInterval time.Duration, userID string) { for i := 0; i < maxRetries; i++ { err, responseBody, statusCode := p.sendWebhook(url, body, userID)