diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9dcc2e2a..e37e60e7 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -21,13 +21,18 @@ jobs:
       - uses: actions/checkout@v4
       - {uses: gacts/github-slug@v1, id: slug}
       - id: values
-        run: echo "binary-name=webhook-tester-${{ matrix.os }}-${{ matrix.arch }}`[ ${{ matrix.os }} = 'windows' ] && echo '.exe'`" >> $GITHUB_OUTPUT
+        run: |
+          majorMinorPatch="${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}"
+          echo "version=${majorMinorPatch}" >> $GITHUB_OUTPUT
+          echo "binary-name=webhook-tester-${{ matrix.os }}-${{ matrix.arch }}`[ ${{ matrix.os }} = 'windows' ] && echo '.exe'`" >> $GITHUB_OUTPUT
+          echo "deb-name=webhook-tester_${majorMinorPatch}-1_${{ matrix.arch }}" >> $GITHUB_OUTPUT
       # build the frontend
       - uses: actions/setup-node@v4
-        with: {node-version: 22, cache: 'npm', cache-dependency-path: ./web/package-lock.json}
-      - run: npm --prefix "$NPM_PREFIX" install --no-audit
-      - run: npm --prefix "$NPM_PREFIX" run generate
-      - run: npm --prefix "$NPM_PREFIX" run build
+        with: {node-version-file: ./web/package.json, cache: 'npm', cache-dependency-path: ./web/package-lock.json}
+      - run: |
+          npm --prefix "$NPM_PREFIX" install --no-audit
+          npm --prefix "$NPM_PREFIX" run generate
+          npm --prefix "$NPM_PREFIX" run build
       # build the backend
       - {uses: actions/setup-go@v5, with: {go-version-file: go.mod}}
       - run: go install "github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.4.1"
@@ -45,6 +50,24 @@ jobs:
           file: ${{ steps.values.outputs.binary-name }}
           asset_name: ${{ steps.values.outputs.binary-name }}
           tag: ${{ github.ref }}
+      # build a Debian package
+      - if: matrix.os == 'linux' && matrix.arch == 'amd64'
+        env:
+          PKG_NAME: ${{ steps.values.outputs.deb-name }}
+        run: |
+          mkdir -p ${{ env.PKG_NAME }}/usr/local/bin ${{ env.PKG_NAME }}/DEBIAN
+          cp ${{ steps.values.outputs.binary-name }} ${{ env.PKG_NAME }}/usr/local/bin/webhook-tester
+          echo -e "Package: webhook-tester\nVersion: ${{ steps.values.outputs.version }}" > ${{ env.PKG_NAME }}/DEBIAN/control
+          echo -e "Architecture: ${{ matrix.arch }}\nMaintainer: ${{ github.actor }}" >> ${{ env.PKG_NAME }}/DEBIAN/control
+          echo -e "Description: Powerful tool for testing WebHooks and more" >> ${{ env.PKG_NAME }}/DEBIAN/control
+          dpkg-deb --build --root-owner-group ${{ env.PKG_NAME }}
+      - if: matrix.os == 'linux' && matrix.arch == 'amd64'
+        uses: svenstaro/upload-release-action@v2
+        with:
+          repo_token: ${{ secrets.GITHUB_TOKEN }}
+          file: ${{ steps.values.outputs.deb-name }}.deb
+          asset_name: ${{ steps.values.outputs.binary-name }}.deb
+          tag: ${{ github.ref }}
 
   build-docker-image:
     name: Build the docker image
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index c7be9698..0e646b8a 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -42,7 +42,7 @@ jobs:
     steps:
       - uses: actions/checkout@v4
       - uses: actions/setup-node@v4
-        with: {node-version: 22, cache: 'npm', cache-dependency-path: ./web/package-lock.json}
+        with: {node-version-file: ./web/package.json, cache: 'npm', cache-dependency-path: ./web/package-lock.json}
       - run: npm --prefix "$NPM_PREFIX" install -dd --no-audit
       - run: npm --prefix "$NPM_PREFIX" run generate
       - run: npm --prefix "$NPM_PREFIX" run lint
@@ -63,13 +63,18 @@ jobs:
       - uses: actions/checkout@v4
       - {uses: gacts/github-slug@v1, id: slug}
       - id: values
-        run: echo "binary-name=webhook-tester-${{ matrix.os }}-${{ matrix.arch }}`[ ${{ matrix.os }} = 'windows' ] && echo '.exe'`" >> $GITHUB_OUTPUT
+        run: |
+          majorMinorPatch="${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}"
+          echo "version=${majorMinorPatch}" >> $GITHUB_OUTPUT
+          echo "binary-name=webhook-tester-${{ matrix.os }}-${{ matrix.arch }}`[ ${{ matrix.os }} = 'windows' ] && echo '.exe'`" >> $GITHUB_OUTPUT
+          echo "deb-name=webhook-tester_${majorMinorPatch}-1_${{ matrix.arch }}" >> $GITHUB_OUTPUT
       # build the frontend
       - uses: actions/setup-node@v4
-        with: {node-version: 22, cache: 'npm', cache-dependency-path: ./web/package-lock.json}
-      - run: npm --prefix "$NPM_PREFIX" install --no-audit
-      - run: npm --prefix "$NPM_PREFIX" run generate
-      - run: npm --prefix "$NPM_PREFIX" run build
+        with: {node-version-file: ./web/package.json, cache: 'npm', cache-dependency-path: ./web/package-lock.json}
+      - run: |
+          npm --prefix "$NPM_PREFIX" install --no-audit
+          npm --prefix "$NPM_PREFIX" run generate
+          npm --prefix "$NPM_PREFIX" run build
       # build the backend
       - {uses: actions/setup-go@v5, with: {go-version-file: go.mod}}
       - run: go install "github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.4.1"
@@ -78,17 +83,63 @@ jobs:
           GOOS: ${{ matrix.os }}
           GOARCH: ${{ matrix.arch }}
           CGO_ENABLED: 0
-          LDFLAGS: -s -w -X gh.tarampamp.am/webhook-tester/v2/internal/version.version=v0.0.0@${{ steps.slug.outputs.commit-hash-short }}
+          LDFLAGS: -s -w -X gh.tarampamp.am/webhook-tester/v2/internal/version.version=v${{ steps.values.outputs.version }}@${{ steps.slug.outputs.commit-hash-short }}
         run: go build -trimpath -ldflags "$LDFLAGS" -o ./${{ steps.values.outputs.binary-name }} ./cmd/webhook-tester/
-      # try to run the binary
-      - if: matrix.os == runner.os && matrix.arch == 'amd64'
+      - if: matrix.os == runner.os && matrix.arch == 'amd64' # try to run the binary
         run: ./${{ steps.values.outputs.binary-name }} -h
+      # upload the binary
       - uses: actions/upload-artifact@v4
         with:
           name: ${{ steps.values.outputs.binary-name }}
           path: ./${{ steps.values.outputs.binary-name }}
           if-no-files-found: error
           retention-days: 7
+      # build a Debian package
+      - if: matrix.os == 'linux' && matrix.arch == 'amd64'
+        env:
+          PKG_NAME: ${{ steps.values.outputs.deb-name }}
+        run: |
+          mkdir -p ${{ env.PKG_NAME }}/usr/local/bin ${{ env.PKG_NAME }}/DEBIAN
+          cp ${{ steps.values.outputs.binary-name }} ${{ env.PKG_NAME }}/usr/local/bin/webhook-tester
+          echo -e "Package: webhook-tester\nVersion: ${{ steps.values.outputs.version }}" > ${{ env.PKG_NAME }}/DEBIAN/control
+          echo -e "Architecture: ${{ matrix.arch }}\nMaintainer: ${{ github.actor }}" >> ${{ env.PKG_NAME }}/DEBIAN/control
+          echo -e "Description: Powerful tool for testing WebHooks and more" >> ${{ env.PKG_NAME }}/DEBIAN/control
+          dpkg-deb --build --root-owner-group ${{ env.PKG_NAME }}
+      - if: matrix.os == 'linux' && matrix.arch == 'amd64'
+        uses: actions/upload-artifact@v4
+        with:
+          name: ${{ steps.values.outputs.binary-name }}.deb
+          path: ./${{ steps.values.outputs.deb-name }}.deb
+          if-no-files-found: error
+          retention-days: 7
+
+  e2e-test:
+    name: End-to-end tests
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        storage-driver: [memory, redis]
+        pubsub-driver: [memory, redis]
+    services:
+      redis:
+        image: docker.io/library/redis:7-alpine
+        ports: ['6379:6379/tcp']
+        options: --health-cmd "redis-cli ping" --health-interval 3s --health-timeout 2s --health-retries 3
+    needs: [build-app]
+    steps:
+      - uses: actions/checkout@v4
+      - {uses: actions/download-artifact@v4, with: {name: webhook-tester-linux-amd64}}
+      - run: | # start the server in the background
+          chmod +x ./webhook-tester-linux-amd64
+          ./webhook-tester-linux-amd64 start --port 8081 \
+            --storage-driver "${{ matrix.storage-driver }}" \
+            --pubsub-driver "${{ matrix.pubsub-driver }}" \
+            --redis-dsn "redis://127.0.0.1:6379/0" &
+      - uses: grafana/setup-k6-action@v1
+      - uses: grafana/run-k6-action@v1
+        env: {BASE_URL: 'http://127.0.0.1:8081'}
+        with: {path: ./tests/k6/run.js}
 
   build-docker-image:
     name: Build the docker image
diff --git a/Makefile b/Makefile
index 7b48125d..60d9836f 100644
--- a/Makefile
+++ b/Makefile
@@ -27,11 +27,12 @@ node-fmt: ## Format frontend code
 lint: ## Run linters
 	docker compose run $(DC_RUN_ARGS) app golangci-lint run
 
+e2e: ## Run end-to-end tests
+	docker compose run $(DC_RUN_ARGS) k6 run ./tests/k6/run.js
+
 up: ## Start the application in watch mode
 	#docker compose build
-	docker compose up --detach --build --remove-orphans
 	docker compose kill app-http --remove-orphans 2>/dev/null || true
-	#docker compose up --detach --wait whoami httpbin
 	docker compose up app-http
 
 down: ## Stop the application
diff --git a/README.md b/README.md
index 6d2cc490..598b42d4 100644
--- a/README.md
+++ b/README.md
@@ -120,7 +120,7 @@ The following flags are supported:
 | Name                          | Description                                                                                                               |       Default value        |    Environment variables     |
 |-------------------------------|---------------------------------------------------------------------------------------------------------------------------|:--------------------------:|:----------------------------:|
 | `--addr="…"`                  | IP (v4 or v6) address to listen on (0.0.0.0 to bind to all interfaces)                                                    |         `0.0.0.0`          | `SERVER_ADDR`, `LISTEN_ADDR` |
-| `--http-port="…"`             | HTTP server port                                                                                                          |           `8080`           |         `HTTP_PORT`          |
+| `--port="…"`                  | HTTP server port                                                                                                          |           `8080`           |         `HTTP_PORT`          |
 | `--read-timeout="…"`          | maximum duration for reading the entire request, including the body (zero = no timeout)                                   |           `1m0s`           |     `HTTP_READ_TIMEOUT`      |
 | `--write-timeout="…"`         | maximum duration before timing out writes of the response (zero = no timeout)                                             |           `1m0s`           |     `HTTP_WRITE_TIMEOUT`     |
 | `--idle-timeout="…"`          | maximum amount of time to wait for the next request (keep-alive, zero = no timeout)                                       |           `1m0s`           |     `HTTP_IDLE_TIMEOUT`      |
@@ -146,9 +146,9 @@ $ app [GLOBAL FLAGS] start healthcheck [COMMAND FLAGS] [ARGUMENTS...]
 
 The following flags are supported:
 
-| Name              | Description      | Default value | Environment variables |
-|-------------------|------------------|:-------------:|:---------------------:|
-| `--http-port="…"` | HTTP server port |    `8080`     |      `HTTP_PORT`      |
+| Name         | Description      | Default value | Environment variables |
+|--------------|------------------|:-------------:|:---------------------:|
+| `--port="…"` | HTTP server port |    `8080`     |      `HTTP_PORT`      |
 
 <!--/GENERATED:CLI_DOCS-->
 
diff --git a/api/openapi.yml b/api/openapi.yml
index 9a39ddcc..9fcf40f8 100644
--- a/api/openapi.yml
+++ b/api/openapi.yml
@@ -1,5 +1,3 @@
-# yaml-language-server: $schema=https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/schemas/v3.0/schema.json
-
 openapi: 3.0.3
 info:
   title: WebHook Tester
@@ -186,7 +184,7 @@ components:
       description: HTTP status code
       type: integer
       example: 301
-      minimum: 100
+      minimum: 200
       maximum: 530
 
     Base64Encoded:
diff --git a/compose.yml b/compose.yml
index 809ccdde..08f47563 100644
--- a/compose.yml
+++ b/compose.yml
@@ -20,12 +20,28 @@ services:
 
   app-http:
     build: *app-build
-    command: go run ./cmd/webhook-tester/ start --use-live-frontend --auto-create-sessions --max-requests 8
+    entrypoint: sh -c 'go build -buildvcs=false -o /var/tmp/app ./cmd/webhook-tester/ && exec $0 "$@"'
+    command: /var/tmp/app start --use-live-frontend --auto-create-sessions --max-requests 8
     volumes: [.:/src:rw]
     ports: ['8080:8080/tcp']
+    healthcheck:
+      test: ['CMD', '/var/tmp/app', 'start', 'healthcheck']
+      start_interval: 1s
+      interval: 10s
+      start_period: 10s
     depends_on: {app-web-dist: {condition: service_healthy}}
     security_opt: [no-new-privileges:true]
 
+  k6:
+    image: ghcr.io/grafana/k6:latest
+    volumes: [.:/src:ro]
+    working_dir: /src
+    environment:
+      BASE_URL: http://app-http:8080
+      K6_NO_USAGE_REPORT: 'true'
+    depends_on: {app-http: {condition: service_healthy}}
+    security_opt: [no-new-privileges:true]
+
 volumes:
   app-modules-cache: {}
   app-tmp-data: {}
diff --git a/internal/cli/start/command.go b/internal/cli/start/command.go
index 0258266e..c7fa2e2f 100644
--- a/internal/cli/start/command.go
+++ b/internal/cli/start/command.go
@@ -92,7 +92,7 @@ func NewCommand(log *zap.Logger, defaultHttpPort uint16) *cli.Command { //nolint
 			},
 		}
 		httpPortFlag = cli.UintFlag{
-			Name:     "http-port",
+			Name:     "port",
 			Category: httpCategory,
 			Usage:    "HTTP server port",
 			Value:    uint64(defaultHttpPort),
diff --git a/internal/cli/start/healthcheck/command.go b/internal/cli/start/healthcheck/command.go
index b2435d4e..f71078a9 100644
--- a/internal/cli/start/healthcheck/command.go
+++ b/internal/cli/start/healthcheck/command.go
@@ -11,7 +11,7 @@ import (
 func NewCommand(defaultHttpPort uint16) *cli.Command {
 	var (
 		httpPortFlag = cli.UintFlag{
-			Name:     "http-port",
+			Name:     "port",
 			Usage:    "HTTP server port",
 			Value:    uint64(defaultHttpPort),
 			Sources:  cli.EnvVars("HTTP_PORT"),
diff --git a/internal/http/middleware/webhook/middleware.go b/internal/http/middleware/webhook/middleware.go
index f6db6d88..a995abbe 100644
--- a/internal/http/middleware/webhook/middleware.go
+++ b/internal/http/middleware/webhook/middleware.go
@@ -127,6 +127,8 @@ func New( //nolint:funlen,gocognit,gocyclo
 				return
 			}
 
+			w.Header().Set("X-Wh-Request-Id", rID)
+
 			// publish the captured request to the pub/sub. important note - we should use the app ctx instead of the req ctx
 			// because the request context can be canceled before the goroutine finishes (and moreover - before the
 			// subscribers will receive the event - in this case the event will be lost)
diff --git a/internal/http/openapi/models_validate.go b/internal/http/openapi/models_validate.go
index 41a7c07b..658d41a0 100644
--- a/internal/http/openapi/models_validate.go
+++ b/internal/http/openapi/models_validate.go
@@ -14,7 +14,7 @@ func (data CreateSessionRequest) Validate() error {
 		minHeaderKeyLen, maxHeaderKeyLen = 1, 40
 		maxHeaderValueLen                = 2048
 		maxResponseBodyLen               = 10240
-		minStatusCode, maxStatusCode     = StatusCode(100), StatusCode(530)
+		minStatusCode, maxStatusCode     = StatusCode(200), StatusCode(530)
 	)
 
 	if data.Delay > maxDelaySeconds {
diff --git a/tests/k6/run.js b/tests/k6/run.js
new file mode 100644
index 00000000..7a65941c
--- /dev/null
+++ b/tests/k6/run.js
@@ -0,0 +1,310 @@
+import http from 'k6/http'
+import {b64decode, b64encode} from 'k6/encoding'
+import {check, group} from './shared/k6.js'
+import {randomString} from './shared/utils.js'
+import {isArraysEqual, isJson, isTimestampMillisInDelta, isUUID} from './shared/checks.js'
+
+/** @link https://grafana.com/docs/k6/latest/using-k6/k6-options/reference/ */
+export const options = {
+  scenarios: {
+    default: {
+      executor: 'per-vu-iterations',
+      vus: 1, // force to use only one VU
+    },
+  },
+}
+
+/** @typedef {{baseUrl: String}} Context */
+/** @return Context */
+export const setup = () => {
+  const baseUrl = __ENV['BASE_URL']
+
+  if (!baseUrl) {
+    throw new Error('BASE_URL is required')
+  }
+
+  return {
+    baseUrl: baseUrl.replace(/\/$/, ''), // remove trailing slash
+  }
+}
+
+/** @param {Context} ctx */
+export default (ctx) => {
+  const {baseUrl} = ctx
+
+  group('spa', () => {
+    testSpaIndex(baseUrl)
+    testSpaNotFound(baseUrl)
+    testSpaRobots(baseUrl)
+  })
+
+  group('api', () => {
+    testApiNotFound(baseUrl)
+    testApiReady(baseUrl)
+    testApiSettings(baseUrl)
+
+    group('session', () => {
+      testApiCreateSessionNegative(baseUrl)
+      testApiSessionGetNegative(baseUrl)
+
+      for (let statusCode = 200; statusCode <= 530; statusCode++) { // all possible status codes
+        const [headerName, headerValue] = ['X-Custom' + randomString(10).toLowerCase(), randomString(10)]
+        const responseBody = JSON.stringify({foo: randomString(10)})
+
+        const sID = testApiCreateSession(baseUrl, statusCode, [{name: headerName, value: headerValue}], responseBody)
+
+        testApiSessionGet(baseUrl, sID, {statusCode, headers: [{name: headerName, value: headerValue}], responseBody})
+
+        group('requests', () => {
+          testApiSessionHasNoRequests(baseUrl, sID) // initially, there are no requests
+          testApiSessionCatchRequest(baseUrl, sID, {
+            wantStatus: statusCode,
+            wantHeaders: [{name: headerName, value: headerValue}],
+            wantBody: responseBody,
+          })
+        })
+
+        testApiSessionDelete(baseUrl, sID)
+      }
+    })
+  })
+}
+
+/** @param {String} baseUrl */
+const testSpaIndex = (baseUrl) => group('index', () =>
+  check(http.get(baseUrl), {
+    'status is 200': (r) => r.status === 200,
+    'content type': (r) => r.headers['Content-Type'].includes('text/html'),
+    'contains HTML': (r) => r.body.includes('<html'),
+  })
+)
+
+/** @param {String} baseUrl */
+const testSpaNotFound = (baseUrl) => group('404', () =>
+  check(http.get(`${baseUrl}/foo${randomString(10)}`), {
+    'status is 200': (r) => r.status === 200,
+    'content type': (r) => r.headers['Content-Type'].includes('text/html'),
+    'contains HTML': (r) => r.body.includes('<html'),
+  })
+)
+
+/** @param {String} baseUrl */
+const testSpaRobots = (baseUrl) => group('robots.txt', () =>
+  check(http.get(`${baseUrl}/robots.txt`), {
+    'status is 200': (r) => r.status === 200,
+    'content type': (r) => r.headers['Content-Type'].includes('text/plain'),
+    'contains useragent': (r) => r.body.includes('User-agent'),
+    'contains disallow': (r) => r.body.includes('Disallow'),
+  })
+)
+
+/** @param {String} baseUrl */
+const testApiNotFound = (baseUrl) => group('404', () =>
+  check(http.get(`${baseUrl}/////api/foo${randomString(10)}`), {
+    'status is 404': (r) => r.status === 404,
+    'is json': (r) => isJson(r),
+    'contains error': (r) => r.body.includes('error'),
+  })
+)
+
+/** @param {String} baseUrl */
+const testApiReady = (baseUrl) => group('ready', () =>
+  check(http.get(`${baseUrl}/ready`), {
+    'status is 200': (r) => r.status === 200,
+    'content type': (r) => r.headers['Content-Type'].includes('text/plain'),
+    'contains ready': (r) => r.body.includes('OK'),
+  })
+)
+
+/** @param {String} baseUrl */
+const testApiSettings = (baseUrl) => group('settings', () =>
+  check(http.get(`${baseUrl}/api/settings`), {
+    'status is 200': (r) => r.status === 200,
+    'is json': (r) => isJson(r),
+    // TODO: add object properties checks
+  })
+)
+
+/**
+ * @param {String} baseUrl
+ * @param {Number} statusCode
+ * @param {Array<{name: String, value: String}>} headers
+ * @param {String} respBody
+ * @return {String} session ID
+ */
+const testApiCreateSession = (baseUrl, statusCode, headers, respBody) => group('create', () => {
+  const resp = http.post(`${baseUrl}/api/session`, JSON.stringify({ // create a new session
+    status_code: statusCode,
+    headers: headers,
+    delay: 0,
+    response_body_base64: b64encode(respBody),
+  }))
+
+  check(resp, {
+    'status is 200': (r) => r.status === 200,
+    'is json': (r) => isJson(r),
+    'uuid is not empty': (r) => isUUID(r.json('uuid')),
+    'created_at is not empty': (r) => isTimestampMillisInDelta(r.json('created_at_unix_milli'), 2_000),
+    'response status code is expected': (r) => r.json('response.status_code') === statusCode,
+    'response headers': (r) => isArraysEqual(r.json('response.headers'), headers),
+    'response delay': (r) => r.json('response.delay') === 0, // TODO: add test with delay
+    'response response body (base64)': (r) => r.json('response.response_body_base64') === b64encode(respBody),
+  })
+
+  return resp.json('uuid')
+})
+
+/** @param {String} baseUrl */
+const testApiCreateSessionNegative = (baseUrl) => group('create', () => {
+  for (const [name, [wantErrSubstr, payload]] of Object.entries({
+    'too small status code': ['wrong status code', {status_code: 99, headers: [], delay: 0, response_body_base64: ''}],
+    'too big status code': ['wrong status code', {status_code: 531, headers: [], delay: 0, response_body_base64: ''}],
+    'invalid header name': ['header key length', {
+      status_code: 200,
+      headers: [{name: '', value: 'bar'}],
+      delay: 0,
+      response_body_base64: ''
+    }],
+    'invalid header value': ['header value length', {
+      status_code: 200,
+      headers: [{name: 'foo', value: 'x'.repeat(2049)}],
+      delay: 0,
+      response_body_base64: ''
+    }],
+    'negative delay': ['delay', {status_code: 200, headers: [], delay: -1, response_body_base64: ''}],
+    'too big delay': ['delay', {status_code: 200, headers: [], delay: 31, response_body_base64: ''}],
+    'invalid base64': ['cannot decode response body', {
+      status_code: 200,
+      headers: [],
+      delay: 0,
+      response_body_base64: 'foobar'
+    }],
+  })) {
+    group(name, () =>
+      check(http.post(`${baseUrl}/api/session`, JSON.stringify(payload)), {
+        'status is 400': (r) => r.status === 400,
+        'is json': (r) => isJson(r),
+        'contains error': (r) => r.body.includes(wantErrSubstr),
+      })
+    )
+  }
+})
+
+/**
+ * @param {String} baseUrl
+ * @param {String} sID
+ * @param {{statusCode: Number, headers: Array<{name: String, value: String}>, responseBody: string}} want
+ */
+const testApiSessionGet = (baseUrl, sID, want) => group('create', () =>
+  check(http.get(`${baseUrl}/api/session/${sID}`), {
+    'status is 200': (r) => r.status === 200,
+    'is json': (r) => isJson(r),
+    'uuid is not empty': (r) => isUUID(r.json('uuid')),
+    'created_at is not empty': (r) => isTimestampMillisInDelta(r.json('created_at_unix_milli'), 2_000),
+    'response status code': (r) => r.json('response.status_code') === want.statusCode,
+    'response headers': (r) => isArraysEqual(r.json('response.headers'), want.headers),
+    'response delay': (r) => r.json('response.delay') === 0,
+    'response response body (base64)': (r) => r.json('response.response_body_base64') === b64encode(want.responseBody),
+  })
+)
+
+/** @param {String} baseUrl */
+const testApiSessionGetNegative = (baseUrl) => group('negative', () => {
+  group('not found', () => {
+    check(http.get(`${baseUrl}/api/session/00000000-0000-0000-0000-000000000000`), {
+      'status is 404': (r) => r.status === 404,
+      'is json': (r) => isJson(r),
+      'contains error': (r) => r.body.includes('session not found'),
+    })
+  })
+
+  group('wrong session ID format', () => {
+    check(http.get(`${baseUrl}/api/session/foobar`), {
+      'status is 400': (r) => r.status === 400,
+      'is json': (r) => isJson(r),
+      'contains error': (r) => r.body.includes('invalid UUID format'),
+    })
+  })
+})
+
+/**
+ * @param {String} baseUrl
+ * @param {String} sID
+ */
+const testApiSessionHasNoRequests = (baseUrl, sID) => group('requests', () =>
+  check(http.get(`${baseUrl}/api/session/${sID}/requests`), {
+    'status is 200': (r) => r.status === 200,
+    'is json': (r) => isJson(r),
+    'is array': (r) => Array.isArray(r.json()),
+    'is empty': (r) => r.json().length === 0,
+  })
+)
+
+/**
+ * @param {String} baseUrl
+ * @param {String} sID
+ * @param {{wantStatus: Number, wantHeaders: Array<{name: String, value: String}>, wantBody: String}} want
+ */
+const testApiSessionCatchRequest = (baseUrl, sID, {wantStatus, wantHeaders, wantBody}) => group('catch', () => {
+  for (const path of ['', '/foo', '////bar////////baz?yes=no']) {
+    for (const payload of ['', 'foobar'.repeat(100)]) {
+      for (const method of ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']) {
+        const [headerName, headerValue] = ['X-Custom' + randomString(10).toLowerCase(), randomString(10)]
+
+        const resp = http.request(method, `${baseUrl}/${sID}${path}`, payload, { // send a request
+          headers: {[headerName]: headerValue},
+          tags: {sid: sID},
+        })
+
+        const responseMustBeEmpty = wantStatus === 204 || wantStatus === 304 // 204 = No Content, 304 = Not Modified
+        const rID = resp.headers['X-Wh-Request-Id']
+
+        check(resp, {
+          'request id (from headers) is not empty': () => rID.length > 0,
+          'response status': (r) => r.status === wantStatus,
+          'response body is expected': (r) => r.body === (responseMustBeEmpty ? null : wantBody),
+          'response headers are expected': (r) => wantHeaders.every(({name, value}) => r.headers[name] === value),
+          'cors headers are expected': (r) => (
+            r.headers['Access-Control-Allow-Origin'] === '*' &&
+            r.headers['Access-Control-Allow-Methods'] === '*' &&
+            r.headers['Access-Control-Allow-Headers'] === '*'
+          ),
+        })
+
+        check(http.get(`${baseUrl}/api/session/${sID}/requests/${rID}`).json(), {
+          'last request uuid': (r) => isUUID(r.uuid),
+          'last request client address': (r) => r.client_address.length > 0,
+          'last request method': (r) => r.method === method,
+          'last request payload': (r) => b64decode(r.request_payload_base64, 'std', 's') === payload,
+          'last request headers': (r) => r.headers.some(({
+                                                           name,
+                                                           value
+                                                         }) => name === headerName && value === headerValue),
+          'last request url': (r) => r.url.endsWith(`/${sID}${path}`),
+        })
+      }
+    }
+  }
+})
+
+/**
+ * @param {String} baseUrl
+ * @param {String} sID
+ */
+const testApiSessionDelete = (baseUrl, sID) => group('delete', () => {
+  // ensure that the session exists first
+  check(http.get(`${baseUrl}/api/session/${sID}`), {'status is 200': (r) => r.status === 200})
+
+  // delete the session
+  check(http.del(`${baseUrl}/api/session/${sID}`), {
+    'status is 200': (r) => r.status === 200,
+    'is json': (r) => isJson(r),
+    'success': (r) => r.json('success') === true,
+  })
+
+  // try to get it back
+  check(http.get(`${baseUrl}/api/session/${sID}`), {'status is 404': (r) => r.status === 404})
+
+  // try to delete it again
+  check(http.del(`${baseUrl}/api/session/${sID}`), {'status is 404': (r) => r.status === 404})
+})
diff --git a/tests/k6/shared/checks.js b/tests/k6/shared/checks.js
new file mode 100644
index 00000000..d68fb286
--- /dev/null
+++ b/tests/k6/shared/checks.js
@@ -0,0 +1,106 @@
+/**
+ * Check if the response is a JSON object and can be parsed.
+ *
+ * @return {boolean}
+ */
+export const isJson = (r) => {
+  if (!r.headers['Content-Type'].includes('application/json')) {
+    return false
+  }
+
+  try {
+    JSON.parse(r.body)
+  } catch (e) {
+    return false
+  }
+
+  return true
+}
+
+/**
+ * Checks if arrays are equal.
+ *
+ * @param {Array<*>} a
+ * @param {Array<*>} b
+ * @return {boolean}
+ */
+export const isArraysEqual = (a, b) => {
+  return a.length === b.length && a.every((v, i) => {
+    if (Array.isArray(v) && Array.isArray(b[i])) {
+      return isArraysEqual(v, b)
+    }
+
+    if (typeof v === 'object' && typeof b[i] === 'object') {
+      return deepEqual(v, b[i])
+    }
+
+    return v === b[i]
+  })
+}
+
+/**
+ * @param {Object} obj1
+ * @param {Object} obj2
+ * @return {boolean}
+ */
+function deepEqual(obj1, obj2) {
+  if (obj1 === obj2) {
+    return true
+  }
+
+  if (typeof obj1 !== 'object' || obj1 === null || typeof obj2 !== 'object' || obj2 === null) {
+    return false
+  }
+
+  const keys1 = Object.keys(obj1)
+  const keys2 = Object.keys(obj2)
+
+  if (keys1.length !== keys2.length) {
+    return false
+  }
+
+  for (const key of keys1) {
+    if (!obj2.hasOwnProperty(key)) {
+      return false
+    }
+
+    if (!deepEqual(obj1[key], obj2[key])) {
+      return false
+    }
+  }
+
+  return true
+}
+
+/**
+ * Check if the string is a UUID and not the nil UUID.
+ *
+ * @param {String} uuid
+ * @return {boolean}
+ */
+export const isUUID = (uuid) => {
+  if (typeof uuid !== 'string') {
+    return false
+  }
+
+  if (uuid.length !== 36) {
+    return false
+  }
+
+  if (!uuid.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)) {
+    return false
+  }
+
+  return uuid !== '00000000-0000-0000-0000-000000000000';
+}
+
+/**
+ * Check if the timestamp is within the delta.
+ *
+ * @param {Number} timestamp
+ * @param {Number} delta
+ * @return {boolean}
+ */
+export const isTimestampMillisInDelta = (timestamp, delta) => {
+  return timestamp > new Date().getTime() - delta
+}
diff --git a/tests/k6/shared/k6.js b/tests/k6/shared/k6.js
new file mode 100644
index 00000000..486d5a8e
--- /dev/null
+++ b/tests/k6/shared/k6.js
@@ -0,0 +1,43 @@
+import {check as k6check, group as k6group} from 'k6'
+import execution from 'k6/execution'
+
+/**
+ * This is an overridden version of the native check function, designed to abort the test when an expectation fails.
+ * In addition, it improves the type checking by using the JSDoc annotation.
+ *
+ * @template VT
+ * @param {VT} val
+ * @param {Record<string, (VT) => Boolean>} set
+ * @returns {boolean}
+ */
+export const check = (val, set) => {
+  try {
+    for (const [key, fn] of Object.entries(set)) {
+      if (!k6check(val, { [key]: fn })) {
+        execution.test.abort(`Failed expectation: ${key}`)
+      }
+    }
+  } catch (e) {
+    execution.test.abort(String(e))
+
+    throw e
+  }
+}
+
+/**
+ * This is an overridden version of the native group function, designed to abort the test when an error occurs.
+ *
+ * @template RT
+ * @param {String} name
+ * @param {() => RT} fn
+ * @returns {RT}
+ */
+export const group = (name, fn) => {
+  try {
+    return k6group(name, fn)
+  } catch (e) {
+    execution.test.abort(String(e))
+
+    throw e
+  }
+}
diff --git a/tests/k6/shared/utils.js b/tests/k6/shared/utils.js
new file mode 100644
index 00000000..54681292
--- /dev/null
+++ b/tests/k6/shared/utils.js
@@ -0,0 +1,38 @@
+/**
+ * Returns a random integer between min (inclusive) and max (inclusive).
+ *
+ * @param {Number} min The minimum value
+ * @param {Number} max The maximum value
+ * @returns {Number} The random integer
+ */
+export const randomIntBetween = (min, max) => Math.floor(Math.random() * (max - min + 1) + min)
+
+/**
+ * Returns a random element from the array.
+ *
+ * @template T
+ * @param {Array<T>} array The array
+ * @returns {T} The random element
+ */
+export const randomArrayElement = (array) => array[randomIntBetween(0, array.length - 1)]
+
+/**
+ * Generates a random string of the specified length.
+ *
+ * @param {Number} length The length of the string
+ * @param {String} [charset] The charset to use
+ * @returns {String} The random string
+ */
+export const randomString = (length, charset) => {
+  let res = ''
+
+  if (!charset) {
+    charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
+  }
+
+  while (length--) {
+    res += charset[(Math.random() * charset.length) | 0]
+  }
+
+  return res
+}
diff --git a/web/package.json b/web/package.json
index 0ae6b9cc..dafb81a3 100644
--- a/web/package.json
+++ b/web/package.json
@@ -52,7 +52,10 @@
     "vitest": "^2.1.3",
     "vitest-fetch-mock": "^0.3.0"
   },
+  "volta": {
+    "node": "22"
+  },
   "engines": {
-    "node": ">=21"
+    "node": ">=22"
   }
 }
diff --git a/web/src/screens/components/header-new-session-modal.tsx b/web/src/screens/components/header-new-session-modal.tsx
index afc6bb45..f2908a6f 100644
--- a/web/src/screens/components/header-new-session-modal.tsx
+++ b/web/src/screens/components/header-new-session-modal.tsx
@@ -4,7 +4,7 @@ import { IconCodeAsterisk, IconHeading, IconHourglassHigh, IconVersions } from '
 import { useStorage, UsedStorageKeys } from '~/shared'
 
 const limits = {
-  statusCode: { min: 100, max: 530 },
+  statusCode: { min: 200, max: 530 },
   responseHeaders: { maxCount: 10, minNameLen: 1, maxNameLen: 40, maxValueLen: 2048 },
   delay: { min: 0, max: 30 },
 }