הטמעת הצעות קידום למנויים קיימים באפליקציות מובייל
לעתים קרובות אנו נתקלים באתגר: כיצד להחזיר משתמש שביטל את המנוי שלו, או למנוע ביטול מלכתחילה? במקרים כאלה, אנו משתמשים בהצעות קידום (Promotional Offers)—הנחות מותאמות אישית למשתמשים שהיה להם בעבר מנוי. בניגוד להצעות היכרות (Introductory Offers), שפועלות רק למנויים חדשים פעם אחת, ניתן להציע הצעות קידום מספר פעמים למנויים לשעבר ולמנויים נוכחיים. תרחישים אופייניים: החזרת משתמש לאחר ביטול, מניעת ביטול (תקופת חסד) עם תקופה חינמית, ושדרוג לדרגה גבוהה יותר עם הנחה.
מדוע חתימה בצד השרת היא קריטית
Apple דורשת שכל הצעת קידום תיחתם עם מפתח פרטי ECDSA (P-256). החתימה נוצרת בשרת שלך באמצעות קובץ ה-.p8 מ-App Store Connect. בלעדיה, StoreKit 2 מחזיר שגיאת invalidSignature. אנו מבטיחים יצירת חתימה נכונה, תוך עמידה בכל דרישות Apple. במשך יותר מ-5 שנים, הטמענו הצעות קידום ביותר מ-20 פרויקטים, והגדלנו את שיעור ההמרה להחזרת משתמשים ב-30% בממוצע.
ההבדל בין הצעות קידום להצעות היכרות
| הצעת היכרות | הצעת קידום | |
|---|---|---|
| למי | מנויים חדשים | מנויים קיימים/לשעבר |
| כמה פעמים | פעם אחת | מספר פעמים |
| דורש חתימת שרת | לא | כן—חובה |
| תצורה | App Store Connect | App Store Connect + שרת |
חתימה בצד השרת היא ההבדל המרכזי. Apple דורשת שההצעה תיחתם עם מפתח פרטי שנוצר ב-App Store Connect. בלעדיה, ההצעה לא תחול—StoreKit מחזיר שגיאת invalidSignature.
כיצד להגדיר הצעת קידום ב-App Store Connect
- מנויים (Subscriptions) → [המנוי] → הצעות קידום (Promotional Offers) →
+ - הגדר את שם הייחוס (Reference Name), מזהה ההצעה (Offer ID), סוג (freeTrial / payAsYouGo / payUpFront), משך ומחיר
- שמור את מזהה ההצעה—הוא יידרש בעת יצירת החתימה
במקביל: מפתחות (Keys) → מפתח מנוי (Subscription Key) → צור מפתח, הורד את קובץ ה-.p8, ושים לב למזהה המפתח (Key ID).
חתימה בצד השרת: אלגוריתם ופרמטרים
השרת יוצר חתימה באמצעות ECDSA עם מפתח .p8. פרמטרים:
-
appBundleId— מזהה ה-Bundle של האפליקציה -
keyIdentifier— מזהה המפתח מ-App Store Connect -
productIdentifier— מזהה המוצר -
offerIdentifier— מזהה ההצעה -
applicationUsername— מזהה המשתמש במערכת שלך (אופציונלי אך מומלץ) -
nonce— UUID, נוצר בצד השרת -
timestamp— זמן נוכחי במילישניות
החתימה נוצרת על ידי שרשור ערכים אלה עם \n, וחתימה עם SHA-256 ECDSA:
# Python-пример для сервера (упрощённо)
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}השרת מחזיר נתונים אלה ללקוח; הלקוח משתמש בהם בעת השלמת הרכישה.
הטמעה בצד הלקוח (StoreKit 2)
import StoreKit
// Получаем параметры подписи с сервера
let signatureData = try await apiClient.fetchPromoOfferSignature(
productId: "premium_monthly",
offerId: "win_back_30_percent"
)
// Получаем продукт
guard let product = try? await Product.products(for: ["premium_monthly"]).first else { return }
// Находим оффер по ID
guard let offer = product.subscription?.promotionalOffers.first(where: { $0.id == "win_back_30_percent" }) else { return }
// Создаём объект подписанного оффера
let signedOffer = try await offer.purchase(
confirmIn: self, // WindowScene или UIViewController
options: [
.promotionalOffer(
offerIdentifier: signatureData.offerId,
keyIdentifier: signatureData.keyIdentifier,
nonce: UUID(uuidString: signatureData.nonce)!,
signature: Data(base64Encoded: signatureData.signature)!,
timestamp: signatureData.timestamp
)
]
)לפרטים נוספים, ראה תיעוד StoreKit 2 של Apple
כיצד לבדוק זכאות?
הזכאות היא באחריות המפתח. Apple לא בודקת אם מותר לך להציג הצעה. אנו מיישמים בדיקות זכאות בצד השרת: אנו בודקים את היסטוריית העסקאות דרך Apple Server Notifications v2 או מנתחים את הקבלה (receipt). משתמש מקבל את ההצעה אם הוא היה מנוי לפחות פעם אחת. זה מונע יישום שגוי ומגן מפני דליפת הכנסות.
טעויות נפוצות והניסיון שלנו
זיהינו שלוש בעיות תכופות:
- חותמת זמן שפגה. החתימה תקפה ל-24 שעות. אם היא נשמרת במטמון זמן רב יותר, Apple מחזירה שגיאה. צור את החתימה ממש לפני הצגת ה-paywall, לא בעת השקת האפליקציה.
-
Nonce שגוי. ה-nonce חייב להיות באותיות קטנות (
# Python-пример для сервера (упрощённо) 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}). רישיות משפיעה על תקינות החתימה. - הצגת ההצעה לכולם. בדיקת זכאות להצעות קידום היא באחריות המפתח. Apple לא חוסמת את הרכישה אם הזכאות לא אומתה. יש צורך בבדיקת היסטוריית עסקאות בצד השרת: האם המשתמש היה מנוי לפחות פעם אחת?
השוואה: הגישה שלנו עם חתימה בצד השרת אמינה ב-30% יותר מיישום מלא בצד הלקוח (שבלתי אפשרי, אבל חלק מנסים לעקוף ללא חתימה—ניסיונות כאלה נכשלים בוודאות בסביבת הייצור).
מה כלול בעבודה במפתח מוכן
- הגדרת הצעת הקידום ב-App Store Connect
- Endpoint בשרת ליצירת חתימה (ECDSA)
- אינטגרציה של StoreKit 2 בצד הלקוח עם אפשרויות
import StoreKit // Получаем параметры подписи с сервера let signatureData = try await apiClient.fetchPromoOfferSignature( productId: "premium_monthly", offerId: "win_back_30_percent" ) // Получаем продукт guard let product = try? await Product.products(for: ["premium_monthly"]).first else { return } // Находим оффер по ID guard let offer = product.subscription?.promotionalOffers.first(where: { $0.id == "win_back_30_percent" }) else { return } // Создаём объект подписанного оффера let signedOffer = try await offer.purchase( confirmIn: self, // WindowScene или UIViewController options: [ .promotionalOffer( offerIdentifier: signatureData.offerId, keyIdentifier: signatureData.keyIdentifier, nonce: UUID(uuidString: signatureData.nonce)!, signature: Data(base64Encoded: signatureData.signature)!, timestamp: signatureData.timestamp ) ] ) - בדיקת זכאות (היסטוריית עסקאות בצד השרת)
- בדיקות בסביבת Sandbox באמצעות StoreKit Configuration File
רשימת בדיקה לדוגמה לשלב הבדיקות
- בדוק חתימה עם חותמת זמן שפגה - בדוק חתימה עם nonce שגוי - בדוק רכישה ללא זכאות - בדוק שימוש חוזר בהצעהלוחות זמנים ועלות
לוחות זמנים משוערים: 3–5 ימים, כולל עבודת שרת. אם תשתית השרת כבר קיימת, 2–3 ימים. העלות מחושבת באופן אישי—צור קשר להערכת פרויקט. קבל ייעוץ ואחריות על אינטגרציה נכונה.







