diff --git a/pkg/integrations/sentry/client.go b/pkg/integrations/sentry/client.go
new file mode 100644
index 0000000000..d2ff531b7a
--- /dev/null
+++ b/pkg/integrations/sentry/client.go
@@ -0,0 +1,164 @@
+package sentry
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+type Client struct {
+ Token string
+ BaseURL string
+ http core.HTTPContext
+ OrgSlug string
+}
+
+func NewClient(http core.HTTPContext, ctx core.IntegrationContext) (*Client, error) {
+ authToken, err := ctx.GetConfig("authToken")
+ if err != nil {
+ return nil, fmt.Errorf("error finding auth token: %v", err)
+ }
+
+ orgSlug, err := ctx.GetConfig("orgSlug")
+ if err != nil {
+ return nil, fmt.Errorf("error finding organization slug: %v", err)
+ }
+
+ baseURL, err := ctx.GetConfig("baseUrl")
+ if err != nil || baseURL == nil {
+ // Default to sentry.io
+ baseURL = []byte("https://sentry.io/api/0")
+ } else {
+ // Ensure URL has /api/0 suffix
+ if !bytes.HasSuffix(baseURL, []byte("/api/0")) {
+ baseURL = []byte(fmt.Sprintf("%s/api/0", string(baseURL)))
+ }
+ }
+
+ return &Client{
+ Token: string(authToken),
+ BaseURL: string(baseURL),
+ OrgSlug: string(orgSlug),
+ http: http,
+ }, nil
+}
+
+func (c *Client) execRequest(method, url string, body io.Reader) ([]byte, error) {
+ req, err := http.NewRequest(method, url, body)
+ if err != nil {
+ return nil, fmt.Errorf("error building request: %v", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.Token))
+
+ res, err := c.http.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("error executing request: %v", err)
+ }
+ defer res.Body.Close()
+
+ responseBody, err := io.ReadAll(res.Body)
+ if err != nil {
+ return nil, fmt.Errorf("error reading body: %v", err)
+ }
+
+ if res.StatusCode < 200 || res.StatusCode >= 300 {
+ return nil, fmt.Errorf("request got %d code: %s", res.StatusCode, string(responseBody))
+ }
+
+ return responseBody, nil
+}
+
+// CreateSentryApp creates a new internal Sentry integration (Sentry App)
+func (c *Client) CreateSentryApp(name, webhookURL string, events []SentryAppEvent) (*SentryApp, error) {
+ apiURL := fmt.Sprintf("%s/sentry-apps/", c.BaseURL)
+
+ app := SentryAppCreateRequest{
+ Name: name,
+ IsInternal: true,
+ Organization: c.OrgSlug,
+ Scopes: []string{"org:read", "event:read", "event:write", "event:admin"},
+ WebhookURL: webhookURL,
+ Events: events,
+ }
+
+ body, err := json.Marshal(app)
+ if err != nil {
+ return nil, fmt.Errorf("error marshaling request: %v", err)
+ }
+
+ responseBody, err := c.execRequest(http.MethodPost, apiURL, bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+
+ var sentryApp SentryApp
+ err = json.Unmarshal(responseBody, &sentryApp)
+ if err != nil {
+ return nil, fmt.Errorf("error parsing response: %v", err)
+ }
+
+ return &sentryApp, nil
+}
+
+// DeleteSentryApp deletes a Sentry App by its slug
+func (c *Client) DeleteSentryApp(slug string) error {
+ apiURL := fmt.Sprintf("%s/sentry-apps/%s/", c.BaseURL, slug)
+ _, err := c.execRequest(http.MethodDelete, apiURL, nil)
+ return err
+}
+
+// UpdateIssue updates a Sentry issue (org-scoped per Sentry API)
+func (c *Client) UpdateIssue(issueID string, update IssueUpdateRequest) (any, error) {
+ apiURL := fmt.Sprintf("%s/organizations/%s/issues/%s/", c.BaseURL, c.OrgSlug, issueID)
+
+ body, err := json.Marshal(update)
+ if err != nil {
+ return nil, fmt.Errorf("error marshaling request: %v", err)
+ }
+
+ responseBody, err := c.execRequest(http.MethodPut, apiURL, bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+
+ var response map[string]any
+ err = json.Unmarshal(responseBody, &response)
+ if err != nil {
+ return nil, fmt.Errorf("error parsing response: %v", err)
+ }
+
+ return response, nil
+}
+
+// SentryApp represents a Sentry Internal Integration
+type SentryApp struct {
+ Slug string `json:"slug"`
+ Name string `json:"name"`
+ ClientSecret string `json:"clientSecret"`
+ UUID string `json:"uuid"`
+}
+
+type SentryAppCreateRequest struct {
+ Name string `json:"name"`
+ IsInternal bool `json:"isInternal"`
+ Organization string `json:"organization"`
+ Scopes []string `json:"scopes"`
+ WebhookURL string `json:"webhookUrl"`
+ Events []SentryAppEvent `json:"events"`
+}
+
+type SentryAppEvent struct {
+ Type string `json:"type"`
+}
+
+// IssueUpdateRequest represents the request to update an issue
+type IssueUpdateRequest struct {
+ Status *string `json:"status,omitempty"`
+ AssignedTo *string `json:"assignedTo,omitempty"`
+}
diff --git a/pkg/integrations/sentry/common.go b/pkg/integrations/sentry/common.go
new file mode 100644
index 0000000000..a485432bef
--- /dev/null
+++ b/pkg/integrations/sentry/common.go
@@ -0,0 +1,8 @@
+package sentry
+
+type NodeMetadata struct{}
+
+type SentryAppMetadata struct {
+ Slug string `json:"slug"`
+ ClientSecret string `json:"clientSecret"`
+}
diff --git a/pkg/integrations/sentry/example.go b/pkg/integrations/sentry/example.go
new file mode 100644
index 0000000000..02b79fbd84
--- /dev/null
+++ b/pkg/integrations/sentry/example.go
@@ -0,0 +1,28 @@
+package sentry
+
+import (
+ _ "embed"
+ "sync"
+
+ "github.com/superplanehq/superplane/pkg/utils"
+)
+
+//go:embed example_output_update_issue.json
+var exampleOutputUpdateIssueBytes []byte
+
+var exampleOutputUpdateIssueOnce sync.Once
+var exampleOutputUpdateIssue map[string]any
+
+//go:embed example_data_on_issue.json
+var exampleDataOnIssueBytes []byte
+
+var exampleDataOnIssueOnce sync.Once
+var exampleDataOnIssue map[string]any
+
+func (c *UpdateIssue) ExampleOutput() map[string]any {
+ return utils.UnmarshalEmbeddedJSON(&exampleOutputUpdateIssueOnce, exampleOutputUpdateIssueBytes, &exampleOutputUpdateIssue)
+}
+
+func (t *OnIssue) ExampleData() map[string]any {
+ return utils.UnmarshalEmbeddedJSON(&exampleDataOnIssueOnce, exampleDataOnIssueBytes, &exampleDataOnIssue)
+}
diff --git a/pkg/integrations/sentry/example_data_on_issue.json b/pkg/integrations/sentry/example_data_on_issue.json
new file mode 100644
index 0000000000..4e00702fda
--- /dev/null
+++ b/pkg/integrations/sentry/example_data_on_issue.json
@@ -0,0 +1,24 @@
+{
+ "type": "sentry.onIssue",
+ "data": {
+ "event": "issue.created",
+ "issue": {
+ "id": "1234567890",
+ "shortId": "MY-PROJECT-1",
+ "title": "TypeError: Cannot read property 'x' of undefined",
+ "level": "error",
+ "status": "unresolved",
+ "project": {
+ "id": "1",
+ "slug": "my-project",
+ "name": "My Project"
+ }
+ },
+ "actionUser": {
+ "id": "1",
+ "username": "user@example.com",
+ "email": "user@example.com"
+ }
+ },
+ "timestamp": "2026-02-16T12:00:00Z"
+}
diff --git a/pkg/integrations/sentry/example_output_update_issue.json b/pkg/integrations/sentry/example_output_update_issue.json
new file mode 100644
index 0000000000..7a22b751bc
--- /dev/null
+++ b/pkg/integrations/sentry/example_output_update_issue.json
@@ -0,0 +1,23 @@
+{
+ "type": "sentry.issue",
+ "data": {
+ "issue": {
+ "id": "1234567890",
+ "shortId": "MY-PROJECT-1",
+ "title": "TypeError: Cannot read property 'x' of undefined",
+ "level": "error",
+ "status": "resolved",
+ "project": {
+ "id": "1",
+ "slug": "my-project",
+ "name": "My Project"
+ },
+ "assigned": {
+ "id": "1",
+ "username": "user@example.com",
+ "email": "user@example.com"
+ }
+ }
+ },
+ "timestamp": "2026-02-16T12:00:00Z"
+}
diff --git a/pkg/integrations/sentry/on_issue.go b/pkg/integrations/sentry/on_issue.go
new file mode 100644
index 0000000000..0e28c4a6b5
--- /dev/null
+++ b/pkg/integrations/sentry/on_issue.go
@@ -0,0 +1,209 @@
+package sentry
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "slices"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+ "github.com/superplanehq/superplane/pkg/crypto"
+)
+
+type OnIssue struct{}
+
+type OnIssueConfiguration struct {
+ Events []string `json:"events"`
+}
+
+func (t *OnIssue) Name() string {
+ return "sentry.onIssue"
+}
+
+func (t *OnIssue) Label() string {
+ return "On Issue"
+}
+
+func (t *OnIssue) Description() string {
+ return "Listen to issue events from Sentry"
+}
+
+func (t *OnIssue) Documentation() string {
+ return `The On Issue trigger starts a workflow execution when Sentry issue events occur.
+
+## Use Cases
+
+- **Issue automation**: Automate responses to issue events
+- **Notification workflows**: Send notifications when issues are created or resolved
+- **Integration workflows**: Sync issues with external systems
+- **Assignment handling**: Handle issue assignments automatically
+
+## Configuration
+
+- **Events**: Select which issue events to listen for (created, resolved, assigned, ignored, unresolved)
+
+## Event Data
+
+Each issue event includes:
+- **event**: Event type (issue.created, issue.resolved, issue.assigned, issue.ignored, issue.unresolved)
+- **issue**: Complete issue information including title, status, assignee, project
+- **actionUser**: User who triggered the event (if applicable)
+
+## Webhook Setup
+
+This trigger uses a Sentry Internal Integration to receive webhook events. The integration is managed by SuperPlane and will be cleaned up when the trigger is removed.`
+}
+
+func (t *OnIssue) Icon() string {
+ return "alert-circle"
+}
+
+func (t *OnIssue) Color() string {
+ return "purple"
+}
+
+func (t *OnIssue) Configuration() []configuration.Field {
+ return []configuration.Field{
+ {
+ Name: "events",
+ Label: "Events",
+ Type: configuration.FieldTypeMultiSelect,
+ Required: true,
+ Default: []string{"issue.created"},
+ TypeOptions: &configuration.TypeOptions{
+ MultiSelect: &configuration.MultiSelectTypeOptions{
+ Options: []configuration.FieldOption{
+ {Label: "Created", Value: "issue.created"},
+ {Label: "Resolved", Value: "issue.resolved"},
+ {Label: "Assigned", Value: "issue.assigned"},
+ {Label: "Ignored", Value: "issue.ignored"},
+ {Label: "Unresolved", Value: "issue.unresolved"},
+ },
+ },
+ },
+ },
+ }
+}
+
+func (t *OnIssue) Setup(ctx core.TriggerContext) error {
+ metadata := NodeMetadata{}
+ err := mapstructure.Decode(ctx.Metadata.Get(), &metadata)
+ if err != nil {
+ return fmt.Errorf("failed to decode metadata: %v", err)
+ }
+
+ //
+ // If metadata is already set, skip setup
+ //
+ return ctx.Integration.RequestWebhook(WebhookConfiguration{})
+}
+
+func (t *OnIssue) Actions() []core.Action {
+ return []core.Action{}
+}
+
+func (t *OnIssue) HandleAction(ctx core.TriggerActionContext) (map[string]any, error) {
+ return nil, nil
+}
+
+func (t *OnIssue) HandleWebhook(ctx core.WebhookRequestContext) (int, error) {
+ config := OnIssueConfiguration{}
+ err := mapstructure.Decode(ctx.Configuration, &config)
+ if err != nil {
+ return http.StatusInternalServerError, fmt.Errorf("failed to decode configuration: %w", err)
+ }
+
+ // Verify signature using the client secret from the Sentry App
+ signature := ctx.Headers.Get("Sentry-Hook-Signature")
+ if signature == "" {
+ return http.StatusForbidden, fmt.Errorf("missing signature")
+ }
+
+ // Get the client secret from integration secrets
+ secrets, err := ctx.Integration.GetSecrets()
+ if err != nil {
+ return http.StatusInternalServerError, fmt.Errorf("error getting secrets: %v", err)
+ }
+
+ var clientSecret []byte
+ for _, secret := range secrets {
+ if secret.Name == "sentryClientSecret" {
+ clientSecret = secret.Value
+ break
+ }
+ }
+
+ if clientSecret == nil {
+ return http.StatusForbidden, fmt.Errorf("missing client secret")
+ }
+
+ // Verify signature
+ if err := crypto.VerifySignature(clientSecret, ctx.Body, signature); err != nil {
+ return http.StatusForbidden, fmt.Errorf("invalid signature: %v", err)
+ }
+
+ // Parse webhook payload
+ var webhook SentryWebhook
+ err = json.Unmarshal(ctx.Body, &webhook)
+ if err != nil {
+ return http.StatusBadRequest, fmt.Errorf("error parsing request body: %v", err)
+ }
+
+ //
+ // Filter events by type - webhook may receive events for all configured issue types
+ //
+ if !slices.Contains(config.Events, webhook.Action) {
+ return http.StatusOK, nil
+ }
+
+ err = ctx.Events.Emit(
+ fmt.Sprintf("sentry.%s", webhook.Action),
+ map[string]any{
+ "event": webhook.Action,
+ "issue": webhook.Data.Issue,
+ "actionUser": webhook.Data.ActionUser,
+ },
+ )
+
+ if err != nil {
+ return http.StatusInternalServerError, fmt.Errorf("error emitting event: %v", err)
+ }
+
+ return http.StatusOK, nil
+}
+
+// SentryWebhook represents a Sentry webhook payload
+type SentryWebhook struct {
+ Action string `json:"action"` // e.g., "created", "resolved", "assigned", "ignored", "unresolved"
+ Data SentryWebhookData `json:"data"`
+}
+
+// SentryWebhookData contains the data from the webhook
+type SentryWebhookData struct {
+ ActionUser *map[string]any `json:"actionUser,omitempty"`
+ Issue *IssueData `json:"issue,omitempty"`
+}
+
+// IssueData represents a Sentry issue from the webhook
+type IssueData struct {
+ ID string `json:"id"`
+ ShortID string `json:"shortId"`
+ Title string `json:"title"`
+ Level string `json:"level"`
+ Status string `json:"status"`
+ Assigned map[string]any `json:"assigned,omitempty"`
+ Project *ProjectRef `json:"project,omitempty"`
+}
+
+// ProjectRef represents a Sentry project reference
+type ProjectRef struct {
+ ID string `json:"id"`
+ Slug string `json:"slug"`
+ Name string `json:"name"`
+}
+
+func (t *OnIssue) Cleanup(ctx core.TriggerContext) error {
+ return nil
+}
diff --git a/pkg/integrations/sentry/sentry.go b/pkg/integrations/sentry/sentry.go
new file mode 100644
index 0000000000..acc57223c5
--- /dev/null
+++ b/pkg/integrations/sentry/sentry.go
@@ -0,0 +1,209 @@
+package sentry
+
+import (
+ "fmt"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+ "github.com/superplanehq/superplane/pkg/registry"
+)
+
+func init() {
+ registry.RegisterIntegrationWithWebhookHandler("sentry", &Sentry{}, &SentryWebhookHandler{})
+}
+
+type Sentry struct{}
+
+const (
+ AuthTypeAPIToken = "apiToken"
+)
+
+type Configuration struct {
+ AuthToken string `json:"authToken"`
+ OrgSlug string `json:"orgSlug"`
+ BaseURL string `json:"baseUrl,omitempty"`
+}
+
+type Metadata struct{}
+
+func (s *Sentry) Name() string {
+ return "sentry"
+}
+
+func (s *Sentry) Label() string {
+ return "Sentry"
+}
+
+func (s *Sentry) Icon() string {
+ return "alert-circle"
+}
+
+func (s *Sentry) Description() string {
+ return "Manage and react to issues in Sentry"
+}
+
+func (s *Sentry) Instructions() string {
+ return ""
+}
+
+func (s *Sentry) Configuration() []configuration.Field {
+ return []configuration.Field{
+ {
+ Name: "authToken",
+ Label: "Auth Token",
+ Type: configuration.FieldTypeString,
+ Required: true,
+ Sensitive: true,
+ Description: "Sentry authentication token with org:admin scope",
+ },
+ {
+ Name: "orgSlug",
+ Label: "Organization Slug",
+ Type: configuration.FieldTypeString,
+ Required: true,
+ Description: "Your Sentry organization slug (e.g., my-org)",
+ },
+ {
+ Name: "baseUrl",
+ Label: "Base URL",
+ Type: configuration.FieldTypeString,
+ Required: false,
+ Description: "Custom Sentry base URL for self-hosted Sentry (e.g., https://sentry.example.com). Leave blank for sentry.io",
+ Placeholder: "https://sentry.example.com",
+ },
+ }
+}
+
+func (s *Sentry) Components() []core.Component {
+ return []core.Component{
+ &UpdateIssue{},
+ }
+}
+
+func (s *Sentry) Triggers() []core.Trigger {
+ return []core.Trigger{
+ &OnIssue{},
+ }
+}
+
+func (s *Sentry) Cleanup(ctx core.IntegrationCleanupContext) error {
+ // Delete the auto-created Sentry App if it exists
+ metadata := Metadata{}
+ err := mapstructure.Decode(ctx.Integration.GetMetadata(), &metadata)
+ if err != nil {
+ return fmt.Errorf("failed to decode metadata: %v", err)
+ }
+
+ // Check if we have Sentry App metadata in secrets
+ secrets, err := ctx.Integration.GetSecrets()
+ if err != nil {
+ return nil
+ }
+
+ var appSlug, clientSecret string
+ for _, secret := range secrets {
+ if secret.Name == "sentryAppSlug" {
+ appSlug = string(secret.Value)
+ } else if secret.Name == "sentryClientSecret" {
+ clientSecret = string(secret.Value)
+ }
+ }
+
+ // Only delete if we have both the app slug and client secret
+ if appSlug != "" && clientSecret != "" {
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return fmt.Errorf("error creating client: %v", err)
+ }
+
+ err = client.DeleteSentryApp(appSlug)
+ if err != nil {
+ // Log error but don't fail cleanup - app may have been deleted manually
+ fmt.Printf("Warning: failed to delete Sentry app %s: %v\n", appSlug, err)
+ }
+ }
+
+ return nil
+}
+
+func (s *Sentry) Sync(ctx core.SyncContext) error {
+ configuration := Configuration{}
+ err := mapstructure.Decode(ctx.Configuration, &configuration)
+ if err != nil {
+ return fmt.Errorf("failed to decode config: %v", err)
+ }
+
+ if configuration.AuthToken == "" {
+ return fmt.Errorf("authToken is required")
+ }
+
+ if configuration.OrgSlug == "" {
+ return fmt.Errorf("orgSlug is required")
+ }
+
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return fmt.Errorf("error creating client: %v", err)
+ }
+
+ // Try to auto-create a Sentry App
+ appName := "SuperPlane Integration"
+ webhookURL := ctx.WebhooksBaseURL
+
+ events := []SentryAppEvent{
+ {Type: "issue.created"},
+ {Type: "issue.resolved"},
+ {Type: "issue.assigned"},
+ {Type: "issue.ignored"},
+ {Type: "issue.unresolved"},
+ }
+
+ sentryApp, err := client.CreateSentryApp(appName, webhookURL, events)
+ if err != nil {
+ // Auto-create failed - this could be due to insufficient permissions or self-hosted Sentry
+ // Fall back to browser action for manual setup
+ ctx.Integration.NewBrowserAction(core.BrowserAction{
+ Description: fmt.Sprintf("To set up Sentry webhooks, please create a new Internal Integration manually:\n\n1. Go to Settings > Developer Settings > Internal Integrations in Sentry\n2. Create a new integration named 'SuperPlane'\n3. Set the webhook URL to: %s\n4. Enable issue events: created, resolved, assigned, ignored, unresolved\n5. After creating, provide the Client Secret and App Slug below", webhookURL),
+ URL: "https://sentry.io/settings/organizations/",
+ Method: "GET",
+ FormFields: map[string]string{
+ "sentryClientSecret": "Client Secret",
+ "sentryAppSlug": "App Slug",
+ },
+ })
+ return nil
+ }
+
+ // Auto-create succeeded - store the app details in secrets
+ err = ctx.Integration.SetSecret("sentryClientSecret", []byte(sentryApp.ClientSecret))
+ if err != nil {
+ return fmt.Errorf("error setting client secret: %v", err)
+ }
+
+ err = ctx.Integration.SetSecret("sentryAppSlug", []byte(sentryApp.Slug))
+ if err != nil {
+ return fmt.Errorf("error setting app slug: %v", err)
+ }
+
+ ctx.Integration.SetMetadata(Metadata{})
+ ctx.Integration.Ready()
+ return nil
+}
+
+func (s *Sentry) HandleRequest(ctx core.HTTPRequestContext) {
+ // no-op
+}
+
+func (s *Sentry) ListResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) {
+ // No resource pickers in Sentry trigger/component; return empty list
+ return []core.IntegrationResource{}, nil
+}
+
+func (s *Sentry) Actions() []core.Action {
+ return []core.Action{}
+}
+
+func (s *Sentry) HandleAction(ctx core.IntegrationActionContext) error {
+ return nil
+}
diff --git a/pkg/integrations/sentry/update_issue.go b/pkg/integrations/sentry/update_issue.go
new file mode 100644
index 0000000000..18158c2569
--- /dev/null
+++ b/pkg/integrations/sentry/update_issue.go
@@ -0,0 +1,178 @@
+package sentry
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/google/uuid"
+ "github.com/mitchellh/mapstructure"
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+type UpdateIssue struct{}
+
+type UpdateIssueSpec struct {
+ IssueID string `json:"issueId"`
+ Status *string `json:"status"`
+ AssignedTo *string `json:"assignedTo"`
+}
+
+func (c *UpdateIssue) Name() string {
+ return "sentry.updateIssue"
+}
+
+func (c *UpdateIssue) Label() string {
+ return "Update Issue"
+}
+
+func (c *UpdateIssue) Description() string {
+ return "Update an existing issue in Sentry"
+}
+
+func (c *UpdateIssue) Documentation() string {
+ return `The Update Issue component modifies an existing Sentry issue.
+
+## Use Cases
+
+- **Status updates**: Update issue status (resolved, ignored, unresolved)
+- **Assignment**: Assign issues to users
+
+## Configuration
+
+- **Issue ID**: The ID of the issue to update (e.g., 1234567890)
+- **Status**: Update issue status (resolved, ignored, unresolved)
+- **Assigned To**: Assign to a user ID or email
+
+## Output
+
+Returns the updated issue object with all current information.`
+}
+
+func (c *UpdateIssue) Icon() string {
+ return "edit"
+}
+
+func (c *UpdateIssue) Color() string {
+ return "purple"
+}
+
+func (c *UpdateIssue) OutputChannels(configuration any) []core.OutputChannel {
+ return []core.OutputChannel{core.DefaultOutputChannel}
+}
+
+func (c *UpdateIssue) Configuration() []configuration.Field {
+ return []configuration.Field{
+ {
+ Name: "issueId",
+ Label: "Issue ID",
+ Type: configuration.FieldTypeString,
+ Required: true,
+ Description: "The ID of the issue to update (e.g., 1234567890)",
+ Placeholder: "e.g., 1234567890",
+ },
+ {
+ Name: "status",
+ Label: "Status",
+ Type: configuration.FieldTypeSelect,
+ Required: false,
+ Description: "Update the issue status",
+ TypeOptions: &configuration.TypeOptions{
+ Select: &configuration.SelectTypeOptions{
+ Options: []configuration.FieldOption{
+ {Label: "Resolved", Value: "resolved"},
+ {Label: "Ignored", Value: "ignored"},
+ {Label: "Unresolved", Value: "unresolved"},
+ },
+ },
+ },
+ },
+ {
+ Name: "assignedTo",
+ Label: "Assigned To",
+ Type: configuration.FieldTypeString,
+ Required: false,
+ Description: "Assign to a user ID or email (leave empty to unassign)",
+ Placeholder: "e.g., user@example.com or user_id",
+ },
+ }
+}
+
+func (c *UpdateIssue) Setup(ctx core.SetupContext) error {
+ spec := UpdateIssueSpec{}
+ err := mapstructure.Decode(ctx.Configuration, &spec)
+ if err != nil {
+ return fmt.Errorf("error decoding configuration: %v", err)
+ }
+
+ if spec.IssueID == "" {
+ return errors.New("issueId is required")
+ }
+
+ // Validate that at least one field to update is provided
+ if spec.Status == nil && spec.AssignedTo == nil {
+ return errors.New("at least one field to update must be provided (status or assignedTo)")
+ }
+
+ // Store minimal metadata (no external API call needed for setup)
+ return ctx.Metadata.Set(NodeMetadata{})
+}
+
+func (c *UpdateIssue) Execute(ctx core.ExecutionContext) error {
+ spec := UpdateIssueSpec{}
+ err := mapstructure.Decode(ctx.Configuration, &spec)
+ if err != nil {
+ return fmt.Errorf("error decoding configuration: %v", err)
+ }
+
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return fmt.Errorf("error creating client: %v", err)
+ }
+
+ updateRequest := IssueUpdateRequest{}
+
+ if spec.Status != nil {
+ updateRequest.Status = spec.Status
+ }
+
+ if spec.AssignedTo != nil {
+ updateRequest.AssignedTo = spec.AssignedTo
+ }
+
+ issue, err := client.UpdateIssue(spec.IssueID, updateRequest)
+ if err != nil {
+ return fmt.Errorf("failed to update issue: %v", err)
+ }
+
+ return ctx.ExecutionState.Emit(
+ core.DefaultOutputChannel.Name,
+ "sentry.issue",
+ []any{issue},
+ )
+}
+
+func (c *UpdateIssue) Cancel(ctx core.ExecutionContext) error {
+ return nil
+}
+
+func (c *UpdateIssue) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) {
+ return ctx.DefaultProcessing()
+}
+
+func (c *UpdateIssue) Actions() []core.Action {
+ return []core.Action{}
+}
+
+func (c *UpdateIssue) HandleAction(ctx core.ActionContext) error {
+ return nil
+}
+
+func (c *UpdateIssue) HandleWebhook(ctx core.WebhookRequestContext) (int, error) {
+ return http.StatusOK, nil
+}
+
+func (c *UpdateIssue) Cleanup(ctx core.SetupContext) error {
+ return nil
+}
diff --git a/pkg/integrations/sentry/webhook_handler.go b/pkg/integrations/sentry/webhook_handler.go
new file mode 100644
index 0000000000..90e517e4ba
--- /dev/null
+++ b/pkg/integrations/sentry/webhook_handler.go
@@ -0,0 +1,33 @@
+package sentry
+
+import (
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+type WebhookConfiguration struct{}
+
+type WebhookMetadata struct{}
+
+type SentryWebhookHandler struct{}
+
+func (h *SentryWebhookHandler) CompareConfig(a, b any) (bool, error) {
+ // All Sentry webhooks for this integration use the same app
+ // with the same events enabled, so configs are always compatible
+ return true, nil
+}
+
+func (h *SentryWebhookHandler) Merge(current, requested any) (any, bool, error) {
+ return current, false, nil
+}
+
+func (h *SentryWebhookHandler) Setup(ctx core.WebhookHandlerContext) (any, error) {
+ // Webhook is set up during the Sentry App creation in Sync()
+ // No additional setup needed here
+ return WebhookMetadata{}, nil
+}
+
+func (h *SentryWebhookHandler) Cleanup(ctx core.WebhookHandlerContext) error {
+ // Webhook cleanup happens in the main integration Cleanup()
+ // No additional cleanup needed here
+ return nil
+}
diff --git a/pkg/server/server.go b/pkg/server/server.go
index f909652ba0..c7c4748860 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -53,6 +53,7 @@ import (
_ "github.com/superplanehq/superplane/pkg/integrations/rootly"
_ "github.com/superplanehq/superplane/pkg/integrations/semaphore"
_ "github.com/superplanehq/superplane/pkg/integrations/sendgrid"
+ _ "github.com/superplanehq/superplane/pkg/integrations/sentry"
_ "github.com/superplanehq/superplane/pkg/integrations/slack"
_ "github.com/superplanehq/superplane/pkg/integrations/smtp"
_ "github.com/superplanehq/superplane/pkg/triggers/schedule"
diff --git a/test/support/support.go b/test/support/support.go
index 2bf66c101d..2ea85bd526 100644
--- a/test/support/support.go
+++ b/test/support/support.go
@@ -32,6 +32,7 @@ import (
_ "github.com/superplanehq/superplane/pkg/integrations/circleci"
_ "github.com/superplanehq/superplane/pkg/integrations/github"
_ "github.com/superplanehq/superplane/pkg/integrations/semaphore"
+ _ "github.com/superplanehq/superplane/pkg/integrations/sentry"
_ "github.com/superplanehq/superplane/pkg/triggers/schedule"
_ "github.com/superplanehq/superplane/pkg/triggers/start"
_ "github.com/superplanehq/superplane/pkg/widgets/annotation"
diff --git a/web_src/src/assets/icons/integrations/sentry.svg b/web_src/src/assets/icons/integrations/sentry.svg
new file mode 100644
index 0000000000..6071828b43
--- /dev/null
+++ b/web_src/src/assets/icons/integrations/sentry.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web_src/src/pages/workflowv2/mappers/index.ts b/web_src/src/pages/workflowv2/mappers/index.ts
index 6b682b3bc0..37fb40d9de 100644
--- a/web_src/src/pages/workflowv2/mappers/index.ts
+++ b/web_src/src/pages/workflowv2/mappers/index.ts
@@ -19,6 +19,11 @@ import {
triggerRenderers as semaphoreTriggerRenderers,
eventStateRegistry as semaphoreEventStateRegistry,
} from "./semaphore/index";
+import {
+ componentMappers as sentryComponentMappers,
+ triggerRenderers as sentryTriggerRenderers,
+ eventStateRegistry as sentryEventStateRegistry,
+} from "./sentry/index";
import {
componentMappers as githubComponentMappers,
triggerRenderers as githubTriggerRenderers,
@@ -158,6 +163,7 @@ const componentBaseMappers: Record = {
const appMappers: Record> = {
cloudflare: cloudflareComponentMappers,
semaphore: semaphoreComponentMappers,
+ sentry: sentryComponentMappers,
github: githubComponentMappers,
gitlab: gitlabComponentMappers,
pagerduty: pagerdutyComponentMappers,
@@ -183,6 +189,7 @@ const appMappers: Record> = {
const appTriggerRenderers: Record> = {
cloudflare: cloudflareTriggerRenderers,
semaphore: semaphoreTriggerRenderers,
+ sentry: sentryTriggerRenderers,
github: githubTriggerRenderers,
gitlab: gitlabTriggerRenderers,
pagerduty: pagerdutyTriggerRenderers,
@@ -207,6 +214,7 @@ const appTriggerRenderers: Record> = {
const appEventStateRegistries: Record> = {
cloudflare: cloudflareEventStateRegistry,
semaphore: semaphoreEventStateRegistry,
+ sentry: sentryEventStateRegistry,
github: githubEventStateRegistry,
pagerduty: pagerdutyEventStateRegistry,
dash0: dash0EventStateRegistry,
diff --git a/web_src/src/pages/workflowv2/mappers/sentry/index.ts b/web_src/src/pages/workflowv2/mappers/sentry/index.ts
new file mode 100644
index 0000000000..4dd052f601
--- /dev/null
+++ b/web_src/src/pages/workflowv2/mappers/sentry/index.ts
@@ -0,0 +1,16 @@
+import { ComponentBaseMapper, EventStateRegistry, TriggerRenderer } from "../types";
+import { onIssueTriggerRenderer } from "./on_issue";
+import { updateIssueMapper } from "./update_issue";
+import { buildActionStateRegistry } from "../utils";
+
+export const componentMappers: Record = {
+ updateIssue: updateIssueMapper,
+};
+
+export const triggerRenderers: Record = {
+ onIssue: onIssueTriggerRenderer,
+};
+
+export const eventStateRegistry: Record = {
+ updateIssue: buildActionStateRegistry("updated"),
+};
diff --git a/web_src/src/pages/workflowv2/mappers/sentry/on_issue.ts b/web_src/src/pages/workflowv2/mappers/sentry/on_issue.ts
new file mode 100644
index 0000000000..7d761049db
--- /dev/null
+++ b/web_src/src/pages/workflowv2/mappers/sentry/on_issue.ts
@@ -0,0 +1,88 @@
+import { getBackgroundColorClass } from "@/utils/colors";
+import { formatTimeAgo } from "@/utils/date";
+import { TriggerEventContext, TriggerRenderer, TriggerRendererContext } from "../types";
+import { TriggerProps } from "@/ui/trigger";
+import sentryIcon from "@/assets/icons/integrations/sentry.svg";
+import { ActionUser, Issue, OnIssueEventData } from "./types";
+
+interface OnIssueEventDataExtended extends OnIssueEventData {
+ event?: string;
+ issue?: Issue;
+ actionUser?: ActionUser;
+}
+
+/**
+ * Renderer for "sentry.onIssue" trigger type
+ */
+export const onIssueTriggerRenderer: TriggerRenderer = {
+ getTitleAndSubtitle: (context: TriggerEventContext): { title: string; subtitle: string } => {
+ const eventData = context.event?.data?.data as OnIssueEventDataExtended;
+ const issue = eventData?.issue;
+ const contentParts = [issue?.level, issue?.status].filter(Boolean).join(" · ");
+ const subtitle = buildSubtitle(contentParts, context.event?.createdAt);
+
+ return {
+ title: `${issue?.shortId || ""} - ${issue?.title || ""}`,
+ subtitle,
+ };
+ },
+
+ getRootEventValues: (context: TriggerEventContext): Record => {
+ const eventData = context.event?.data?.data as OnIssueEventDataExtended;
+ const issue = eventData?.issue;
+ return {
+ Issue: issue?.shortId || "",
+ Title: issue?.title || "",
+ Status: issue?.status || "",
+ Level: issue?.level || "",
+ Project: issue?.project?.name || "",
+ ID: issue?.id || "",
+ };
+ },
+
+ getTriggerProps: (context: TriggerRendererContext) => {
+ const { node, definition, lastEvent } = context;
+ const configuration = node.configuration as any;
+ const metadataItems = [];
+
+ if (configuration.events) {
+ metadataItems.push({
+ icon: "funnel",
+ label: `Events: ${configuration.events.join(", ")}`,
+ });
+ }
+
+ const props: TriggerProps = {
+ title: node.name || definition.label || "Unnamed trigger",
+ iconSrc: sentryIcon,
+ collapsedBackground: getBackgroundColorClass(definition.color),
+ metadata: metadataItems,
+ };
+
+ if (lastEvent) {
+ const eventData = lastEvent.data as OnIssueEventDataExtended;
+ const issue = eventData?.issue;
+ const contentParts = [issue?.level, issue?.status].filter(Boolean).join(" · ");
+ const subtitle = buildSubtitle(contentParts, lastEvent.createdAt);
+
+ props.lastEventData = {
+ title: `${issue?.shortId || ""} - ${issue?.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;
+}
diff --git a/web_src/src/pages/workflowv2/mappers/sentry/types.ts b/web_src/src/pages/workflowv2/mappers/sentry/types.ts
new file mode 100644
index 0000000000..a9e6a8afa5
--- /dev/null
+++ b/web_src/src/pages/workflowv2/mappers/sentry/types.ts
@@ -0,0 +1,37 @@
+export interface BaseNodeMetadata {}
+
+export interface Issue {
+ id?: string;
+ shortId?: string;
+ title?: string;
+ level?: string;
+ status?: string;
+ project?: ProjectRef;
+ assigned?: {
+ id?: string;
+ username?: string;
+ email?: string;
+ };
+}
+
+export interface ProjectRef {
+ id?: string;
+ slug?: string;
+ name?: string;
+}
+
+export interface ActionUser {
+ id?: string;
+ username?: string;
+ email?: string;
+}
+
+export interface OnIssueEventData {
+ event?: string;
+ issue?: Issue;
+ actionUser?: ActionUser;
+}
+
+export interface UpdateIssueResponse {
+ issue?: Issue;
+}
diff --git a/web_src/src/pages/workflowv2/mappers/sentry/update_issue.ts b/web_src/src/pages/workflowv2/mappers/sentry/update_issue.ts
new file mode 100644
index 0000000000..e41f7cfdfb
--- /dev/null
+++ b/web_src/src/pages/workflowv2/mappers/sentry/update_issue.ts
@@ -0,0 +1,95 @@
+import { ComponentBaseProps, EventSection } from "@/ui/componentBase";
+import { getBackgroundColorClass } from "@/utils/colors";
+import { getState, getStateMap } from "..";
+import {
+ ComponentBaseContext,
+ ComponentBaseMapper,
+ ExecutionDetailsContext,
+ ExecutionInfo,
+ NodeInfo,
+ SubtitleContext,
+} from "../types";
+import { MetadataItem } from "@/ui/metadataList";
+import sentryIcon from "@/assets/icons/integrations/sentry.svg";
+import { formatTimeAgo } from "@/utils/date";
+
+export const updateIssueMapper: ComponentBaseMapper = {
+ props(context: ComponentBaseContext): ComponentBaseProps {
+ const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null;
+ const componentName = context.componentDefinition.name ?? "sentry";
+
+ return {
+ iconSrc: sentryIcon,
+ collapsedBackground: getBackgroundColorClass(context.componentDefinition.color),
+ collapsed: context.node.isCollapsed,
+ title:
+ context.node.name ||
+ context.componentDefinition.label ||
+ context.componentDefinition.name ||
+ "Unnamed component",
+ eventSections: lastExecution ? baseEventSections(context.nodes, lastExecution, componentName) : undefined,
+ metadata: metadataList(context.node),
+ includeEmptyState: !lastExecution,
+ eventStateMap: getStateMap(componentName),
+ };
+ },
+
+ getExecutionDetails(context: ExecutionDetailsContext): Record {
+ const data = context.execution.outputs?.["data"] as any;
+ const issue = data?.["issue"] as any;
+ const details: Record = {};
+
+ if (issue) {
+ details["Issue"] = issue.shortId || issue.id;
+ details["Title"] = issue.title;
+ details["Status"] = issue.status;
+ details["Level"] = issue.level;
+ if (issue.project) {
+ details["Project"] = issue.project.name;
+ }
+ }
+
+ return details;
+ },
+
+ subtitle(context: SubtitleContext): string {
+ if (!context.execution.createdAt) return "";
+ return formatTimeAgo(new Date(context.execution.createdAt));
+ },
+};
+
+function metadataList(node: NodeInfo): MetadataItem[] {
+ const metadata: MetadataItem[] = [];
+ const configuration = node.configuration as any;
+
+ if (configuration.issueId) {
+ metadata.push({ icon: "alert-circle", label: `Issue: ${configuration.issueId}` });
+ }
+
+ // Show which fields are being updated
+ const updates: string[] = [];
+ if (configuration.status) {
+ updates.push(`Status: ${configuration.status}`);
+ }
+ if (configuration.assignedTo) {
+ updates.push("Assigned");
+ }
+
+ if (updates.length > 0) {
+ metadata.push({ icon: "funnel", label: `Updating: ${updates.join(", ")}` });
+ }
+
+ return metadata;
+}
+
+function baseEventSections(_nodes: NodeInfo[], execution: ExecutionInfo, componentName: string): EventSection[] {
+ return [
+ {
+ receivedAt: new Date(execution.createdAt!),
+ eventTitle: execution.rootEvent?.data?.issue?.title || "Issue Event",
+ eventSubtitle: formatTimeAgo(new Date(execution.createdAt!)),
+ eventState: getState(componentName)(execution),
+ eventId: execution.rootEvent!.id!,
+ },
+ ];
+}
diff --git a/web_src/src/ui/BuildingBlocksSidebar/index.tsx b/web_src/src/ui/BuildingBlocksSidebar/index.tsx
index f7254cd022..0d2bb97d6c 100644
--- a/web_src/src/ui/BuildingBlocksSidebar/index.tsx
+++ b/web_src/src/ui/BuildingBlocksSidebar/index.tsx
@@ -38,6 +38,7 @@ import SemaphoreLogo from "@/assets/semaphore-logo-sign-black.svg";
import sendgridIcon from "@/assets/icons/integrations/sendgrid.svg";
import prometheusIcon from "@/assets/icons/integrations/prometheus.svg";
import renderIcon from "@/assets/icons/integrations/render.svg";
+import sentryIcon from "@/assets/icons/integrations/sentry.svg";
import dockerIcon from "@/assets/icons/integrations/docker.svg";
import hetznerIcon from "@/assets/icons/integrations/hetzner.svg";
@@ -420,6 +421,7 @@ function CategorySection({
sendgrid: sendgridIcon,
prometheus: prometheusIcon,
render: renderIcon,
+ sentry: sentryIcon,
dockerhub: dockerIcon,
aws: {
codeArtifact: awsIcon,
@@ -499,6 +501,7 @@ function CategorySection({
sendgrid: sendgridIcon,
prometheus: prometheusIcon,
render: renderIcon,
+ sentry: sentryIcon,
dockerhub: dockerIcon,
aws: {
codeArtifact: awsCodeArtifactIcon,
diff --git a/web_src/src/ui/componentSidebar/integrationIcons.tsx b/web_src/src/ui/componentSidebar/integrationIcons.tsx
index 23f8305436..63813e4066 100644
--- a/web_src/src/ui/componentSidebar/integrationIcons.tsx
+++ b/web_src/src/ui/componentSidebar/integrationIcons.tsx
@@ -24,6 +24,7 @@ import SemaphoreLogo from "@/assets/semaphore-logo-sign-black.svg";
import sendgridIcon from "@/assets/icons/integrations/sendgrid.svg";
import prometheusIcon from "@/assets/icons/integrations/prometheus.svg";
import renderIcon from "@/assets/icons/integrations/render.svg";
+import sentryIcon from "@/assets/icons/integrations/sentry.svg";
import dockerIcon from "@/assets/icons/integrations/docker.svg";
import hetznerIcon from "@/assets/icons/integrations/hetzner.svg";
@@ -52,6 +53,7 @@ export const INTEGRATION_APP_LOGO_MAP: Record = {
sendgrid: sendgridIcon,
prometheus: prometheusIcon,
render: renderIcon,
+ sentry: sentryIcon,
dockerhub: dockerIcon,
};
@@ -78,6 +80,7 @@ export const APP_LOGO_MAP: Record> = {
sendgrid: sendgridIcon,
prometheus: prometheusIcon,
render: renderIcon,
+ sentry: sentryIcon,
dockerhub: dockerIcon,
aws: {
cloudwatch: awsCloudwatchIcon,