Implementing Promotional Offers for Existing Subscribers in Mobile Applications
Promotional Offers are personalized discounts for users who have already been subscribers. Introductory Offer can be offered only once and only to a new subscriber. Promotional Offer can be repeated and only to those who have had or have an active subscription. Typical scenarios: bring back a user who cancelled their subscription; prevent cancellation with a win-back offer; transition to a higher tier with a discount.
Difference from Introductory Offers
| Introductory Offer | Promotional Offer | |
|---|---|---|
| For whom | New subscribers | Existing/former |
| How many times | Once | Multiple times |
| Requires server signature | No | Yes — mandatory |
| Configuration | App Store Connect | App Store Connect + server |
Server signature is the key difference. Apple requires that the offer be signed with a private key generated in App Store Connect. Without this, the offer won't apply — StoreKit will return an invalidSignature error.
Configuration in App Store Connect
- Subscriptions → [Subscription] → Promotional Offers →
+ - Set Reference Name, Offer ID, type (freeTrial / payAsYouGo / payUpFront), duration, and price
- Save the Offer ID — you'll need it when generating the signature
In parallel: Keys → Subscription Key → create a key, download the .p8 file, and remember the Key ID.
Server-Side Signature
The server generates a signature using the ECDSA algorithm with the .p8 key. Parameters:
-
appBundleId— application bundle ID -
keyIdentifier— Key ID from App Store Connect -
productIdentifier— product ID -
offerIdentifier— Offer ID -
applicationUsername— user ID in your system (optional but recommended) -
nonce— UUID generated by the server -
timestamp— current time in milliseconds
Signature is created from concatenating these values through \n, signed with SHA-256 ECDSA:
# Python example for server (simplified)
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
import base64, uuid, time
def generate_signature(bundle_id, key_id, product_id, offer_id, username):
nonce = str(uuid.uuid4()).lower()
timestamp = str(int(time.time() * 1000))
message = "\n".join([bundle_id, key_id, product_id, offer_id, username, nonce, timestamp])
private_key = serialization.load_pem_private_key(PRIVATE_KEY_PEM, password=None)
signature = private_key.sign(message.encode(), ec.ECDSA(hashes.SHA256()))
encoded = base64.b64encode(signature).decode()
return {"nonce": nonce, "timestamp": timestamp, "signature": encoded, "keyIdentifier": key_id}
The server returns this data to the client; the client uses it when confirming the purchase.
Application on the Client (StoreKit 2)
import StoreKit
// Get signature parameters from server
let signatureData = try await apiClient.fetchPromoOfferSignature(
productId: "premium_monthly",
offerId: "win_back_30_percent"
)
// Get the product
guard let product = try? await Product.products(for: ["premium_monthly"]).first else { return }
// Find the offer by ID
guard let offer = product.subscription?.promotionalOffers.first(where: {
$0.id == "win_back_30_percent"
}) else { return }
// Create signed offer object
let signedOffer = try await offer.purchase(
confirmIn: self, // WindowScene or UIViewController
options: [
.promotionalOffer(
offerIdentifier: signatureData.offerId,
keyIdentifier: signatureData.keyIdentifier,
nonce: UUID(uuidString: signatureData.nonce)!,
signature: Data(base64Encoded: signatureData.signature)!,
timestamp: signatureData.timestamp
)
]
)
Common Mistakes
Expired timestamp. Signature is valid for 24 hours. If you cache it longer — Apple will return an error. You need to generate the signature immediately before showing the paywall, not at app startup.
Invalid nonce. Nonce should be lowercase (UUID.uuidString.lowercased()). Case affects signature validity.
Offer shown to everyone. Checking eligibility for promotional offer is the developer's responsibility. Apple doesn't block the purchase if eligibility wasn't verified. You need a server-side check of transaction history: was the user a subscriber at least once?
What's Included in the Work
- Setting up Promotional Offer in App Store Connect
- Server endpoint for signature generation (ECDSA)
- Client-side StoreKit 2 integration with
promotionalOfferoptions - Eligibility check (server transaction history)
- Testing in Sandbox via StoreKit Configuration File
Timeline
3–5 days — including the server part (signature generation). If server infrastructure is already ready — 2–3 days. Pricing is calculated individually.







