diff --git a/docs/components/Rootly.mdx b/docs/components/Rootly.mdx
index 4e24d366c7..4fa688ef5c 100644
--- a/docs/components/Rootly.mdx
+++ b/docs/components/Rootly.mdx
@@ -10,6 +10,7 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components";
+
## Actions
@@ -71,6 +72,87 @@ This trigger automatically sets up a Rootly webhook endpoint when configured. Th
}
```
+
+
+## On Incident Timeline Event
+
+The On Incident Timeline Event trigger starts a workflow execution when Rootly incident timeline events are created or updated.
+Only events with kind "event" are emitted.
+
+### Use Cases
+
+- **Note automation**: Run workflows when investigation notes are added
+- **Timeline sync**: Sync incident timeline events to Slack or external systems
+- **Annotation tracking**: Track updates to incident annotations
+- **Audit logging**: Capture timeline events for compliance or reporting
+
+### Configuration
+
+- **Incident Status**: Optional filter by incident status (open, resolved, etc.)
+- **Severity**: Optional filter by incident severity
+- **Service**: Optional filter by service name
+- **Team**: Optional filter by team name
+- **Event Source**: Optional filter by event source (web, api, system)
+- **Visibility**: Optional filter by event visibility (internal or external)
+
+### Event Data
+
+Each incident event includes:
+- **id**: Event ID
+- **event**: Event content
+- **event_raw**: Raw event content
+- **event_id**: Webhook event ID
+- **event_type**: Event type (incident_event.created or incident_event.updated)
+- **kind**: Event kind
+- **source**: Event source
+- **visibility**: Event visibility
+- **occurred_at**: When the event occurred
+- **created_at**: When the event was created
+- **updated_at**: When the event was last updated
+- **issued_at**: When the webhook was issued
+- **incident_id**: Incident ID
+- **incident**: Incident information
+
+### Webhook Setup
+
+This trigger automatically sets up a Rootly webhook endpoint when configured. The endpoint is managed by SuperPlane and will be cleaned up when the trigger is removed.
+
+### Example Data
+
+```json
+{
+ "data": {
+ "created_at": "2026-02-22T09:46:23.868-08:00",
+ "event": "Investigation started, will update accordingly",
+ "event_id": "b3065ca8-69a6-4781-b6b4-94d6f0317ccf",
+ "event_raw": "Investigation started, will update accordingly",
+ "event_type": "incident_event.created",
+ "id": "56f7b488-e3c5-4091-9bb4-cf132007f98c",
+ "incident": {
+ "id": "64c39fde-1626-4f78-874e-9db91c0639d3",
+ "services": [
+ "UI - User Profile Block"
+ ],
+ "severity": "sev2",
+ "status": "mitigated",
+ "teams": [
+ "Customer Relations"
+ ],
+ "title": "new remake from main"
+ },
+ "incident_id": "64c39fde-1626-4f78-874e-9db91c0639d3",
+ "issued_at": "2026-02-22T09:46:24.018-08:00",
+ "kind": "event",
+ "occurred_at": "2026-02-22T09:46:23.868-08:00",
+ "source": "web",
+ "updated_at": "2026-02-22T09:46:23.868-08:00",
+ "visibility": "internal"
+ },
+ "timestamp": "2026-02-22T17:46:40.603539728Z",
+ "type": "rootly.onIncidentTimelineEvent"
+}
+```
+
## Create Event
diff --git a/pkg/integrations/rootly/client.go b/pkg/integrations/rootly/client.go
index c82789c156..669a3bffc4 100644
--- a/pkg/integrations/rootly/client.go
+++ b/pkg/integrations/rootly/client.go
@@ -259,19 +259,43 @@ type IncidentEventResponse struct {
Data IncidentEventData `json:"data"`
}
-// severityString extracts the severity slug from the API response.
-// Rootly returns severity as a string (slug) or an object with slug/name fields.
+// severityString extracts a severity slug/name from Rootly responses.
+// Rootly may return severity as a string, a flat object, or a JSON:API nested object.
func severityString(v any) string {
switch s := v.(type) {
case string:
+ // Severity already provided as a slug/name string.
return s
case map[string]any:
- if slug, ok := s["slug"].(string); ok {
- return slug
+ // Severity provided as a flat object with slug/name fields.
+ if value := severityFromMap(s); value != "" {
+ return value
}
- if name, ok := s["name"].(string); ok {
- return name
+
+ // Severity provided as a nested JSON:API object: {data:{attributes:{slug/name}}}.
+ data, ok := s["data"].(map[string]any)
+ if !ok {
+ return ""
+ }
+
+ attrs, ok := data["attributes"].(map[string]any)
+ if !ok {
+ return ""
}
+
+ return severityFromMap(attrs)
+ default:
+ return ""
+ }
+}
+
+func severityFromMap(values map[string]any) string {
+ if slug, ok := values["slug"].(string); ok && slug != "" {
+ return slug
+ }
+
+ if name, ok := values["name"].(string); ok && name != "" {
+ return name
}
return ""
@@ -709,9 +733,11 @@ func (c *Client) UpdateIncident(id string, attrs UpdateIncidentAttributes) (*Inc
// WebhookEndpoint represents a Rootly webhook endpoint
type WebhookEndpoint struct {
- ID string `json:"id"`
- URL string `json:"url"`
- Secret string `json:"secret"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ URL string `json:"url"`
+ Secret string `json:"secret"`
+ Events []string `json:"event_types"`
}
type WebhookEndpointData struct {
@@ -721,6 +747,7 @@ type WebhookEndpointData struct {
}
type WebhookEndpointAttributes struct {
+ Name string `json:"name"`
URL string `json:"url"`
Secret string `json:"secret"`
EventTypes []string `json:"event_types"`
@@ -732,6 +759,10 @@ type WebhookEndpointResponse struct {
Data WebhookEndpointData `json:"data"`
}
+type WebhookEndpointsResponse struct {
+ Data []WebhookEndpointData `json:"data"`
+}
+
type CreateWebhookEndpointRequest struct {
Data CreateWebhookEndpointData `json:"data"`
}
@@ -749,12 +780,12 @@ type CreateWebhookEndpointAttributes struct {
SigningEnabled bool `json:"signing_enabled"`
}
-func (c *Client) CreateWebhookEndpoint(url string, events []string) (*WebhookEndpoint, error) {
+func (c *Client) CreateWebhookEndpoint(name, url string, events []string) (*WebhookEndpoint, error) {
request := CreateWebhookEndpointRequest{
Data: CreateWebhookEndpointData{
Type: "webhooks_endpoints",
Attributes: CreateWebhookEndpointAttributes{
- Name: "SuperPlane",
+ Name: name,
URL: url,
EventTypes: events,
Enabled: true,
@@ -782,8 +813,75 @@ func (c *Client) CreateWebhookEndpoint(url string, events []string) (*WebhookEnd
return &WebhookEndpoint{
ID: response.Data.ID,
+ Name: response.Data.Attributes.Name,
+ URL: response.Data.Attributes.URL,
+ Secret: response.Data.Attributes.Secret,
+ Events: response.Data.Attributes.EventTypes,
+ }, nil
+}
+
+func (c *Client) ListWebhookEndpoints() ([]WebhookEndpoint, error) {
+ apiURL := fmt.Sprintf("%s/webhooks/endpoints", c.BaseURL)
+ responseBody, err := c.execRequest(http.MethodGet, apiURL, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var response WebhookEndpointsResponse
+ if err := json.Unmarshal(responseBody, &response); err != nil {
+ return nil, fmt.Errorf("error parsing response: %v", err)
+ }
+
+ endpoints := make([]WebhookEndpoint, 0, len(response.Data))
+ for _, data := range response.Data {
+ endpoints = append(endpoints, WebhookEndpoint{
+ ID: data.ID,
+ Name: data.Attributes.Name,
+ URL: data.Attributes.URL,
+ Secret: data.Attributes.Secret,
+ Events: data.Attributes.EventTypes,
+ })
+ }
+
+ return endpoints, nil
+}
+
+func (c *Client) UpdateWebhookEndpoint(id, name, url string, events []string, enabled bool) (*WebhookEndpoint, error) {
+ request := CreateWebhookEndpointRequest{
+ Data: CreateWebhookEndpointData{
+ Type: "webhooks_endpoints",
+ Attributes: CreateWebhookEndpointAttributes{
+ Name: name,
+ URL: url,
+ EventTypes: events,
+ Enabled: enabled,
+ SigningEnabled: true,
+ },
+ },
+ }
+
+ body, err := json.Marshal(request)
+ if err != nil {
+ return nil, fmt.Errorf("error marshaling request: %v", err)
+ }
+
+ apiURL := fmt.Sprintf("%s/webhooks/endpoints/%s", c.BaseURL, id)
+ responseBody, err := c.execRequest(http.MethodPut, apiURL, bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+
+ var response WebhookEndpointResponse
+ if err := json.Unmarshal(responseBody, &response); err != nil {
+ return nil, fmt.Errorf("error parsing response: %v", err)
+ }
+
+ return &WebhookEndpoint{
+ ID: response.Data.ID,
+ Name: response.Data.Attributes.Name,
URL: response.Data.Attributes.URL,
Secret: response.Data.Attributes.Secret,
+ Events: response.Data.Attributes.EventTypes,
}, nil
}
diff --git a/pkg/integrations/rootly/example.go b/pkg/integrations/rootly/example.go
index 6798eda8db..e8a802f7cf 100644
--- a/pkg/integrations/rootly/example.go
+++ b/pkg/integrations/rootly/example.go
@@ -31,6 +31,12 @@ var exampleDataOnIncidentBytes []byte
var exampleDataOnIncidentOnce sync.Once
var exampleDataOnIncident map[string]any
+//go:embed example_data_on_incident_timeline_event.json
+var exampleDataOnIncidentTimelineEventBytes []byte
+
+var exampleDataOnIncidentTimelineEventOnce sync.Once
+var exampleDataOnIncidentTimelineEvent map[string]any
+
func (c *CreateIncident) ExampleOutput() map[string]any {
return utils.UnmarshalEmbeddedJSON(&exampleOutputCreateIncidentOnce, exampleOutputCreateIncidentBytes, &exampleOutputCreateIncident)
}
@@ -56,3 +62,7 @@ var exampleOutputGetIncident map[string]any
func (c *GetIncident) ExampleOutput() map[string]any {
return utils.UnmarshalEmbeddedJSON(&exampleOutputGetIncidentOnce, exampleOutputGetIncidentBytes, &exampleOutputGetIncident)
}
+
+func (t *OnIncidentTimelineEvent) ExampleData() map[string]any {
+ return utils.UnmarshalEmbeddedJSON(&exampleDataOnIncidentTimelineEventOnce, exampleDataOnIncidentTimelineEventBytes, &exampleDataOnIncidentTimelineEvent)
+}
diff --git a/pkg/integrations/rootly/example_data_on_incident_timeline_event.json b/pkg/integrations/rootly/example_data_on_incident_timeline_event.json
new file mode 100644
index 0000000000..e0356c5e81
--- /dev/null
+++ b/pkg/integrations/rootly/example_data_on_incident_timeline_event.json
@@ -0,0 +1,31 @@
+{
+ "data": {
+ "created_at": "2026-02-22T09:46:23.868-08:00",
+ "event": "Investigation started, will update accordingly",
+ "event_id": "b3065ca8-69a6-4781-b6b4-94d6f0317ccf",
+ "event_raw": "Investigation started, will update accordingly",
+ "event_type": "incident_event.created",
+ "id": "56f7b488-e3c5-4091-9bb4-cf132007f98c",
+ "incident": {
+ "id": "64c39fde-1626-4f78-874e-9db91c0639d3",
+ "services": [
+ "UI - User Profile Block"
+ ],
+ "severity": "sev2",
+ "status": "mitigated",
+ "teams": [
+ "Customer Relations"
+ ],
+ "title": "new remake from main"
+ },
+ "incident_id": "64c39fde-1626-4f78-874e-9db91c0639d3",
+ "issued_at": "2026-02-22T09:46:24.018-08:00",
+ "kind": "event",
+ "occurred_at": "2026-02-22T09:46:23.868-08:00",
+ "source": "web",
+ "updated_at": "2026-02-22T09:46:23.868-08:00",
+ "visibility": "internal"
+ },
+ "timestamp": "2026-02-22T17:46:40.603539728Z",
+ "type": "rootly.onIncidentTimelineEvent"
+}
diff --git a/pkg/integrations/rootly/on_incident_timeline_event.go b/pkg/integrations/rootly/on_incident_timeline_event.go
new file mode 100644
index 0000000000..5fda2c4135
--- /dev/null
+++ b/pkg/integrations/rootly/on_incident_timeline_event.go
@@ -0,0 +1,583 @@
+package rootly
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "slices"
+ "strings"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+const rootlyIncidentTimelineEventPayloadType = "rootly.onIncidentTimelineEvent"
+
+var rootlyIncidentTimelineEventWebhookTypes = []string{
+ "incident_event.created",
+ "incident_event.updated",
+}
+
+type OnIncidentTimelineEvent struct{}
+
+type OnIncidentTimelineEventConfiguration struct {
+ IncidentStatus []string `json:"incidentStatus" mapstructure:"incidentStatus"`
+ Severity []string `json:"severity" mapstructure:"severity"`
+ Service []string `json:"service" mapstructure:"service"`
+ Team []string `json:"team" mapstructure:"team"`
+ EventSource []string `json:"eventSource" mapstructure:"eventSource"`
+ Visibility string `json:"visibility" mapstructure:"visibility"`
+}
+
+type OnIncidentTimelineEventMetadata struct {
+ EventStates map[string]string `json:"eventStates"`
+}
+
+func (t *OnIncidentTimelineEvent) Name() string {
+ return "rootly.onIncidentTimelineEvent"
+}
+
+func (t *OnIncidentTimelineEvent) Label() string {
+ return "On Incident Timeline Event"
+}
+
+func (t *OnIncidentTimelineEvent) Description() string {
+ return "Listen to incident timeline events"
+}
+
+func (t *OnIncidentTimelineEvent) Documentation() string {
+ return `The On Incident Timeline Event trigger starts a workflow execution when Rootly incident timeline events are created or updated.
+Only events with kind "event" are emitted.
+
+## Use Cases
+
+- **Note automation**: Run workflows when investigation notes are added
+- **Timeline sync**: Sync incident timeline events to Slack or external systems
+- **Annotation tracking**: Track updates to incident annotations
+- **Audit logging**: Capture timeline events for compliance or reporting
+
+## Configuration
+
+- **Incident Status**: Optional filter by incident status (open, resolved, etc.)
+- **Severity**: Optional filter by incident severity
+- **Service**: Optional filter by service name
+- **Team**: Optional filter by team name
+- **Event Source**: Optional filter by event source (web, api, system)
+- **Visibility**: Optional filter by event visibility (internal or external)
+
+## Event Data
+
+Each incident event includes:
+- **id**: Event ID
+- **event**: Event content
+- **event_raw**: Raw event content
+- **event_id**: Webhook event ID
+- **event_type**: Event type (incident_event.created or incident_event.updated)
+- **kind**: Event kind
+- **source**: Event source
+- **visibility**: Event visibility
+- **occurred_at**: When the event occurred
+- **created_at**: When the event was created
+- **updated_at**: When the event was last updated
+- **issued_at**: When the webhook was issued
+- **incident_id**: Incident ID
+- **incident**: Incident information
+
+## Webhook Setup
+
+This trigger automatically sets up a Rootly webhook endpoint when configured. The endpoint is managed by SuperPlane and will be cleaned up when the trigger is removed.`
+}
+
+func (t *OnIncidentTimelineEvent) Icon() string {
+ return "message-square"
+}
+
+func (t *OnIncidentTimelineEvent) Color() string {
+ return "gray"
+}
+
+func (t *OnIncidentTimelineEvent) Configuration() []configuration.Field {
+ return []configuration.Field{
+ {
+ Name: "incidentStatus",
+ Label: "Incident Status",
+ Type: configuration.FieldTypeMultiSelect,
+ Required: false,
+ Togglable: true,
+ Description: "Only emit events for incidents with this status",
+ TypeOptions: &configuration.TypeOptions{
+ MultiSelect: &configuration.MultiSelectTypeOptions{
+ Options: []configuration.FieldOption{
+ {Label: "In Triage", Value: "in_triage"},
+ {Label: "Started", Value: "started"},
+ {Label: "Detected", Value: "detected"},
+ {Label: "Acknowledged", Value: "acknowledged"},
+ {Label: "Mitigated", Value: "mitigated"},
+ {Label: "Resolved", Value: "resolved"},
+ {Label: "Closed", Value: "closed"},
+ {Label: "Cancelled", Value: "cancelled"},
+ },
+ },
+ },
+ },
+ {
+ Name: "severity",
+ Label: "Severity",
+ Type: configuration.FieldTypeIntegrationResource,
+ Required: false,
+ Togglable: true,
+ Description: "Only emit events for incidents with this severity",
+ Placeholder: "Select a severity",
+ TypeOptions: &configuration.TypeOptions{
+ Resource: &configuration.ResourceTypeOptions{
+ Type: "severity",
+ UseNameAsValue: true,
+ Multi: true,
+ },
+ },
+ },
+ {
+ Name: "service",
+ Label: "Service",
+ Type: configuration.FieldTypeIntegrationResource,
+ Required: false,
+ Togglable: true,
+ Description: "Only emit events for incidents impacting this service",
+ Placeholder: "Select a service",
+ TypeOptions: &configuration.TypeOptions{
+ Resource: &configuration.ResourceTypeOptions{
+ Type: "service",
+ UseNameAsValue: true,
+ Multi: true,
+ },
+ },
+ },
+ {
+ Name: "team",
+ Label: "Team",
+ Type: configuration.FieldTypeIntegrationResource,
+ Required: false,
+ Togglable: true,
+ Description: "Only emit events for incidents owned by this team",
+ Placeholder: "Select a team",
+ TypeOptions: &configuration.TypeOptions{
+ Resource: &configuration.ResourceTypeOptions{
+ Type: "team",
+ UseNameAsValue: true,
+ Multi: true,
+ },
+ },
+ },
+ {
+ Name: "eventSource",
+ Label: "Event Source",
+ Type: configuration.FieldTypeMultiSelect,
+ Required: false,
+ Togglable: true,
+ Description: "Only emit events from these sources",
+ TypeOptions: &configuration.TypeOptions{
+ MultiSelect: &configuration.MultiSelectTypeOptions{
+ Options: []configuration.FieldOption{
+ {Label: "Web", Value: "web"},
+ {Label: "API", Value: "api"},
+ {Label: "System", Value: "system"},
+ },
+ },
+ },
+ },
+ {
+ Name: "visibility",
+ Label: "Visibility",
+ Type: configuration.FieldTypeSelect,
+ Required: false,
+ Togglable: true,
+ TypeOptions: &configuration.TypeOptions{
+ Select: &configuration.SelectTypeOptions{
+ Options: []configuration.FieldOption{
+ {Label: "Internal", Value: "internal"},
+ {Label: "External", Value: "external"},
+ },
+ },
+ },
+ Description: "Only emit events with this visibility",
+ },
+ }
+}
+
+func (t *OnIncidentTimelineEvent) Setup(ctx core.TriggerContext) error {
+ return ctx.Integration.RequestWebhook(WebhookConfiguration{
+ Events: rootlyIncidentTimelineEventWebhookTypes,
+ })
+}
+
+func (t *OnIncidentTimelineEvent) Actions() []core.Action {
+ return []core.Action{}
+}
+
+func (t *OnIncidentTimelineEvent) HandleAction(ctx core.TriggerActionContext) (map[string]any, error) {
+ return nil, nil
+}
+
+func (t *OnIncidentTimelineEvent) HandleWebhook(ctx core.WebhookRequestContext) (int, error) {
+ config := OnIncidentTimelineEventConfiguration{}
+ if err := mapstructure.Decode(ctx.Configuration, &config); err != nil {
+ return http.StatusInternalServerError, fmt.Errorf("failed to decode configuration: %w", err)
+ }
+
+ signature := ctx.Headers.Get("X-Rootly-Signature")
+ secret, err := ctx.Webhook.GetSecret()
+ if err != nil {
+ return http.StatusInternalServerError, fmt.Errorf("error getting secret: %v", err)
+ }
+
+ if err := verifyWebhookSignature(signature, ctx.Body, secret); err != nil {
+ return http.StatusForbidden, fmt.Errorf("invalid signature: %v", err)
+ }
+
+ var webhook WebhookPayload
+ if err := json.Unmarshal(ctx.Body, &webhook); err != nil {
+ return http.StatusBadRequest, fmt.Errorf("error parsing request body: %v", err)
+ }
+
+ if !slices.Contains(rootlyIncidentTimelineEventWebhookTypes, webhook.Event.Type) {
+ return http.StatusOK, nil
+ }
+
+ data := webhook.Data
+ if data == nil {
+ return http.StatusOK, nil
+ }
+
+ incidentEvent := extractEventFromData(data)
+ if incidentEvent == nil {
+ return http.StatusOK, nil
+ }
+
+ // Apply event-level filters directly from the webhook payload.
+ if !matchesEventFilter(config.EventSource, extractString(incidentEvent, "source")) {
+ return http.StatusOK, nil
+ }
+
+ if config.Visibility != "" && !strings.EqualFold(config.Visibility, extractString(incidentEvent, "visibility")) {
+ return http.StatusOK, nil
+ }
+
+ incidentID := extractString(incidentEvent, "incident_id", "incidentId")
+
+ // Only process events with kind "event" to avoid emitting for non-timeline events like "trail/start/close".
+ if !strings.EqualFold(extractString(incidentEvent, "kind"), "event") {
+ return http.StatusOK, nil
+ }
+
+ incidentFiltersEnabled := len(config.IncidentStatus) > 0 || len(config.Severity) > 0 || len(config.Service) > 0 || len(config.Team) > 0
+ var incidentDetails map[string]any
+ if incidentID != "" && ctx.HTTP != nil && ctx.Integration != nil {
+ // Fetch incident details to apply status/severity/service/team filters
+ // and enrich the emitted payload with incident context.
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return http.StatusInternalServerError, fmt.Errorf("error creating client: %v", err)
+ }
+
+ incidentDetails, err = client.GetIncidentDetailed(incidentID)
+ if err != nil {
+ return http.StatusInternalServerError, fmt.Errorf("error fetching incident: %v", err)
+ }
+ }
+
+ if incidentFiltersEnabled {
+ if incidentID == "" {
+ return http.StatusOK, nil
+ }
+
+ if incidentDetails == nil {
+ return http.StatusInternalServerError, fmt.Errorf("incident details unavailable for filtering")
+ }
+
+ if !matchesIncidentFilters(incidentDetails, config) {
+ return http.StatusOK, nil
+ }
+ }
+
+ metadata := loadOnIncidentTimelineEventMetadata(ctx.Metadata)
+ updatedStates := map[string]string{}
+ for key, value := range metadata.EventStates {
+ updatedStates[key] = value
+ }
+
+ emitted := 0
+ eventID := extractString(incidentEvent, "id")
+ fingerprint := eventFingerprint(incidentEvent)
+
+ if eventID != "" {
+ updatedStates[eventID] = fingerprint
+ }
+
+ if eventID != "" {
+ if previous, exists := metadata.EventStates[eventID]; exists && previous == fingerprint {
+ return http.StatusOK, nil
+ }
+ }
+
+ payload := buildIncidentTimelineEventPayload(incidentDetails, incidentEvent, webhook.Event)
+ if err := ctx.Events.Emit(rootlyIncidentTimelineEventPayloadType, payload); err != nil {
+ return http.StatusInternalServerError, fmt.Errorf("error emitting event: %v", err)
+ }
+ emitted++
+
+ if ctx.Metadata != nil {
+ if err := ctx.Metadata.Set(OnIncidentTimelineEventMetadata{EventStates: updatedStates}); err != nil {
+ return http.StatusInternalServerError, fmt.Errorf("error updating metadata: %v", err)
+ }
+ }
+
+ if emitted == 0 {
+ return http.StatusOK, nil
+ }
+
+ return http.StatusOK, nil
+}
+
+func (t *OnIncidentTimelineEvent) Cleanup(ctx core.TriggerContext) error {
+ return nil
+}
+
+// loadOnIncidentTimelineEventMetadata pulls persisted state for deduping repeated webhook deliveries.
+func loadOnIncidentTimelineEventMetadata(ctx core.MetadataContext) OnIncidentTimelineEventMetadata {
+ if ctx == nil {
+ return OnIncidentTimelineEventMetadata{EventStates: map[string]string{}}
+ }
+
+ metadata := OnIncidentTimelineEventMetadata{}
+ if err := mapstructure.Decode(ctx.Get(), &metadata); err != nil || metadata.EventStates == nil {
+ return OnIncidentTimelineEventMetadata{EventStates: map[string]string{}}
+ }
+
+ return metadata
+}
+
+// extractEventFromData normalizes either top-level or nested incident_event payloads.
+func extractEventFromData(data map[string]any) map[string]any {
+ if event, ok := data["incident_event"].(map[string]any); ok {
+ if isIncidentEventPayload(event) {
+ return event
+ }
+ }
+
+ if isIncidentEventPayload(data) {
+ return data
+ }
+
+ return nil
+}
+
+// buildIncidentTimelineEventPayload keeps the Rootly event shape and augments with incident context.
+func buildIncidentTimelineEventPayload(incident map[string]any, incidentEvent map[string]any, webhookEvent WebhookEvent) map[string]any {
+ incidentPayload := buildIncidentSummaryPayload(incident, incidentEvent)
+ payload := map[string]any{
+ "id": extractString(incidentEvent, "id"),
+ "event": extractString(incidentEvent, "event"),
+ "event_raw": extractString(incidentEvent, "event_raw"),
+ "kind": extractString(incidentEvent, "kind"),
+ "source": extractString(incidentEvent, "source"),
+ "visibility": extractString(incidentEvent, "visibility"),
+ "occurred_at": extractString(incidentEvent, "occurred_at"),
+ "created_at": extractString(incidentEvent, "created_at"),
+ "updated_at": extractString(incidentEvent, "updated_at"),
+ "incident_id": extractString(incidentEvent, "incident_id", "incidentId"),
+ "event_id": webhookEvent.ID,
+ "event_type": webhookEvent.Type,
+ "issued_at": webhookEvent.IssuedAt,
+ "incident": incidentPayload,
+ }
+
+ return payload
+}
+
+// buildIncidentSummaryPayload emits a compact incident stub for filtering context.
+func buildIncidentSummaryPayload(incident map[string]any, incidentEvent map[string]any) map[string]any {
+ incidentID := ""
+ if incident != nil {
+ incidentID = extractString(incident, "id")
+ }
+ if incidentID == "" {
+ incidentID = extractString(incidentEvent, "incident_id", "incidentId")
+ }
+
+ if incidentID == "" && incident == nil {
+ return nil
+ }
+
+ payload := map[string]any{
+ "id": incidentID,
+ }
+
+ if incident == nil {
+ return payload
+ }
+
+ if title := extractString(incident, "title"); title != "" {
+ payload["title"] = title
+ }
+ if status := extractString(incident, "status", "state"); status != "" {
+ payload["status"] = status
+ }
+ if severity := severityString(incident["severity"]); severity != "" {
+ payload["severity"] = severity
+ }
+ if services := extractResourceNames(incident, "services"); len(services) > 0 {
+ payload["services"] = services
+ }
+ if teams := extractResourceNames(incident, "groups"); len(teams) > 0 {
+ payload["teams"] = teams
+ }
+
+ return payload
+}
+
+func extractString(data map[string]any, keys ...string) string {
+ for _, key := range keys {
+ value, ok := data[key]
+ if !ok || value == nil {
+ continue
+ }
+
+ str, ok := value.(string)
+ if ok && str != "" {
+ return str
+ }
+ }
+
+ return ""
+}
+
+func matchesEventFilter(filters []string, value string) bool {
+ if len(filters) == 0 {
+ return true
+ }
+
+ return slices.ContainsFunc(filters, func(filter string) bool {
+ return strings.EqualFold(filter, value)
+ })
+}
+
+// eventFingerprint helps avoid double-processing when Rootly retries a webhook.
+func eventFingerprint(event map[string]any) string {
+ if value := extractString(event, "updated_at"); value != "" {
+ return value
+ }
+
+ if value := extractString(event, "created_at"); value != "" {
+ return value
+ }
+
+ if value := extractString(event, "occurred_at"); value != "" {
+ return value
+ }
+
+ raw, err := json.Marshal(event)
+ if err != nil {
+ return ""
+ }
+
+ return string(raw)
+}
+
+// isIncidentEventPayload checks for expected incident event fields.
+func isIncidentEventPayload(data map[string]any) bool {
+ if data == nil {
+ return false
+ }
+
+ if extractString(data, "event", "kind") != "" {
+ return true
+ }
+
+ if extractString(data, "occurred_at", "created_at") != "" {
+ return true
+ }
+
+ return false
+}
+
+// matchesIncidentFilters applies incident-level filters from fetched incident details.
+func matchesIncidentFilters(incident map[string]any, config OnIncidentTimelineEventConfiguration) bool {
+ if len(config.IncidentStatus) > 0 {
+ status := extractString(incident, "status", "state")
+ if !matchesEventFilter(config.IncidentStatus, status) {
+ return false
+ }
+ }
+
+ if len(config.Severity) > 0 {
+ severity := severityString(incident["severity"])
+ if severity == "" {
+ severity = extractString(incident, "severity")
+ }
+ if !matchesEventFilter(config.Severity, severity) {
+ return false
+ }
+ }
+
+ if len(config.Service) > 0 {
+ services := extractResourceNames(incident, "services")
+ if !matchesAnyResource(services, config.Service) {
+ return false
+ }
+ }
+
+ if len(config.Team) > 0 {
+ teams := extractResourceNames(incident, "groups")
+ if !matchesAnyResource(teams, config.Team) {
+ return false
+ }
+ }
+
+ return true
+}
+
+// extractResourceNames pulls service/team names from varied response shapes.
+func extractResourceNames(incident map[string]any, key string) []string {
+ raw, ok := incident[key]
+ if !ok || raw == nil {
+ return nil
+ }
+
+ switch items := raw.(type) {
+ case []any:
+ names := make([]string, 0, len(items))
+ for _, item := range items {
+ resource, ok := item.(map[string]any)
+ if !ok {
+ continue
+ }
+ if name := extractString(resource, "name", "slug"); name != "" {
+ names = append(names, name)
+ }
+ }
+ return names
+ case []map[string]any:
+ names := make([]string, 0, len(items))
+ for _, resource := range items {
+ if name := extractString(resource, "name", "slug"); name != "" {
+ names = append(names, name)
+ }
+ }
+ return names
+ case map[string]any:
+ if name := extractString(items, "name", "slug"); name != "" {
+ return []string{name}
+ }
+ }
+
+ return nil
+}
+
+func matchesAnyResource(resources []string, filters []string) bool {
+ return slices.ContainsFunc(filters, func(filter string) bool {
+ return slices.ContainsFunc(resources, func(resource string) bool {
+ return strings.EqualFold(resource, filter)
+ })
+ })
+}
diff --git a/pkg/integrations/rootly/on_incident_timeline_event_test.go b/pkg/integrations/rootly/on_incident_timeline_event_test.go
new file mode 100644
index 0000000000..9e2ed3804e
--- /dev/null
+++ b/pkg/integrations/rootly/on_incident_timeline_event_test.go
@@ -0,0 +1,313 @@
+package rootly
+
+import (
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/superplanehq/superplane/pkg/core"
+ "github.com/superplanehq/superplane/test/support/contexts"
+)
+
+func Test__OnIncidentTimelineEvent__HandleWebhook(t *testing.T) {
+ trigger := &OnIncidentTimelineEvent{}
+
+ signatureFor := func(secret string, timestamp string, body []byte) string {
+ payload := append([]byte(timestamp), body...)
+ sig := computeHMACSHA256([]byte(secret), payload)
+ return "t=" + timestamp + ",v1=" + sig
+ }
+
+ baseConfig := map[string]any{}
+
+ t.Run("missing X-Rootly-Signature -> 403", func(t *testing.T) {
+ code, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Headers: http.Header{},
+ Configuration: baseConfig,
+ Webhook: &contexts.WebhookContext{Secret: "test-secret"},
+ Events: &contexts.EventContext{},
+ })
+
+ assert.Equal(t, http.StatusForbidden, code)
+ assert.ErrorContains(t, err, "invalid signature")
+ })
+
+ t.Run("invalid JSON body -> 400", func(t *testing.T) {
+ body := []byte("invalid json")
+ secret := "test-secret"
+ timestamp := "1234567890"
+
+ headers := http.Header{}
+ headers.Set("X-Rootly-Signature", signatureFor(secret, timestamp, body))
+
+ code, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Body: body,
+ Headers: headers,
+ Configuration: baseConfig,
+ Webhook: &contexts.WebhookContext{Secret: secret},
+ Events: &contexts.EventContext{},
+ })
+
+ assert.Equal(t, http.StatusBadRequest, code)
+ assert.ErrorContains(t, err, "error parsing request body")
+ })
+
+ t.Run("event type not configured -> no emit", func(t *testing.T) {
+ body := []byte(`{"event":{"type":"incident.created","id":"evt-123","issued_at":"2026-02-10T15:34:36Z"},"data":{"id":"6bbb5dac-b5a4-4569-a398-0897f683cc54","event":"another try","kind":"event","visibility":"internal","occurred_at":"2026-02-10T15:34:29Z","created_at":"2026-02-10T15:34:29Z","incident_id":"inc-123"}}`)
+ secret := "test-secret"
+ timestamp := "1234567890"
+
+ headers := http.Header{}
+ headers.Set("X-Rootly-Signature", signatureFor(secret, timestamp, body))
+
+ eventContext := &contexts.EventContext{}
+ code, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Body: body,
+ Headers: headers,
+ Configuration: baseConfig,
+ Webhook: &contexts.WebhookContext{Secret: secret},
+ Events: eventContext,
+ Metadata: &contexts.MetadataContext{},
+ })
+
+ assert.Equal(t, http.StatusOK, code)
+ assert.NoError(t, err)
+ assert.Equal(t, 0, eventContext.Count())
+ })
+
+ t.Run("incident_event.updated emits when metadata empty", func(t *testing.T) {
+ body := []byte(`{"event":{"type":"incident_event.updated","id":"evt-123","issued_at":"2026-02-10T15:34:36Z"},"data":{"id":"ev-1","event":"Initial note","kind":"event","visibility":"internal","occurred_at":"2026-02-10T15:00:00Z","created_at":"2026-02-10T15:00:01Z","incident_id":"inc-123"}}`)
+ secret := "test-secret"
+ timestamp := "1234567890"
+
+ headers := http.Header{}
+ headers.Set("X-Rootly-Signature", signatureFor(secret, timestamp, body))
+
+ metadata := &contexts.MetadataContext{}
+ eventContext := &contexts.EventContext{}
+ code, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Body: body,
+ Headers: headers,
+ Configuration: baseConfig,
+ Webhook: &contexts.WebhookContext{Secret: secret},
+ Events: eventContext,
+ Metadata: metadata,
+ })
+
+ require.Equal(t, http.StatusOK, code)
+ require.NoError(t, err)
+ require.Equal(t, 1, eventContext.Count())
+ })
+
+ t.Run("incident_event.updated dedupes unchanged event", func(t *testing.T) {
+ body := []byte(`{"event":{"type":"incident_event.updated","id":"evt-124","issued_at":"2026-02-10T15:40:00Z"},"data":{"id":"ev-1","event":"Initial note","kind":"event","visibility":"internal","occurred_at":"2026-02-10T15:00:00Z","created_at":"2026-02-10T15:00:01Z","updated_at":"2026-02-10T15:00:02Z","incident_id":"inc-123"}}`)
+ secret := "test-secret"
+ timestamp := "1234567890"
+
+ headers := http.Header{}
+ headers.Set("X-Rootly-Signature", signatureFor(secret, timestamp, body))
+
+ metadata := &contexts.MetadataContext{Metadata: OnIncidentTimelineEventMetadata{EventStates: map[string]string{"ev-1": "2026-02-10T15:00:02Z"}}}
+ eventContext := &contexts.EventContext{}
+ code, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Body: body,
+ Headers: headers,
+ Configuration: baseConfig,
+ Webhook: &contexts.WebhookContext{Secret: secret},
+ Events: eventContext,
+ Metadata: metadata,
+ })
+
+ require.Equal(t, http.StatusOK, code)
+ require.NoError(t, err)
+ require.Equal(t, 0, eventContext.Count())
+ })
+
+ t.Run("visibility filter -> skips non-matching events", func(t *testing.T) {
+ body := []byte(`{"event":{"type":"incident_event.updated","id":"evt-125","issued_at":"2026-02-10T15:40:00Z"},"data":{"id":"ev-2","event":"External update","kind":"event","visibility":"external","occurred_at":"2026-02-10T15:10:00Z","created_at":"2026-02-10T15:10:01Z","incident_id":"inc-123"}}`)
+ secret := "test-secret"
+ timestamp := "1234567890"
+
+ headers := http.Header{}
+ headers.Set("X-Rootly-Signature", signatureFor(secret, timestamp, body))
+
+ metadata := &contexts.MetadataContext{Metadata: OnIncidentTimelineEventMetadata{EventStates: map[string]string{"ev-2": "2026-02-10T15:10:01Z"}}}
+ eventContext := &contexts.EventContext{}
+ code, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Body: body,
+ Headers: headers,
+ Configuration: map[string]any{"visibility": "internal"},
+ Webhook: &contexts.WebhookContext{Secret: secret},
+ Events: eventContext,
+ Metadata: metadata,
+ })
+
+ require.Equal(t, http.StatusOK, code)
+ require.NoError(t, err)
+ assert.Equal(t, 0, eventContext.Count())
+ })
+
+ t.Run("incident_event.created -> emits even without baseline", func(t *testing.T) {
+ body := []byte(`{"event":{"type":"incident_event.created","id":"evt-200","issued_at":"2026-02-10T16:00:00Z"},"data":{"id":"ev-200","event":"Failover initiated","kind":"event","visibility":"internal","occurred_at":"2026-02-10T16:00:00Z","created_at":"2026-02-10T16:00:01Z","incident_id":"inc-200"}}`)
+ secret := "test-secret"
+ timestamp := "1234567890"
+
+ headers := http.Header{}
+ headers.Set("X-Rootly-Signature", signatureFor(secret, timestamp, body))
+
+ metadata := &contexts.MetadataContext{}
+ eventContext := &contexts.EventContext{}
+ code, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Body: body,
+ Headers: headers,
+ Configuration: baseConfig,
+ Webhook: &contexts.WebhookContext{Secret: secret},
+ Events: eventContext,
+ Metadata: metadata,
+ })
+
+ require.Equal(t, http.StatusOK, code)
+ require.NoError(t, err)
+ require.Equal(t, 1, eventContext.Count())
+ })
+
+ t.Run("incident_event.created flat payload -> emits", func(t *testing.T) {
+ body := []byte(`{"event":{"id":"d390b7e6-2f80-4d71-92e3-5d19dc7f9c68","type":"incident_event.created","issued_at":"2026-02-17T05:54:29.581-08:00"},"data":{"id":"6bbb5dac-b5a4-4569-a398-0897f683cc54","event":"another try","event_raw":"another try","kind":"event","source":"web","visibility":"internal","occurred_at":"2026-02-17T05:54:29.522-08:00","created_at":"2026-02-17T05:54:29.522-08:00","updated_at":"2026-02-17T05:54:29.522-08:00","incident_id":"5dcbfe70-0416-469a-8629-be5353f4fd60"}}`)
+ secret := "test-secret"
+ timestamp := "1234567890"
+
+ headers := http.Header{}
+ headers.Set("X-Rootly-Signature", signatureFor(secret, timestamp, body))
+
+ eventContext := &contexts.EventContext{}
+ code, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Body: body,
+ Headers: headers,
+ Configuration: baseConfig,
+ Webhook: &contexts.WebhookContext{Secret: secret},
+ Events: eventContext,
+ Metadata: &contexts.MetadataContext{},
+ })
+
+ require.Equal(t, http.StatusOK, code)
+ require.NoError(t, err)
+ require.Equal(t, 1, eventContext.Count())
+
+ payload := eventContext.Payloads[0].Data.(map[string]any)
+ assert.Equal(t, "another try", payload["event"])
+ assert.Equal(t, "another try", payload["event_raw"])
+ assert.Equal(t, "d390b7e6-2f80-4d71-92e3-5d19dc7f9c68", payload["event_id"])
+ assert.Equal(t, "incident_event.created", payload["event_type"])
+ assert.Equal(t, "2026-02-17T05:54:29.581-08:00", payload["issued_at"])
+ assert.Equal(t, "5dcbfe70-0416-469a-8629-be5353f4fd60", payload["incident_id"])
+ assert.Equal(t, "2026-02-17T05:54:29.522-08:00", payload["updated_at"])
+ incident := payload["incident"].(map[string]any)
+ assert.Equal(t, "5dcbfe70-0416-469a-8629-be5353f4fd60", incident["id"])
+ })
+
+ t.Run("non-event kind -> no emit", func(t *testing.T) {
+ body := []byte(`{"event":{"type":"incident_event.updated","id":"evt-300","issued_at":"2026-02-10T16:10:00Z"},"data":{"id":"ev-300","event":"Note update","kind":"trail","visibility":"internal","occurred_at":"2026-02-10T16:10:00Z","created_at":"2026-02-10T16:10:01Z","incident_id":"inc-300"}}`)
+ secret := "test-secret"
+ timestamp := "1234567890"
+
+ headers := http.Header{}
+ headers.Set("X-Rootly-Signature", signatureFor(secret, timestamp, body))
+
+ eventContext := &contexts.EventContext{}
+ code, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Body: body,
+ Headers: headers,
+ Configuration: baseConfig,
+ Webhook: &contexts.WebhookContext{Secret: secret},
+ Events: eventContext,
+ Metadata: &contexts.MetadataContext{},
+ })
+
+ require.Equal(t, http.StatusOK, code)
+ require.NoError(t, err)
+ require.Equal(t, 0, eventContext.Count())
+ })
+
+ t.Run("incident filters -> fetches incident and emits", func(t *testing.T) {
+ now := time.Now().UTC().Format(time.RFC3339)
+ body := []byte(`{"event":{"type":"incident_event.updated","id":"evt-200","issued_at":"` + now + `"},"data":{"id":"ev-200","event":"Status update","kind":"event","source":"web","visibility":"internal","occurred_at":"` + now + `","created_at":"` + now + `","incident_id":"inc-200"}}`)
+ secret := "test-secret"
+ timestamp := "1234567890"
+
+ headers := http.Header{}
+ headers.Set("X-Rootly-Signature", signatureFor(secret, timestamp, body))
+
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{
+ "data": {
+ "id": "inc-200",
+ "attributes": {
+ "status": "resolved",
+ "severity": "sev2"
+ },
+ "relationships": {
+ "services": {"data": [{"type": "services", "id": "svc-1"}]},
+ "groups": {"data": [{"type": "groups", "id": "team-1"}]}
+ }
+ },
+ "included": [
+ {"id": "svc-1", "type": "services", "attributes": {"name": "API"}},
+ {"id": "team-1", "type": "groups", "attributes": {"name": "Platform"}}
+ ]
+}`)),
+ },
+ },
+ }
+
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{"apiKey": "test-key"},
+ }
+
+ eventContext := &contexts.EventContext{}
+ code, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Body: body,
+ Headers: headers,
+ Configuration: map[string]any{"incidentStatus": []string{"resolved"}, "severity": []string{"sev2"}, "service": []string{"API"}, "team": []string{"Platform"}},
+ Webhook: &contexts.WebhookContext{Secret: secret},
+ Events: eventContext,
+ Metadata: &contexts.MetadataContext{},
+ HTTP: httpCtx,
+ Integration: integrationCtx,
+ })
+
+ require.Equal(t, http.StatusOK, code)
+ require.NoError(t, err)
+ require.Equal(t, 1, eventContext.Count())
+ require.Len(t, httpCtx.Requests, 1)
+
+ payload := eventContext.Payloads[0].Data.(map[string]any)
+ incident := payload["incident"].(map[string]any)
+ assert.Equal(t, "inc-200", incident["id"])
+ assert.Equal(t, "resolved", incident["status"])
+ assert.Equal(t, "sev2", incident["severity"])
+ assert.Equal(t, []string{"API"}, incident["services"])
+ assert.Equal(t, []string{"Platform"}, incident["teams"])
+ })
+}
+
+func Test__OnIncidentTimelineEvent__Setup(t *testing.T) {
+ trigger := &OnIncidentTimelineEvent{}
+
+ integrationCtx := &contexts.IntegrationContext{}
+ err := trigger.Setup(core.TriggerContext{
+ Integration: integrationCtx,
+ })
+
+ require.NoError(t, err)
+ require.Len(t, integrationCtx.WebhookRequests, 1)
+
+ webhookConfig := integrationCtx.WebhookRequests[0].(WebhookConfiguration)
+ assert.Equal(t, []string{"incident_event.created", "incident_event.updated"}, webhookConfig.Events)
+}
diff --git a/pkg/integrations/rootly/rootly.go b/pkg/integrations/rootly/rootly.go
index 34790b4083..c1282b46c9 100644
--- a/pkg/integrations/rootly/rootly.go
+++ b/pkg/integrations/rootly/rootly.go
@@ -71,6 +71,7 @@ func (r *Rootly) Components() []core.Component {
func (r *Rootly) Triggers() []core.Trigger {
return []core.Trigger{
&OnIncident{},
+ &OnIncidentTimelineEvent{},
}
}
diff --git a/pkg/integrations/rootly/webhook_handler.go b/pkg/integrations/rootly/webhook_handler.go
index 68833d9717..9771769126 100644
--- a/pkg/integrations/rootly/webhook_handler.go
+++ b/pkg/integrations/rootly/webhook_handler.go
@@ -1,8 +1,10 @@
package rootly
import (
+ "crypto/sha256"
"fmt"
"slices"
+ "strings"
"github.com/mitchellh/mapstructure"
"github.com/superplanehq/superplane/pkg/core"
@@ -18,6 +20,8 @@ type WebhookMetadata struct {
type RootlyWebhookHandler struct{}
+const rootlyLegacyWebhookName = "SuperPlane"
+
func (h *RootlyWebhookHandler) CompareConfig(a, b any) (bool, error) {
configA := WebhookConfiguration{}
configB := WebhookConfiguration{}
@@ -43,7 +47,29 @@ func (h *RootlyWebhookHandler) CompareConfig(a, b any) (bool, error) {
}
func (h *RootlyWebhookHandler) Merge(current, requested any) (any, bool, error) {
- return current, false, nil
+ currentConfig := WebhookConfiguration{}
+ requestedConfig := WebhookConfiguration{}
+
+ if err := mapstructure.Decode(current, ¤tConfig); err != nil {
+ return nil, false, err
+ }
+
+ if err := mapstructure.Decode(requested, &requestedConfig); err != nil {
+ return nil, false, err
+ }
+
+ merged := WebhookConfiguration{
+ Events: append([]string{}, currentConfig.Events...),
+ }
+
+ for _, event := range requestedConfig.Events {
+ if !slices.Contains(merged.Events, event) {
+ merged.Events = append(merged.Events, event)
+ }
+ }
+
+ changed := len(merged.Events) != len(currentConfig.Events)
+ return merged, changed, nil
}
func (h *RootlyWebhookHandler) Setup(ctx core.WebhookHandlerContext) (any, error) {
@@ -58,14 +84,17 @@ func (h *RootlyWebhookHandler) Setup(ctx core.WebhookHandlerContext) (any, error
return nil, fmt.Errorf("error decoding webhook configuration: %v", err)
}
- endpoint, err := client.CreateWebhookEndpoint(ctx.Webhook.GetURL(), config.Events)
+ webhookURL := ctx.Webhook.GetURL()
+ endpoint, err := h.findOrCreateWebhookEndpoint(client, ctx.Webhook.GetID(), webhookURL, config.Events)
if err != nil {
- return nil, fmt.Errorf("error creating webhook endpoint: %v", err)
+ return nil, err
}
- err = ctx.Webhook.SetSecret([]byte(endpoint.Secret))
- if err != nil {
- return nil, fmt.Errorf("error updating webhook secret: %v", err)
+ if endpoint.Secret != "" {
+ err = ctx.Webhook.SetSecret([]byte(endpoint.Secret))
+ if err != nil {
+ return nil, fmt.Errorf("error updating webhook secret: %v", err)
+ }
}
return WebhookMetadata{
@@ -80,6 +109,10 @@ func (h *RootlyWebhookHandler) Cleanup(ctx core.WebhookHandlerContext) error {
return fmt.Errorf("error decoding webhook metadata: %v", err)
}
+ if metadata.EndpointID == "" {
+ return nil
+ }
+
client, err := NewClient(ctx.HTTP, ctx.Integration)
if err != nil {
return err
@@ -92,3 +125,101 @@ func (h *RootlyWebhookHandler) Cleanup(ctx core.WebhookHandlerContext) error {
return nil
}
+
+// locates a matching Rootly endpoint for this webhook and updates its event types if needed.
+func (h *RootlyWebhookHandler) findOrCreateWebhookEndpoint(client *Client, webhookID, webhookURL string, requestedEvents []string) (*WebhookEndpoint, error) {
+ webhookName := rootlyWebhookName(webhookID)
+ endpoints, err := client.ListWebhookEndpoints()
+ if err == nil {
+ if endpoint := selectWebhookEndpoint(endpoints, webhookName, webhookURL); endpoint != nil {
+ mergedEvents := mergeEventTypes(endpoint.Events, requestedEvents)
+ if slices.Equal(normalizeEventTypes(endpoint.Events), normalizeEventTypes(mergedEvents)) {
+ return &WebhookEndpoint{
+ ID: endpoint.ID,
+ Name: endpoint.Name,
+ URL: endpoint.URL,
+ Secret: "",
+ Events: endpoint.Events,
+ }, nil
+ }
+
+ updated, updateErr := client.UpdateWebhookEndpoint(endpoint.ID, webhookName, endpoint.URL, mergedEvents, true)
+ if updateErr != nil {
+ return nil, fmt.Errorf("error updating webhook endpoint: %v", updateErr)
+ }
+ return updated, nil
+ }
+ }
+
+ endpoint, err := client.CreateWebhookEndpoint(webhookName, webhookURL, requestedEvents)
+ if err != nil {
+ return nil, fmt.Errorf("error creating webhook endpoint: %v", err)
+ }
+
+ return endpoint, nil
+}
+
+// prefers the deterministic name and falls back to matching webhook URLs.
+func selectWebhookEndpoint(endpoints []WebhookEndpoint, webhookName, webhookURL string) *WebhookEndpoint {
+ for _, endpoint := range endpoints {
+ if endpoint.Name == webhookName {
+ return &endpoint
+ }
+ }
+
+ var urlMatches []WebhookEndpoint
+ for _, endpoint := range endpoints {
+ if endpoint.URL == webhookURL {
+ urlMatches = append(urlMatches, endpoint)
+ }
+ }
+
+ if len(urlMatches) == 0 {
+ return nil
+ }
+
+ for _, endpoint := range urlMatches {
+ if endpoint.Name == rootlyLegacyWebhookName {
+ return &endpoint
+ }
+ }
+
+ return &urlMatches[0]
+}
+
+// unions event types and normalizes ordering for stable comparisons.
+func mergeEventTypes(current, requested []string) []string {
+ merged := append([]string{}, current...)
+ for _, event := range requested {
+ if !slices.Contains(merged, event) {
+ merged = append(merged, event)
+ }
+ }
+
+ return normalizeEventTypes(merged)
+}
+
+// deduplicates and sorts event types for stable comparisons/updates.
+func normalizeEventTypes(events []string) []string {
+ normalized := make([]string, 0, len(events))
+ for _, event := range events {
+ event = strings.TrimSpace(event)
+ if event == "" {
+ continue
+ }
+ if !slices.Contains(normalized, event) {
+ normalized = append(normalized, event)
+ }
+ }
+
+ slices.Sort(normalized)
+ return normalized
+}
+
+// creates a deterministic Rootly webhook name,
+func rootlyWebhookName(webhookID string) string {
+ hash := sha256.New()
+ hash.Write([]byte(webhookID))
+ suffix := fmt.Sprintf("%x", hash.Sum(nil))
+ return fmt.Sprintf("SuperPlane-%s", suffix[:12])
+}
diff --git a/pkg/integrations/rootly/webhook_handler_test.go b/pkg/integrations/rootly/webhook_handler_test.go
index f7eb999fe5..6b4d69e09f 100644
--- a/pkg/integrations/rootly/webhook_handler_test.go
+++ b/pkg/integrations/rootly/webhook_handler_test.go
@@ -3,6 +3,7 @@ package rootly
import (
"testing"
+ "github.com/mitchellh/mapstructure"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -106,3 +107,72 @@ func Test__RootlyWebhookHandler__CompareConfig(t *testing.T) {
})
}
}
+
+func Test__RootlyWebhookHandler__Merge(t *testing.T) {
+ handler := &RootlyWebhookHandler{}
+
+ current := WebhookConfiguration{
+ Events: []string{"incident.created", "incident.updated"},
+ }
+
+ requested := WebhookConfiguration{
+ Events: []string{"incident_event.created", "incident.updated"},
+ }
+
+ merged, changed, err := handler.Merge(current, requested)
+ require.NoError(t, err)
+
+ result := WebhookConfiguration{}
+ require.NoError(t, mapstructure.Decode(merged, &result))
+
+ assert.ElementsMatch(t, []string{"incident.created", "incident.updated", "incident_event.created"}, result.Events)
+ assert.True(t, changed)
+}
+
+func Test__SelectWebhookEndpoint(t *testing.T) {
+ webhookID := "whk-123"
+ targetURL := "https://hooks.superplane.dev/rootly"
+ deterministicName := rootlyWebhookName(webhookID)
+
+ t.Run("prefers deterministic name match", func(t *testing.T) {
+ endpoints := []WebhookEndpoint{
+ {ID: "1", Name: "SuperPlane", URL: targetURL},
+ {ID: "2", Name: deterministicName, URL: "https://other.example.com"},
+ }
+
+ selected := selectWebhookEndpoint(endpoints, deterministicName, targetURL)
+ require.NotNil(t, selected)
+ assert.Equal(t, "2", selected.ID)
+ })
+
+ t.Run("falls back to legacy name on URL match", func(t *testing.T) {
+ endpoints := []WebhookEndpoint{
+ {ID: "legacy", Name: rootlyLegacyWebhookName, URL: targetURL},
+ {ID: "other", Name: "Other", URL: targetURL},
+ }
+
+ selected := selectWebhookEndpoint(endpoints, deterministicName, targetURL)
+ require.NotNil(t, selected)
+ assert.Equal(t, "legacy", selected.ID)
+ })
+
+ t.Run("falls back to first URL match when no name matches", func(t *testing.T) {
+ endpoints := []WebhookEndpoint{
+ {ID: "a", Name: "Alpha", URL: targetURL},
+ {ID: "b", Name: "Beta", URL: targetURL},
+ }
+
+ selected := selectWebhookEndpoint(endpoints, deterministicName, targetURL)
+ require.NotNil(t, selected)
+ assert.Equal(t, "a", selected.ID)
+ })
+
+ t.Run("returns nil when no URL or name matches", func(t *testing.T) {
+ endpoints := []WebhookEndpoint{
+ {ID: "a", Name: "Alpha", URL: "https://example.com"},
+ }
+
+ selected := selectWebhookEndpoint(endpoints, deterministicName, targetURL)
+ assert.Nil(t, selected)
+ })
+}
diff --git a/web_src/src/pages/workflowv2/mappers/rootly/index.ts b/web_src/src/pages/workflowv2/mappers/rootly/index.ts
index 27e8a8e439..4505f189ae 100644
--- a/web_src/src/pages/workflowv2/mappers/rootly/index.ts
+++ b/web_src/src/pages/workflowv2/mappers/rootly/index.ts
@@ -1,5 +1,6 @@
import { ComponentBaseMapper, EventStateRegistry, TriggerRenderer } from "../types";
import { onIncidentTriggerRenderer } from "./on_incident";
+import { onEventTriggerRenderer } from "./on_incident_timeline_event";
import { createIncidentMapper } from "./create_incident";
import { createEventMapper } from "./create_event";
import { updateIncidentMapper } from "./update_incident";
@@ -15,6 +16,7 @@ export const componentMappers: Record = {
export const triggerRenderers: Record = {
onIncident: onIncidentTriggerRenderer,
+ onIncidentTimelineEvent: onEventTriggerRenderer,
};
export const eventStateRegistry: Record = {
diff --git a/web_src/src/pages/workflowv2/mappers/rootly/on_incident_timeline_event.ts b/web_src/src/pages/workflowv2/mappers/rootly/on_incident_timeline_event.ts
new file mode 100644
index 0000000000..ab0f158b24
--- /dev/null
+++ b/web_src/src/pages/workflowv2/mappers/rootly/on_incident_timeline_event.ts
@@ -0,0 +1,206 @@
+import { getBackgroundColorClass } from "@/utils/colors";
+import { formatTimeAgo } from "@/utils/date";
+import { TriggerEventContext, TriggerRenderer, TriggerRendererContext } from "../types";
+import { TriggerProps } from "@/ui/trigger";
+import rootlyIcon from "@/assets/icons/integrations/rootly.svg";
+import { IncidentEvent } from "./types";
+
+interface IncidentSummary {
+ id?: string;
+ title?: string;
+ status?: string;
+ severity?: string;
+ services?: string[];
+ teams?: string[];
+}
+
+interface OnEventEventData extends IncidentEvent {
+ incident?: IncidentSummary;
+ event_type?: string;
+}
+
+/**
+ * Renderer for the "rootly.onIncidentTimelineEvent" trigger type
+ */
+export const onEventTriggerRenderer: TriggerRenderer = {
+ getTitleAndSubtitle: (context: TriggerEventContext): { title: string; subtitle: string } => {
+ const eventData = context.event?.data as OnEventEventData;
+ const incident = eventData?.incident;
+ const title = eventData?.event || incident?.title || "Incident event";
+ const contentParts = [incident?.title, incident?.severity, incident?.status].filter(Boolean).join(" · ");
+ const subtitle = buildSubtitle(contentParts, context.event?.createdAt);
+
+ return {
+ title,
+ subtitle,
+ };
+ },
+
+ getRootEventValues: (context: TriggerEventContext): Record => {
+ const eventData = context.event?.data as OnEventEventData;
+ return getDetailsForIncidentEventPayload(eventData);
+ },
+
+ getTriggerProps: (context: TriggerRendererContext) => {
+ const { node, definition, lastEvent } = context;
+ const configuration = node.configuration as {
+ incidentStatus?: string[];
+ severity?: string[];
+ service?: string[];
+ team?: string[];
+ eventSource?: string[];
+ visibility?: string;
+ };
+ const metadataItems = [];
+
+ if (configuration?.incidentStatus?.length) {
+ metadataItems.push({
+ icon: "funnel",
+ label: `Status: ${configuration.incidentStatus.join(", ")}`,
+ });
+ }
+
+ if (configuration?.severity?.length) {
+ metadataItems.push({
+ icon: "alert-circle",
+ label: `Severity: ${configuration.severity.join(", ")}`,
+ });
+ }
+
+ if (configuration?.service?.length) {
+ metadataItems.push({
+ icon: "layers",
+ label: `Service: ${configuration.service.join(", ")}`,
+ });
+ }
+
+ if (configuration?.team?.length) {
+ metadataItems.push({
+ icon: "users",
+ label: `Team: ${configuration.team.join(", ")}`,
+ });
+ }
+
+ if (configuration?.eventSource?.length) {
+ metadataItems.push({
+ icon: "activity",
+ label: `Source: ${configuration.eventSource.join(", ")}`,
+ });
+ }
+
+ if (configuration?.visibility) {
+ metadataItems.push({
+ icon: "eye",
+ label: `Visibility: ${configuration.visibility}`,
+ });
+ }
+
+ const props: TriggerProps = {
+ title: node.name!,
+ iconSrc: rootlyIcon,
+ collapsedBackground: getBackgroundColorClass(definition.color),
+ metadata: metadataItems,
+ };
+
+ if (lastEvent) {
+ const eventData = lastEvent.data as OnEventEventData;
+ const incident = eventData?.incident;
+ const title = eventData?.event || incident?.title || "Incident event";
+ const contentParts = [eventData?.kind, incident?.severity, incident?.status].filter(Boolean).join(" · ");
+ const subtitle = buildSubtitle(contentParts, lastEvent.createdAt);
+
+ props.lastEventData = {
+ title,
+ subtitle,
+ receivedAt: new Date(lastEvent.createdAt),
+ state: "triggered",
+ eventId: lastEvent.id,
+ };
+ }
+
+ return props;
+ },
+};
+
+function buildSubtitle(content: string, createdAt?: string): string {
+ const timeAgo = createdAt ? formatTimeAgo(new Date(createdAt)) : "";
+ if (content && timeAgo) {
+ return `${content} · ${timeAgo}`;
+ }
+
+ return content || timeAgo;
+}
+
+function getDetailsForIncidentEventPayload(eventData?: OnEventEventData): Record {
+ const details: Record = {};
+
+ if (!eventData) {
+ return details;
+ }
+
+ if (eventData.created_at) {
+ details["Created At"] = new Date(eventData.created_at).toLocaleString();
+ }
+
+ if (eventData.event) {
+ details.Event = eventData.event;
+ }
+
+ if (eventData.id) {
+ details["Event ID"] = eventData.id;
+ }
+
+ if (eventData.kind) {
+ details.Kind = eventData.kind;
+ }
+
+ if (eventData.source) {
+ details.Source = eventData.source;
+ }
+
+ if (eventData.visibility) {
+ details.Visibility = eventData.visibility;
+ }
+
+ if (eventData.event_type) {
+ details["Event Type"] = eventData.event_type;
+ }
+
+ if (eventData.occurred_at) {
+ details["Occurred At"] = new Date(eventData.occurred_at).toLocaleString();
+ }
+
+ if (eventData.incident) {
+ Object.assign(details, getDetailsForIncidentSummary(eventData.incident));
+ }
+
+ return details;
+}
+
+function getDetailsForIncidentSummary(incident: IncidentSummary): Record {
+ const details: Record = {};
+
+ details.ID = incident.id || "-";
+
+ if (incident.title) {
+ details.Title = incident.title;
+ }
+
+ if (incident.status) {
+ details.Status = incident.status;
+ }
+
+ if (incident.severity) {
+ details.Severity = incident.severity;
+ }
+
+ if (incident.services?.length) {
+ details.Services = incident.services.join(", ");
+ }
+
+ if (incident.teams?.length) {
+ details.Teams = incident.teams.join(", ");
+ }
+
+ return details;
+}
diff --git a/web_src/src/pages/workflowv2/mappers/rootly/types.ts b/web_src/src/pages/workflowv2/mappers/rootly/types.ts
index 7ceaa56aca..6bceff418c 100644
--- a/web_src/src/pages/workflowv2/mappers/rootly/types.ts
+++ b/web_src/src/pages/workflowv2/mappers/rootly/types.ts
@@ -31,7 +31,14 @@ export interface Incident {
export interface IncidentEvent {
id?: string;
event?: string;
+ event_raw?: string;
+ kind?: string;
+ source?: string;
visibility?: string;
occurred_at?: string;
created_at?: string;
+ updated_at?: string;
+ incident_id?: string;
+ event_id?: string;
+ issued_at?: string;
}