Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions appstoreserverlibrary/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,13 @@ class APIError(IntEnum):
https://developer.apple.com/documentation/appstoreserverapi/invalidtransactiontypenotsupportederror
"""

APP_TRANSACTION_ID_NOT_SUPPORTED_ERROR = 4000048
"""
An error that indicates the endpoint doesn't support an app transaction ID.

https://developer.apple.com/documentation/appstoreserverapi/apptransactionidnotsupportederror
"""

SUBSCRIPTION_EXTENSION_INELIGIBLE = 4030004
"""
An error that indicates the subscription doesn't qualify for a renewal-date extension due to its subscription state.
Expand Down
140 changes: 140 additions & 0 deletions appstoreserverlibrary/jws_signature_creator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Copyright (c) 2025 Apple Inc. Licensed under MIT License.

import datetime
from typing import Any, Dict, Optional
import base64
import json
import jwt
import uuid

from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization

from appstoreserverlibrary.models.LibraryUtility import _get_cattrs_converter

class AdvancedCommerceAPIInAppRequest:
def __init__(self):
pass

class JWSSignatureCreator:
def __init__(self, audience: str, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str):
self._audience = audience
self._signing_key = serialization.load_pem_private_key(signing_key, password=None, backend=default_backend())
self._key_id = key_id
self._issuer_id = issuer_id
self._bundle_id = bundle_id

def _create_signature(self, feature_specific_claims: Dict[str, Any]) -> str:
claims = feature_specific_claims
current_time = datetime.datetime.now(datetime.timezone.utc)

claims["bid"] = self._bundle_id
claims["iss"] = self._issuer_id
claims["aud"] = self._audience
claims["iat"] = current_time
claims["nonce"] = str(uuid.uuid4())

return jwt.encode(claims,
self._signing_key,
algorithm="ES256",
headers={"kid": self._key_id},
)

class PromotionalOfferV2SignatureCreator(JWSSignatureCreator):
def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str):
"""
Create a PromotionalOfferV2SignatureCreator

:param signing_key: Your private key downloaded from App Store Connect
:param key_id: Your private key ID from App Store Connect
:param issuer_id: Your issuer ID from the Keys page in App Store Connect
:param bundle_id: Your app's bundle ID
"""
super().__init__(audience="promotional-offer", signing_key=signing_key, key_id=key_id, issuer_id=issuer_id, bundle_id=bundle_id)

def create_signature(self, product_id: str, offer_identifier: str, transaction_id: Optional[str]) -> str:
"""
Create a promotional offer V2 signature.
https://developer.apple.com/documentation/storekit/generating-jws-to-sign-app-store-requests

:param product_id: The unique identifier of the product
:param offer_identifier: The promotional offer identifier that you set up in App Store Connect
:param transaction_id: The unique identifier of any transaction that belongs to the customer. You can use the customer's appTransactionId, even for customers who haven't made any In-App Purchases in your app. This field is optional, but recommended.
:return: The signed JWS.
"""
if product_id is None:
raise ValueError("product_id cannot be null")
if offer_identifier is None:
raise ValueError("offer_identifier cannot be null")
feature_specific_claims = {
"productId": product_id,
"offerIdentifier": offer_identifier
}
if transaction_id is not None:
feature_specific_claims["transactionId"] = transaction_id
return self._create_signature(feature_specific_claims=feature_specific_claims)

class IntroductoryOfferEligibilitySignatureCreator(JWSSignatureCreator):
def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str):
"""
Create an IntroductoryOfferEligibilitySignatureCreator

:param signing_key: Your private key downloaded from App Store Connect
:param key_id: Your private key ID from App Store Connect
:param issuer_id: Your issuer ID from the Keys page in App Store Connect
:param bundle_id: Your app's bundle ID
"""
super().__init__(audience="introductory-offer-eligibility", signing_key=signing_key, key_id=key_id, issuer_id=issuer_id, bundle_id=bundle_id)

def create_signature(self, product_id: str, allow_introductory_offer: bool, transaction_id: str) -> str:
"""
Create an introductory offer eligibility signature.
https://developer.apple.com/documentation/storekit/generating-jws-to-sign-app-store-requests

:param product_id: The unique identifier of the product
:param allow_introductory_offer: A boolean value that determines whether the customer is eligible for an introductory offer
:param transaction_id: The unique identifier of any transaction that belongs to the customer. You can use the customer's appTransactionId, even for customers who haven't made any In-App Purchases in your app.
:return: The signed JWS.
"""
if product_id is None:
raise ValueError("product_id cannot be null")
if allow_introductory_offer is None:
raise ValueError("allow_introductory_offer cannot be null")
if transaction_id is None:
raise ValueError("transaction_id cannot be null")
feature_specific_claims = {
"productId": product_id,
"allowIntroductoryOffer": allow_introductory_offer,
"transactionId": transaction_id
}
return self._create_signature(feature_specific_claims=feature_specific_claims)

class AdvancedCommerceAPIInAppSignatureCreator(JWSSignatureCreator):
def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str):
"""
Create an AdvancedCommerceAPIInAppSignatureCreator

:param signing_key: Your private key downloaded from App Store Connect
:param key_id: Your private key ID from App Store Connect
:param issuer_id: Your issuer ID from the Keys page in App Store Connect
:param bundle_id: Your app's bundle ID
"""
super().__init__(audience="advanced-commerce-api", signing_key=signing_key, key_id=key_id, issuer_id=issuer_id, bundle_id=bundle_id)

def create_signature(self, advanced_commerce_in_app_request: AdvancedCommerceAPIInAppRequest) -> str:
"""
Create an Advanced Commerce in-app signed request.
https://developer.apple.com/documentation/storekit/generating-jws-to-sign-app-store-requests

:param advanced_commerce_in_app_request: The request to be signed.
:return: The signed JWS.
"""
if advanced_commerce_in_app_request is None:
raise ValueError("advanced_commerce_in_app_request cannot be null")
c = _get_cattrs_converter(type(advanced_commerce_in_app_request))
request = c.unstructure(advanced_commerce_in_app_request)
encoded_request = base64.b64encode(json.dumps(request).encode(encoding='utf-8')).decode('utf-8')
feature_specific_claims = {
"request": encoded_request
}
return self._create_signature(feature_specific_claims=feature_specific_claims)
20 changes: 20 additions & 0 deletions appstoreserverlibrary/models/AppTransaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .LibraryUtility import AttrsRawValueAware

from .Environment import Environment
from .PurchasePlatform import PurchasePlatform

@define
class AppTransaction(AttrsRawValueAware):
Expand Down Expand Up @@ -96,4 +97,23 @@ class AppTransaction(AttrsRawValueAware):
The date the customer placed an order for the app before it's available in the App Store.

https://developer.apple.com/documentation/storekit/apptransaction/4013175-preorderdate
"""

appTransactionId: Optional[str] = attr.ib(default=None)
"""
The unique identifier of the app download transaction.

https://developer.apple.com/documentation/storekit/apptransaction/apptransactionid
"""

originalPlatform: Optional[PurchasePlatform] = PurchasePlatform.create_main_attr('rawOriginalPlatform')
"""
The platform on which the customer originally purchased the app.

https://developer.apple.com/documentation/storekit/apptransaction/originalplatform-4mogz
"""

rawOriginalPlatform: Optional[str] = PurchasePlatform.create_raw_attr('originalPlatform')
"""
See originalPlatform
"""
21 changes: 21 additions & 0 deletions appstoreserverlibrary/models/JWSRenewalInfoDecodedPayload.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,25 @@ class JWSRenewalInfoDecodedPayload(AttrsRawValueAware):
An array of win-back offer identifiers that a customer is eligible to redeem, which sorts the identifiers to present the better offers first.

https://developer.apple.com/documentation/appstoreserverapi/eligiblewinbackofferids
"""

appAccountToken: Optional[str] = attr.ib(default=None)
"""
The UUID that an app optionally generates to map a customer's in-app purchase with its resulting App Store transaction.

https://developer.apple.com/documentation/appstoreserverapi/appaccounttoken
"""

appTransactionId: Optional[str] = attr.ib(default=None)
"""
The unique identifier of the app download transaction.

https://developer.apple.com/documentation/appstoreserverapi/appTransactionId
"""

offerPeriod: Optional[str] = attr.ib(default=None)
"""
The duration of the offer.

https://developer.apple.com/documentation/appstoreserverapi/offerPeriod
"""
14 changes: 14 additions & 0 deletions appstoreserverlibrary/models/JWSTransactionDecodedPayload.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,4 +237,18 @@ class JWSTransactionDecodedPayload(AttrsRawValueAware):
rawOfferDiscountType: Optional[str] = OfferDiscountType.create_raw_attr('offerDiscountType')
"""
See offerDiscountType
"""

appTransactionId: Optional[str] = attr.ib(default=None)
"""
The unique identifier of the app download transaction.

https://developer.apple.com/documentation/appstoreserverapi/appTransactionId
"""

offerPeriod: Optional[str] = attr.ib(default=None)
"""
The duration of the offer.

https://developer.apple.com/documentation/appstoreserverapi/offerPeriod
"""
16 changes: 16 additions & 0 deletions appstoreserverlibrary/models/PurchasePlatform.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Copyright (c) 2025 Apple Inc. Licensed under MIT License.

from enum import Enum

from .LibraryUtility import AppStoreServerLibraryEnumMeta

class PurchasePlatform(str, Enum, metaclass=AppStoreServerLibraryEnumMeta):
"""
Values that represent Apple platforms.

https://developer.apple.com/documentation/storekit/appstore/platform
"""
IOS = "iOS"
MAC_OS = "macOS"
TV_OS = "tvOS"
VISION_OS = "visionOS"
4 changes: 3 additions & 1 deletion tests/resources/models/appTransaction.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,7 @@
"originalApplicationVersion": "1.1.2",
"deviceVerification": "device_verification_value",
"deviceVerificationNonce": "48ccfa42-7431-4f22-9908-7e88983e105a",
"preorderDate": 1698148700000
"preorderDate": 1698148700000,
"appTransactionId": "71134",
"originalPlatform": "iOS"
}
5 changes: 4 additions & 1 deletion tests/resources/models/signedRenewalInfo.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,8 @@
"eligibleWinBackOfferIds": [
"eligible1",
"eligible2"
]
],
"appTransactionId": "71134",
"offerPeriod": "P1Y",
"appAccountToken": "7e3fb20b-4cdb-47cc-936d-99d65f608138"
}
4 changes: 3 additions & 1 deletion tests/resources/models/signedTransaction.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,7 @@
"storefrontId":"143441",
"price": 10990,
"currency": "USD",
"offerDiscountType": "PAY_AS_YOU_GO"
"offerDiscountType": "PAY_AS_YOU_GO",
"appTransactionId": "71134",
"offerPeriod": "P1Y"
}
9 changes: 9 additions & 0 deletions tests/test_decoded_payloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from appstoreserverlibrary.models.OfferDiscountType import OfferDiscountType
from appstoreserverlibrary.models.OfferType import OfferType
from appstoreserverlibrary.models.PriceIncreaseStatus import PriceIncreaseStatus
from appstoreserverlibrary.models.PurchasePlatform import PurchasePlatform
from appstoreserverlibrary.models.RevocationReason import RevocationReason
from appstoreserverlibrary.models.Status import Status
from appstoreserverlibrary.models.Subtype import Subtype
Expand Down Expand Up @@ -38,6 +39,9 @@ def test_app_transaction_decoding(self):
self.assertEqual("device_verification_value", app_transaction.deviceVerification)
self.assertEqual("48ccfa42-7431-4f22-9908-7e88983e105a", app_transaction.deviceVerificationNonce)
self.assertEqual(1698148700000, app_transaction.preorderDate)
self.assertEqual("71134", app_transaction.appTransactionId)
self.assertEqual(PurchasePlatform.IOS, app_transaction.originalPlatform)
self.assertEqual("iOS", app_transaction.rawOriginalPlatform)

def test_transaction_decoding(self):
signed_transaction = create_signed_data_from_json('tests/resources/models/signedTransaction.json')
Expand Down Expand Up @@ -79,6 +83,8 @@ def test_transaction_decoding(self):
self.assertEqual("USD", transaction.currency)
self.assertEqual(OfferDiscountType.PAY_AS_YOU_GO, transaction.offerDiscountType)
self.assertEqual("PAY_AS_YOU_GO", transaction.rawOfferDiscountType)
self.assertEqual("71134", transaction.appTransactionId)
self.assertEqual("P1Y", transaction.offerPeriod)


def test_renewal_info_decoding(self):
Expand Down Expand Up @@ -112,6 +118,9 @@ def test_renewal_info_decoding(self):
self.assertEqual(OfferDiscountType.PAY_AS_YOU_GO, renewal_info.offerDiscountType)
self.assertEqual("PAY_AS_YOU_GO", renewal_info.rawOfferDiscountType)
self.assertEqual(['eligible1', 'eligible2'], renewal_info.eligibleWinBackOfferIds)
self.assertEqual("71134", renewal_info.appTransactionId)
self.assertEqual("P1Y", renewal_info.offerPeriod)
self.assertEqual("7e3fb20b-4cdb-47cc-936d-99d65f608138", renewal_info.appAccountToken)

def test_notification_decoding(self):
signed_notification = create_signed_data_from_json('tests/resources/models/signedNotification.json')
Expand Down
Loading