diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0534969d..ed8f35ae 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -45,6 +45,10 @@ jobs:
run: |
pip install python-dotenv
python scripts/check_snippet_coverage.py
+
+ - name: Generate Documentation
+ run: |
+ make docs
- name: Lint and format check with Ruff
run: |
diff --git a/.github/workflows/documentation-upload.yaml b/.github/workflows/documentation-upload.yaml
new file mode 100644
index 00000000..05f3ce93
--- /dev/null
+++ b/.github/workflows/documentation-upload.yaml
@@ -0,0 +1,82 @@
+name: Generate and upload documentation
+
+on:
+ release:
+ types: [published]
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Version to use for the documentation package in semver format (e.g. 1.2.3)'
+ required: true
+
+jobs:
+ upload-documentation:
+ runs-on: ubuntu-latest
+ env:
+ SDK_NAME: sinch-sdk-python
+
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v4
+
+ - name: Resolve Version
+ id: version
+ run: |
+ if [ "${{ github.event_name }}" = "release" ]; then
+ VERSION="${{ github.event.release.tag_name }}"
+ else
+ VERSION="${{ inputs.version }}"
+ fi
+ # Strip leading 'v' if present (e.g. v1.2.3 → 1.2.3)
+ VERSION="${VERSION#v}"
+ echo "value=${VERSION}" >> "$GITHUB_OUTPUT"
+
+ - name: Validate Version Format
+ run: |
+ VERSION="${{ steps.version.outputs.value }}"
+ SEMVER_REGEX='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((alpha|beta|preview)(\.[0-9]+)?))?$'
+ if [[ ! "$VERSION" =~ $SEMVER_REGEX ]]; then
+ echo "::error::Invalid version format: '$VERSION'. Expected semver (e.g. 1.2.3, 1.2.3-alpha, 1.2.3-beta.1, 1.2.3-preview)"
+ exit 1
+ fi
+ echo "Version '$VERSION' is valid"
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Install dev dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r requirements-dev.txt
+
+ - name: Generate Documentation
+ run: |
+ make docs
+
+ - name: Package Documentation
+ run: |
+ cd docs/build/html
+ zip -r "../../../${{ env.SDK_NAME }}-${{ steps.version.outputs.value }}.zip" .
+
+ - name: Upload to GitLab Registry
+ run: |
+ echo "Uploading documentation package to GitLab Registry..."
+ VERSION="${{ steps.version.outputs.value }}"
+ curl --fail --show-error --location --header "PRIVATE-TOKEN: ${{ secrets.GITLAB_REGISTRY_UPLOAD_DOC_TOKEN }}" \
+ --upload-file "./${{ env.SDK_NAME }}-${VERSION}.zip" \
+ "https://gitlab.com/api/v4/projects/63164411/packages/generic/${{ env.SDK_NAME }}/${VERSION}/${{ env.SDK_NAME }}-${VERSION}.zip"
+ echo "Documentation package for version ${VERSION} uploaded to GitLab Registry"
+
+ - name: Trigger Downstream GitLab Pipeline
+ run: |
+ echo "Triggering downstream GitLab pipeline to notify about new documentation package version..."
+ VERSION="${{ steps.version.outputs.value }}"
+ curl --fail --show-error --location --request POST \
+ --form "token=${{ secrets.GITLAB_NOTIFY_REGISTRY_UPLOADED_DOC_TOKEN }}" \
+ --form "ref=main" \
+ --form "variables[UPSTREAM_PACKAGE_NAME]=${{ env.SDK_NAME }}" \
+ --form "variables[UPSTREAM_PACKAGE_VERSION]=${VERSION}" \
+ "https://gitlab.com/api/v4/projects/63164411/trigger/pipeline"
+ echo "Documentation repo notified about new package version ${VERSION}"
diff --git a/.gitignore b/.gitignore
index 3246a258..c11d9513 100644
--- a/.gitignore
+++ b/.gitignore
@@ -73,7 +73,9 @@ instance/
.scrapy
# Sphinx documentation
-docs/_build/
+docs/build/
+docs/api/
+
# PyBuilder
.pybuilder/
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..9d123075
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,21 @@
+.PHONY: docs
+
+docs:
+ rm -rf docs/build
+ rm -rf docs/api
+ sphinx-apidoc --force --separate --no-toc --maxdepth 2 \
+ --templatedir docs/_templates/apidoc -o docs/api sinch \
+ "sinch/core/models/base_model.py" \
+ "sinch/core/models/utils.py" \
+ "sinch/core/deserializers.py" \
+ "sinch/core/endpoint.py" \
+ "sinch/core/enums.py" \
+ "sinch/core/types.py" \
+ "sinch/domains/sms/enums.py" \
+ "sinch/*/internal" "sinch/*/internal/*" \
+ "sinch/*/api/v1/base" "sinch/*/api/v1/base/*" \
+ "sinch/*/api/v1/utils" "sinch/*/api/v1/utils/*" \
+ "sinch/domains/authentication/endpoints" "sinch/domains/authentication/endpoints/*" \
+ "sinch/domains/authentication/sinch_events" "sinch/domains/authentication/sinch_events/*" \
+ "sinch/domains/numbers/models/v1/utils" "sinch/domains/numbers/models/v1/utils/*"
+ sphinx-build -b html docs docs/build/html
diff --git a/docs/_static/custom.css b/docs/_static/custom.css
new file mode 100644
index 00000000..91609f05
--- /dev/null
+++ b/docs/_static/custom.css
@@ -0,0 +1,21 @@
+/* ---------------------------------------------------------------------------
+ Multi-line signatures (one parameter per line).
+
+ When a signature is wrapped, Sphinx renders each parameter as a
inside
+ a nested . The Read the Docs theme styles every /- with borders,
+ background tint and vertical margins, which leak into the signature and draw
+ ugly "lines" between parameters. Flatten that nested list so the parameters
+ read as a clean indented column.
+--------------------------------------------------------------------------- */
+.rst-content .sig dl,
+.rst-content .sig dd {
+ margin: 0;
+ padding: 0;
+ border: none;
+ background: none;
+}
+
+/* Keep each parameter indented under the opening parenthesis. */
+.rst-content .sig dd {
+ margin-left: 2em;
+}
diff --git a/docs/_templates/apidoc/module.rst.jinja b/docs/_templates/apidoc/module.rst.jinja
new file mode 100644
index 00000000..c694aaee
--- /dev/null
+++ b/docs/_templates/apidoc/module.rst.jinja
@@ -0,0 +1,8 @@
+{%- if show_headings %}
+{{- [basename, "module"] | join(" ") | e | heading }}
+
+{% endif -%}
+.. automodule:: {{ qualname }}
+{%- for option in automodule_options %}
+ :{{ option }}:
+{%- endfor %}
diff --git a/docs/_templates/apidoc/package.rst.jinja b/docs/_templates/apidoc/package.rst.jinja
new file mode 100644
index 00000000..4e34d501
--- /dev/null
+++ b/docs/_templates/apidoc/package.rst.jinja
@@ -0,0 +1,39 @@
+{%- macro automodule(modname, options) -%}
+.. automodule:: {{ modname }}
+{%- for option in options %}
+ :{{ option }}:
+{%- endfor %}
+{%- endmacro %}
+
+{%- macro toctree(docnames) -%}
+.. toctree::
+ :maxdepth: {{ maxdepth }}
+{% for docname in docnames %}
+ {{ docname }}
+{%- endfor %}
+{%- endmacro %}
+
+{{- [pkgname, "package"] | join(" ") | e | heading }}
+
+{%- if is_namespace %}
+.. py:module:: {{ pkgname }}
+{% endif %}
+
+{%- if subpackages %}
+{{ toctree(subpackages) }}
+{% endif %}
+
+{%- if submodules %}
+{% if separatemodules %}
+{{ toctree(submodules) }}
+{% else %}
+{% for submodule in submodules %}
+{{ [submodule.split(".")[-1], "module"] | join(" ") | e | heading(2) }}
+{{ automodule(submodule, automodule_options) }}
+{% endfor %}
+{%- endif %}
+{%- endif %}
+
+{%- if not is_namespace %}
+{{ automodule(pkgname, automodule_options) }}
+{% endif %}
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 00000000..5d8b159e
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,62 @@
+import os
+import sys
+
+# Allow autodoc to import the sinch package from the project root
+sys.path.insert(0, os.path.abspath(".."))
+from sinch import __version__
+
+# -- Project information -------------------------------------------------------
+
+project = "Sinch Python SDK"
+copyright = "2026, Sinch Developer Experience Team"
+author = "Sinch Developer Experience Team"
+release = __version__
+
+# -- General configuration -----------------------------------------------------
+
+extensions = [
+ # Pulls docstrings from Python source into the generated .rst files
+ "sphinx.ext.autodoc",
+ # Adds [source] links that open the highlighted source file
+ "sphinx.ext.viewcode",
+]
+
+# The .rst files under api/ are generated by the `sphinx-apidoc` CLI invoked
+# from the Makefile (`make docs`), using the custom templates in
+# _templates/apidoc/ to strip the "package" suffix and the
+# "Subpackages"/"Submodules"/"Module contents" headings. The
+# sphinx.ext.apidoc extension is intentionally NOT used because it ignores
+# the template directory.
+
+# -- sphinx.ext.autodoc --------------------------------------------------------
+
+autodoc_default_options = {
+ # Document all public members (methods, attributes, nested classes)
+ "members": True,
+ 'undoc-members': True,
+ # Show the class inheritance chain
+ "show-inheritance": True,
+ # Preserve the order in which members appear in the source file
+ "member-order": "bysource",
+}
+
+# Render type hints as part of the parameter/return descriptions, not the signature
+autodoc_typehints = "both"
+python_maximum_signature_line_length = 88
+
+# -- HTML output ---------------------------------------------------------------
+
+html_theme = "sphinx_rtd_theme"
+
+html_theme_options = {
+ # Maximum depth of the navigation sidebar (-1 = unlimited)
+ "navigation_depth": -1,
+ # Keep all navigation entries expanded by default
+ "collapse_navigation": False,
+}
+
+# Directory with extra static files (custom CSS, etc.), relative to this conf.py
+html_static_path = ["_static"]
+
+# Extra stylesheets loaded after the theme's own CSS
+html_css_files = ["custom.css"]
\ No newline at end of file
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 00000000..9cb6c3fe
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,8 @@
+Sinch Python SDK
+================
+
+.. toctree::
+ :maxdepth: 3
+ :caption: API Reference
+
+ api/sinch
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 384715db..5dd21212 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -11,4 +11,9 @@ ruff
requests
# Data Validation
-pydantic >= 2.0.0
\ No newline at end of file
+pydantic >= 2.0.0
+
+# Documentation
+# Sphinx 7.1 introduced python_maximum_signature_line_length and 7.x is also the last series supporting Python 3.9.
+sphinx >= 7.1
+sphinx-rtd-theme >= 2.0
diff --git a/sinch/domains/sms/enums.py b/sinch/domains/conversation/models/v1/messages/__init__.py
similarity index 100%
rename from sinch/domains/sms/enums.py
rename to sinch/domains/conversation/models/v1/messages/__init__.py
diff --git a/sinch/domains/conversation/models/v1/sinch_events/events/__init__.py b/sinch/domains/conversation/models/v1/sinch_events/events/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/sinch/domains/number_lookup/api/__init__.py b/sinch/domains/number_lookup/api/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/sinch/domains/number_lookup/models/v1/__init__.py b/sinch/domains/number_lookup/models/v1/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/sinch/domains/sms/sinch_events/__init__.py b/sinch/domains/sms/sinch_events/__init__.py
new file mode 100644
index 00000000..e69de29b