├── _config.yml ├── inapppy ├── asyncio │ ├── __init__.py │ └── appstore.py ├── __init__.py ├── errors.py ├── appstore.py └── googleplay.py ├── SECURITY.md ├── .whitesource ├── pytest.ini ├── tests ├── asyncio │ ├── conftest.py │ └── test_asyncio_appstore.py ├── conftest.py ├── test_errors.py ├── test_appstore.py ├── data │ └── androidpublisher.json └── test_google_verifier.py ├── .github ├── workflows │ ├── codeql-analysis.yml │ ├── publish.yml │ └── ci.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── setup.py ├── Pipfile ├── .gitignore ├── LICENSE ├── Makefile ├── requirements.txt ├── PULL_REQUEST_TEMPLATE.md ├── pyproject.toml ├── CODE_OF_CONDUCT.md └── README.rst /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-minimal -------------------------------------------------------------------------------- /inapppy/asyncio/__init__.py: -------------------------------------------------------------------------------- 1 | from .appstore import AppStoreValidator 2 | 3 | __all__ = ["AppStoreValidator"] 4 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Vulnerability Disclosure 2 | 3 | If you think you have found a potential security vulnerability in InAppPy, please email [me](mailto:halfas.online@gmail.com) directly. 4 | 5 | **Do not file a public issue.** 6 | -------------------------------------------------------------------------------- /.whitesource: -------------------------------------------------------------------------------- 1 | ########################################################## 2 | #### WhiteSource Integration configuration file #### 3 | ########################################################## 4 | 5 | # Configuration # 6 | #---------------# 7 | ws.repo.scan=true 8 | vulnerable.check.run.conclusion.level=failure 9 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs = 3 | .git 4 | .idea 5 | .tox 6 | .vscode 7 | build 8 | dist 9 | .pytest_cache 10 | .venv 11 | *.egg-info 12 | python_files = tests.py test_*.py *_tests.py 13 | python_classes = Test* *Tests 14 | python_functions = test_* 15 | addopts = 16 | --color=auto 17 | --durations=16 18 | -p no:doctest 19 | -p no:pytest_doctest 20 | -------------------------------------------------------------------------------- /inapppy/__init__.py: -------------------------------------------------------------------------------- 1 | from .appstore import AppStoreValidator, AppStoreVerificationResult 2 | from .errors import InAppPyValidationError 3 | from .googleplay import GooglePlayValidator, GooglePlayVerifier, GoogleVerificationResult 4 | 5 | __all__ = [ 6 | "AppStoreValidator", 7 | "AppStoreVerificationResult", 8 | "InAppPyValidationError", 9 | "GooglePlayValidator", 10 | "GooglePlayVerifier", 11 | "GoogleVerificationResult", 12 | ] 13 | -------------------------------------------------------------------------------- /tests/asyncio/conftest.py: -------------------------------------------------------------------------------- 1 | from pytest import fixture 2 | 3 | from inapppy.asyncio import AppStoreValidator 4 | 5 | 6 | @fixture 7 | def appstore_validator() -> AppStoreValidator: 8 | return AppStoreValidator() 9 | 10 | 11 | @fixture 12 | def appstore_validator_sandbox() -> AppStoreValidator: 13 | return AppStoreValidator(sandbox=True) 14 | 15 | 16 | @fixture 17 | def appstore_validator_auto_retry_on_sandbox() -> AppStoreValidator: 18 | return AppStoreValidator(auto_retry_wrong_env_request=True) 19 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pytest import fixture 2 | 3 | from inapppy import AppStoreValidator 4 | 5 | 6 | @fixture 7 | def generic_error_message() -> str: 8 | return "error message" 9 | 10 | 11 | @fixture 12 | def generic_raw_response() -> dict: 13 | return {"foo": "bar"} 14 | 15 | 16 | @fixture 17 | def appstore_validator() -> AppStoreValidator: 18 | return AppStoreValidator() 19 | 20 | 21 | @fixture 22 | def appstore_validator_sandbox() -> AppStoreValidator: 23 | return AppStoreValidator(sandbox=True) 24 | 25 | 26 | @fixture 27 | def appstore_validator_auto_retry_on_sandbox() -> AppStoreValidator: 28 | return AppStoreValidator(auto_retry_wrong_env_request=True) 29 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | 2 | name: "CodeQL" 3 | 4 | on: 5 | push: 6 | branches: [ master ] 7 | pull_request: 8 | branches: [ master ] 9 | schedule: 10 | - cron: '43 0 * * 2' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | language: [ 'python' ] 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v2 24 | 25 | # Initializes the CodeQL tools for scanning. 26 | - name: Initialize CodeQL 27 | uses: github/codeql-action/init@v1 28 | with: 29 | languages: ${{ matrix.language }} 30 | 31 | - name: Perform CodeQL Analysis 32 | uses: github/codeql-action/analyze@v1 33 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | from setuptools import setup 3 | 4 | 5 | def get_description(): 6 | with open("README.rst") as info: 7 | return info.read() 8 | 9 | 10 | setup( 11 | name="inapppy", 12 | version="2.6", 13 | packages=["inapppy", "inapppy.asyncio"], 14 | install_requires=["aiohttp", "rsa", "requests", "google-api-python-client", "oauth2client", "pyOpenSSL<=24.2.1"], 15 | description="In-app purchase validation library for Apple AppStore and GooglePlay.", 16 | keywords="in-app store purchase googleplay appstore validation", 17 | author="Lukas Šalkauskas", 18 | author_email="halfas.online@gmail.com", 19 | url="https://github.com/dotpot/InAppPy", 20 | long_description=get_description(), 21 | license="MIT", 22 | ) 23 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | requests = "*" 8 | google-api-python-client = "*" 9 | "oauth2client" = "*" 10 | aiohttp = "*" 11 | urllib3 = "*" 12 | pyopenssl = "<=24.2.1" 13 | 14 | [dev-packages] 15 | pytest-sugar = "*" 16 | typing-extensions = "*" 17 | twine = "*" 18 | setuptools = "*" 19 | wheel = "*" 20 | pylint = "*" 21 | "flake8" = "*" 22 | "flake8-bugbear" = "*" 23 | "flake8-comprehensions" = "*" 24 | pytest-asyncio = "*" 25 | pytest-cov = "*" 26 | black = "*" 27 | flake8-builtins = "*" 28 | flake8-commas = "*" 29 | flake8-eradicate = "*" 30 | flake8-quotes = "*" 31 | flake8-super-call = "*" 32 | isort = "*" 33 | 34 | [requires] 35 | python_version = "3.9" 36 | 37 | [pipenv] 38 | allow_prereleases = true 39 | -------------------------------------------------------------------------------- /inapppy/errors.py: -------------------------------------------------------------------------------- 1 | class InAppPyError(Exception): 2 | """Base class for all errors""" 3 | 4 | pass 5 | 6 | 7 | class InAppPyValidationError(InAppPyError): 8 | """Base class for all validation errors""" 9 | 10 | raw_response = None 11 | message = None 12 | 13 | def __init__(self, message: str = None, raw_response: dict = None, *args, **kwargs): 14 | self.raw_response = raw_response 15 | self.message = message 16 | 17 | super().__init__(message, *args, **kwargs) 18 | 19 | def __str__(self): 20 | return f"{self.__class__.__name__} {self.message} {self.raw_response}" 21 | 22 | def __repr__(self): 23 | return f"{self.__class__.__name__}(message={self.message!r}, raw_response={self.raw_response!r})" 24 | 25 | 26 | class GoogleError(InAppPyValidationError): 27 | pass 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE]" 5 | labels: feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | ## Detailed Description 13 | 14 | 15 | ## Context 16 | 17 | 18 | 19 | ## Possible Implementation 20 | 21 | 22 | ## Your Environment 23 | 24 | * Version used: 25 | * Operating System and version: 26 | * Link to your project (if it's open source): 27 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | contents: read 10 | id-token: write # Required for trusted publishing 11 | 12 | jobs: 13 | publish: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: '3.12' 23 | cache: 'pip' 24 | 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install build setuptools wheel twine 29 | 30 | - name: Build package 31 | run: python -m build 32 | 33 | - name: Publish package to PyPI 34 | uses: pypa/gh-action-pypi-publish@release/v1 35 | with: 36 | user: __token__ 37 | password: ${{ secrets.PYPI_API_TOKEN }} 38 | skip_existing: true 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | .DS_Store 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .pytest_cache/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *,cover 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | 57 | # Sphinx documentation 58 | docs/_build/ 59 | 60 | # PyBuilder 61 | target/ 62 | 63 | # Virtual environments 64 | .venv/ 65 | venv/ 66 | 67 | # Ruff cache 68 | .ruff_cache/ 69 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Lukas Šalkauskas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | ## Description 13 | 14 | 15 | ## Expected Behavior 16 | 17 | 18 | ## Actual Behavior 19 | 20 | 21 | ## Possible Fix 22 | 23 | 24 | ## Steps to Reproduce 25 | 26 | 27 | 1. 28 | 2. 29 | 3. 30 | 4. 31 | 32 | ## Context 33 | 34 | 35 | ## Your Environment 36 | 37 | * Version used: 38 | * Operating System and version: 39 | * Link to your project (if it's open source): 40 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help setup clean build release test lint format check install dev 2 | 3 | help: 4 | @echo "Available commands:" 5 | @echo " make setup - Install dependencies with uv" 6 | @echo " make dev - Install development dependencies with uv" 7 | @echo " make clean - Remove build artifacts" 8 | @echo " make build - Build distribution packages" 9 | @echo " make release - Upload to PyPI" 10 | @echo " make test - Run tests with pytest" 11 | @echo " make lint - Run ruff linting" 12 | @echo " make format - Format code with ruff" 13 | @echo " make check - Run lint and format check" 14 | @echo " make install - Install package in editable mode" 15 | 16 | setup: 17 | uv pip install -e . 18 | 19 | dev: 20 | uv pip install -e ".[dev]" 21 | 22 | clean: 23 | rm -rf dist build *.egg-info 24 | find . -type d -name __pycache__ -exec rm -rf {} + 25 | find . -type f -name "*.pyc" -delete 26 | find . -type f -name "*.pyo" -delete 27 | find . -type f -name "*.coverage" -delete 28 | rm -rf .pytest_cache .ruff_cache .venv 29 | 30 | build: clean 31 | uv build 32 | 33 | release: build 34 | uv publish 35 | 36 | test: 37 | .venv/bin/pytest -v 38 | 39 | lint: 40 | .venv/bin/ruff check . 41 | 42 | format: 43 | .venv/bin/ruff format . 44 | 45 | check: 46 | .venv/bin/ruff check . 47 | .venv/bin/ruff format --check . 48 | 49 | install: 50 | uv pip install -e . 51 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # These requirements were autogenerated by pipenv 3 | # To regenerate from the project's Pipfile, run: 4 | # 5 | # pipenv lock --requirements 6 | # 7 | 8 | -i https://pypi.org/simple 9 | aiohttp==4.0.0a1 10 | async-timeout==3.0.1; python_full_version >= '3.5.3' 11 | attrs==21.2.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' 12 | cachetools==4.2.4; python_version ~= '3.5' 13 | certifi==2024.7.4 14 | chardet==3.0.4 15 | charset-normalizer==2.0.7; python_version >= '3' 16 | google-api-core==2.2.2; python_version >= '3.6' 17 | google-api-python-client==2.31.0 18 | google-auth-httplib2==0.1.0 19 | google-auth==2.3.3; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5' 20 | googleapis-common-protos==1.53.0; python_version >= '3.6' 21 | httplib2==0.20.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 22 | idna==3.7; python_version >= '3' 23 | multidict==4.7.6; python_version >= '3.5' 24 | oauth2client==4.1.3 25 | protobuf==4.25.8; python_version >= '3.5' 26 | pyasn1-modules==0.2.8 27 | pyasn1==0.4.8 28 | pyparsing==3.0.6; python_version >= '3.1' 29 | requests==2.32.4 30 | rsa==4.7.2; python_version >= '3.6' 31 | six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 32 | typing-extensions==4.0.0; python_version >= '3.6' 33 | uritemplate==4.1.1; python_version >= '3.6' 34 | urllib3==2.5.0 35 | yarl==1.7.2; python_version >= '3.6' 36 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | ## Related Issue 7 | 8 | 9 | 10 | 11 | 12 | ## Motivation and Context 13 | 14 | 15 | ## How Has This Been Tested? 16 | 17 | 18 | 19 | 20 | ## Types of changes 21 | 22 | - [ ] Bug fix (non-breaking change which fixes an issue) 23 | - [ ] New feature (non-breaking change which adds functionality) 24 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 25 | 26 | ## Checklist: 27 | 28 | 29 | - [ ] My code follows the code style of this project. 30 | - [ ] My change requires a change to the documentation. 31 | - [ ] I have updated the documentation accordingly. 32 | - [ ] I have added tests to cover my changes. 33 | - [ ] All new and existing tests passed. 34 | -------------------------------------------------------------------------------- /tests/test_errors.py: -------------------------------------------------------------------------------- 1 | from inapppy.errors import GoogleError, InAppPyValidationError 2 | 3 | 4 | def test_base_error_plain_stringify(): 5 | error = InAppPyValidationError() 6 | assert error 7 | assert repr(error) == "InAppPyValidationError(message=None, raw_response=None)" 8 | assert str(error) == "InAppPyValidationError None None" 9 | 10 | 11 | def test_base_error_message_stringify(generic_error_message): 12 | error = InAppPyValidationError(generic_error_message) 13 | assert repr(error) == f"InAppPyValidationError(message={generic_error_message!r}, raw_response=None)" 14 | assert str(error) == f"InAppPyValidationError {generic_error_message} None" 15 | 16 | 17 | def test_base_error_message_and_raw_response_stringify(generic_error_message, generic_raw_response): 18 | error = InAppPyValidationError(generic_error_message, generic_raw_response) 19 | assert ( 20 | repr(error) == f"InAppPyValidationError(" 21 | f"message={generic_error_message!r}, raw_response={generic_raw_response!r})" 22 | ) 23 | assert str(error) == "InAppPyValidationError error message {'foo': 'bar'}" 24 | 25 | 26 | def test_google_error_plain_stringify(): 27 | error = GoogleError() 28 | assert error 29 | assert repr(error) == "GoogleError(message=None, raw_response=None)" 30 | assert str(error) == "GoogleError None None" 31 | 32 | 33 | def test_google_error_message_stringify(generic_error_message): 34 | error = GoogleError(generic_error_message) 35 | assert repr(error) == f"GoogleError(message={generic_error_message!r}, raw_response=None)" 36 | assert str(error) == f"GoogleError {generic_error_message} None" 37 | 38 | 39 | def test_google_error_message_and_raw_response_stringify(generic_error_message, generic_raw_response): 40 | error = GoogleError(generic_error_message, generic_raw_response) 41 | assert repr(error) == f"GoogleError(message={generic_error_message!r}, raw_response={generic_raw_response!r})" 42 | assert str(error) == "GoogleError error message {'foo': 'bar'}" 43 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | lint: 14 | name: Lint and Style Checks 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Install uv 21 | uses: astral-sh/setup-uv@v4 22 | with: 23 | enable-cache: true 24 | 25 | - name: Set up Python 26 | run: uv python install 3.12 27 | 28 | - name: Create virtual environment 29 | run: uv venv 30 | 31 | - name: Install dependencies 32 | run: uv pip install ruff 33 | 34 | - name: Run ruff linting 35 | run: uv run ruff check . 36 | 37 | - name: Run ruff formatting check 38 | run: uv run ruff format --check . 39 | 40 | test: 41 | name: Test Python ${{ matrix.python-version }} 42 | runs-on: ubuntu-latest 43 | needs: lint 44 | strategy: 45 | fail-fast: false 46 | matrix: 47 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 48 | include: 49 | - python-version: "3.14" 50 | experimental: true 51 | continue-on-error: ${{ matrix.experimental || false }} 52 | 53 | steps: 54 | - uses: actions/checkout@v4 55 | 56 | - name: Install uv 57 | uses: astral-sh/setup-uv@v4 58 | with: 59 | enable-cache: true 60 | cache-dependency-glob: "pyproject.toml" 61 | 62 | - name: Set up Python ${{ matrix.python-version }} 63 | run: uv python install ${{ matrix.python-version }} 64 | 65 | - name: Create virtual environment 66 | run: uv venv 67 | 68 | - name: Install dependencies 69 | run: | 70 | uv pip install -e . 71 | uv pip install ruff pytest pytest-sugar pytest-cov pytest-asyncio 72 | 73 | - name: Run linting 74 | run: | 75 | uv run ruff check . 76 | uv run ruff format --check . 77 | 78 | - name: Run tests 79 | run: uv run pytest -v 80 | 81 | - name: Upload coverage reports 82 | if: matrix.python-version == '3.12' 83 | uses: codecov/codecov-action@v4 84 | with: 85 | fail_ci_if_error: false 86 | -------------------------------------------------------------------------------- /inapppy/asyncio/appstore.py: -------------------------------------------------------------------------------- 1 | from aiohttp import ClientError, ClientSession, ClientTimeout 2 | 3 | from ..appstore import AppStoreValidator, api_result_errors, api_result_ok 4 | from ..errors import InAppPyValidationError 5 | 6 | 7 | class AppStoreValidator(AppStoreValidator): 8 | """The asyncio version of the app store validator.""" 9 | 10 | def __init__( 11 | self, 12 | bundle_id: str = "", 13 | sandbox: bool = False, 14 | auto_retry_wrong_env_request: bool = False, 15 | http_timeout: int = None, 16 | ): 17 | super().__init__(bundle_id, sandbox, auto_retry_wrong_env_request, http_timeout) 18 | self._session = None 19 | 20 | async def __aenter__(self): 21 | self._session = ClientSession() 22 | return self 23 | 24 | async def __aexit__(self, exc_type, exc_val, exc_tb): 25 | await self._session.close() 26 | self._session = None 27 | 28 | async def post_json(self, request_json: dict) -> dict: 29 | self._change_url_by_sandbox() 30 | response_text = None 31 | status_code = None 32 | try: 33 | async with self._session.post( 34 | self.url, json=request_json, timeout=ClientTimeout(total=self.http_timeout) 35 | ) as resp: 36 | status_code = resp.status 37 | response_text = await resp.text() 38 | # Try to parse as JSON 39 | import json 40 | 41 | return json.loads(response_text) 42 | except (ValueError, ClientError) as e: 43 | # Build raw_response with available information 44 | raw_response = {"error": str(e)} 45 | 46 | # Try to include response details if available 47 | if status_code is not None: 48 | raw_response["status_code"] = status_code 49 | if response_text is not None: 50 | raw_response["content"] = response_text 51 | 52 | raise InAppPyValidationError("HTTP error", raw_response=raw_response) 53 | 54 | async def validate(self, receipt: str, shared_secret: str = None, exclude_old_transactions: bool = False) -> dict: 55 | """Validates receipt against apple services. 56 | 57 | :param receipt: receipt 58 | :param shared_secret: optional shared secret. 59 | :param exclude_old_transactions: optional to include only the latest renewal transaction 60 | :return: validation result or exception. 61 | """ 62 | receipt_json = self._prepare_receipt(receipt, shared_secret, exclude_old_transactions) 63 | 64 | api_response = await self.post_json(receipt_json) 65 | status = api_response["status"] 66 | 67 | # Check retry case. 68 | if self.auto_retry_wrong_env_request and status in [21007, 21008]: 69 | # switch environment 70 | self.sandbox = not self.sandbox 71 | 72 | api_response = await self.post_json(receipt_json) 73 | status = api_response["status"] 74 | 75 | if status != api_result_ok: 76 | error = api_result_errors.get(status, InAppPyValidationError("Unknown API status")) 77 | error.raw_response = api_response 78 | 79 | raise error 80 | 81 | return api_response 82 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=45", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "inapppy" 7 | version = "2.6" 8 | description = "In-app purchase validation library for Apple AppStore and GooglePlay." 9 | authors = [ 10 | {name = "Lukas Šalkauskas", email = "halfas.online@gmail.com"} 11 | ] 12 | readme = "README.rst" 13 | license = {text = "MIT"} 14 | keywords = ["in-app", "store", "purchase", "googleplay", "appstore", "validation"] 15 | classifiers = [ 16 | "Programming Language :: Python :: 3", 17 | "Programming Language :: Python :: 3.8", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "Programming Language :: Python :: 3.12", 22 | "Programming Language :: Python :: 3.13", 23 | "License :: OSI Approved :: MIT License", 24 | ] 25 | requires-python = ">=3.8" 26 | dependencies = [ 27 | "aiohttp", 28 | "rsa", 29 | "requests", 30 | "google-api-python-client", 31 | "oauth2client", 32 | "pyOpenSSL<=24.2.1", 33 | ] 34 | 35 | [project.optional-dependencies] 36 | dev = [ 37 | "ruff", 38 | "pytest", 39 | "pytest-sugar", 40 | "pytest-cov", 41 | "pytest-asyncio", 42 | ] 43 | 44 | [project.urls] 45 | Homepage = "https://github.com/dotpot/InAppPy" 46 | 47 | [tool.setuptools] 48 | packages = ["inapppy", "inapppy.asyncio"] 49 | 50 | [tool.black] 51 | line-length = 120 52 | target-version = ["py38", "py39", "py310", "py311", "py312", "py313"] 53 | include = '\.pyi?$' 54 | exclude = ''' 55 | /( 56 | \.git 57 | | \.idea 58 | | \build 59 | | \dist 60 | | \.vscode 61 | | \.pytest_cache 62 | | \.venv 63 | | \.ruff_cache 64 | | .*\.egg-info 65 | )/ 66 | ''' 67 | 68 | [tool.ruff] 69 | line-length = 120 70 | target-version = "py38" 71 | exclude = [ 72 | ".git", 73 | ".idea", 74 | "build", 75 | "dist", 76 | ".pytest_cache", 77 | ".ruff_cache", 78 | "__pycache__", 79 | ".venv", 80 | ".vscode", 81 | "*.egg-info", 82 | ] 83 | 84 | [tool.ruff.lint] 85 | # Enable flake8 rules that were in the original config 86 | select = [ 87 | "E", # pycodestyle errors 88 | "W", # pycodestyle warnings 89 | "F", # pyflakes 90 | "I", # isort 91 | "B", # flake8-bugbear 92 | "C4", # flake8-comprehensions 93 | "A", # flake8-builtins 94 | "COM", # flake8-commas 95 | "ERA", # flake8-eradicate 96 | "Q", # flake8-quotes 97 | "UP", # pyupgrade 98 | ] 99 | 100 | # Ignore rules matching the original .flake8 config 101 | ignore = [ 102 | "D1", # Missing docstrings 103 | "COM812", # Missing trailing comma (conflicts with formatter) 104 | "COM819", # Prohibited trailing comma 105 | "B028", # No explicit stacklevel argument in warnings.warn 106 | "B904", # raise ... from err/None in except clause 107 | ] 108 | 109 | [tool.ruff.lint.mccabe] 110 | max-complexity = 8 111 | 112 | [tool.ruff.lint.flake8-quotes] 113 | inline-quotes = "double" 114 | 115 | [tool.ruff.lint.isort] 116 | # Match .isort.cfg settings 117 | force-single-line = false 118 | force-wrap-aliases = false 119 | split-on-trailing-comma = true 120 | known-first-party = ["inapppy", "helpers", "tests"] 121 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at dotpot@tutanota.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /tests/test_appstore.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | 5 | from inapppy import AppStoreValidator, InAppPyValidationError 6 | 7 | 8 | def test_appstore_validator_initiation_simple(appstore_validator: AppStoreValidator): 9 | assert appstore_validator.url == "https://buy.itunes.apple.com/verifyReceipt" 10 | 11 | 12 | def test_appstore_validator_initiation_sandbox(appstore_validator_sandbox: AppStoreValidator): 13 | assert appstore_validator_sandbox.url == "https://sandbox.itunes.apple.com/verifyReceipt" 14 | 15 | 16 | def test_appstore_validate_simple(appstore_validator: AppStoreValidator): 17 | with patch.object(AppStoreValidator, "post_json", return_value={"status": 0}) as mock_method: 18 | appstore_validator.validate(receipt="test-receipt", shared_secret="shared-secret") 19 | assert mock_method.call_count == 1 20 | assert appstore_validator.url == "https://buy.itunes.apple.com/verifyReceipt" 21 | assert mock_method.call_args[0][0] == {"receipt-data": "test-receipt", "password": "shared-secret"} 22 | 23 | with patch.object(AppStoreValidator, "post_json", return_value={"status": 0}) as mock_method: 24 | appstore_validator.validate(receipt="test-receipt") 25 | assert mock_method.call_count == 1 26 | assert appstore_validator.url == "https://buy.itunes.apple.com/verifyReceipt" 27 | assert mock_method.call_args[0][0] == {"receipt-data": "test-receipt"} 28 | 29 | 30 | def test_appstore_validate_sandbox(appstore_validator_sandbox: AppStoreValidator): 31 | with patch.object(AppStoreValidator, "post_json", return_value={"status": 0}) as mock_method: 32 | appstore_validator_sandbox.validate(receipt="test-receipt", shared_secret="shared-secret") 33 | assert mock_method.call_count == 1 34 | assert appstore_validator_sandbox.url == "https://sandbox.itunes.apple.com/verifyReceipt" 35 | assert mock_method.call_args[0][0] == {"receipt-data": "test-receipt", "password": "shared-secret"} 36 | 37 | with patch.object(AppStoreValidator, "post_json", return_value={"status": 0}) as mock_method: 38 | appstore_validator_sandbox.validate(receipt="test-receipt") 39 | assert mock_method.call_count == 1 40 | assert appstore_validator_sandbox.url == "https://sandbox.itunes.apple.com/verifyReceipt" 41 | assert mock_method.call_args[0][0] == {"receipt-data": "test-receipt"} 42 | 43 | 44 | def test_appstore_validate_attach_raw_response_to_the_exception(appstore_validator: AppStoreValidator): 45 | raw_response = {"status": 21000, "foo": "bar"} 46 | 47 | with pytest.raises(InAppPyValidationError) as ex: 48 | with patch.object(AppStoreValidator, "post_json", return_value=raw_response) as mock_method: 49 | appstore_validator.validate(receipt="test-receipt", shared_secret="shared-secret") 50 | assert mock_method.call_count == 1 51 | assert appstore_validator.url == "https://buy.itunes.apple.com/verifyReceipt" 52 | assert mock_method.call_args[0][0] == {"receipt-data": "test-receipt", "password": "shared-secret"} 53 | assert ex.raw_response is not None 54 | assert ex.raw_response == raw_response 55 | 56 | 57 | def test_appstore_validate_attach_raw_response_to_the_exception_when_status_unkown( 58 | appstore_validator: AppStoreValidator, 59 | ): 60 | raw_response = {"status": "x", "foo": "bar"} 61 | 62 | with pytest.raises(InAppPyValidationError) as ex: 63 | with patch.object(AppStoreValidator, "post_json", return_value=raw_response) as mock_method: 64 | appstore_validator.validate(receipt="test-receipt", shared_secret="shared-secret") 65 | assert mock_method.call_count == 1 66 | assert appstore_validator.url == "https://buy.itunes.apple.com/verifyReceipt" 67 | assert mock_method.call_args[0][0] == {"receipt-data": "test-receipt", "password": "shared-secret"} 68 | assert ex.raw_response is not None 69 | assert ex.raw_response == raw_response 70 | 71 | 72 | def test_appstore_auto_retry_wrong_env_request(appstore_validator_auto_retry_on_sandbox: AppStoreValidator): 73 | validator = appstore_validator_auto_retry_on_sandbox 74 | assert not validator.sandbox 75 | assert validator.auto_retry_wrong_env_request 76 | 77 | raw_response = {"status": 21007, "foo": "bar"} 78 | with pytest.raises(InAppPyValidationError): 79 | with patch.object(AppStoreValidator, "post_json", return_value=raw_response) as mock_method: 80 | validator.validate(receipt="test-receipt", shared_secret="shared-secret") 81 | assert mock_method.call_count == 1 82 | assert validator.url == "https://buy.itunes.apple.com/verifyReceipt" 83 | assert mock_method.call_args[0][0] == {"receipt-data": "test-receipt", "password": "shared-secret"} 84 | assert validator.sandbox is True 85 | 86 | raw_response = {"status": 21008, "foo": "bar"} 87 | with pytest.raises(InAppPyValidationError): 88 | with patch.object(AppStoreValidator, "post_json", return_value=raw_response) as mock_method: 89 | validator.validate(receipt="test-receipt", shared_secret="shared-secret") 90 | assert validator.sandbox is False 91 | assert mock_method.call_count == 1 92 | assert validator.url == "https://buy.itunes.apple.com/verifyReceipt" 93 | assert mock_method.call_args[0][0] == {"receipt-data": "test-receipt", "password": "shared-secret"} 94 | 95 | 96 | def test_appstore_http_error_includes_raw_response(appstore_validator: AppStoreValidator): 97 | """Test that HTTP errors include raw_response with status code and content""" 98 | from unittest.mock import Mock 99 | 100 | # Mock a response that has status code but invalid JSON 101 | mock_response = Mock() 102 | mock_response.status_code = 503 103 | mock_response.text = "Service Unavailable" 104 | mock_response.json.side_effect = ValueError("No JSON object could be decoded") 105 | 106 | with pytest.raises(InAppPyValidationError) as exc_info: 107 | with patch("requests.post", return_value=mock_response): 108 | appstore_validator.post_json({"receipt-data": "test"}) 109 | 110 | # Verify raw_response is not None and contains useful information 111 | assert exc_info.value.raw_response is not None 112 | assert "error" in exc_info.value.raw_response 113 | assert "status_code" in exc_info.value.raw_response 114 | assert exc_info.value.raw_response["status_code"] == 503 115 | assert "content" in exc_info.value.raw_response 116 | assert exc_info.value.raw_response["content"] == "Service Unavailable" 117 | 118 | 119 | def test_appstore_network_error_includes_raw_response(appstore_validator: AppStoreValidator): 120 | """Test that network errors include raw_response with error details""" 121 | from requests.exceptions import RequestException 122 | 123 | with pytest.raises(InAppPyValidationError) as exc_info: 124 | with patch("requests.post", side_effect=RequestException("Connection timeout")): 125 | appstore_validator.post_json({"receipt-data": "test"}) 126 | 127 | # Verify raw_response is not None and contains error information 128 | assert exc_info.value.raw_response is not None 129 | assert "error" in exc_info.value.raw_response 130 | assert "Connection timeout" in exc_info.value.raw_response["error"] 131 | -------------------------------------------------------------------------------- /tests/data/androidpublisher.json: -------------------------------------------------------------------------------- 1 | { 2 | "rootUrl": "https://www.googleapis.com/", 3 | "servicePath": "androidpublisher/v3/applications/", 4 | "schemas": { 5 | "IntroductoryPriceInfo": { 6 | "id": "IntroductoryPriceInfo", 7 | "type": "object", 8 | "properties": { 9 | "introductoryPriceAmountMicros": { 10 | "type": "string", 11 | "format": "int64" 12 | }, 13 | "introductoryPriceCurrencyCode": { 14 | "type": "string" 15 | }, 16 | "introductoryPriceCycles": { 17 | "type": "integer", 18 | "format": "int32" 19 | }, 20 | "introductoryPricePeriod": { 21 | "type": "string" 22 | } 23 | } 24 | }, 25 | "Price": { 26 | "id": "Price", 27 | "type": "object", 28 | "properties": { 29 | "currency": { 30 | "type": "string" 31 | }, 32 | "priceMicros": { 33 | "type": "string" 34 | } 35 | } 36 | }, 37 | "ProductPurchase": { 38 | "id": "ProductPurchase", 39 | "type": "object", 40 | "properties": { 41 | "acknowledgementState": { 42 | "type": "integer", 43 | "format": "int32" 44 | }, 45 | "consumptionState": { 46 | "type": "integer", 47 | "format": "int32" 48 | }, 49 | "developerPayload": { 50 | "type": "string" 51 | }, 52 | "kind": { 53 | "type": "string", 54 | "default": "androidpublisher#productPurchase" 55 | }, 56 | "orderId": { 57 | "type": "string" 58 | }, 59 | "purchaseState": { 60 | "type": "integer", 61 | "format": "int32" 62 | }, 63 | "purchaseTimeMillis": { 64 | "type": "string", 65 | "format": "int64" 66 | }, 67 | "purchaseType": { 68 | "type": "integer", 69 | "format": "int32" 70 | } 71 | } 72 | }, 73 | "SubscriptionCancelSurveyResult": { 74 | "id": "SubscriptionCancelSurveyResult", 75 | "type": "object", 76 | "properties": { 77 | "cancelSurveyReason": { 78 | "type": "integer", 79 | "format": "int32" 80 | }, 81 | "userInputCancelReason": { 82 | "type": "string" 83 | } 84 | } 85 | }, 86 | "SubscriptionPriceChange": { 87 | "id": "SubscriptionPriceChange", 88 | "type": "object", 89 | "properties": { 90 | "newPrice": { 91 | "$ref": "Price" 92 | }, 93 | "state": { 94 | "type": "integer", 95 | "format": "int32" 96 | } 97 | } 98 | }, 99 | "SubscriptionPurchase": { 100 | "id": "SubscriptionPurchase", 101 | "type": "object", 102 | "properties": { 103 | "acknowledgementState": { 104 | "type": "integer", 105 | "format": "int32" 106 | }, 107 | "autoRenewing": { 108 | "type": "boolean" 109 | }, 110 | "autoResumeTimeMillis": { 111 | "type": "string", 112 | "format": "int64" 113 | }, 114 | "cancelReason": { 115 | "type": "integer", 116 | "format": "int32" 117 | }, 118 | "cancelSurveyResult": { 119 | "$ref": "SubscriptionCancelSurveyResult" 120 | }, 121 | "countryCode": { 122 | "type": "string" 123 | }, 124 | "developerPayload": { 125 | "type": "string" 126 | }, 127 | "emailAddress": { 128 | "type": "string" 129 | }, 130 | "expiryTimeMillis": { 131 | "type": "string", 132 | "format": "int64" 133 | }, 134 | "familyName": { 135 | "type": "string" 136 | }, 137 | "givenName": { 138 | "type": "string" 139 | }, 140 | "introductoryPriceInfo": { 141 | "$ref": "IntroductoryPriceInfo" 142 | }, 143 | "kind": { 144 | "type": "string", 145 | "default": "androidpublisher#subscriptionPurchase" 146 | }, 147 | "linkedPurchaseToken": { 148 | "type": "string" 149 | }, 150 | "orderId": { 151 | "type": "string" 152 | }, 153 | "paymentState": { 154 | "type": "integer", 155 | "format": "int32" 156 | }, 157 | "priceAmountMicros": { 158 | "type": "string", 159 | "format": "int64" 160 | }, 161 | "priceChange": { 162 | "$ref": "SubscriptionPriceChange" 163 | }, 164 | "priceCurrencyCode": { 165 | "type": "string" 166 | }, 167 | "profileId": { 168 | "type": "string" 169 | }, 170 | "profileName": { 171 | "type": "string" 172 | }, 173 | "purchaseType": { 174 | "type": "integer", 175 | "format": "int32" 176 | }, 177 | "startTimeMillis": { 178 | "type": "string", 179 | "format": "int64" 180 | }, 181 | "userCancellationTimeMillis": { 182 | "type": "string", 183 | "format": "int64" 184 | } 185 | } 186 | } 187 | }, 188 | "resources": { 189 | "purchases": { 190 | "resources": { 191 | "products": { 192 | "methods": { 193 | "get": { 194 | "id": "androidpublisher.purchases.products.get", 195 | "path": "{packageName}/purchases/products/{productId}/tokens/{token}", 196 | "httpMethod": "GET", 197 | "parameters": { 198 | "packageName": { 199 | "type": "string", 200 | "required": true, 201 | "location": "path" 202 | }, 203 | "productId": { 204 | "type": "string", 205 | "required": true, 206 | "location": "path" 207 | }, 208 | "token": { 209 | "type": "string", 210 | "required": true, 211 | "location": "path" 212 | } 213 | }, 214 | "parameterOrder": [ 215 | "packageName", 216 | "productId", 217 | "token" 218 | ], 219 | "response": { 220 | "$ref": "ProductPurchase" 221 | }, 222 | "scopes": [ 223 | "https://www.googleapis.com/auth/androidpublisher" 224 | ] 225 | } 226 | } 227 | }, 228 | "subscriptions": { 229 | "methods": { 230 | "get": { 231 | "id": "androidpublisher.purchases.subscriptions.get", 232 | "path": "{packageName}/purchases/subscriptions/{subscriptionId}/tokens/{token}", 233 | "httpMethod": "GET", 234 | "parameters": { 235 | "packageName": { 236 | "type": "string", 237 | "required": true, 238 | "location": "path" 239 | }, 240 | "subscriptionId": { 241 | "type": "string", 242 | "required": true, 243 | "location": "path" 244 | }, 245 | "token": { 246 | "type": "string", 247 | "required": true, 248 | "location": "path" 249 | } 250 | }, 251 | "parameterOrder": [ 252 | "packageName", 253 | "subscriptionId", 254 | "token" 255 | ], 256 | "response": { 257 | "$ref": "SubscriptionPurchase" 258 | }, 259 | "scopes": [ 260 | "https://www.googleapis.com/auth/androidpublisher" 261 | ] 262 | } 263 | } 264 | } 265 | } 266 | } 267 | } 268 | } -------------------------------------------------------------------------------- /tests/asyncio/test_asyncio_appstore.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | import pytest 4 | 5 | from inapppy import InAppPyValidationError 6 | from inapppy.asyncio import AppStoreValidator 7 | 8 | 9 | def test_appstore_validator_initiation_simple(appstore_validator: AppStoreValidator): 10 | assert appstore_validator.url == "https://buy.itunes.apple.com/verifyReceipt" 11 | 12 | 13 | def test_appstore_validator_initiation_sandbox(appstore_validator_sandbox): 14 | assert appstore_validator_sandbox.url == "https://sandbox.itunes.apple.com/verifyReceipt" 15 | 16 | 17 | @pytest.mark.asyncio 18 | async def test_appstore_validate_simple(appstore_validator: AppStoreValidator): 19 | async def post_json(self, request_json): 20 | assert request_json == {"receipt-data": "test-receipt", "password": "shared-secret"} 21 | return {"status": 0} 22 | 23 | async def post_json_no_secret(self, request_json): 24 | assert request_json == {"receipt-data": "test-receipt"} 25 | return {"status": 0} 26 | 27 | with patch.object(AppStoreValidator, "post_json", new=post_json): 28 | await appstore_validator.validate(receipt="test-receipt", shared_secret="shared-secret") 29 | assert appstore_validator.url == "https://buy.itunes.apple.com/verifyReceipt" 30 | 31 | with patch.object(AppStoreValidator, "post_json", new=post_json_no_secret): 32 | await appstore_validator.validate(receipt="test-receipt") 33 | assert appstore_validator.url == "https://buy.itunes.apple.com/verifyReceipt" 34 | 35 | 36 | @pytest.mark.asyncio 37 | async def test_appstore_validate_sandbox(appstore_validator_sandbox: AppStoreValidator): 38 | async def post_json(self, receipt): 39 | assert receipt == {"receipt-data": "test-receipt", "password": "shared-secret"} 40 | return {"status": 0} 41 | 42 | async def post_json_no_secret(self, receipt): 43 | assert receipt == {"receipt-data": "test-receipt"} 44 | assert receipt == {"receipt-data": "test-receipt"} 45 | return {"status": 0} 46 | 47 | with patch.object(AppStoreValidator, "post_json", new=post_json): 48 | await appstore_validator_sandbox.validate(receipt="test-receipt", shared_secret="shared-secret") 49 | assert appstore_validator_sandbox.url == "https://sandbox.itunes.apple.com/verifyReceipt" 50 | 51 | with patch.object(AppStoreValidator, "post_json", new=post_json_no_secret): 52 | await appstore_validator_sandbox.validate(receipt="test-receipt") 53 | assert appstore_validator_sandbox.url == "https://sandbox.itunes.apple.com/verifyReceipt" 54 | 55 | 56 | @pytest.mark.asyncio 57 | async def test_appstore_validate_attach_raw_response_to_the_exception(appstore_validator: AppStoreValidator): 58 | raw_response = {"status": 21000, "foo": "bar"} 59 | 60 | async def post_json(self, receipt): 61 | assert receipt == {"receipt-data": "test-receipt", "password": "shared-secret"} 62 | return raw_response 63 | 64 | with pytest.raises(InAppPyValidationError) as ex: 65 | with patch.object(AppStoreValidator, "post_json", new=post_json): 66 | await appstore_validator.validate(receipt="test-receipt", shared_secret="shared-secret") 67 | assert appstore_validator.url == "https://buy.itunes.apple.com/verifyReceipt" 68 | assert ex.raw_response is not None 69 | assert ex.raw_response == raw_response 70 | 71 | 72 | @pytest.mark.asyncio 73 | async def test_appstore_auto_retry_wrong_env_request(appstore_validator_auto_retry_on_sandbox: AppStoreValidator): 74 | validator = appstore_validator_auto_retry_on_sandbox 75 | assert validator is not None 76 | assert not validator.sandbox 77 | assert validator.auto_retry_wrong_env_request 78 | 79 | raw_response = {"status": 21007, "foo": "bar"} 80 | 81 | async def post_json(self, receipt): 82 | assert receipt == {"receipt-data": "test-receipt", "password": "shared-secret"} 83 | return raw_response 84 | 85 | with pytest.raises(InAppPyValidationError): 86 | with patch.object(AppStoreValidator, "post_json", new=post_json): 87 | await validator.validate(receipt="test-receipt", shared_secret="shared-secret") 88 | assert validator.url == "https://buy.itunes.apple.com/verifyReceipt" 89 | assert validator.sandbox is True 90 | 91 | raw_response = {"status": 21008, "foo": "bar"} 92 | 93 | async def post_json(self, receipt): 94 | assert receipt == {"receipt-data": "test-receipt", "password": "shared-secret"} 95 | return raw_response 96 | 97 | with pytest.raises(InAppPyValidationError): 98 | with patch.object(AppStoreValidator, "post_json", new=post_json): 99 | await validator.validate(receipt="test-receipt", shared_secret="shared-secret") 100 | assert validator.sandbox is False 101 | assert validator.url == "https://buy.itunes.apple.com/verifyReceipt" 102 | 103 | 104 | @pytest.mark.asyncio 105 | async def test_appstore_async_context_manager(): 106 | """Test that async context manager properly initializes and returns self.""" 107 | bundle_id = "com.test.app" 108 | validator = AppStoreValidator(bundle_id) 109 | 110 | # Verify session is None before entering context 111 | assert validator._session is None 112 | 113 | # Test async context manager 114 | async with validator as v: 115 | # Verify __aenter__ returns self 116 | assert v is validator 117 | # Verify session is initialized 118 | assert validator._session is not None 119 | 120 | # Verify session is closed after exiting context 121 | assert validator._session is None 122 | 123 | 124 | @pytest.mark.asyncio 125 | async def test_appstore_async_http_error_includes_raw_response(appstore_validator: AppStoreValidator): 126 | """Test that async HTTP errors include raw_response with status code and content""" 127 | from unittest.mock import AsyncMock, Mock 128 | 129 | # Create a mock response 130 | mock_resp = Mock() 131 | mock_resp.status = 503 132 | mock_resp.text = AsyncMock(return_value="Service Unavailable") 133 | 134 | # Create a mock session post that returns our mock response 135 | def mock_post(*args, **kwargs): 136 | class MockContext: 137 | async def __aenter__(self): 138 | return mock_resp 139 | 140 | async def __aexit__(self, *args): 141 | pass 142 | 143 | return MockContext() 144 | 145 | with pytest.raises(InAppPyValidationError) as exc_info: 146 | appstore_validator._session = Mock() 147 | appstore_validator._session.post = mock_post 148 | # Mock json.loads to raise ValueError (simulating invalid JSON) 149 | with patch("json.loads", side_effect=ValueError("Invalid JSON")): 150 | await appstore_validator.post_json({"receipt-data": "test"}) 151 | 152 | # Verify raw_response is not None and contains useful information 153 | assert exc_info.value.raw_response is not None 154 | assert "error" in exc_info.value.raw_response 155 | assert "status_code" in exc_info.value.raw_response 156 | assert exc_info.value.raw_response["status_code"] == 503 157 | assert "content" in exc_info.value.raw_response 158 | assert exc_info.value.raw_response["content"] == "Service Unavailable" 159 | 160 | 161 | @pytest.mark.asyncio 162 | async def test_appstore_async_network_error_includes_raw_response(appstore_validator: AppStoreValidator): 163 | """Test that async network errors include raw_response with error details""" 164 | from aiohttp import ClientError 165 | 166 | # Create a mock session that raises ClientError 167 | def mock_post(*args, **kwargs): 168 | class MockContext: 169 | async def __aenter__(self): 170 | raise ClientError("Connection timeout") 171 | 172 | async def __aexit__(self, *args): 173 | pass 174 | 175 | return MockContext() 176 | 177 | with pytest.raises(InAppPyValidationError) as exc_info: 178 | appstore_validator._session = Mock() 179 | appstore_validator._session.post = mock_post 180 | await appstore_validator.post_json({"receipt-data": "test"}) 181 | 182 | # Verify raw_response is not None and contains error information 183 | assert exc_info.value.raw_response is not None 184 | assert "error" in exc_info.value.raw_response 185 | assert "Connection timeout" in exc_info.value.raw_response["error"] 186 | -------------------------------------------------------------------------------- /inapppy/appstore.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import warnings 3 | 4 | import requests 5 | from requests.exceptions import RequestException 6 | 7 | from inapppy.errors import InAppPyValidationError 8 | 9 | # https://developer.apple.com/library/content/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html 10 | # `Table 2-1 Status codes` 11 | api_result_ok = 0 12 | api_result_errors = { 13 | 21000: InAppPyValidationError("Bad json"), 14 | 21002: InAppPyValidationError("Bad data"), 15 | 21003: InAppPyValidationError("Receipt authentication"), 16 | 21004: InAppPyValidationError("Shared secret mismatch"), 17 | 21005: InAppPyValidationError("Server is unavailable"), 18 | 21006: InAppPyValidationError("Subscription has expired"), 19 | # two following errors can use auto_retry_wrong_env_request. 20 | 21007: InAppPyValidationError("Sandbox receipt was sent to the production env"), 21 | 21008: InAppPyValidationError("Production receipt was sent to the sandbox env"), 22 | 21009: InAppPyValidationError("Internal data access error"), 23 | 21010: InAppPyValidationError("The user account cannot be found or has been deleted"), 24 | } 25 | 26 | 27 | class AppStoreVerificationResult: 28 | """App Store verification result class.""" 29 | 30 | raw_response: dict = {} 31 | is_expired: bool = False 32 | is_cancelled: bool = False 33 | 34 | def __init__(self, raw_response: dict, is_expired: bool, is_cancelled: bool): 35 | self.raw_response = raw_response 36 | self.is_expired = is_expired 37 | self.is_cancelled = is_cancelled 38 | 39 | def __repr__(self): 40 | return ( 41 | f"AppStoreVerificationResult(" 42 | f"raw_response={self.raw_response}, " 43 | f"is_expired={self.is_expired}, " 44 | f"is_cancelled={self.is_cancelled})" 45 | ) 46 | 47 | 48 | class AppStoreValidator: 49 | def __init__( 50 | self, 51 | bundle_id: str = "", 52 | sandbox: bool = False, 53 | auto_retry_wrong_env_request: bool = False, 54 | http_timeout: int = None, 55 | ): 56 | """Constructor for AppStoreValidator 57 | 58 | :param bundle_id: apple bundle id (no longer required). 59 | :param sandbox: sandbox mode ? 60 | :param auto_retry_wrong_env_request: auto retry on wrong env ? 61 | """ 62 | if bundle_id: 63 | warnings.warn( 64 | "bundle_id will be removed in version 3, since it's not used here.", 65 | PendingDeprecationWarning, 66 | ) 67 | 68 | self.bundle_id = bundle_id 69 | self.sandbox = sandbox 70 | self.http_timeout = http_timeout 71 | self.auto_retry_wrong_env_request = auto_retry_wrong_env_request 72 | 73 | self._change_url_by_sandbox() 74 | 75 | def _change_url_by_sandbox(self): 76 | self.url = ( 77 | "https://sandbox.itunes.apple.com/verifyReceipt" 78 | if self.sandbox 79 | else "https://buy.itunes.apple.com/verifyReceipt" 80 | ) 81 | 82 | def _prepare_receipt(self, receipt: str, shared_secret: str, exclude_old_transactions: bool) -> dict: 83 | receipt_json = {"receipt-data": receipt} 84 | 85 | if shared_secret: 86 | receipt_json["password"] = shared_secret 87 | 88 | if exclude_old_transactions: 89 | receipt_json["exclude-old-transactions"] = True 90 | 91 | return receipt_json 92 | 93 | def post_json(self, request_json: dict) -> dict: 94 | self._change_url_by_sandbox() 95 | 96 | response = None 97 | try: 98 | response = requests.post(self.url, json=request_json, timeout=self.http_timeout) 99 | return response.json() 100 | except (ValueError, RequestException) as e: 101 | # Build raw_response with available information 102 | raw_response = {"error": str(e)} 103 | 104 | # Try to include response details if available 105 | if response is not None: 106 | raw_response["status_code"] = response.status_code 107 | try: 108 | raw_response["content"] = response.text 109 | except Exception: 110 | pass 111 | 112 | raise InAppPyValidationError("HTTP error", raw_response=raw_response) 113 | 114 | @staticmethod 115 | def _ms_timestamp_expired(ms_timestamp: str) -> bool: 116 | """Check if a millisecond timestamp has expired. 117 | 118 | :param ms_timestamp: timestamp in milliseconds as string 119 | :return: True if expired, False otherwise 120 | """ 121 | now = datetime.datetime.utcnow() 122 | 123 | # Return if it's 0/None, expired. 124 | if not ms_timestamp: 125 | return True 126 | 127 | try: 128 | ms_timestamp_value = int(ms_timestamp) / 1000 129 | except (ValueError, TypeError): 130 | return True 131 | 132 | # Return if it's 0, expired. 133 | if not ms_timestamp_value: 134 | return True 135 | 136 | return datetime.datetime.utcfromtimestamp(ms_timestamp_value) < now 137 | 138 | @staticmethod 139 | def _check_subscription_expired(receipt_info: dict) -> bool: 140 | """Check if subscription is expired based on latest_receipt_info. 141 | 142 | :param receipt_info: latest receipt info from Apple's response 143 | :return: True if expired, False otherwise 144 | """ 145 | if not receipt_info: 146 | return True 147 | 148 | # Get the expires_date_ms from the latest receipt 149 | expires_date_ms = receipt_info.get("expires_date_ms", "0") 150 | return AppStoreValidator._ms_timestamp_expired(expires_date_ms) 151 | 152 | @staticmethod 153 | def _check_subscription_cancelled(receipt_info: dict) -> bool: 154 | """Check if subscription is cancelled based on cancellation_date. 155 | 156 | :param receipt_info: latest receipt info from Apple's response 157 | :return: True if cancelled, False otherwise 158 | """ 159 | if not receipt_info: 160 | return False 161 | 162 | # If cancellation_date or cancellation_date_ms exists, subscription was cancelled/refunded 163 | return "cancellation_date" in receipt_info or "cancellation_date_ms" in receipt_info 164 | 165 | def validate( 166 | self, 167 | receipt: str, 168 | shared_secret: str = None, 169 | exclude_old_transactions: bool = False, 170 | ) -> dict: 171 | """Validates receipt against apple services. 172 | 173 | :param receipt: receipt 174 | :param shared_secret: optional shared secret. 175 | :param exclude_old_transactions: optional to include only the latest renewal transaction 176 | :return: validation result or exception. 177 | """ 178 | receipt_json = self._prepare_receipt(receipt, shared_secret, exclude_old_transactions) 179 | 180 | api_response = self.post_json(receipt_json) 181 | status = api_response.get("status", "unknown") 182 | 183 | # Check retry case. 184 | if self.auto_retry_wrong_env_request and status in [21007, 21008]: 185 | # switch environment 186 | self.sandbox = not self.sandbox 187 | 188 | api_response = self.post_json(receipt_json) 189 | status = api_response["status"] 190 | 191 | if status != api_result_ok: 192 | error = api_result_errors.get(status, InAppPyValidationError("Unknown API status")) 193 | error.raw_response = api_response 194 | 195 | raise error 196 | 197 | return api_response 198 | 199 | def verify_with_result( 200 | self, 201 | receipt: str, 202 | shared_secret: str = None, 203 | exclude_old_transactions: bool = False, 204 | ) -> AppStoreVerificationResult: 205 | """Validates receipt and returns verification result instead of raising an error. 206 | 207 | This is an alternative to validate() method that returns a result object 208 | with is_expired and is_cancelled properties instead of raising exceptions. 209 | 210 | :param receipt: receipt 211 | :param shared_secret: optional shared secret. 212 | :param exclude_old_transactions: optional to include only the latest renewal transaction 213 | :return: AppStoreVerificationResult with validation details 214 | """ 215 | try: 216 | api_response = self.validate(receipt, shared_secret, exclude_old_transactions) 217 | except InAppPyValidationError as e: 218 | # If validation fails, return result with raw_response from exception 219 | api_response = getattr(e, "raw_response", {}) 220 | 221 | # Get latest receipt info (last element in array as it's the most recent) 222 | latest_receipt_info_list = api_response.get("latest_receipt_info", []) 223 | latest_receipt_info = latest_receipt_info_list[-1] if latest_receipt_info_list else {} 224 | 225 | # Check if subscription is expired or cancelled 226 | is_expired = self._check_subscription_expired(latest_receipt_info) 227 | is_cancelled = self._check_subscription_cancelled(latest_receipt_info) 228 | 229 | return AppStoreVerificationResult(raw_response=api_response, is_expired=is_expired, is_cancelled=is_cancelled) 230 | -------------------------------------------------------------------------------- /tests/test_google_verifier.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | from unittest.mock import patch 4 | 5 | import httplib2 6 | import pytest 7 | from googleapiclient.http import HttpMock, RequestMockBuilder 8 | 9 | from inapppy import GooglePlayVerifier, errors, googleplay 10 | 11 | 12 | def test_google_verify_subscription(): 13 | with patch.object(googleplay, "build", return_value=None): 14 | with patch.object(googleplay.GooglePlayVerifier, "_authorize", return_value=None): 15 | verifier = googleplay.GooglePlayVerifier("test-bundle-id", "private_key_path", 30) 16 | 17 | # expired 18 | with patch.object(verifier, "check_purchase_subscription", return_value={"expiryTimeMillis": 666}): 19 | with pytest.raises(errors.GoogleError): 20 | verifier.verify("test-token", "test-product", is_subscription=True) 21 | 22 | # canceled - non-zero cancelReason 23 | with patch.object(verifier, "check_purchase_subscription", return_value={"cancelReason": 666}): 24 | with pytest.raises(errors.GoogleError): 25 | verifier.verify("test-token", "test-product", is_subscription=True) 26 | 27 | # canceled - user canceled (cancelReason = 0) 28 | with patch.object(verifier, "check_purchase_subscription", return_value={"cancelReason": 0}): 29 | with pytest.raises(errors.GoogleError): 30 | verifier.verify("test-token", "test-product", is_subscription=True) 31 | 32 | # norm 33 | now = datetime.datetime.utcnow().timestamp() 34 | with patch.object( 35 | verifier, "check_purchase_subscription", return_value={"expiryTimeMillis": now * 1000 + 10**10} 36 | ): 37 | verifier.verify("test-token", "test-product", is_subscription=True) 38 | 39 | 40 | def test_google_verify_with_result_subscription(): 41 | with patch.object(googleplay, "build", return_value=None): 42 | with patch.object(googleplay.GooglePlayVerifier, "_authorize", return_value=None): 43 | verifier = googleplay.GooglePlayVerifier("test-bundle-id", "private_key_path", 30) 44 | 45 | # expired 46 | with patch.object(verifier, "check_purchase_subscription", return_value={"expiryTimeMillis": 666}): 47 | result = verifier.verify_with_result("test-token", "test-product", is_subscription=True) 48 | assert result.is_canceled is False 49 | assert result.is_expired 50 | assert result.raw_response == {"expiryTimeMillis": 666} 51 | assert ( 52 | str(result) == "GoogleVerificationResult(raw_response=" 53 | "{'expiryTimeMillis': 666}, " 54 | "is_expired=True, " 55 | "is_canceled=False)" 56 | ) 57 | 58 | # canceled - non-zero cancelReason 59 | with patch.object(verifier, "check_purchase_subscription", return_value={"cancelReason": 666}): 60 | result = verifier.verify_with_result("test-token", "test-product", is_subscription=True) 61 | assert result.is_canceled 62 | assert result.is_expired 63 | assert result.raw_response == {"cancelReason": 666} 64 | assert ( 65 | str(result) == "GoogleVerificationResult(" 66 | "raw_response={'cancelReason': 666}, " 67 | "is_expired=True, " 68 | "is_canceled=True)" 69 | ) 70 | 71 | # canceled - user canceled (cancelReason = 0) 72 | # This is the fix for issue #66: cancelReason=0 means user canceled, not "not canceled" 73 | with patch.object(verifier, "check_purchase_subscription", return_value={"cancelReason": 0}): 74 | result = verifier.verify_with_result("test-token", "test-product", is_subscription=True) 75 | assert result.is_canceled 76 | # Note: is_expired=True because expiryTimeMillis is missing (backward compatibility) 77 | assert result.is_expired 78 | assert result.raw_response == {"cancelReason": 0} 79 | assert ( 80 | str(result) == "GoogleVerificationResult(" 81 | "raw_response={'cancelReason': 0}, " 82 | "is_expired=True, " 83 | "is_canceled=True)" 84 | ) 85 | 86 | # norm 87 | now = datetime.datetime.utcnow().timestamp() 88 | exp_value = now * 1000 + 10**10 89 | with patch.object(verifier, "check_purchase_subscription", return_value={"expiryTimeMillis": exp_value}): 90 | result = verifier.verify_with_result("test-token", "test-product", is_subscription=True) 91 | assert result.is_canceled is False 92 | assert result.is_expired is False 93 | assert result.raw_response == {"expiryTimeMillis": exp_value} 94 | assert str(result) is not None 95 | 96 | 97 | def test_google_verify_product(): 98 | with patch.object(googleplay, "build", return_value=None): 99 | with patch.object(googleplay.GooglePlayVerifier, "_authorize", return_value=None): 100 | verifier = googleplay.GooglePlayVerifier("test-bundle-id", "private_key_path", 30) 101 | 102 | # purchase 103 | with patch.object(verifier, "check_purchase_product", return_value={"purchaseState": 0}): 104 | verifier.verify("test-token", "test-product") 105 | 106 | # cancelled 107 | with patch.object(verifier, "check_purchase_product", return_value={"purchaseState": 1}): 108 | with pytest.raises(errors.GoogleError): 109 | verifier.verify("test-token", "test-product") 110 | 111 | 112 | def test_google_verify_with_result_product(): 113 | with patch.object(googleplay, "build", return_value=None): 114 | with patch.object(googleplay.GooglePlayVerifier, "_authorize", return_value=None): 115 | verifier = googleplay.GooglePlayVerifier("test-bundle-id", "private_key_path", 30) 116 | 117 | # purchase 118 | with patch.object(verifier, "check_purchase_product", return_value={"purchaseState": 0}): 119 | result = verifier.verify_with_result("test-token", "test-product") 120 | assert result.is_canceled is False 121 | assert result.is_expired is False 122 | assert result.raw_response == {"purchaseState": 0} 123 | assert str(result) is not None 124 | 125 | # cancelled 126 | with patch.object(verifier, "check_purchase_product", return_value={"purchaseState": 1}): 127 | result = verifier.verify_with_result("test-token", "test-product") 128 | assert result.is_canceled 129 | assert result.is_expired is False 130 | assert result.raw_response == {"purchaseState": 1} 131 | assert ( 132 | str(result) == "GoogleVerificationResult(" 133 | "raw_response={'purchaseState': 1}, " 134 | "is_expired=False, " 135 | "is_canceled=True)" 136 | ) 137 | 138 | 139 | DATA_DIR = os.path.join(os.path.dirname(__file__), "data") 140 | 141 | 142 | def datafile(filename): 143 | return os.path.join(DATA_DIR, filename) 144 | 145 | 146 | def test_bad_request_subscription(): 147 | with patch.object(googleplay.GooglePlayVerifier, "_authorize", return_value=None): 148 | verifier = GooglePlayVerifier("bundle_id", "private_key_path") 149 | 150 | auth = HttpMock(datafile("androidpublisher.json"), headers={"status": 200}) 151 | 152 | request_mock_builder = RequestMockBuilder( 153 | { 154 | "androidpublisher.purchases.subscriptionsv2.get": ( 155 | httplib2.Response({"status": 400, "reason": b"Bad request"}), 156 | b'{"reason": "Bad request"}', 157 | ) 158 | } 159 | ) 160 | build_mock_result = googleplay.build("androidpublisher", "v3", http=auth, requestBuilder=request_mock_builder) 161 | 162 | with patch.object(googleplay, "build", return_value=build_mock_result): 163 | with pytest.raises(errors.GoogleError, match="Bad request"): 164 | verifier.verify("broken_purchase_token", "product_scu", is_subscription=True) 165 | 166 | 167 | def test_bad_request_product(): 168 | with patch.object(googleplay.GooglePlayVerifier, "_authorize", return_value=None): 169 | verifier = GooglePlayVerifier("bundle_id", "private_key_path") 170 | 171 | auth = HttpMock(datafile("androidpublisher.json"), headers={"status": 200}) 172 | 173 | request_mock_builder = RequestMockBuilder( 174 | { 175 | "androidpublisher.purchases.products.get": ( 176 | httplib2.Response({"status": 400, "reason": b"Bad request"}), 177 | b'{"reason": "Bad request"}', 178 | ) 179 | } 180 | ) 181 | build_mock_result = googleplay.build("androidpublisher", "v3", http=auth, requestBuilder=request_mock_builder) 182 | 183 | with patch.object(googleplay, "build", return_value=build_mock_result): 184 | with pytest.raises(errors.GoogleError, match="Bad request"): 185 | verifier.verify("broken_purchase_token", "product_scu") 186 | 187 | 188 | def test_consume_product(): 189 | with patch.object(googleplay.GooglePlayVerifier, "_authorize", return_value=None): 190 | verifier = GooglePlayVerifier("test-bundle-id", "private_key_path", 30) 191 | 192 | auth = HttpMock(datafile("androidpublisher.json"), headers={"status": 200}) 193 | 194 | request_mock_builder = RequestMockBuilder( 195 | { 196 | "androidpublisher.purchases.products.consume": ( 197 | httplib2.Response({"status": 200, "content-type": "application/json"}), 198 | b"{}", 199 | ) 200 | } 201 | ) 202 | build_mock_result = googleplay.build("androidpublisher", "v3", http=auth, requestBuilder=request_mock_builder) 203 | 204 | with patch.object(googleplay, "build", return_value=build_mock_result): 205 | result = verifier.consume_product("test-purchase-token", "test-product-sku") 206 | assert result == {} 207 | 208 | 209 | def test_consume_product_bad_request(): 210 | with patch.object(googleplay.GooglePlayVerifier, "_authorize", return_value=None): 211 | verifier = GooglePlayVerifier("bundle_id", "private_key_path") 212 | 213 | auth = HttpMock(datafile("androidpublisher.json"), headers={"status": 200}) 214 | 215 | request_mock_builder = RequestMockBuilder( 216 | { 217 | "androidpublisher.purchases.products.consume": ( 218 | httplib2.Response({"status": 400, "reason": b"Bad request"}), 219 | b'{"reason": "Bad request"}', 220 | ) 221 | } 222 | ) 223 | build_mock_result = googleplay.build("androidpublisher", "v3", http=auth, requestBuilder=request_mock_builder) 224 | 225 | with patch.object(googleplay, "build", return_value=build_mock_result): 226 | with pytest.raises(errors.GoogleError, match="Bad request"): 227 | verifier.consume_product("broken_purchase_token", "product_scu") 228 | -------------------------------------------------------------------------------- /inapppy/googleplay.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import datetime 3 | import json 4 | import os 5 | from typing import Union 6 | 7 | import httplib2 8 | import rsa 9 | from googleapiclient.discovery import build 10 | from googleapiclient.errors import HttpError 11 | from oauth2client.service_account import ServiceAccountCredentials 12 | 13 | from inapppy.errors import GoogleError, InAppPyError, InAppPyValidationError 14 | 15 | 16 | def make_pem(public_key: str) -> str: 17 | value = (public_key[i : i + 64] for i in range(0, len(public_key), 64)) # noqa: E203 18 | return "\n".join(("-----BEGIN PUBLIC KEY-----", "\n".join(value), "-----END PUBLIC KEY-----")) 19 | 20 | 21 | class GooglePlayValidator: 22 | purchase_state_ok = 0 23 | 24 | def __init__(self, bundle_id: str, api_key: str, default_valid_purchase_state: int = 0) -> None: 25 | """ 26 | Arguments: 27 | bundle_id: str - Also known as Android app's package name. E.g.: 28 | "com.example.calendar". 29 | 30 | api_key: str - Application's Base64-encoded RSA public key. 31 | As of 03.19 this can be found in Google Play Console under 32 | Services & APIs. 33 | 34 | default_valid_purchase_state: int - Accepted purchase state. 35 | """ 36 | if not bundle_id: 37 | raise InAppPyValidationError("bundle_id cannot be empty.") 38 | 39 | elif not api_key: 40 | raise InAppPyValidationError("api_key cannot be empty.") 41 | 42 | self.bundle_id = bundle_id 43 | self.purchase_state_ok = default_valid_purchase_state 44 | 45 | pem = make_pem(api_key) 46 | 47 | try: 48 | self.public_key = rsa.PublicKey.load_pkcs1_openssl_pem(pem) 49 | except TypeError: 50 | raise InAppPyValidationError("Bad API key") 51 | 52 | def validate(self, receipt: str, signature: str) -> dict: 53 | if not self._validate_signature(receipt, signature): 54 | raise InAppPyValidationError("Bad signature") 55 | 56 | try: 57 | receipt_json = json.loads(receipt) 58 | 59 | if receipt_json["packageName"] != self.bundle_id: 60 | raise InAppPyValidationError("Bundle ID mismatch") 61 | 62 | elif receipt_json["purchaseState"] != self.purchase_state_ok: 63 | raise InAppPyValidationError("Item is not purchased") 64 | 65 | return receipt_json 66 | except (KeyError, ValueError): 67 | raise InAppPyValidationError("Bad receipt") 68 | 69 | def _validate_signature(self, receipt: str, signature: str) -> bool: 70 | try: 71 | sig = base64.standard_b64decode(signature) 72 | return rsa.verify(receipt.encode(), sig, self.public_key) 73 | except BaseException: 74 | return False 75 | 76 | 77 | class GoogleVerificationResult: 78 | """Google verification result class.""" 79 | 80 | raw_response: dict = {} 81 | is_expired: bool = False 82 | is_canceled: bool = False 83 | 84 | def __init__(self, raw_response: dict, is_expired: bool, is_canceled: bool): 85 | self.raw_response = raw_response 86 | self.is_expired = is_expired 87 | self.is_canceled = is_canceled 88 | 89 | def __repr__(self): 90 | return ( 91 | f"GoogleVerificationResult(" 92 | f"raw_response={self.raw_response}, " 93 | f"is_expired={self.is_expired}, " 94 | f"is_canceled={self.is_canceled})" 95 | ) 96 | 97 | 98 | class GooglePlayVerifier: 99 | DEFAULT_AUTH_SCOPE = "https://www.googleapis.com/auth/androidpublisher" 100 | 101 | def __init__(self, bundle_id: str, play_console_credentials: Union[str, dict], http_timeout: int = 15) -> None: 102 | """ 103 | Arguments: 104 | bundle_id: str - Also known as Android app's package name. 105 | play_console_credentials - Path or dict contents of Google's Service Credentials 106 | http_timeout: int - HTTP connection timeout. 107 | """ 108 | self.bundle_id = bundle_id 109 | self.play_console_credentials = play_console_credentials 110 | self.http_timeout = http_timeout 111 | self.http = self._authorize() 112 | 113 | @staticmethod 114 | def _ms_timestamp_expired(ms_timestamp: str) -> bool: 115 | now = datetime.datetime.utcnow() 116 | 117 | # Return if it's 0/None, expired. 118 | if not ms_timestamp: 119 | return True 120 | 121 | ms_timestamp_value = int(ms_timestamp) / 1000 122 | 123 | # Return if it's 0, expired. 124 | if not ms_timestamp_value: 125 | return True 126 | 127 | return datetime.datetime.utcfromtimestamp(ms_timestamp_value) < now 128 | 129 | @staticmethod 130 | def _parse_iso8601_timestamp(iso_timestamp: str) -> datetime.datetime: 131 | """Parse ISO 8601 timestamp (e.g., '2024-01-15T10:00:00Z' or '2024-01-15T10:00:00.123456Z').""" 132 | # Remove 'Z' suffix and split timezone offset 133 | cleaned = iso_timestamp.replace("Z", "+00:00") 134 | timestamp_part = cleaned.split("+")[0] 135 | 136 | # Parse with or without microseconds 137 | if "." in timestamp_part: 138 | dt = datetime.datetime.strptime(timestamp_part, "%Y-%m-%dT%H:%M:%S.%f") 139 | else: 140 | dt = datetime.datetime.strptime(timestamp_part, "%Y-%m-%dT%H:%M:%S") 141 | 142 | return dt.replace(tzinfo=datetime.timezone.utc) 143 | 144 | @staticmethod 145 | def _iso_timestamp_expired(iso_timestamp: str) -> bool: 146 | """Check if an ISO 8601 timestamp is expired.""" 147 | try: 148 | expiry_dt = GooglePlayVerifier._parse_iso8601_timestamp(iso_timestamp) 149 | now = datetime.datetime.now(datetime.timezone.utc) 150 | return expiry_dt < now 151 | except (ValueError, AttributeError): 152 | # If parsing fails, don't treat as expired 153 | return False 154 | 155 | @staticmethod 156 | def _create_credentials(play_console_credentials: Union[str, dict], scope_str: str): 157 | # If str, assume it's a filepath 158 | if isinstance(play_console_credentials, str): 159 | if not os.path.exists(play_console_credentials): 160 | raise InAppPyError(f"Google play console credentials file does not exist: {play_console_credentials}") 161 | return ServiceAccountCredentials.from_json_keyfile_name(play_console_credentials, scope_str) 162 | # If dict, assume parsed json 163 | if isinstance(play_console_credentials, dict): 164 | return ServiceAccountCredentials.from_json_keyfile_dict(play_console_credentials, scope_str) 165 | raise InAppPyError( 166 | f"Unknown play console credentials format: {repr(play_console_credentials)}, expected 'dict' or 'str' types" 167 | ) 168 | 169 | def _authorize(self): 170 | http = httplib2.Http(timeout=self.http_timeout) 171 | credentials = self._create_credentials(self.play_console_credentials, self.DEFAULT_AUTH_SCOPE) 172 | http = credentials.authorize(http) 173 | return http 174 | 175 | def check_purchase_subscription(self, purchase_token: str, product_sku: str, service) -> dict: 176 | try: 177 | purchases = service.purchases() 178 | subscriptions = purchases.subscriptionsv2() 179 | subscriptions_get = subscriptions.get(packageName=self.bundle_id, token=purchase_token) 180 | result = subscriptions_get.execute(http=self.http) 181 | return result 182 | except HttpError as e: 183 | if e.resp.status == 400: 184 | raise GoogleError(e.resp.reason, repr(e)) 185 | else: 186 | raise e 187 | 188 | def check_purchase_product(self, purchase_token: str, product_sku: str, service) -> dict: 189 | try: 190 | purchases = service.purchases() 191 | products = purchases.products() 192 | products_get = products.get(packageName=self.bundle_id, productId=product_sku, token=purchase_token) 193 | result = products_get.execute(http=self.http) 194 | return result 195 | except HttpError as e: 196 | if e.resp.status == 400: 197 | raise GoogleError(e.resp.reason, repr(e)) 198 | else: 199 | raise e 200 | 201 | def consume_product(self, purchase_token: str, product_sku: str) -> dict: 202 | """ 203 | Consume a one-time product purchase. 204 | 205 | This method marks a product as consumed in Google Play, preventing refunds 206 | and allowing the user to purchase the product again. This should be called 207 | as a separate operation after validating the purchase, to handle cases like 208 | power outages between validation and consumption. 209 | 210 | Arguments: 211 | purchase_token: str - The purchase token from the purchase receipt 212 | product_sku: str - The product ID (SKU) of the purchased item 213 | 214 | Returns: 215 | dict - Response from Google Play API 216 | 217 | Raises: 218 | GoogleError: If the API request fails 219 | """ 220 | try: 221 | service = build("androidpublisher", "v3", http=self.http) 222 | purchases = service.purchases() 223 | products = purchases.products() 224 | consume_request = products.consume(packageName=self.bundle_id, productId=product_sku, token=purchase_token) 225 | result = consume_request.execute(http=self.http) 226 | # Handle both parsed dict (real API) and bytes (mocked responses) 227 | if isinstance(result, bytes): 228 | result = json.loads(result.decode("utf-8")) 229 | return result 230 | except HttpError as e: 231 | if e.resp.status == 400: 232 | raise GoogleError(e.resp.reason, repr(e)) 233 | else: 234 | raise e 235 | 236 | def verify(self, purchase_token: str, product_sku: str, is_subscription: bool = False) -> dict: 237 | service = build("androidpublisher", "v3", http=self.http) 238 | 239 | if is_subscription: 240 | result = self.check_purchase_subscription(purchase_token, product_sku, service) 241 | 242 | # Check cancellation status 243 | # For old API (v1): check if cancelReason field exists (any value means canceled) 244 | # For new API (v2): check if canceledStateContext exists or subscriptionState is CANCELED 245 | if "cancelReason" in result: 246 | # Old API: presence of cancelReason field means subscription is canceled 247 | raise GoogleError("Subscription is canceled", result) 248 | elif result.get("subscriptionState") == "SUBSCRIPTION_STATE_CANCELED": 249 | # New API (v2): check subscription state 250 | raise GoogleError("Subscription is canceled", result) 251 | elif result.get("canceledStateContext") is not None: 252 | # New API (v2): check canceled state context 253 | raise GoogleError("Subscription is canceled", result) 254 | 255 | # Check expiry status 256 | # For old API (v1): expiryTimeMillis in root 257 | # For new API (v2): lineItems[0].expiryTime in ISO format 258 | ms_timestamp = result.get("expiryTimeMillis", 0) 259 | if ms_timestamp: 260 | # Old API format with milliseconds timestamp 261 | if self._ms_timestamp_expired(ms_timestamp): 262 | raise GoogleError("Subscription expired", result) 263 | else: 264 | # New API format: check lineItems for ISO 8601 timestamp 265 | line_items = result.get("lineItems", []) 266 | if line_items and "expiryTime" in line_items[0]: 267 | if self._iso_timestamp_expired(line_items[0]["expiryTime"]): 268 | raise GoogleError("Subscription expired", result) 269 | else: 270 | result = self.check_purchase_product(purchase_token, product_sku, service) 271 | purchase_state = int(result.get("purchaseState", 1)) 272 | 273 | if purchase_state != 0: 274 | raise GoogleError("Purchase cancelled", result) 275 | 276 | return result 277 | 278 | def verify_with_result( 279 | self, purchase_token: str, product_sku: str, is_subscription: bool = False 280 | ) -> GoogleVerificationResult: 281 | """Verifies by returning verification result instead of raising an error, 282 | basically it's and better alternative to verify method.""" 283 | service = build("androidpublisher", "v3", http=self.http) 284 | verification_result = GoogleVerificationResult({}, False, False) 285 | 286 | if is_subscription: 287 | result = self.check_purchase_subscription(purchase_token, product_sku, service) 288 | verification_result.raw_response = result 289 | 290 | # Check cancellation status 291 | # For old API (v1): check if cancelReason field exists (any value means canceled) 292 | # For new API (v2): check if canceledStateContext exists or subscriptionState is CANCELED 293 | if "cancelReason" in result: 294 | # Old API: presence of cancelReason field means subscription is canceled 295 | # (values: 0=user, 1=system, 2=replaced, 3=developer) 296 | verification_result.is_canceled = True 297 | elif result.get("subscriptionState") == "SUBSCRIPTION_STATE_CANCELED": 298 | # New API (v2): check subscription state 299 | verification_result.is_canceled = True 300 | elif result.get("canceledStateContext") is not None: 301 | # New API (v2): check canceled state context 302 | verification_result.is_canceled = True 303 | 304 | # Check expiry status 305 | # For old API (v1): expiryTimeMillis in root (0 or missing means expired) 306 | # For new API (v2): lineItems[0].expiryTime in ISO format 307 | ms_timestamp = result.get("expiryTimeMillis") 308 | if ms_timestamp is not None: 309 | # Old API format: use existing _ms_timestamp_expired logic 310 | # (treats 0 or missing as expired for backward compatibility) 311 | if self._ms_timestamp_expired(ms_timestamp): 312 | verification_result.is_expired = True 313 | else: 314 | # New API format: check lineItems for ISO 8601 timestamp 315 | line_items = result.get("lineItems", []) 316 | if line_items and "expiryTime" in line_items[0]: 317 | if self._iso_timestamp_expired(line_items[0]["expiryTime"]): 318 | verification_result.is_expired = True 319 | elif not line_items: 320 | # No lineItems either - for backward compatibility with old API, 321 | # treat missing expiry info as expired 322 | verification_result.is_expired = True 323 | else: 324 | result = self.check_purchase_product(purchase_token, product_sku, service) 325 | verification_result.raw_response = result 326 | 327 | purchase_state = int(result.get("purchaseState", 1)) 328 | if purchase_state != 0: 329 | verification_result.is_canceled = True 330 | 331 | return verification_result 332 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | InAppPy 2 | ======= 3 | |ci| |pypi| |downloads| 4 | 5 | .. |ci| image:: https://github.com/dotpot/InAppPy/actions/workflows/ci.yml/badge.svg 6 | :target: https://github.com/dotpot/InAppPy/actions/workflows/ci.yml 7 | .. |pypi| image:: https://badge.fury.io/py/inapppy.svg 8 | :target: https://badge.fury.io/py/inapppy 9 | .. |downloads| image:: https://img.shields.io/pypi/dm/inapppy.svg 10 | :target: https://pypi.python.org/pypi/inapppy 11 | 12 | 13 | Table of contents 14 | ================= 15 | 16 | 1. Introduction 17 | 2. Installation 18 | 3. Google Play (`receipt` + `signature`) 19 | 4. Google Play (verification) 20 | 21 | - Setting up Google Service Account Credentials 22 | - Usage Example (with file path) 23 | - Usage Example (with credentials dictionary) 24 | 25 | 5. Google Play (verification with result) 26 | 6. Google Play (consuming products) 27 | 7. App Store (`receipt` + using optional `shared-secret`) 28 | 8. App Store Response (`validation_result` / `raw_response`) example 29 | 9. App Store, **asyncio** version (available in the inapppy.asyncio package) 30 | 10. Development 31 | 11. Donate 32 | 33 | 34 | 1. Introduction 35 | =============== 36 | 37 | In-app purchase validation library for `Apple AppStore` and `GooglePlay` (`App Store` validator have **async** support!). Works on python3.6+ 38 | 39 | 2. Installation 40 | =============== 41 | :: 42 | 43 | pip install inapppy 44 | 45 | 46 | 3. Google Play (validates `receipt` against provided `signature` using RSA) 47 | =========================================================================== 48 | .. code:: python 49 | 50 | from inapppy import GooglePlayValidator, InAppPyValidationError 51 | 52 | 53 | bundle_id = 'com.yourcompany.yourapp' 54 | api_key = 'API key from the developer console' 55 | validator = GooglePlayValidator(bundle_id, api_key) 56 | 57 | try: 58 | # receipt means `androidData` in result of purchase 59 | # signature means `signatureAndroid` in result of purchase 60 | validation_result = validator.validate('receipt', 'signature') 61 | except InAppPyValidationError: 62 | # handle validation error 63 | pass 64 | 65 | 66 | An additional example showing how to authenticate using dict credentials instead of loading from a file 67 | 68 | .. code:: python 69 | 70 | import json 71 | from inapppy import GooglePlayValidator, InAppPyValidationError 72 | 73 | 74 | bundle_id = 'com.yourcompany.yourapp' 75 | # Avoid hard-coding credential data in your code. This is just an example. 76 | api_credentials = json.loads('{' 77 | ' "type": "service_account",' 78 | ' "project_id": "xxxxxxx",' 79 | ' "private_key_id": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",' 80 | ' "private_key": "-----BEGIN PRIVATE KEY-----\nXXXXXXXXXXXXXXXXXXXXXXXXXXXXX==\n-----END PRIVATE KEY-----\n",' 81 | ' "client_email": "XXXXXXXXX@XXXXXXXX.XXX",' 82 | ' "client_id": "XXXXXXXXXXXXXXXXXX",' 83 | ' "auth_uri": "https://accounts.google.com/o/oauth2/auth",' 84 | ' "token_uri": "https://oauth2.googleapis.com/token",' 85 | ' "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",' 86 | ' "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/XXXXXXXXXXXXXXXXX.iam.gserviceaccount.com"' 87 | ' }') 88 | validator = GooglePlayValidator(bundle_id, api_credentials) 89 | 90 | try: 91 | # receipt means `androidData` in result of purchase 92 | # signature means `signatureAndroid` in result of purchase 93 | validation_result = validator.validate('receipt', 'signature') 94 | except InAppPyValidationError: 95 | # handle validation error 96 | pass 97 | 98 | 99 | 100 | 4. Google Play verification 101 | =========================== 102 | 103 | Setting up Google Service Account Credentials 104 | ---------------------------------------------- 105 | 106 | Before using Google Play verification, you need to set up a Google Service Account and obtain the credentials file. This section explains what ``GOOGLE_SERVICE_ACCOUNT_KEY_FILE`` is and how to obtain it. 107 | 108 | **What is GOOGLE_SERVICE_ACCOUNT_KEY_FILE?** 109 | 110 | ``GOOGLE_SERVICE_ACCOUNT_KEY_FILE`` is a JSON file containing a service account's private key and credentials. This file authorizes your application to access the Google Play Developer API to verify in-app purchases and subscriptions. 111 | 112 | The credentials can be provided in two ways: 113 | 114 | 1. **As a file path** (string): Path to the JSON key file downloaded from Google Cloud Console 115 | 2. **As a dictionary** (dict): The parsed JSON content of the key file 116 | 117 | **How to obtain the Service Account Key File:** 118 | 119 | 1. **Link Google Cloud Project to Google Play Console** 120 | 121 | - Go to `Google Play Console `_ 122 | - Select your app 123 | - Navigate to **Settings → Developer account → API access** 124 | - If you haven't linked a project yet, click **Link** to create or link a Google Cloud project 125 | - Accept the terms and conditions 126 | 127 | 2. **Create a Service Account** 128 | 129 | - In the API access page, scroll to **Service accounts** 130 | - Click **Create new service account** or **Learn how to create service accounts** (this will take you to Google Cloud Console) 131 | - In Google Cloud Console: 132 | 133 | - Go to **IAM & Admin → Service Accounts** 134 | - Click **+ CREATE SERVICE ACCOUNT** 135 | - Enter a name (e.g., "InAppPy Validator") and description 136 | - Click **CREATE AND CONTINUE** 137 | - Skip granting roles (not needed for this step) 138 | - Click **DONE** 139 | 140 | 3. **Grant Permissions in Google Play Console** 141 | 142 | - Return to Google Play Console → **Settings → Developer account → API access** 143 | - Find your newly created service account in the list 144 | - Click **Grant access** 145 | - Under **App permissions**, select your app 146 | - Under **Account permissions**, enable: 147 | 148 | - **View financial data** (for viewing purchase/subscription info) 149 | - **Manage orders and subscriptions** (if you need to consume products or manage subscriptions) 150 | 151 | - Click **Invite user** and then **Send invitation** 152 | 153 | 4. **Download the JSON Key File** 154 | 155 | - Go back to **Google Cloud Console → IAM & Admin → Service Accounts** 156 | - Click on your service account email 157 | - Go to the **KEYS** tab 158 | - Click **ADD KEY → Create new key** 159 | - Select **JSON** as the key type 160 | - Click **CREATE** 161 | - The JSON key file will be automatically downloaded 162 | - **IMPORTANT**: Store this file securely! It contains a private key and cannot be recovered if lost 163 | 164 | 5. **Important Notes** 165 | 166 | - The JSON key file should contain fields like: ``type``, ``project_id``, ``private_key_id``, ``private_key``, ``client_email``, etc. 167 | - Keep this file secure and never commit it to version control 168 | - In some cases, you may need to create at least one product in your Google Play Console before the API access works properly 169 | - It may take a few minutes for permissions to propagate after granting access 170 | 171 | **Example JSON key file structure:** 172 | 173 | .. code:: json 174 | 175 | { 176 | "type": "service_account", 177 | "project_id": "your-project-id", 178 | "private_key_id": "a1b2c3d4e5f6...", 179 | "private_key": "-----BEGIN PRIVATE KEY-----\nYourPrivateKeyHere\n-----END PRIVATE KEY-----\n", 180 | "client_email": "your-service-account@your-project.iam.gserviceaccount.com", 181 | "client_id": "123456789", 182 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 183 | "token_uri": "https://oauth2.googleapis.com/token", 184 | "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", 185 | "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/..." 186 | } 187 | 188 | Usage Example (with file path) 189 | ------------------------------- 190 | 191 | .. code:: python 192 | 193 | from inapppy import GooglePlayVerifier, errors 194 | 195 | 196 | def google_validator(receipt): 197 | """ 198 | Accepts receipt, validates in Google. 199 | """ 200 | purchase_token = receipt['purchaseToken'] 201 | product_sku = receipt['productId'] 202 | 203 | # Pass the path to your service account JSON key file 204 | verifier = GooglePlayVerifier( 205 | GOOGLE_BUNDLE_ID, 206 | '/path/to/your-service-account-key.json', # Path to the JSON key file 207 | ) 208 | response = {'valid': False, 'transactions': []} 209 | try: 210 | result = verifier.verify( 211 | purchase_token, 212 | product_sku, 213 | is_subscription=True 214 | ) 215 | response['valid'] = True 216 | response['transactions'].append( 217 | (result['orderId'], product_sku) 218 | ) 219 | except errors.GoogleError as exc: 220 | logging.error('Purchase validation failed {}'.format(exc)) 221 | return response 222 | 223 | 224 | Usage Example (with credentials dictionary) 225 | -------------------------------------------- 226 | 227 | .. code:: python 228 | 229 | import json 230 | from inapppy import GooglePlayVerifier, errors 231 | 232 | 233 | def google_validator(receipt): 234 | """ 235 | Accepts receipt, validates in Google using dict credentials. 236 | """ 237 | purchase_token = receipt['purchaseToken'] 238 | product_sku = receipt['productId'] 239 | 240 | # Load credentials from environment variable or secure storage 241 | # NEVER hard-code credentials in your source code! 242 | credentials_json = os.environ.get('GOOGLE_SERVICE_ACCOUNT_JSON') 243 | credentials_dict = json.loads(credentials_json) 244 | 245 | # Pass the credentials as a dictionary 246 | verifier = GooglePlayVerifier( 247 | GOOGLE_BUNDLE_ID, 248 | credentials_dict, # Dictionary containing the JSON key data 249 | ) 250 | response = {'valid': False, 'transactions': []} 251 | try: 252 | result = verifier.verify( 253 | purchase_token, 254 | product_sku, 255 | is_subscription=True 256 | ) 257 | response['valid'] = True 258 | response['transactions'].append( 259 | (result['orderId'], product_sku) 260 | ) 261 | except errors.GoogleError as exc: 262 | logging.error('Purchase validation failed {}'.format(exc)) 263 | return response 264 | 265 | 266 | 5. Google Play verification (with result) 267 | ========================================= 268 | Alternative to `.verify` method, instead of raising an error result class will be returned. 269 | 270 | **Note:** See section 4 for instructions on setting up ``GOOGLE_SERVICE_ACCOUNT_KEY_FILE``. 271 | 272 | .. code:: python 273 | 274 | from inapppy import GooglePlayVerifier, errors 275 | 276 | 277 | def google_validator(receipt): 278 | """ 279 | Accepts receipt, validates in Google. 280 | """ 281 | purchase_token = receipt['purchaseToken'] 282 | product_sku = receipt['productId'] 283 | 284 | # Use the service account credentials (see section 4 for setup) 285 | verifier = GooglePlayVerifier( 286 | GOOGLE_BUNDLE_ID, 287 | GOOGLE_SERVICE_ACCOUNT_KEY_FILE, # Path to JSON key file or dict 288 | ) 289 | response = {'valid': False, 'transactions': []} 290 | 291 | result = verifier.verify_with_result( 292 | purchase_token, 293 | product_sku, 294 | is_subscription=True 295 | ) 296 | 297 | # result contains data 298 | raw_response = result.raw_response 299 | is_canceled = result.is_canceled 300 | is_expired = result.is_expired 301 | 302 | return result 303 | 304 | 305 | 6. Google Play (consuming products) 306 | =================================== 307 | After validating a purchase, you can consume a one-time product to prevent refunds and allow the user to purchase it again. 308 | This is a separate operation that should be called after verification to handle cases like power outages between validation and consumption. 309 | 310 | **Note:** See section 4 for instructions on setting up ``GOOGLE_SERVICE_ACCOUNT_KEY_FILE``. 311 | 312 | .. code:: python 313 | 314 | from inapppy import GooglePlayVerifier, errors 315 | 316 | 317 | def consume_purchase(receipt): 318 | """ 319 | Consume a purchase after validation. 320 | """ 321 | purchase_token = receipt['purchaseToken'] 322 | product_sku = receipt['productId'] 323 | 324 | # Use the service account credentials (see section 4 for setup) 325 | verifier = GooglePlayVerifier( 326 | GOOGLE_BUNDLE_ID, 327 | GOOGLE_SERVICE_ACCOUNT_KEY_FILE, # Path to JSON key file or dict 328 | ) 329 | 330 | try: 331 | # First verify the purchase 332 | verification_result = verifier.verify( 333 | purchase_token, 334 | product_sku, 335 | is_subscription=False 336 | ) 337 | 338 | # Then consume it to prevent refunds 339 | consume_result = verifier.consume_product( 340 | purchase_token, 341 | product_sku 342 | ) 343 | 344 | return {'success': True, 'consumed': True} 345 | except errors.GoogleError as exc: 346 | logging.error('Purchase consumption failed {}'.format(exc)) 347 | return {'success': False, 'error': str(exc)} 348 | 349 | 350 | **Note:** Only consumable products (one-time purchases) can be consumed. Subscriptions cannot be consumed. 351 | 352 | 353 | 7. App Store (validates `receipt` using optional `shared-secret` against iTunes service) 354 | ======================================================================================== 355 | .. code:: python 356 | 357 | from inapppy import AppStoreValidator, InAppPyValidationError 358 | 359 | 360 | bundle_id = 'com.yourcompany.yourapp' 361 | auto_retry_wrong_env_request=False # if True, automatically query sandbox endpoint if 362 | # validation fails on production endpoint 363 | validator = AppStoreValidator(bundle_id, auto_retry_wrong_env_request=auto_retry_wrong_env_request) 364 | 365 | try: 366 | exclude_old_transactions=False # if True, include only the latest renewal transaction 367 | validation_result = validator.validate('receipt', 'optional-shared-secret', exclude_old_transactions=exclude_old_transactions) 368 | except InAppPyValidationError as ex: 369 | # handle validation error 370 | response_from_apple = ex.raw_response # contains actual response from AppStore service. 371 | pass 372 | 373 | 374 | 375 | 8. App Store Response (`validation_result` / `raw_response`) example 376 | ==================================================================== 377 | .. code:: json 378 | 379 | { 380 | "latest_receipt": "MIIbngYJKoZIhvcNAQcCoIIbj...", 381 | "status": 0, 382 | "receipt": { 383 | "download_id": 0, 384 | "receipt_creation_date_ms": "1486371475000", 385 | "application_version": "2", 386 | "app_item_id": 0, 387 | "receipt_creation_date": "2017-02-06 08:57:55 Etc/GMT", 388 | "original_purchase_date": "2013-08-01 07:00:00 Etc/GMT", 389 | "request_date_pst": "2017-02-06 04:41:09 America/Los_Angeles", 390 | "original_application_version": "1.0", 391 | "original_purchase_date_pst": "2013-08-01 00:00:00 America/Los_Angeles", 392 | "request_date_ms": "1486384869996", 393 | "bundle_id": "com.yourcompany.yourapp", 394 | "request_date": "2017-02-06 12:41:09 Etc/GMT", 395 | "original_purchase_date_ms": "1375340400000", 396 | "in_app": [{ 397 | "purchase_date_ms": "1486371474000", 398 | "web_order_line_item_id": "1000000034281189", 399 | "original_purchase_date_ms": "1486371475000", 400 | "original_purchase_date": "2017-02-06 08:57:55 Etc/GMT", 401 | "expires_date_pst": "2017-02-06 01:00:54 America/Los_Angeles", 402 | "original_purchase_date_pst": "2017-02-06 00:57:55 America/Los_Angeles", 403 | "purchase_date_pst": "2017-02-06 00:57:54 America/Los_Angeles", 404 | "expires_date_ms": "1486371654000", 405 | "expires_date": "2017-02-06 09:00:54 Etc/GMT", 406 | "original_transaction_id": "1000000271014363", 407 | "purchase_date": "2017-02-06 08:57:54 Etc/GMT", 408 | "quantity": "1", 409 | "is_trial_period": "false", 410 | "product_id": "com.yourcompany.yourapp", 411 | "transaction_id": "1000000271014363" 412 | }], 413 | "version_external_identifier": 0, 414 | "receipt_creation_date_pst": "2017-02-06 00:57:55 America/Los_Angeles", 415 | "adam_id": 0, 416 | "receipt_type": "ProductionSandbox" 417 | }, 418 | "latest_receipt_info": [{ 419 | "purchase_date_ms": "1486371474000", 420 | "web_order_line_item_id": "1000000034281189", 421 | "original_purchase_date_ms": "1486371475000", 422 | "original_purchase_date": "2017-02-06 08:57:55 Etc/GMT", 423 | "expires_date_pst": "2017-02-06 01:00:54 America/Los_Angeles", 424 | "original_purchase_date_pst": "2017-02-06 00:57:55 America/Los_Angeles", 425 | "purchase_date_pst": "2017-02-06 00:57:54 America/Los_Angeles", 426 | "expires_date_ms": "1486371654000", 427 | "expires_date": "2017-02-06 09:00:54 Etc/GMT", 428 | "original_transaction_id": "1000000271014363", 429 | "purchase_date": "2017-02-06 08:57:54 Etc/GMT", 430 | "quantity": "1", 431 | "is_trial_period": "true", 432 | "product_id": "com.yourcompany.yourapp", 433 | "transaction_id": "1000000271014363" 434 | }, { 435 | "purchase_date_ms": "1486371719000", 436 | "web_order_line_item_id": "1000000034281190", 437 | "original_purchase_date_ms": "1486371720000", 438 | "original_purchase_date": "2017-02-06 09:02:00 Etc/GMT", 439 | "expires_date_pst": "2017-02-06 01:06:59 America/Los_Angeles", 440 | "original_purchase_date_pst": "2017-02-06 01:02:00 America/Los_Angeles", 441 | "purchase_date_pst": "2017-02-06 01:01:59 America/Los_Angeles", 442 | "expires_date_ms": "1486372019000", 443 | "expires_date": "2017-02-06 09:06:59 Etc/GMT", 444 | "original_transaction_id": "1000000271014363", 445 | "purchase_date": "2017-02-06 09:01:59 Etc/GMT", 446 | "quantity": "1", 447 | "is_trial_period": "false", 448 | "product_id": "com.yourcompany.yourapp", 449 | "transaction_id": "1000000271016119" 450 | }], 451 | "environment": "Sandbox" 452 | } 453 | 454 | 455 | 9. App Store, asyncio version (available in the inapppy.asyncio package) 456 | ======================================================================== 457 | .. code:: python 458 | 459 | from inapppy import InAppPyValidationError 460 | from inapppy.asyncio import AppStoreValidator 461 | 462 | 463 | bundle_id = 'com.yourcompany.yourapp' 464 | auto_retry_wrong_env_request=False # if True, automatically query sandbox endpoint if 465 | # validation fails on production endpoint 466 | validator = AppStoreValidator(bundle_id, auto_retry_wrong_env_request=auto_retry_wrong_env_request) 467 | 468 | try: 469 | exclude_old_transactions=False # if True, include only the latest renewal transaction 470 | async with validator: # Use async context manager to ensure proper session management 471 | validation_result = await validator.validate('receipt', 'optional-shared-secret', exclude_old_transactions=exclude_old_transactions) 472 | except InAppPyValidationError as ex: 473 | # handle validation error 474 | response_from_apple = ex.raw_response # contains actual response from AppStore service. 475 | pass 476 | 477 | 478 | 479 | 10. Development 480 | =============== 481 | 482 | Prerequisites 483 | ------------- 484 | 485 | Install `uv `_ for fast Python package management: 486 | 487 | .. code:: bash 488 | 489 | # macOS/Linux 490 | curl -LsSf https://astral.sh/uv/install.sh | sh 491 | 492 | # Windows 493 | powershell -c "irm https://astral.sh/uv/install.ps1 | iex" 494 | 495 | Setup and Testing 496 | ----------------- 497 | 498 | .. code:: bash 499 | 500 | # Install development dependencies 501 | make dev 502 | 503 | # Run linting checks 504 | make lint 505 | 506 | # Format code with ruff 507 | make format 508 | 509 | # Run both lint and format checks 510 | make check 511 | 512 | # Run tests 513 | make test 514 | 515 | # Run tests with pytest directly 516 | uv run pytest -v 517 | 518 | Available Make Commands 519 | ----------------------- 520 | 521 | .. code:: bash 522 | 523 | make setup # Install dependencies with uv 524 | make dev # Install development dependencies with uv 525 | make clean # Remove build artifacts 526 | make build # Build distribution packages 527 | make release # Upload to PyPI 528 | make test # Run tests with pytest 529 | make lint # Run ruff linting 530 | make format # Format code with ruff 531 | make check # Run lint and format check 532 | make install # Install package in editable mode 533 | 534 | 11. Donate 535 | ========== 536 | You can support development of this project by buying me a coffee ;) 537 | 538 | +------+--------------------------------------------+ 539 | | Coin | Wallet | 540 | +======+============================================+ 541 | | EUR | https://paypal.me/LukasSalkauskas | 542 | +------+--------------------------------------------+ 543 | | DOGE | DGjSG3T6g9h2k6iSku7mtKCynCpmwowpyN | 544 | +------+--------------------------------------------+ 545 | | BTC | 1LZAiWmLYzZae4hq3ai9hFYD3e3qcwjDsU | 546 | +------+--------------------------------------------+ 547 | | ETH | 0xD62245986345130edE10e4b545fF577Bd5BaE3E4 | 548 | +------+--------------------------------------------+ 549 | --------------------------------------------------------------------------------