diff --git a/doc/whats_new/v1.3.rst b/doc/whats_new/v1.3.rst
index 9cab0db995c5d..ec1301844b877 100644
--- a/doc/whats_new/v1.3.rst
+++ b/doc/whats_new/v1.3.rst
@@ -487,6 +487,11 @@ Changelog
   categorical encoding based on target mean conditioned on the value of the
   category. :pr:`25334` by `Thomas Fan`_.

+- |Enhancement| A new parameter `sparse_output` was added to
+  :class:`SplineTransformer`, available as of SciPy 1.8. If `sparse_output=True`,
+  :class:`SplineTransformer` returns a sparse CSR matrix.
+  :pr:`24145` by :user:`Christian Lorentzen <lorentzenchr>`.
+
 - |Enhancement| Adds a `feature_name_combiner` parameter to
   :class:`preprocessing.OneHotEncoder`. This specifies a custom callable to create
   feature names to be returned by :meth:`get_feature_names_out`.
diff --git a/sklearn/preprocessing/_polynomial.py b/sklearn/preprocessing/_polynomial.py
index 64ecb9864fae0..f379ee9135706 100644
--- a/sklearn/preprocessing/_polynomial.py
+++ b/sklearn/preprocessing/_polynomial.py
@@ -13,11 +13,11 @@

 from ..base import BaseEstimator, TransformerMixin
 from ..utils import check_array
+from ..utils.fixes import sp_version, parse_version
 from ..utils.validation import check_is_fitted, FLOAT_DTYPES, _check_sample_weight
 from ..utils.validation import _check_feature_names_in
 from ..utils._param_validation import Interval, StrOptions
 from ..utils.stats import _weighted_percentile
-from ..utils.fixes import sp_version, parse_version

 from ._csr_polynomial_expansion import (
     _csr_polynomial_expansion,
@@ -574,8 +574,6 @@ def transform(self, X):
         return XP

-# TODO:
-# - sparse support (either scipy or own cython solution)?
 class SplineTransformer(TransformerMixin, BaseEstimator):
     """Generate univariate B-spline bases for features.

@@ -635,8 +633,14 @@ class SplineTransformer(TransformerMixin, BaseEstimator):
         i.e. a column of ones. It acts as an intercept term in a linear models.

     order : {'C', 'F'}, default='C'
-        Order of output array. 'F' order is faster to compute, but may slow
-        down subsequent estimators.
+        Order of output array in the dense case. `'F'` order is faster to compute, but
+        may slow down subsequent estimators.
+
+    sparse_output : bool, default=False
+        Will return sparse CSR matrix if set True else will return an array. This
+        option is only available with `scipy>=1.8`.
+
+        .. versionadded:: 1.2

     Attributes
     ----------
@@ -699,6 +703,7 @@ class SplineTransformer(TransformerMixin, BaseEstimator):
         ],
         "include_bias": ["boolean"],
         "order": [StrOptions({"C", "F"})],
+        "sparse_output": ["boolean"],
     }

     def __init__(
@@ -710,6 +715,7 @@ def __init__(
         extrapolation="constant",
         include_bias=True,
         order="C",
+        sparse_output=False,
     ):
         self.n_knots = n_knots
         self.degree = degree
@@ -717,6 +723,7 @@ def __init__(
         self.extrapolation = extrapolation
         self.include_bias = include_bias
         self.order = order
+        self.sparse_output = sparse_output

     @staticmethod
     def _get_base_knot_positions(X, n_knots=10, knots="uniform", sample_weight=None):
@@ -843,6 +850,12 @@ def fit(self, X, y=None, sample_weight=None):
             elif not np.all(np.diff(base_knots, axis=0) > 0):
                 raise ValueError("knots must be sorted without duplicates.")

+        if self.sparse_output and sp_version < parse_version("1.8.0"):
+            raise ValueError(
+                "Option sparse_output=True is only available with scipy>=1.8.0, "
+                f"but here scipy=={sp_version} is used."
+            )
+
         # number of knots for base interval
         n_knots = base_knots.shape[0]

@@ -934,7 +947,7 @@ def transform(self, X):

         Returns
         -------
-        XBS : ndarray of shape (n_samples, n_features * n_splines)
+        XBS : {ndarray, sparse matrix} of shape (n_samples, n_features * n_splines)
             The matrix of features, where n_splines is the number of bases
             elements of the B-splines, n_knots + degree - 1.
         """
@@ -946,6 +959,19 @@ def transform(self, X):
         n_splines = self.bsplines_[0].c.shape[1]
         degree = self.degree

+        # TODO: Remove this condition, once scipy 1.10 is the minimum version.
+        #       Only scipy => 1.10 supports design_matrix(.., extrapolate=..).
+        #       The default (implicit in scipy < 1.10) is extrapolate=False.
+        scipy_1_10 = sp_version >= parse_version("1.10.0")
+        # Note: self.bsplines_[0].extrapolate is True for extrapolation in
+        # ["periodic", "continue"]
+        if scipy_1_10:
+            use_sparse = self.sparse_output
+            kwargs_extrapolate = {"extrapolate": self.bsplines_[0].extrapolate}
+        else:
+            use_sparse = self.sparse_output and not self.bsplines_[0].extrapolate
+            kwargs_extrapolate = dict()
+
         # Note that scipy BSpline returns float64 arrays and converts input
         # x=X[:, i] to c-contiguous float64.
         n_out = self.n_features_out_ + n_features * (1 - self.include_bias)
@@ -953,7 +979,10 @@ def transform(self, X):
             dtype = X.dtype
         else:
             dtype = np.float64
-        XBS = np.zeros((n_samples, n_out), dtype=dtype, order=self.order)
+        if use_sparse:
+            output_list = []
+        else:
+            XBS = np.zeros((n_samples, n_out), dtype=dtype, order=self.order)

         for i in range(n_features):
             spl = self.bsplines_[i]
@@ -972,20 +1001,53 @@ def transform(self, X):
                 else:
                     x = X[:, i]

-                XBS[:, (i * n_splines) : ((i + 1) * n_splines)] = spl(x)
-
-            else:
-                xmin = spl.t[degree]
-                xmax = spl.t[-degree - 1]
+                if use_sparse:
+                    XBS_sparse = BSpline.design_matrix(
+                        x, spl.t, spl.k, **kwargs_extrapolate
+                    )
+                    if self.extrapolation == "periodic":
+                        # See the construction of coef in fit. We need to add the last
+                        # degree spline basis function to the first degree ones and
+                        # then drop the last ones.
+                        # Note: See comment about SparseEfficiencyWarning below.
+                        XBS_sparse = XBS_sparse.tolil()
+                        XBS_sparse[:, :degree] += XBS_sparse[:, -degree:]
+                        XBS_sparse = XBS_sparse[:, :-degree]
+                else:
+                    XBS[:, (i * n_splines) : ((i + 1) * n_splines)] = spl(x)
+            else:  # extrapolation in ("constant", "linear")
+                xmin, xmax = spl.t[degree], spl.t[-degree - 1]
+                # spline values at boundaries
+                f_min, f_max = spl(xmin), spl(xmax)
                 mask = (xmin <= X[:, i]) & (X[:, i] <= xmax)
-                XBS[mask, (i * n_splines) : ((i + 1) * n_splines)] = spl(X[mask, i])
+                if use_sparse:
+                    mask_inv = ~mask
+                    x = X[:, i].copy()
+                    # Set some arbitrary values outside boundary that will be reassigned
+                    # later.
+                    x[mask_inv] = spl.t[self.degree]
+                    XBS_sparse = BSpline.design_matrix(x, spl.t, spl.k)
+                    # Note: Without converting to lil_matrix we would get:
+                    # scipy.sparse._base.SparseEfficiencyWarning: Changing the sparsity
+                    # structure of a csr_matrix is expensive. lil_matrix is more
+                    # efficient.
+                    if np.any(mask_inv):
+                        XBS_sparse = XBS_sparse.tolil()
+                        XBS_sparse[mask_inv, :] = 0
+                else:
+                    XBS[mask, (i * n_splines) : ((i + 1) * n_splines)] = spl(X[mask, i])

             # Note for extrapolation:
             # 'continue' is already returned as is by scipy BSplines
             if self.extrapolation == "error":
                 # BSpline with extrapolate=False does not raise an error, but
-                # output np.nan.
-                if np.any(np.isnan(XBS[:, (i * n_splines) : ((i + 1) * n_splines)])):
+                # outputs np.nan.
+                if (use_sparse and np.any(np.isnan(XBS_sparse.data))) or (
+                    not use_sparse
+                    and np.any(
+                        np.isnan(XBS[:, (i * n_splines) : ((i + 1) * n_splines)])
+                    )
+                ):
                     raise ValueError(
                         "X contains values beyond the limits of the knots."
                     )
@@ -995,21 +1057,29 @@ def transform(self, X):
                 # Only the first degree and last degree number of splines
                 # have non-zero values at the boundaries.

-                # spline values at boundaries
-                f_min = spl(xmin)
-                f_max = spl(xmax)
                 mask = X[:, i] < xmin
                 if np.any(mask):
-                    XBS[mask, (i * n_splines) : (i * n_splines + degree)] = f_min[
-                        :degree
-                    ]
+                    if use_sparse:
+                        # Note: See comment about SparseEfficiencyWarning above.
+                        XBS_sparse = XBS_sparse.tolil()
+                        XBS_sparse[mask, :degree] = f_min[:degree]
+
+                    else:
+                        XBS[mask, (i * n_splines) : (i * n_splines + degree)] = f_min[
+                            :degree
+                        ]

                 mask = X[:, i] > xmax
                 if np.any(mask):
-                    XBS[
-                        mask,
-                        ((i + 1) * n_splines - degree) : ((i + 1) * n_splines),
-                    ] = f_max[-degree:]
+                    if use_sparse:
+                        # Note: See comment about SparseEfficiencyWarning above.
+                        XBS_sparse = XBS_sparse.tolil()
+                        XBS_sparse[mask, -degree:] = f_max[-degree:]
+                    else:
+                        XBS[
+                            mask,
+                            ((i + 1) * n_splines - degree) : ((i + 1) * n_splines),
+                        ] = f_max[-degree:]

             elif self.extrapolation == "linear":
                 # Continue the degree first and degree last spline bases
@@ -1018,8 +1088,6 @@ def transform(self, X):
                 # Note that all others have derivative = value = 0 at the
                 # boundaries.

-                # spline values at boundaries
-                f_min, f_max = spl(xmin), spl(xmax)
                 # spline derivatives = slopes at boundaries
                 fp_min, fp_max = spl(xmin, nu=1), spl(xmax, nu=1)
                 # Compute the linear continuation.
@@ -1030,16 +1098,57 @@ def transform(self, X):
                 for j in range(degree):
                     mask = X[:, i] < xmin
                     if np.any(mask):
-                        XBS[mask, i * n_splines + j] = (
-                            f_min[j] + (X[mask, i] - xmin) * fp_min[j]
-                        )
+                        linear_extr = f_min[j] + (X[mask, i] - xmin) * fp_min[j]
+                        if use_sparse:
+                            # Note: See comment about SparseEfficiencyWarning above.
+                            XBS_sparse = XBS_sparse.tolil()
+                            XBS_sparse[mask, j] = linear_extr
+                        else:
+                            XBS[mask, i * n_splines + j] = linear_extr

                     mask = X[:, i] > xmax
                     if np.any(mask):
                         k = n_splines - 1 - j
-                        XBS[mask, i * n_splines + k] = (
-                            f_max[k] + (X[mask, i] - xmax) * fp_max[k]
-                        )
+                        linear_extr = f_max[k] + (X[mask, i] - xmax) * fp_max[k]
+                        if use_sparse:
+                            # Note: See comment about SparseEfficiencyWarning above.
+                            XBS_sparse = XBS_sparse.tolil()
+                            XBS_sparse[mask, k : k + 1] = linear_extr[:, None]
+                        else:
+                            XBS[mask, i * n_splines + k] = linear_extr
+
+            if use_sparse:
+                if not sparse.isspmatrix_csr(XBS_sparse):
+                    XBS_sparse = XBS_sparse.tocsr()
+                output_list.append(XBS_sparse)
+
+        if use_sparse:
+            # TODO: Remove this conditional error when the minimum supported version of
+            # SciPy is 1.9.2
+            # `scipy.sparse.hstack` breaks in scipy<1.9.2
+            # when `n_features_out_ > max_int32`
+            max_int32 = np.iinfo(np.int32).max
+            all_int32 = True
+            for mat in output_list:
+                all_int32 &= mat.indices.dtype == np.int32
+            if (
+                sp_version < parse_version("1.9.2")
+                and self.n_features_out_ > max_int32
+                and all_int32
+            ):
+                raise ValueError(
+                    "In scipy versions `<1.9.2`, the function `scipy.sparse.hstack`"
+                    " produces negative columns when:\n1. The output shape contains"
+                    " `n_cols` too large to be represented by a 32bit signed"
+                    " integer.\n. All sub-matrices to be stacked have indices of"
+                    " dtype `np.int32`.\nTo avoid this error, either use a version"
+                    " of scipy `>=1.9.2` or alter the `SplineTransformer`"
+                    " transformer to produce fewer than 2^31 output features"
+                )
+            XBS = sparse.hstack(output_list)
+        elif self.sparse_output:
+            # TODO: Remove ones scipy 1.10 is the minimum version. See comments above.
+            XBS = sparse.csr_matrix(XBS)

         if self.include_bias:
             return XBS

diff --git a/sklearn/preprocessing/tests/test_polynomial.py b/sklearn/preprocessing/tests/test_polynomial.py
index 727b31b793b1d..1062a3da820e7 100644
--- a/sklearn/preprocessing/tests/test_polynomial.py
+++ b/sklearn/preprocessing/tests/test_polynomial.py
@@ -35,6 +35,22 @@ def is_c_contiguous(a):
     assert np.isfortran(est(order="F").fit_transform(X))

+@pytest.mark.parametrize(
+    "params, err_msg",
+    [
+        ({"knots": [[1]]}, r"Number of knots, knots.shape\[0\], must be >= 2."),
+        ({"knots": [[1, 1], [2, 2]]}, r"knots.shape\[1\] == n_features is violated"),
+        ({"knots": [[1], [0]]}, "knots must be sorted without duplicates."),
+    ],
+)
+def test_spline_transformer_input_validation(params, err_msg):
+    """Test that we raise errors for invalid input in SplineTransformer."""
+    X = [[1], [2]]
+
+    with pytest.raises(ValueError, match=err_msg):
+        SplineTransformer(**params).fit(X)
+
+
 @pytest.mark.parametrize("extrapolation", ["continue", "periodic"])
 def test_spline_transformer_integer_knots(extrapolation):
     """Test that SplineTransformer accepts integer value knot positions."""
@@ -109,8 +125,7 @@ def test_split_transform_feature_names_extrapolation_degree(extrapolation, degre
 def test_spline_transformer_unity_decomposition(degree, n_knots, knots, extrapolation):
     """Test that B-splines are indeed a decomposition of unity.

-    Splines basis functions must sum up to 1 per row, if we stay in between
-    boundaries.
+    Splines basis functions must sum up to 1 per row, if we stay in between boundaries.
     """
     X = np.linspace(0, 1, 100)[:, None]
     # make the boundaries 0 and 1 part of X_train, for sure.
@@ -178,8 +193,7 @@ def test_spline_transformer_linear_regression(bias, intercept):
 def test_spline_transformer_get_base_knot_positions(
     knots, n_knots, sample_weight, expected_knots
 ):
-    # Check the behaviour to find the positions of the knots with and without
-    # `sample_weight`
+    """Check the behaviour to find knot positions with and without sample_weight."""
     X = np.array([[0, 2], [0, 2], [2, 2], [3, 3], [4, 6], [5, 8], [6, 14]])
     base_knots = SplineTransformer._get_base_knot_positions(
         X=X, knots=knots, n_knots=n_knots, sample_weight=sample_weight
@@ -238,9 +252,7 @@ def test_spline_transformer_periodic_spline_backport():

 def test_spline_transformer_periodic_splines_periodicity():
-    """
-    Test if shifted knots result in the same transformation up to permutation.
-    """
+    """Test if shifted knots result in the same transformation up to permutation."""
     X = np.linspace(0, 10, 101)[:, None]

     transformer_1 = SplineTransformer(
@@ -349,9 +361,10 @@ def test_spline_transformer_extrapolation(bias, intercept, degree):
         n_knots=4, degree=degree, include_bias=bias, extrapolation="error"
     )
     splt.fit(X)
-    with pytest.raises(ValueError):
+    msg = "X contains values beyond the limits of the knots"
+    with pytest.raises(ValueError, match=msg):
         splt.transform([[-10]])
-    with pytest.raises(ValueError):
+    with pytest.raises(ValueError, match=msg):
         splt.transform([[5]])

@@ -375,12 +388,94 @@ def test_spline_transformer_kbindiscretizer():
     assert_allclose(splines, kbins, rtol=1e-13)

+@pytest.mark.skipif(
+    sp_version < parse_version("1.8.0"),
+    reason="The option `sparse_output` is available as of scipy 1.8.0",
+)
+@pytest.mark.parametrize("degree", range(1, 3))
+@pytest.mark.parametrize("knots", ["uniform", "quantile"])
+@pytest.mark.parametrize(
+    "extrapolation", ["error", "constant", "linear", "continue", "periodic"]
+)
+@pytest.mark.parametrize("include_bias", [False, True])
+def test_spline_transformer_sparse_output(
+    degree, knots, extrapolation, include_bias, global_random_seed
+):
+    rng = np.random.RandomState(global_random_seed)
+    X = rng.randn(200).reshape(40, 5)
+
+    splt_dense = SplineTransformer(
+        degree=degree,
+        knots=knots,
+        extrapolation=extrapolation,
+        include_bias=include_bias,
+        sparse_output=False,
+    )
+    splt_sparse = SplineTransformer(
+        degree=degree,
+        knots=knots,
+        extrapolation=extrapolation,
+        include_bias=include_bias,
+        sparse_output=True,
+    )
+
+    splt_dense.fit(X)
+    splt_sparse.fit(X)
+
+    assert sparse.isspmatrix_csr(splt_sparse.transform(X))
+    assert_allclose(splt_dense.transform(X), splt_sparse.transform(X).toarray())
+
+    # extrapolation regime
+    X_min = np.amin(X, axis=0)
+    X_max = np.amax(X, axis=0)
+    X_extra = np.r_[
+        np.linspace(X_min - 5, X_min, 10), np.linspace(X_max, X_max + 5, 10)
+    ]
+    if extrapolation == "error":
+        msg = "X contains values beyond the limits of the knots"
+        with pytest.raises(ValueError, match=msg):
+            splt_dense.transform(X_extra)
+        msg = "Out of bounds"
+        with pytest.raises(ValueError, match=msg):
+            splt_sparse.transform(X_extra)
+    else:
+        assert_allclose(
+            splt_dense.transform(X_extra), splt_sparse.transform(X_extra).toarray()
+        )
+
+
+@pytest.mark.skipif(
+    sp_version >= parse_version("1.8.0"),
+    reason="The option `sparse_output` is available as of scipy 1.8.0",
+)
+def test_spline_transformer_sparse_output_raise_error_for_old_scipy():
+    """Test that SplineTransformer with sparse=True raises for scipy<1.8.0."""
+    X = [[1], [2]]
+    with pytest.raises(ValueError, match="scipy>=1.8.0"):
+        SplineTransformer(sparse_output=True).fit(X)
+
+
 @pytest.mark.parametrize("n_knots", [5, 10])
 @pytest.mark.parametrize("include_bias", [True, False])
-@pytest.mark.parametrize("degree", [3, 5])
-def test_spline_transformer_n_features_out(n_knots, include_bias, degree):
+@pytest.mark.parametrize("degree", [3, 4])
+@pytest.mark.parametrize(
+    "extrapolation", ["error", "constant", "linear", "continue", "periodic"]
+)
+@pytest.mark.parametrize("sparse_output", [False, True])
+def test_spline_transformer_n_features_out(
+    n_knots, include_bias, degree, extrapolation, sparse_output
+):
     """Test that transform results in n_features_out_ features."""
-    splt = SplineTransformer(n_knots=n_knots, degree=degree, include_bias=include_bias)
+    if sparse_output and sp_version < parse_version("1.8.0"):
+        pytest.skip("The option `sparse_output` is available as of scipy 1.8.0")
+
+    splt = SplineTransformer(
+        n_knots=n_knots,
+        degree=degree,
+        include_bias=include_bias,
+        extrapolation=extrapolation,
+        sparse_output=sparse_output,
+    )
     X = np.linspace(0, 1, 10)[:, None]
     splt.fit(X)

Add sparse matrix output to SplineTransformer
### Describe the workflow you want to enable

As B-splines naturally have a sparse structure, I'd like to have the option that `SplineTransformer` returns a sparse matrix instead of always an ndarray.
```python
import numpy as np
from sklearn.preprocessing import SplineTransformer

X = np.arange(6).reshape(6, 1)
spline = SplineTransformer(degree=2, n_knots=3, sparse=True)
spline.fit_transform(X)
```

### Describe your proposed solution

With scipy >= 1.8 (yet to be released), we can use `design_matrix` from https://github.com/scipy/scipy/pull/14344.

### Describe alternatives you've considered, if relevant

_No response_

### Additional context

_No response_


diff --git a/docs/channels.rst b/docs/channels.rst
index 7a5cbbf4..0d45ebcc 100644
--- a/docs/channels.rst
+++ b/docs/channels.rst
@@ -6,4 +6,5 @@ Channels
     :name: channels

     Quickstart <channels_quickstart>
+    Configuration <channels_configuration>
     Reference <channels_reference>
diff --git a/docs/channels_configuration.rst b/docs/channels_configuration.rst
new file mode 100644
index 00000000..ddeab0ce
--- /dev/null
+++ b/docs/channels_configuration.rst
@@ -0,0 +1,123 @@
+======================
+Channels Configuration
+======================
+
+This document describes the environment variables and configurations used for establishing different gRPC channel connections to Camunda (Zeebe) instances, either with or without authentication.
+
+Environment Variables
+---------------------
+
+The following environment variables are used to configure channels. The variables are grouped according to their relevance and usage context in each type of channel.
+
+These variables are only considered if a corresponding argument was not passed (Unset) during initialization of a channel.
+
+Common Variables
+----------------
+
+This variables is used across all types of channels:
+
+**ZEEBE_ADDRESS**
+  :Description:
+    The default address of the Zeebe Gateway.
+
+  :Usage:
+    Used in both secure and insecure channel configurations.
+    :func:`pyzeebe.create_insecure_channel`
+    :func:`pyzeebe.create_secure_channel`
+
+  :Default:
+    ``"localhost:26500"``
+
+Common OAuth2 Variables
+-----------------------
+
+These variables are specifically for connecting to generic OAuth2 or Camunda Cloud instances.
+
+**CAMUNDA_CLIENT_ID** / **ZEEBE_CLIENT_ID**
+  :Description:
+    The client ID required for OAuth2 client credential authentication.
+
+  :Usage:
+    Required for OAuth2 and Camunda Cloud channels.
+    :func:`pyzeebe.create_oauth2_client_credentials_channel`
+    :func:`pyzeebe.create_camunda_cloud_channel`
+
+**CAMUNDA_CLIENT_SECRET** / **ZEEBE_CLIENT_SECRET**
+  :Description:
+    The client secret for the OAuth2 client.
+
+  :Usage:
+    Required for OAuth2 and Camunda Cloud channels.
+    :func:`pyzeebe.create_oauth2_client_credentials_channel`
+    :func:`pyzeebe.create_camunda_cloud_channel`
+
+OAuth2 Variables (Self-Managed)
+-------------------------------
+
+These variables are primarily used for OAuth2 authentication in self-managed Camunda 8 instances.
+
+**CAMUNDA_OAUTH_URL** / **ZEEBE_AUTHORIZATION_SERVER_URL**
+  :Description:
+    Specifies the URL of the authorization server issuing access tokens to the client.
+
+  :Usage:
+    Required if channel initialization argument was not specified.
+    :func:`pyzeebe.create_oauth2_client_credentials_channel`
+
+**CAMUNDA_TOKEN_AUDIENCE** / **ZEEBE_TOKEN_AUDIENCE**
+  :Description:
+    Specifies the audience for the OAuth2 token.
+
+  :Usage:
+    Used when creating OAuth2 or Camunda Cloud channels.
+    :func:`pyzeebe.create_oauth2_client_credentials_channel`
+
+  :Default:
+    ``None`` if not provided.
+
+Camunda Cloud Variables (SaaS)
+------------------------------
+
+These variables are specifically for connecting to Camunda Cloud instances.
+
+**CAMUNDA_OAUTH_URL** / **ZEEBE_AUTHORIZATION_SERVER_URL**
+  :Description:
+    Specifies the URL of the authorization server issuing access tokens to the client.
+
+  :Usage:
+    Used in the OAuth2 and Camunda Cloud channel configurations.
+    :func:`pyzeebe.create_camunda_cloud_channel`
+
+  :Default:
+    ``"https://login.cloud.camunda.io/oauth/token"`` if not specified.
+
+**CAMUNDA_CLUSTER_ID**
+  :Description:
+    The unique identifier for the Camunda Cloud cluster to connect to.
+
+  :Usage:
+    Required if channel initialization argument was not specified.
+    :func:`pyzeebe.create_camunda_cloud_channel`
+
+**CAMUNDA_CLUSTER_REGION**
+  :Description:
+    The region where the Camunda Cloud cluster is hosted.
+
+  :Usage:
+    Required for Camunda Cloud channels.
+    :func:`pyzeebe.create_camunda_cloud_channel`
+
+  :Default:
+    ``"bru-2"`` if not provided.
+
+**CAMUNDA_TOKEN_AUDIENCE** / **ZEEBE_TOKEN_AUDIENCE**
+  :Description:
+    Specifies the audience for the OAuth2 token.
+
+  :Usage:
+    Used when creating OAuth2 or Camunda Cloud channels.
+    :func:`pyzeebe.create_camunda_cloud_channel`
+
+  :Default:
+    ``"zeebe.camunda.io"`` if not provided.
+
diff --git a/docs/channels_quickstart.rst b/docs/channels_quickstart.rst
index e725c85c..95f22e89 100644
--- a/docs/channels_quickstart.rst
+++ b/docs/channels_quickstart.rst
@@ -19,10 +19,6 @@ This Channel can be configured with the parameters `channel_credentials` and `ch
     You can override the default `channel_options` by passing
     e.g. `channel_options = (("grpc.keepalive_time_ms", 60_000),)` - for a keepalive time of 60 seconds.

-
-Pyzeebe provides a couple standard ways to achieve this:
-
-
 Insecure
 --------

@@ -151,6 +147,12 @@ Example:

 This method use the :py:class:`.Oauth2ClientCredentialsMetadataPlugin` under the hood.

+Configuration
+-------------
+
+It is possible to omit any arguments to the channel initialization functions and instead provide environment variables.
+See :doc:`Channels Configuration <channels_configuration>` for additional details.
+
 Custom Oauth2 Authorization Flow
 ---------------------------------

diff --git a/docs/channels_reference.rst b/docs/channels_reference.rst
index f9d26799..61e14dc3 100644
--- a/docs/channels_reference.rst
+++ b/docs/channels_reference.rst
@@ -26,3 +26,23 @@ Credentials
     :members:
     :special-members:
     :private-members:
+
+
+Utilities (Environment)
+-----------------------
+
+.. autofunction:: pyzeebe.channel.utils.get_zeebe_address
+
+.. autofunction:: pyzeebe.channel.utils.get_camunda_oauth_url
+
+.. autofunction:: pyzeebe.channel.utils.get_camunda_client_id
+
+.. autofunction:: pyzeebe.channel.utils.get_camunda_client_secret
+
+.. autofunction:: pyzeebe.channel.utils.get_camunda_cluster_id
+
+.. autofunction:: pyzeebe.channel.utils.get_camunda_cluster_region
+
+.. autofunction:: pyzeebe.channel.utils.get_camunda_token_audience
+
+.. autofunction:: pyzeebe.channel.utils.get_camunda_address
diff --git a/pyzeebe/channel/insecure_channel.py b/pyzeebe/channel/insecure_channel.py
index 846cff72..39e86360 100644
--- a/pyzeebe/channel/insecure_channel.py
+++ b/pyzeebe/channel/insecure_channel.py
@@ -3,12 +3,12 @@
 import grpc

 from pyzeebe.channel.channel_options import get_channel_options
-from pyzeebe.channel.utils import create_address
-from pyzeebe.types import ChannelArgumentType
+from pyzeebe.channel.utils import get_zeebe_address
+from pyzeebe.types import ChannelArgumentType, Unset

 def create_insecure_channel(
-    grpc_address: str | None = None, channel_options: ChannelArgumentType | None = None
+    grpc_address: str = Unset, channel_options: ChannelArgumentType | None = None
 ) -> grpc.aio.Channel:
     """
     Create an insecure channel
@@ -22,5 +22,7 @@ def create_insecure_channel(
     Returns:
         grpc.aio.Channel: A GRPC Channel connected to the Zeebe gateway.
     """
-    grpc_address = create_address(grpc_address=grpc_address)
+    if grpc_address is Unset:
+        grpc_address = get_zeebe_address()
+
     return grpc.aio.insecure_channel(target=grpc_address, options=get_channel_options(channel_options))
diff --git a/pyzeebe/channel/oauth_channel.py b/pyzeebe/channel/oauth_channel.py
index d54746a4..a4382cb4 100644
--- a/pyzeebe/channel/oauth_channel.py
+++ b/pyzeebe/channel/oauth_channel.py
@@ -5,52 +5,89 @@
 import grpc

 from pyzeebe.channel.channel_options import get_channel_options
+from pyzeebe.channel.utils import (
+    get_camunda_address,
+    get_camunda_client_id,
+    get_camunda_client_secret,
+    get_camunda_cluster_id,
+    get_camunda_cluster_region,
+    get_camunda_oauth_url,
+    get_camunda_token_audience,
+    get_zeebe_address,
+)
 from pyzeebe.credentials.oauth import Oauth2ClientCredentialsMetadataPlugin
-from pyzeebe.types import ChannelArgumentType
+from pyzeebe.types import ChannelArgumentType, Unset

 def create_oauth2_client_credentials_channel(
-    grpc_address: str,
-    client_id: str,
-    client_secret: str,
-    authorization_server: str,
-    scope: str | None = None,
-    audience: str | None = None,
-    channel_credentials: grpc.ChannelCredentials | None = None,
+    grpc_address: str = Unset,
+    client_id: str = Unset,
+    client_secret: str = Unset,
+    authorization_server: str = Unset,
+    scope: str | None = Unset,
+    audience: str | None = Unset,
+    channel_credentials: grpc.ChannelCredentials = grpc.ssl_channel_credentials(),
     channel_options: ChannelArgumentType | None = None,
     leeway: int = 60,
     expire_in: int | None = None,
 ) -> grpc.aio.Channel:
     """Create a gRPC channel for connecting to Camunda 8 (Self-Managed) with OAuth2ClientCredentials.

-    - https://oauth.net/2/grant-types/client-credentials/
-    - https://datatracker.ietf.org/doc/html/rfc6749#section-11.2.2
+    https://oauth.net/2/grant-types/client-credentials/
+    https://datatracker.ietf.org/doc/html/rfc6749#section-11.2.2

     Args:
-        grpc_address (str): Zeebe Gateway Address.
-
-        client_id (str): The client id.
-        client_secret (str): The client secret.
-        authorization_server (str): The authorization server issuing access tokens
+        grpc_address (str, optional): Zeebe Gateway Address.
+            Defaults to value from ZEEBE_ADDRESS environment variable
+            or "{CAMUNDA_CLUSTER_ID}.{CAMUNDA_CLUSTER_REGION}.zeebe.camunda.io:443"
+            or "localhost:26500".
+        client_id (str, optional): The client id.
+            Defaults to value from CAMUNDA_CLIENT_ID or ZEEBE_CLIENT_ID environment variable
+        client_secret (str, optional): The client secret.
+            Defaults to value from CAMUNDA_CLIENT_SECRET or ZEEBE_CLIENT_SECRET environment variable
+        authorization_server (str, optional): The authorization server issuing access tokens
             to the client after successfully authenticating the client.
-        scope (Optional[str]): The scope of the access request. Defaults to None.
-        audience (Optional[str]): The audience for authentication. Defaults to None.
+            Defaults to value from CAMUNDA_OAUTH_URL or ZEEBE_AUTHORIZATION_SERVER_URL environment variable
+        scope (str | None, optional): The scope of the access request.
+        audience (str | None, optional): The audience for authentication.
+            Defaults to value from CAMUNDA_TOKEN_AUDIENCE or ZEEBE_TOKEN_AUDIENCE environment variable

         channel_credentials (grpc.ChannelCredentials): The gRPC channel credentials.
             Defaults to grpc.ssl_channel_credentials().
-        channel_options (Optional[ChannelArgumentType]): Additional options for the gRPC channel.
+        channel_options (ChannelArgumentType | None): Additional options for the gRPC channel.
             Defaults to None.
             See https://grpc.github.io/grpc/python/glossary.html#term-channel_arguments

         leeway (int): The number of seconds to consider the token as expired before the actual expiration time.
             Defaults to 60.
-        expire_in (Optional[int]): The number of seconds the token is valid for. Defaults to None.
+        expire_in (int | None): The number of seconds the token is valid for. Defaults to None.
             Should only be used if the token does not contain an "expires_in" attribute.

     Returns:
         grpc.aio.Channel: A gRPC channel connected to the Zeebe Gateway.
+
+    Raises:
+        InvalidOAuthCredentialsError: One of the provided camunda credentials is not correct
     """

+    if grpc_address is Unset:
+        grpc_address = get_zeebe_address()
+
+    if client_id is Unset:
+        client_id = get_camunda_client_id()
+
+    if client_secret is Unset:
+        client_secret = get_camunda_client_secret()
+
+    if authorization_server is Unset:
+        authorization_server = get_camunda_oauth_url()
+
+    if scope is Unset:
+        scope = None
+
+    if audience is Unset:
+        audience = get_camunda_token_audience()
+
     oauth2_client_credentials = Oauth2ClientCredentialsMetadataPlugin(
         client_id=client_id,
         client_secret=client_secret,
@@ -67,21 +104,23 @@ def create_oauth2_client_credentials_channel(
     )

     channel: grpc.aio.Channel = grpc.aio.secure_channel(
-        target=grpc_address, credentials=composite_credentials, options=get_channel_options(channel_options)
+        target=grpc_address,
+        credentials=composite_credentials,
+        options=get_channel_options(channel_options),
     )

     return channel

 def create_camunda_cloud_channel(
-    client_id: str,
-    client_secret: str,
-    cluster_id: str,
-    region: str = "bru-2",
-    authorization_server: str = "https://login.cloud.camunda.io/oauth/token",
-    scope: str | None = None,
-    audience: str | None = "zeebe.camunda.io",
-    channel_credentials: grpc.ChannelCredentials | None = None,
+    client_id: str = Unset,
+    client_secret: str = Unset,
+    cluster_id: str = Unset,
+    region: str = Unset,
+    authorization_server: str = Unset,
+    scope: str | None = Unset,
+    audience: str | None = Unset,
+    channel_credentials: grpc.ChannelCredentials = grpc.ssl_channel_credentials(),
     channel_options: ChannelArgumentType | None = None,
     leeway: int = 60,
     expire_in: int | None = None,
@@ -89,32 +128,60 @@ def create_camunda_cloud_channel(
     """Create a gRPC channel for connecting to Camunda 8 Cloud (SaaS).

     Args:
-        client_id (str): The client id.
-        client_secret (str): The client secret.
-        cluster_id (str): The ID of the cluster to connect to.
-        region (Optional[str]): The region of the cluster. Defaults to "bru-2".
-        authorization_server (Optional[str]): The authorization server issuing access tokens
+        client_id (str, optional): The client id.
+            Defaults to value from CAMUNDA_CLIENT_ID or ZEEBE_CLIENT_ID environment variable.
+        client_secret (str, optional): The client secret.
+            Defaults to value from CAMUNDA_CLIENT_SECRET or ZEEBE_CLIENT_SECRET environment variable.
+        cluster_id (str, optional): The ID of the cluster to connect to.
+            Defaults to value from CAMUNDA_CLUSTER_ID environment variable.
+        region (str, optional): The region of the cluster.
+            Defaults to value from CAMUNDA_CLUSTER_REGION environment variable or 'bru-2'.
+        authorization_server (str, optional): The authorization server issuing access tokens
             to the client after successfully authenticating the client.
-            Defaults to "https://login.cloud.camunda.io/oauth/token".
-        scope (Optional[str]): The scope of the access request. Can be set to CAMUNDA_CLUSTER_ID. Defaults to None.
-        audience (Optional[str]): The audience for authentication. Defaults to "zeebe.camunda.io".
+            Defaults to value from CAMUNDA_OAUTH_URL
+            or ZEEBE_AUTHORIZATION_SERVER_URL environment variable
+            or "https://login.cloud.camunda.io/oauth/token".
+        scope (str | None, optional): The scope of the access request.
+        audience (str | None, optional): The audience for authentication.
+            Defaults to value from CAMUNDA_TOKEN_AUDIENCE
+            or ZEEBE_TOKEN_AUDIENCE environment variable
+            or "zeebe.camunda.io".

         channel_credentials (grpc.ChannelCredentials): The gRPC channel credentials.
             Defaults to grpc.ssl_channel_credentials().
-        channel_options (Optional[ChannelArgumentType]): Additional options for the gRPC channel.
+        channel_options (ChannelArgumentType | None): Additional options for the gRPC channel.
             Defaults to None.
             See https://grpc.github.io/grpc/python/glossary.html#term-channel_arguments

         leeway (int): The number of seconds to consider the token as expired before the actual expiration time.
             Defaults to 60.
-        expire_in (Optional[int]): The number of seconds the token is valid for. Defaults to None.
+        expire_in (int | None): The number of seconds the token is valid for. Defaults to None.
             Should only be used if the token does not contain an "expires_in" attribute.

     Returns:
         grpc.aio.Channel: The gRPC channel for connecting to Camunda Cloud.
     """

-    grpc_address = f"{cluster_id}.{region}.zeebe.camunda.io:443"
+    if client_id is Unset:
+        client_id = get_camunda_client_id()
+
+    if client_secret is Unset:
+        client_secret = get_camunda_client_secret()
+
+    if cluster_id is Unset:
+        cluster_id = get_camunda_cluster_id()
+
+    if region is Unset:
+        region = get_camunda_cluster_region("bru-2")
+
+    if authorization_server is Unset:
+        authorization_server = get_camunda_oauth_url("https://login.cloud.camunda.io/oauth/token")
+
+    if scope is Unset:
+        scope = None
+
+    if audience is Unset:
+        audience = get_camunda_token_audience("zeebe.camunda.io")

     oauth2_client_credentials = Oauth2ClientCredentialsMetadataPlugin(
         client_id=client_id,
@@ -141,6 +208,8 @@ def create_camunda_cloud_channel(
         channel_credentials or grpc.ssl_channel_credentials(), call_credentials
     )

+    grpc_address = get_camunda_address(cluster_id=cluster_id, cluster_region=region)
+
     channel: grpc.aio.Channel = grpc.aio.secure_channel(
         target=grpc_address, credentials=composite_credentials, options=get_channel_options(channel_options)
     )
diff --git a/pyzeebe/channel/secure_channel.py b/pyzeebe/channel/secure_channel.py
index e28b4f91..5ffb53da 100644
--- a/pyzeebe/channel/secure_channel.py
+++ b/pyzeebe/channel/secure_channel.py
@@ -3,12 +3,12 @@
 import grpc

 from pyzeebe.channel.channel_options import get_channel_options
-from pyzeebe.channel.utils import create_address
-from pyzeebe.types import ChannelArgumentType
+from pyzeebe.channel.utils import get_zeebe_address
+from pyzeebe.types import ChannelArgumentType, Unset

 def create_secure_channel(
-    grpc_address: str | None = None,
+    grpc_address: str = Unset,
     channel_options: ChannelArgumentType | None = None,
     channel_credentials: grpc.ChannelCredentials | None = None,
 ) -> grpc.aio.Channel:
@@ -26,7 +26,10 @@ def create_secure_channel(
     Returns:
         grpc.aio.Channel: A GRPC Channel connected to the Zeebe gateway.
     """
-    grpc_address = create_address(grpc_address=grpc_address)
+
+    if grpc_address is Unset:
+        grpc_address = get_zeebe_address()
+
     credentials = channel_credentials or grpc.ssl_channel_credentials()
     return grpc.aio.secure_channel(
         target=grpc_address, credentials=credentials, options=get_channel_options(channel_options)
diff --git a/pyzeebe/channel/utils.py b/pyzeebe/channel/utils.py
index 7b81909d..57fbe015 100644
--- a/pyzeebe/channel/utils.py
+++ b/pyzeebe/channel/utils.py
@@ -5,9 +5,151 @@
 DEFAULT_ZEEBE_ADDRESS = "localhost:26500"

-def create_address(
-    grpc_address: str | None = None,
-) -> str:
-    if grpc_address:
-        return grpc_address
-    return os.getenv("ZEEBE_ADDRESS", DEFAULT_ZEEBE_ADDRESS)
+def get_zeebe_address(default: str | None = None) -> str:
+    """
+    Get the Zeebe Gateway Address.
+
+    Args:
+        default (str, optional): Default value to be used if no other value was discovered.
+
+    Returns:
+        str: ZEEBE_ADDRESS environment variable or provided default or "localhost:26500"
+    """
+    return os.getenv("ZEEBE_ADDRESS") or default or DEFAULT_ZEEBE_ADDRESS
+
+
+def get_camunda_oauth_url(default: str | None = None) -> str:
+    """
+    Get the Camunda OAuth URL or Zeebe Authorization Server URL.
+
+    Args:
+        default (str, optional): Default value to be used if no other value was discovered.
+
+    Returns:
+        str: CAMUNDA_OAUTH_URL or ZEEBE_AUTHORIZATION_SERVER_URL environment variable or provided default
+
+    Raises:
+        EnvironmentError: If neither CAMUNDA_OAUTH_URL nor ZEEBE_AUTHORIZATION_SERVER_URL is provided.
+    """
+    r = os.getenv("CAMUNDA_OAUTH_URL") or os.getenv("ZEEBE_AUTHORIZATION_SERVER_URL") or default
+
+    if r is None:
+        raise EnvironmentError("No CAMUNDA_OAUTH_URL or ZEEBE_AUTHORIZATION_SERVER_URL provided!")
+
+    return r
+
+
+def get_camunda_client_id() -> str:
+    """
+    Get the Camunda Client ID.
+
+    Returns:
+        str: CAMUNDA_CLIENT_ID or ZEEBE_CLIENT_ID environment variable
+
+    Raises:
+        EnvironmentError: If neither CAMUNDA_CLIENT_ID nor ZEEBE_CLIENT_ID is provided.
+    """
+    r = os.getenv("CAMUNDA_CLIENT_ID") or os.getenv("ZEEBE_CLIENT_ID")
+
+    if r is None:
+        raise EnvironmentError("No CAMUNDA_CLIENT_ID or ZEEBE_CLIENT_ID provided!")
+
+    return r
+
+
+def get_camunda_client_secret() -> str:
+    """
+    Get the Camunda Client Secret.
+
+    Returns:
+        str: CAMUNDA_CLIENT_SECRET or ZEEBE_CLIENT_SECRET environment variable
+
+    Raises:
+        EnvironmentError: If neither CAMUNDA_CLIENT_SECRET nor ZEEBE_CLIENT_SECRET is provided.
+    """
+    r = os.getenv("CAMUNDA_CLIENT_SECRET") or os.getenv("ZEEBE_CLIENT_SECRET")
+
+    if r is None:
+        raise EnvironmentError("No CAMUNDA_CLIENT_SECRET or ZEEBE_CLIENT_SECRET provided!")
+
+    return r
+
+
+def get_camunda_cluster_id() -> str:
+    """
+    Get the Camunda Cluster ID.
+
+    Returns:
+        str: CAMUNDA_CLUSTER_ID environment variable
+
+    Raises:
+        EnvironmentError: If CAMUNDA_CLUSTER_ID is not provided.
+    """
+    r = os.getenv("CAMUNDA_CLUSTER_ID")
+
+    if r is None:
+        raise EnvironmentError("No CAMUNDA_CLUSTER_ID provided!")
+
+    return r
+
+
+def get_camunda_cluster_region(default: str | None = None) -> str:
+    """
+    Get the Camunda Cluster Region.
+
+    Args:
+        default (str, optional): Default value to be used if no other value was discovered.
+
+    Returns:
+        str: CAMUNDA_CLUSTER_REGION environment variable or provided default
+
+    Raises:
+        EnvironmentError: If CAMUNDA_CLUSTER_REGION is not provided.
+    """
+    r = os.getenv("CAMUNDA_CLUSTER_REGION") or default
+
+    if r is None:
+        raise EnvironmentError("No CAMUNDA_CLUSTER_REGION provided!")
+
+    return r
+
+
+def get_camunda_token_audience(default: str | None = None) -> str:
+    """
+    Get the Camunda Token Audience.
+
+    Args:
+        default (str, optional): Default value to be used if no other value was discovered.
+
+    Returns:
+        str: CAMUNDA_TOKEN_AUDIENCE or ZEEBE_TOKEN_AUDIENCE environment variable or provided default
+
+    Raises:
+        EnvironmentError: If neither CAMUNDA_TOKEN_AUDIENCE nor ZEEBE_TOKEN_AUDIENCE is provided.
+    """
+    r = os.getenv("CAMUNDA_TOKEN_AUDIENCE") or os.getenv("ZEEBE_TOKEN_AUDIENCE") or default
+
+    if r is None:
+        raise EnvironmentError("No CAMUNDA_TOKEN_AUDIENCE or ZEEBE_TOKEN_AUDIENCE provided!")
+
+    return r
+
+
+def get_camunda_address(cluster_id: str | None = None, cluster_region: str | None = None) -> str:
+    """
+    Get the Camunda Cloud gRPC server address.
+
+    Args:
+        cluster_id (str, optional): The Camunda cluster ID provided as parameter.
+        cluster_region (str, optional): The Camunda cluster region provided as parameter.
+
+    Returns:
+        str: The Camunda Cloud gRPC server address.
+
+    Raises:
+        EnvironmentError: If either cluster_id or cluster_region is not provided.
+    """
+    if (cluster_id is None) or (cluster_region is None):
+        raise EnvironmentError("The cluster_id and cluster_region must be provided!")
+
+    return f"{cluster_id}.{cluster_region}.zeebe.camunda.io:443"
diff --git a/pyzeebe/types.py b/pyzeebe/types.py
index 6191be46..696fab30 100644
--- a/pyzeebe/types.py
+++ b/pyzeebe/types.py
@@ -5,5 +5,6 @@

 Headers: TypeAlias = Mapping[str, Any]
 Variables: TypeAlias = Mapping[str, Any]
+Unset = str("UNSET")

 ChannelArgumentType: TypeAlias = Sequence[tuple[str, Any]]

diff --git a/tests/unit/channel/insecure_channel_test.py b/tests/unit/channel/insecure_channel_test.py
index e76fe6ae..35a07d83 100644
--- a/tests/unit/channel/insecure_channel_test.py
+++ b/tests/unit/channel/insecure_channel_test.py
@@ -1,3 +1,4 @@
+import os
 from unittest.mock import Mock, patch

 import grpc
@@ -5,7 +6,7 @@

 from pyzeebe import create_insecure_channel
 from pyzeebe.channel.channel_options import get_channel_options
-from pyzeebe.channel.utils import create_address
+from pyzeebe.channel.utils import get_zeebe_address

 class TestCreateInsecureChannel:
@@ -29,4 +30,14 @@ def test_uses_default_address(self, insecure_channel_mock: Mock):
         create_insecure_channel()

         insecure_channel_call = insecure_channel_mock.mock_calls[0]
-        assert insecure_channel_call.kwargs["target"] == create_address()
+        assert insecure_channel_call.kwargs["target"] == get_zeebe_address()
+
+    @patch.dict(
+        os.environ,
+        {"ZEEBE_ADDRESS": "ZEEBE_ADDRESS"},
+    )
+    def test_uses_zeebe_address_environment_variable(self, insecure_channel_mock: Mock):
+        create_insecure_channel()
+
+        insecure_channel_call = insecure_channel_mock.mock_calls[0]
+        assert insecure_channel_call.kwargs["target"] == "ZEEBE_ADDRESS"
diff --git a/tests/unit/channel/oauth_channel_test.py b/tests/unit/channel/oauth_channel_test.py
index 19e122c3..cc6a6b9d 100644
--- a/tests/unit/channel/oauth_channel_test.py
+++ b/tests/unit/channel/oauth_channel_test.py
@@ -1,3 +1,4 @@
+import os
 from unittest import mock

 import grpc
@@ -42,6 +43,24 @@ def test_create_oauth2_client_credentials_channel(
     assert isinstance(channel, grpc.aio.Channel)

+@mock.patch.dict(
+    os.environ,
+    {
+        "ZEEBE_ADDRESS": "ZEEBE_ADDRESS",
+        "CAMUNDA_CLIENT_ID": "CAMUNDA_CLIENT_ID",
+        "CAMUNDA_CLIENT_SECRET": "CAMUNDA_CLIENT_SECRET",
+        "CAMUNDA_OAUTH_URL": "CAMUNDA_OAUTH_URL",
+        "CAMUNDA_TOKEN_AUDIENCE": "CAMUNDA_TOKEN_AUDIENCE",
+    },
+)
+def test_create_oauth2_client_credentials_channel_using_environment_variables(
+    mock_oauth2metadataplugin,
+):
+    channel = create_oauth2_client_credentials_channel()
+
+    assert isinstance(channel, grpc.aio.Channel)
+
+
 def test_create_camunda_cloud_channel(
     mock_oauth2metadataplugin,
 ):
@@ -68,3 +87,20 @@ def test_create_camunda_cloud_channel(
     )

     assert isinstance(channel, grpc.aio.Channel)
+
+
+@mock.patch.dict(
+    os.environ,
+    {
+        "CAMUNDA_CLUSTER_ID": "CAMUNDA_CLUSTER_ID",
+        "CAMUNDA_CLUSTER_REGION": "CAMUNDA_CLUSTER_REGION",
+        "CAMUNDA_CLIENT_ID": "CAMUNDA_CLIENT_ID",
+        "CAMUNDA_CLIENT_SECRET": "CAMUNDA_CLIENT_SECRET",
+    },
+)
+def test_create_camunda_cloud_channel_using_environment_variables(
+    mock_oauth2metadataplugin,
+):
+    channel = create_camunda_cloud_channel()
+
+    assert isinstance(channel, grpc.aio.Channel)
diff --git a/tests/unit/channel/secure_channel_test.py b/tests/unit/channel/secure_channel_test.py
index 181f5f80..d9bf419c 100644
--- a/tests/unit/channel/secure_channel_test.py
+++ b/tests/unit/channel/secure_channel_test.py
@@ -1,3 +1,4 @@
+import os
 from unittest.mock import Mock, patch

 import grpc
@@ -5,7 +6,7 @@

 from pyzeebe import create_secure_channel
 from pyzeebe.channel.channel_options import get_channel_options
-from pyzeebe.channel.utils import create_address
+from pyzeebe.channel.utils import get_zeebe_address

 class TestCreateSecureChannel:
@@ -35,4 +36,14 @@ def test_uses_default_address(self, secure_channel_mock: Mock):
         create_secure_channel()

         secure_channel_call = secure_channel_mock.mock_calls[0]
-        assert secure_channel_call.kwargs["target"] == create_address()
+        assert secure_channel_call.kwargs["target"] == get_zeebe_address()
+
+    @patch.dict(
+        os.environ,
+        {"ZEEBE_ADDRESS": "ZEEBE_ADDRESS"},
+    )
+    def test_uses_zeebe_address_environment_variable(self, secure_channel_mock: Mock):
+        create_secure_channel()
+
+        secure_channel_call = secure_channel_mock.mock_calls[0]
+        assert secure_channel_call.kwargs["target"] == "ZEEBE_ADDRESS"
diff --git a/tests/unit/channel/utils_test.py b/tests/unit/channel/utils_test.py
index f4d0a9ef..0a6537d3 100644
--- a/tests/unit/channel/utils_test.py
+++ b/tests/unit/channel/utils_test.py
@@ -1,25 +1,190 @@
 import os
+from unittest.mock import patch
 from uuid import uuid4

-from pyzeebe.channel.utils import DEFAULT_ZEEBE_ADDRESS, create_address
+import pytest

+from pyzeebe.channel.utils import (
+    DEFAULT_ZEEBE_ADDRESS,
+    get_camunda_address,
+    get_camunda_client_id,
+    get_camunda_client_secret,
+    get_camunda_cluster_id,
+    get_camunda_cluster_region,
+    get_camunda_oauth_url,
+    get_camunda_token_audience,
+    get_zeebe_address,
+)

-class TestCreateAddress:
+
+class TestGetZeebeAddress:

     def test_returns_passed_address(self):
         address = str(uuid4())

-        assert address == create_address(address)
+        assert address == get_zeebe_address(address)

     def test_returns_default_address(self):
-        address = create_address()
+        address = get_zeebe_address()

         assert address == DEFAULT_ZEEBE_ADDRESS

+    @patch.dict(
+        os.environ,
+        {"ZEEBE_ADDRESS": "ZEEBE_ADDRESS"},
+    )
     def test_returns_env_var_if_provided(self):
-        zeebe_address = str(uuid4())
-        os.environ["ZEEBE_ADDRESS"] = zeebe_address
+        address = get_zeebe_address("zeebe_address")
+
+        assert address == "ZEEBE_ADDRESS"
+
+
+class TestGetCamundaOauthUrl:
+    @patch.dict(
+        os.environ,
+        {"CAMUNDA_OAUTH_URL": "CAMUNDA_OAUTH_URL", "ZEEBE_AUTHORIZATION_SERVER_URL": "ZEEBE_AUTHORIZATION_SERVER_URL"},
+    )
+    def test_camunda_oauth_url_has_highest_priority(self):
+        result = get_camunda_oauth_url("oauth_url")
+
+        assert result == "CAMUNDA_OAUTH_URL"
+
+    @patch.dict(os.environ, {"ZEEBE_AUTHORIZATION_SERVER_URL": "ZEEBE_AUTHORIZATION_SERVER_URL"})
+    def test_zeebe_authorization_server_url_has_second_highest_priority(self):
+        result = get_camunda_oauth_url("oauth_url")
+
+        assert result == "ZEEBE_AUTHORIZATION_SERVER_URL"
+
+    def test_param_has_lowest_priority(self):
+        result = get_camunda_oauth_url("oauth_url")
+
+        assert result == "oauth_url"
+
+    @patch.dict(os.environ, {})
+    def test_none_has_fourth_highest_priority(self):
+        with pytest.raises(EnvironmentError):
+            get_camunda_oauth_url()
+
+
+class TestGetCamundaClientId:
+    @patch.dict(os.environ, {"CAMUNDA_CLIENT_ID": "CAMUNDA_CLIENT_ID", "ZEEBE_CLIENT_ID": "ZEEBE_CLIENT_ID"})
+    def test_is_calculated_from_camunda_environment_variable_as_highest_priority(self):
+        result = get_camunda_client_id()
+
+        assert result == "CAMUNDA_CLIENT_ID"
+
+    @patch.dict(os.environ, {"ZEEBE_CLIENT_ID": "ZEEBE_CLIENT_ID"})
+    def test_is_calculated_from_zeebe_environment_variable_as_second_priority(self):
+        result = get_camunda_client_id()
+
+        assert result == "ZEEBE_CLIENT_ID"
+
+    @patch.dict(os.environ, {})
+    def test_throw_exception_if_not_configured(self):
+        with pytest.raises(EnvironmentError):
+            get_camunda_client_id()
+
+
+class TestGetCamundaClientSecret:
+    @patch.dict(
+        os.environ, {"CAMUNDA_CLIENT_SECRET": "CAMUNDA_CLIENT_SECRET", "ZEEBE_CLIENT_SECRET": "ZEEBE_CLIENT_SECRET"}
+    )
+    def test_is_calculated_from_camunda_environment_variable_as_highest_priority(self):
+        result = get_camunda_client_secret()
+
+        assert result == "CAMUNDA_CLIENT_SECRET"
+
+    @patch.dict(os.environ, {"ZEEBE_CLIENT_SECRET": "ZEEBE_CLIENT_SECRET"})
+    def test_is_calculated_from_zeebe_environment_variable_as_second_priority(self):
+        result = get_camunda_client_secret()
+
+        assert result == "ZEEBE_CLIENT_SECRET"
+
+    @patch.dict(os.environ, {})
+    def test_throw_exception_if_not_configured(self):
+        with pytest.raises(EnvironmentError):
+            get_camunda_client_secret()
+
+
+class TestGetCamundaCloudClusterId:
+    @patch.dict(os.environ, {"CAMUNDA_CLUSTER_ID": "CAMUNDA_CLUSTER_ID"})
+    def test_is_calculated_from_camunda_environment_variable_as_highest_priority(self):
+        result = get_camunda_cluster_id()
+
+        assert result == "CAMUNDA_CLUSTER_ID"
+
+    def test_environment_error(self):
+        with pytest.raises(EnvironmentError):
+            get_camunda_cluster_id()
+
+
+class TestGetCamundaCloudClusterRegion:
+    @patch.dict(os.environ, {"CAMUNDA_CLUSTER_REGION": "CAMUNDA_CLUSTER_REGION"})
+    def test_is_calculated_from_camunda_environment_variable_as_highest_priority(self):
+        result = get_camunda_cluster_region("cluster_region_param")
+
+        assert result == "CAMUNDA_CLUSTER_REGION"
+
+    def test_is_calculated_from_default_value_parameter_as_second_priority(self):
+        result = get_camunda_cluster_region("cluster_region_param")
+
+        assert result == "cluster_region_param"
+
+    @patch.dict(os.environ, {})
+    def test_raises_environment_error_if_no_default_value_as_fallback_provided(self):
+        with pytest.raises(EnvironmentError):
+            get_camunda_cluster_region()
+
+
+class TestGetCamundaTokenAudience:
+    @patch.dict(
+        os.environ,
+        {
+            "CAMUNDA_TOKEN_AUDIENCE": "CAMUNDA_TOKEN_AUDIENCE",
+            "ZEEBE_TOKEN_AUDIENCE": "ZEEBE_TOKEN_AUDIENCE",
+        },
+    )
+    def test_is_calculated_from_camunda_token_audience_as_highest_priority(self):
+        result = get_camunda_token_audience("token_audience_param")
+
+        assert result == "CAMUNDA_TOKEN_AUDIENCE"
+
+    @patch.dict(
+        os.environ,
+        {
+            "ZEEBE_TOKEN_AUDIENCE": "ZEEBE_TOKEN_AUDIENCE",
+        },
+    )
+    def test_is_calculated_from_zeebe_token_audience_as_second_highest_priority(self):
+        result = get_camunda_token_audience("token_audience_param")
+
+        assert result == "ZEEBE_TOKEN_AUDIENCE"
+
+    def test_is_calculated_from_camunda_token_audience_as_third_highest_priority(self):
+        result = get_camunda_token_audience("token_audience_param")
+
+        assert result == "token_audience_param"
+
+    @patch.dict(os.environ, {})
+    def test_raises_environment_error_as_fourth_highest_priority(self):
+        with pytest.raises(EnvironmentError):
+            get_camunda_token_audience()
+
+
+class TestGetCamundaAddress:
+    def test_is_calculated_from_parameters_as_highest_priority(self):
+        result = get_camunda_address("cluster_id_param", "camunda_region_param")
+
+        assert result == f"cluster_id_param.camunda_region_param.zeebe.camunda.io:443"
+
+    def test_raises_error_if_cluster_id_is_none(self):
+        with pytest.raises(EnvironmentError):
+            get_camunda_address(None, "camunda_region_param")

-        address = create_address()
+    def test_raises_error_if_cluster_region_is_none(self):
+        with pytest.raises(EnvironmentError):
+            get_camunda_address("cluster_id_param", None)

-        assert address == zeebe_address
+    def test_raises_error_if_all_args_are_none(self):
+        with pytest.raises(EnvironmentError):
+            get_camunda_address(None, None)

Use environment variables to configure Zeebe
**Is your feature request related to a problem? Please describe.**
The zeebe-client-node library has a "[Zero-Conf constructor](https://github.com/zeebe-io/zeebe-client-node-js#zero-conf)" that allows to use environment variables to set up Zeebe.

This project could benefit from taking the same approach.

It already supports specifying `hostname:port` with `ZEEBE_ADDRESS`.

**Describe the solution you'd like**
To quote from zeebe-client-node docs:

> With no relevant environment variables set, it will default to localhost on the default port with no TLS.
>
> The following environment variable configurations are possible with the Zero-conf constructor:
>
> Camunda Cloud:
>
> ```
> ZEEBE_ADDRESS
> ZEEBE_AUTHORIZATION_SERVER_URL
> ZEEBE_CLIENT_SECRET
> ZEEBE_CLIENT_ID
> ```
>
> Self-hosted or local broker (no TLS or OAuth):
>
> ```
> ZEEBE_ADDRESS
> ```
>
> Self-hosted or local broker with OAuth + TLS:
>
> ```
> ZEEBE_CLIENT_ID
> ZEEBE_CLIENT_SECRET
> ZEEBE_TOKEN_AUDIENCE
> ZEEBE_AUTHORIZATION_SERVER_URL
> ZEEBE_ADDRESS
> ```
>
> Basic Auth:
>
> ```
> ZEEBE_BASIC_AUTH_PASSWORD
> ZEEBE_BASIC_AUTH_USERNAME
> ```

**Describe alternatives you've considered**
- Having a function that's responsible for loading config and pass it to ZeebeWorker/ZeebeClient on instantiation.

E.g.

```py
zeebe_client = ZeebeClient(**get_zeebe_config())
# where `get_zeebe_config` would return a dict
# e.g. {"secure_connection": True, "hostname": "localhost", "port": 26500}
```

---

If this is a direction we'd like to take, I'd be happy to submit a PR for this.
Use environment variables to configure Zeebe
**Is your feature request related to a problem? Please describe.**
The zeebe-client-node library has a "[Zero-Conf constructor](https://github.com/zeebe-io/zeebe-client-node-js#zero-conf)" that allows to use environment variables to set up Zeebe.

This project could benefit from taking the same approach.

It already supports specifying `hostname:port` with `ZEEBE_ADDRESS`.

**Describe the solution you'd like**
To quote from zeebe-client-node docs:

> With no relevant environment variables set, it will default to localhost on the default port with no TLS.
>
> The following environment variable configurations are possible with the Zero-conf constructor:
>
> Camunda Cloud:
>
> ```
> ZEEBE_ADDRESS
> ZEEBE_AUTHORIZATION_SERVER_URL
> ZEEBE_CLIENT_SECRET
> ZEEBE_CLIENT_ID
> ```
>
> Self-hosted or local broker (no TLS or OAuth):
>
> ```
> ZEEBE_ADDRESS
> ```
>
> Self-hosted or local broker with OAuth + TLS:
>
> ```
> ZEEBE_CLIENT_ID
> ZEEBE_CLIENT_SECRET
> ZEEBE_TOKEN_AUDIENCE
> ZEEBE_AUTHORIZATION_SERVER_URL
> ZEEBE_ADDRESS
> ```
>
> Basic Auth:
>
> ```
> ZEEBE_BASIC_AUTH_PASSWORD
> ZEEBE_BASIC_AUTH_USERNAME
> ```

**Describe alternatives you've considered**
- Having a function that's responsible for loading config and pass it to ZeebeWorker/ZeebeClient on instantiation.

E.g.

```py
zeebe_client = ZeebeClient(**get_zeebe_config())
# where `get_zeebe_config` would return a dict
# e.g. {"secure_connection": True, "hostname": "localhost", "port": 26500}
```

---

If this is a direction we'd like to take, I'd be happy to submit a PR for this.

I definitely think this is something that we want in pyzeebe. It would be great if you could submit a PR.
Should I add this issue to the Pyzeebe 3.0.0 milestone @kbakk?
@JonatanMartens I assume #186 was accidentally closed (because the branch it was made against was deleted)? Should I try to rebase this against master/development?
@kbakk, sorry for the late response.

It would be great if you could rebase this agains master :)
I definitely think this is something that we want in pyzeebe. It would be great if you could submit a PR.
Should I add this issue to the Pyzeebe 3.0.0 milestone @kbakk?
@JonatanMartens I assume #186 was accidentally closed (because the branch it was made against was deleted)? Should I try to rebase this against master/development?
@kbakk, sorry for the late response.

It would be great if you could rebase this agains master :)


diff --git a/app/models.py b/app/models.py
index cfb9644b..80f94b94 100644
--- a/app/models.py
+++ b/app/models.py
@@ -365,6 +365,11 @@ class Config:
         schema_extra = {"example": {"answer": [0, 1, 2], "visited": True}}

+"""
+Note : The below model is not being used currently anywhere
+"""
+
+
 class SessionAnswerResponse(SessionAnswer):
     """Model for the response of any request that returns a session answer"""

diff --git a/app/routers/session_answers.py b/app/routers/session_answers.py
index 9d11e4a6..cd3f4937 100644
--- a/app/routers/session_answers.py
+++ b/app/routers/session_answers.py
@@ -2,68 +2,82 @@
 from fastapi.responses import JSONResponse
 from fastapi.encoders import jsonable_encoder
 from database import client
-from models import SessionAnswerResponse, UpdateSessionAnswer
+from models import UpdateSessionAnswer
 from utils import remove_optional_unset_args

 router = APIRouter(prefix="/session_answers", tags=["Session Answers"])

-@router.patch("/{session_answer_id}", response_model=SessionAnswerResponse)
-async def update_session_answer(
-    session_answer_id: str, session_answer: UpdateSessionAnswer
+@router.patch("/{session_id}/{position_index}", response_model=None)
+async def update_session_answer_in_a_session(
+    session_id: str, position_index: int, session_answer: UpdateSessionAnswer
 ):
+    """
+    Update a session answer in a session by its position index in the session answers array
+    Path Params:
+    session_id - the id of the session
+    position_index - the position index of the session answer in the session answers array. This corresponds to the position of the question in the quiz
+    """
     session_answer = remove_optional_unset_args(session_answer)
     session_answer = jsonable_encoder(session_answer)

-    if (client.quiz.session_answers.find_one({"_id": session_answer_id})) is None:
+    # check if the session exists
+    session = client.quiz.sessions.find_one({"_id": session_id})
+    if session is None:
         raise HTTPException(
             status_code=status.HTTP_404_NOT_FOUND,
-            detail=f"session_answer {session_answer_id} not found",
+            detail=f"Provided session with id {session_id} not found",
         )

-    # update the document in the session_answers collection
-    client.quiz.session_answers.update_one(
-        {"_id": session_answer_id}, {"$set": session_answer}
-    )
-
-    updated_session_answer = client.quiz.session_answers.find_one(
-        {"_id": session_answer_id}
-    )
+    # check if the session has session answers key
+    if "session_answers" not in session or session["session_answers"] is None:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail=f"No session answers found in the session with id {session_id}",
+        )

-    # update the document in the sessions collection if this answer
-    # is present in the subset of session answers we store in the document
-    # corresponding to the session
-    session_to_update = client.quiz.sessions.find_one(
-        {"_id": updated_session_answer["session_id"]}
-    )
+    # check if the session answer index that we're trying to access is out of bounds or not
+    if position_index > len(session["session_answers"]):
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail=f"Provided position index {position_index} is out of bounds of length of the session answers array",
+        )

-    session_answers = list(session_to_update["session_answers"])
-    update_session = False
-    for index, _ in enumerate(session_answers):
-        if session_answers[index]["_id"] == session_answer_id:
-            session_answers[index].update(session_answer)
-            update_session = True
-            break
+    # constructing the $set query for mongodb
+    setQuery = {}
+    for key, value in session_answer.items():
+        setQuery[f"session_answers.{position_index}.{key}"] = value

-    if update_session:
-        client.quiz.sessions.update_one(
-            {"_id": session_to_update["_id"]},
-            {"$set": {"session_answers": session_answers}},
-        )
+    # update the document in the session_answers collection
+    client.quiz.sessions.update_one({"_id": session_id}, {"$set": setQuery})

-    return JSONResponse(status_code=status.HTTP_200_OK, content=updated_session_answer)
+    return JSONResponse(status_code=status.HTTP_200_OK)

-@router.get("/{session_answer_id}", response_model=SessionAnswerResponse)
-async def get_session_answer(session_answer_id: str):
-    if (
-        session_answer := client.quiz.session_answers.find_one(
-            {"_id": session_answer_id}
+@router.get("/{session_id}/{position_index}", response_model=None)
+async def get_session_answer_from_a_session(session_id: str, position_index: int):
+    pipeline = [
+        {
+            "$match": {  # match the session with the provided session_id
+                "_id": session_id
+            }
+        },
+        {
+            "$project": {  # project the required element from session_answers array
+                "_id": 0,
+                "session_answer": {
+                    "$arrayElemAt": ["$session_answers", position_index]
+                },
+            }
+        },
+    ]
+    aggregation_result = list(client.quiz.sessions.aggregate(pipeline))
+    if len(aggregation_result) == 0:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Either session_id is wrong or position_index is out of bounds",
         )
-    ) is not None:
-        return session_answer

-    raise HTTPException(
-        status_code=status.HTTP_404_NOT_FOUND,
-        detail=f"session_answer {session_answer_id} not found",
+    return JSONResponse(
+        status_code=status.HTTP_200_OK, content=aggregation_result[0]["session_answer"]
     )
diff --git a/app/routers/sessions.py b/app/routers/sessions.py
index 34f07615..6b14efca 100644
--- a/app/routers/sessions.py
+++ b/app/routers/sessions.py
@@ -100,17 +100,13 @@ async def create_session(session: Session):
         current_session["has_quiz_ended"] = last_session.get("has_quiz_ended", False)

         # restore the answers from the last (previous) sessions
-        last_session_answers = list(
-            client.quiz.session_answers.find(
-                {"session_id": last_session["_id"]},
-                sort=[("_id", pymongo.ASCENDING)],
-            )
-        )
+        session_answers_of_the_last_session = last_session["session_answers"]

-        for index, session_answer in enumerate(last_session_answers):
+        for _, session_answer in enumerate(session_answers_of_the_last_session):
             # note: we retain created_at key in session_answer
             for key in ["_id", "session_id"]:
-                session_answer.pop(key)
+                if key in session_answer:
+                    session_answer.pop(key)

             # append with new session_answer "_id" keys
             session_answers.append(
@@ -120,17 +116,10 @@ async def create_session(session: Session):
     current_session["session_answers"] = session_answers

     # insert current session into db
-    new_session = client.quiz.sessions.insert_one(current_session)
-    created_session = client.quiz.sessions.find_one({"_id": new_session.inserted_id})
-
-    # update with new session_id and insert to db
-    for index, _ in enumerate(session_answers):
-        session_answers[index]["session_id"] = new_session.inserted_id
-
-    client.quiz.session_answers.insert_many(session_answers)
+    client.quiz.sessions.insert_one(current_session)

     # return the created session
-    return JSONResponse(status_code=status.HTTP_201_CREATED, content=created_session)
+    return JSONResponse(status_code=status.HTTP_201_CREATED, content=current_session)

 @router.patch("/{session_id}", response_model=UpdateSessionResponse)
@@ -143,6 +132,7 @@ async def update_session(session_id: str, session_updates: UpdateSession):
     * dummy event logic added for JNV -- will be removed!
     """
     new_event = jsonable_encoder(session_updates)["event"]
+    session_update_query = {}

     # if new_event == EventType.dummy_event:
     #     return JSONResponse(
@@ -159,8 +149,16 @@ async def update_session(session_id: str, session_updates: UpdateSession):
     event_obj = jsonable_encoder(Event.parse_obj({"event_type": new_event}))
     if session["events"] is None:
         session["events"] = [event_obj]
+        if "$set" not in session_update_query:
+            session_update_query["$set"] = {"events": [event_obj]}
+        else:
+            session_update_query["$set"].update({"events": [event_obj]})
     else:
         session["events"].append(event_obj)
+        if "$push" not in session_update_query:
+            session_update_query["$push"] = {"events": event_obj}
+        else:
+            session_update_query["$push"].update({"events": event_obj})

     # diff between times of last two events
     time_elapsed = 0
@@ -212,15 +210,21 @@ async def update_session(session_id: str, session_updates: UpdateSession):
     ):
         # if `time_remaining` key is not present =>
         # no time limit is set, no need to respond with time_remaining
-        session["time_remaining"] = max(0, session["time_remaining"] - time_elapsed)
-        response_content = {"time_remaining": session["time_remaining"]}
+        time_remaining = max(0, session["time_remaining"] - time_elapsed)
+        if "$set" not in session_update_query:
+            session_update_query["$set"] = {"time_remaining": time_remaining}
+        else:
+            session_update_query["$set"].update({"time_remaining": time_remaining})
+        response_content = {"time_remaining": time_remaining}

     # update the document in the sessions collection
     if new_event == EventType.end_quiz:
-        session["has_quiz_ended"] = True
-    client.quiz.sessions.update_one(
-        {"_id": session_id}, {"$set": jsonable_encoder(session)}
-    )
+        if "$set" not in session_update_query:
+            session_update_query["$set"] = {"has_quiz_ended": True}
+        else:
+            session_update_query["$set"].update({"has_quiz_ended": True})
+
+    client.quiz.sessions.update_one({"_id": session_id}, session_update_query)

     return JSONResponse(status_code=status.HTTP_200_OK, content=response_content)

diff --git a/app/tests/test_session_answers.py b/app/tests/test_session_answers.py
index a2c04a1e..2d05a9be 100644
--- a/app/tests/test_session_answers.py
+++ b/app/tests/test_session_answers.py
@@ -7,12 +7,13 @@ class SessionAnswerTestCase(SessionsBaseTestCase):
     def setUp(self):
         super().setUp()
         self.session_answers = self.homework_session["session_answers"]
+        self.session_id = self.homework_session["_id"]
+        self.session_answer_position_index = 0
         self.session_answer = self.session_answers[0]
-        self.session_answer_id = self.session_answer["_id"]

-    def test_gets_session_answer_with_valid_id(self):
+    def test_gets_session_answer_from_a_session(self):
         response = self.client.get(
-            f"{session_answers.router.prefix}/{self.session_answer_id}"
+            f"{session_answers.router.prefix}/{self.session_id}/{self.session_answer_position_index}"
         )
         assert response.status_code == 200
         session_answer = json.loads(response.content)
@@ -22,12 +23,12 @@ def test_gets_session_answer_with_valid_id(self):
     def test_update_session_answer_with_only_answer(self):
         new_answer = [0, 1, 2]
         response = self.client.patch(
-            f"{session_answers.router.prefix}/{self.session_answer_id}",
+            f"{session_answers.router.prefix}/{self.session_id}/{self.session_answer_position_index}",
             json={"answer": new_answer},
         )
         assert response.status_code == 200
         response = self.client.get(
-            f"{session_answers.router.prefix}/{self.session_answer_id}"
+            f"{session_answers.router.prefix}/{self.session_id}/{self.session_answer_position_index}"
         )
         session_answer = json.loads(response.content)

@@ -40,12 +41,12 @@ def test_update_session_answer_with_only_answer(self):
     def test_update_session_answer_with_only_visited(self):
         new_visited = True
         response = self.client.patch(
-            f"{session_answers.router.prefix}/{self.session_answer_id}",
+            f"{session_answers.router.prefix}/{self.session_id}/{self.session_answer_position_index}",
             json={"visited": new_visited},
         )
         assert response.status_code == 200
         response = self.client.get(
-            f"{session_answers.router.prefix}/{self.session_answer_id}"
+            f"{session_answers.router.prefix}/{self.session_id}/{self.session_answer_position_index}"
         )
         session_answer = json.loads(response.content)

diff --git a/app/tests/test_sessions.py b/app/tests/test_sessions.py
index 66105d14..531db98b 100644
--- a/app/tests/test_sessions.py
+++ b/app/tests/test_sessions.py
@@ -96,12 +96,14 @@ def test_create_session_with_previous_session_and_start_event(self):
         assert response["is_first"] is False

     def test_create_session_with_valid_quiz_id_and_previous_session(self):
+        self.session_id = self.homework_session["_id"]
         self.session_answers = self.homework_session["session_answers"]
+        self.session_answer_position_index = 0
         self.session_answer = self.session_answers[0]
         self.session_answer_id = self.session_answer["_id"]
         new_answer = [0, 1, 2]
         response = self.client.patch(
-            f"{session_answers.router.prefix}/{self.session_answer_id}",
+            f"{session_answers.router.prefix}/{self.session_id}/{self.session_answer_position_index}",
             json={"answer": new_answer},
         )
         response = self.client.post(
@@ -214,8 +216,5 @@ def test_time_remaining_in_new_session_with_quiz_resume(self):
             f"{sessions.router.prefix}/{resumed_session_id}"
         ).json()

-        # because time has passed between both quizzes
-        assert (
-            json.loads(response.content)["time_remaining"] < quiz["time_limit"]["max"]
-        )
+        # because time has passed between both sessions
         assert updated_resumed_session["time_remaining"] < quiz["time_limit"]["max"]

At some places we're updating just one key of an object or one element of an array but we send the whole object to MongoDB to update which is inefficient.