diff --git a/pkg/e2etests/integrations.go b/pkg/e2etests/integrations.go
new file mode 100644
index 000000000..4a0b18bd6
--- /dev/null
+++ b/pkg/e2etests/integrations.go
@@ -0,0 +1,146 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package e2etests
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "sync"
+ "testing"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/events"
+ "code.vikunja.io/api/pkg/files"
+ "code.vikunja.io/api/pkg/log"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/modules/auth"
+ "code.vikunja.io/api/pkg/modules/keyvalue"
+ "code.vikunja.io/api/pkg/routes"
+ "code.vikunja.io/api/pkg/user"
+ "code.vikunja.io/api/pkg/web/handler"
+
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/labstack/echo/v5"
+ "github.com/stretchr/testify/require"
+)
+
+var registerListenersOnce sync.Once
+
+// Test users matching the fixture data in pkg/db/fixtures/users.yml
+var (
+ testuser1 = user.User{
+ ID: 1,
+ Username: "user1",
+ Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
+ Email: "user1@example.com",
+ Issuer: "local",
+ }
+)
+
+// setupE2ETestEnv initializes the full application environment with real events.
+// Unlike setupTestEnv in pkg/webtests/, this does NOT call events.Fake(),
+// so events are dispatched through the real Watermill router to registered listeners.
+func setupE2ETestEnv(ctx context.Context) (e *echo.Echo, err error) {
+ config.InitDefaultConfig()
+ config.ServicePublicURL.Set("https://localhost")
+ config.WebhooksEnabled.Set(true)
+
+ log.InitLogger()
+
+ files.InitTests()
+ user.InitTests()
+ models.SetupTests() //nolint:contextcheck
+ keyvalue.InitStorage()
+
+ err = db.LoadFixtures()
+ if err != nil {
+ return
+ }
+
+ // Register all listeners (including webhook listener) before starting the router.
+ // This must happen before InitEventsForTesting because the router wires up
+ // all listeners that were registered via events.RegisterListener().
+ // Use sync.Once because RegisterListeners appends to the global registry
+ // and calling it multiple times would stack duplicate handlers.
+ registerListenersOnce.Do(models.RegisterListeners)
+
+ // Start the real watermill event system. InitEventsForTesting initializes
+ // pubsub and starts the router in a background goroutine, returning a
+ // channel that closes once the router is ready.
+ ready, err := events.InitEventsForTesting(ctx)
+ if err != nil {
+ return
+ }
+
+ // user.InitTests() calls events.Fake() which sets isUnderTest=true and
+ // prevents real event dispatch. Undo that now that pubsub is initialized.
+ events.Unfake()
+
+ // Wait for the router to be ready before proceeding.
+ <-ready
+
+ e = routes.NewEcho() //nolint:contextcheck
+ routes.RegisterRoutes(e) //nolint:contextcheck
+ return
+}
+
+// createRequest builds an httptest request and echo context, mirroring webtests.createRequest
+func createRequest(e *echo.Echo, method string, payload string, queryParam url.Values, urlParams map[string]string) (c *echo.Context, rec *httptest.ResponseRecorder) {
+ req := httptest.NewRequest(method, "/", strings.NewReader(payload))
+ req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
+ req.URL.RawQuery = queryParam.Encode()
+ rec = httptest.NewRecorder()
+
+ c = e.NewContext(req, rec)
+ if len(urlParams) > 0 {
+ pathValues := make(echo.PathValues, 0, len(urlParams))
+ for name, value := range urlParams {
+ pathValues = append(pathValues, echo.PathValue{Name: name, Value: value})
+ }
+ c.SetPathValues(pathValues)
+ }
+ return
+}
+
+// addUserTokenToContext creates a JWT for the user and sets it on the echo context
+func addUserTokenToContext(t *testing.T, u *user.User, c *echo.Context) {
+ token, err := auth.NewUserJWTAuthtoken(u, "test-session-id")
+ require.NoError(t, err)
+ tken, err := jwt.Parse(token, func(_ *jwt.Token) (interface{}, error) {
+ return []byte(config.ServiceJWTSecret.GetString()), nil
+ })
+ require.NoError(t, err)
+ c.Set("user", tken)
+}
+
+// testUpdateWithUser performs a POST (update) request as the given user
+func testUpdateWithUser(e *echo.Echo, t *testing.T, u *user.User, urlParams map[string]string, payload string) (rec *httptest.ResponseRecorder, err error) {
+ c, rec := createRequest(e, http.MethodPost, payload, nil, urlParams)
+ addUserTokenToContext(t, u, c)
+
+ hndl := handler.WebHandler{
+ EmptyStruct: func() handler.CObject {
+ return &models.Task{}
+ },
+ }
+ err = hndl.UpdateWeb(c)
+ return
+}
diff --git a/pkg/e2etests/main_test.go b/pkg/e2etests/main_test.go
new file mode 100644
index 000000000..83641694a
--- /dev/null
+++ b/pkg/e2etests/main_test.go
@@ -0,0 +1,32 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package e2etests
+
+import (
+ "flag"
+ "os"
+ "testing"
+)
+
+func TestMain(m *testing.M) {
+ flag.Parse()
+ if testing.Short() {
+ println("-short requested, skipping e2e tests")
+ return
+ }
+ os.Exit(m.Run())
+}
diff --git a/pkg/e2etests/webhook_test.go b/pkg/e2etests/webhook_test.go
new file mode 100644
index 000000000..0fe4755a7
--- /dev/null
+++ b/pkg/e2etests/webhook_test.go
@@ -0,0 +1,97 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package e2etests
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/models"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTaskUpdateWebhookE2E(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ e, err := setupE2ETestEnv(ctx)
+ require.NoError(t, err)
+
+ // Start a test HTTP server to capture webhook payloads.
+ // Use a non-blocking send so retries or duplicate deliveries don't hang.
+ webhookReceived := make(chan []byte, 1)
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ select {
+ case webhookReceived <- body:
+ default:
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer ts.Close()
+
+ // Clean up any leftover webhook rows from previous test runs, then insert
+ // a fresh webhook for project 1 listening to "task.updated".
+ s := db.NewSession()
+ defer s.Close()
+ _, err = s.Where("1=1").Delete(&models.Webhook{})
+ require.NoError(t, err)
+ _, err = s.Insert(&models.Webhook{
+ TargetURL: ts.URL,
+ Events: []string{"task.updated"},
+ ProjectID: 1,
+ CreatedByID: 1,
+ })
+ require.NoError(t, err)
+ require.NoError(t, s.Commit())
+
+ // Update task 1 via the web handler — this triggers the full pipeline:
+ // UpdateWeb → Task.Update() → DispatchOnCommit → s.Commit() →
+ // DispatchPending → Dispatch → watermill → WebhookListener.Handle →
+ // HTTP POST to ts.URL
+ rec, err := testUpdateWithUser(e, t, &testuser1,
+ map[string]string{"projecttask": "1"},
+ `{"title":"E2E webhook test"}`,
+ )
+ require.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"title":"E2E webhook test"`)
+
+ // Wait for the webhook payload to arrive via the real async pipeline
+ select {
+ case body := <-webhookReceived:
+ var payload map[string]interface{}
+ require.NoError(t, json.Unmarshal(body, &payload))
+ assert.Equal(t, "task.updated", payload["event_name"])
+
+ data, ok := payload["data"].(map[string]interface{})
+ require.True(t, ok, "payload.data should be a map")
+ task, ok := data["task"].(map[string]interface{})
+ require.True(t, ok, "payload.data.task should be a map")
+ assert.Equal(t, "E2E webhook test", task["title"])
+
+ case <-time.After(10 * time.Second):
+ t.Fatal("Webhook payload not received within 10s timeout")
+ }
+}