Webhook בצד השרת לאירועי מנוי (חידוש, ביטול, החזר כספי)
Webhook מ-Stripe, App Store או Google Play הוא שליחת HTTP POST לשרת שלך עם גוף JSON על מה שקרה למנוי. נשמע פשוט. בפועל, זה החלק הפגיע ביותר במערכת מנויים: אירועים מגיעים לא בסדר, כפולים, הולכים לאיבוד, וחלקם דורשים תגובה תוך שניות או שיישלחו שוב. יישמנו עשרות אינטגרציות כאלה — הנה איך להפוך אותן לאמינות. שגיאות בעיבוד webhook יכולות להוביל לאובדן של עד 30% מההכנסות ממנויים — המתודולוגיה שלנו מפחיתה סיכון זה ל-1%.
למה Idempotency קריטי?
Stripe עשויה לשלוח את אותו אירוע מספר פעמים — אם הנקודה שלך הגיבה לאט או החזירה 500. עיבוד invoice.payment_succeeded פעמיים פירושו הארכת המנוי פעמיים במסד הנתונים ושליחת שני מיילים של "תודה על התשלום". פתרון: שמור event.id שכבר עובדו:
def handle_stripe_webhook(event_id: str, event_type: str, event_data: dict):
# Проверяем идемпотентность
if db.is_event_processed(event_id):
return # уже обработали, ничего не делаем
# Обрабатываем
process_event(event_type, event_data)
# Отмечаем как обработанное
db.mark_event_processed(event_id, processed_at=datetime.utcnow())שמור מזהה מעובד למשך 30 יום — Stripe מבטיחה ניסיונות חוזרים רק בתוך כמה ימים. בניסיון שלנו, לקוח שכח idempotency וקיבל חיובים כפולים — נאלצנו לתקן את הלוגיקה מאפס. שימוש ב-idempotency מפחית את הסיכוי לכפילויות ב-99%.
איך לאמת חתימת Webhook?
לעולם אל תעבד webhook ללא אימות חתימה. תוקף יכול לשלוח אירוע def handle_stripe_webhook(event_id: str, event_type: str, event_data: dict): # Проверяем идемпотентность if db.is_event_processed(event_id): return # уже обработали, ничего не делаем # Обрабатываем process_event(event_type, event_data) # Отмечаем как обработанное db.mark_event_processed(event_id, processed_at=datetime.utcnow()) מזויף לנקודה שלך ולקבל גישה ללא תשלום.
import stripe
from fastapi import Request, HTTPException
STRIPE_WEBHOOK_SECRET = "whsec_..."
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
try:
event = stripe.Webhook.construct_event(
payload,
sig_header,
STRIPE_WEBHOOK_SECRET
)
except stripe.error.SignatureVerificationError as e:
raise HTTPException(status_code=400, detail=str(e))
# Обрабатываем асинхронно — возвращаем 200 немедленно
background_tasks.add_task(process_stripe_event, event)
return {"status": "ok"}
חשוב: החזר HTTP 200 מיד, לפני שסיום העיבוד. Stripe מחשיבה webhook כנכשל אם לא התקבלה תגובה תוך 30 שניות. העיבוד צריך להתבצע ברקע.
מפת אירועים: מה לעשות עבור כל אחד
async def process_stripe_event(event: dict):
event_type = event['type']
data = event['data']['object']
match event_type:
case 'invoice.payment_succeeded':
# Подписка продлена — обновляем период доступа
subscription_id = data['subscription']
period_end = data['lines']['data'][0]['period']['end']
db.extend_subscription(
subscription_id=subscription_id,
access_until=datetime.fromtimestamp(period_end)
)
analytics.track('subscription_renewed', {'subscription_id': subscription_id})
case 'invoice.payment_failed':
# Обрабатывается retry-логикой
handle_payment_failure(data['subscription'], data.get('last_payment_error'))
case 'customer.subscription.deleted':
# Отмена: пользователь отменил или исчерпаны retry
reason = data.get('cancellation_details', {}).get('reason')
db.deactivate_subscription(data['id'], reason=reason)
if reason == 'payment_failed':
notify_subscription_expired_payment(data['customer'])
else:
notify_subscription_cancelled(data['customer'])
case 'customer.subscription.updated':
# Смена плана, изменение периода, reactivation
if data['status'] == 'active' and data.get('pause_collection') is None:
db.reactivate_subscription_if_paused(data['id'])
case 'charge.refunded':
# Возврат средств
charge_id = data['id']
amount_refunded = data['amount_refunded']
reason = data.get('refund_reason')
db.record_refund(charge_id, amount_refunded, reason)
revoke_access_if_full_refund(data) הודעות שרת של App Store (iOS)
Apple משתמשת בהודעות חתומות ב-JWT גרסה 2. אימות דרך המפתח הציבורי של Apple (נשלף מ-invoice.payment_succeeded או דרך ספריית import stripe from fastapi import Request, HTTPException STRIPE_WEBHOOK_SECRET = "whsec_..." @app.post("/webhooks/stripe") async def stripe_webhook(request: Request): payload = await request.body() sig_header = request.headers.get("stripe-signature") try: event = stripe.Webhook.construct_event( payload, sig_header, STRIPE_WEBHOOK_SECRET ) except stripe.error.SignatureVerificationError as e: raise HTTPException(status_code=400, detail=str(e)) # Обрабатываем асинхронно — возвращаем 200 немедленно background_tasks.add_task(process_stripe_event, event) return {"status": "ok"} ):
from appstoreconnect import AppStoreServerNotificationsClient
@app.post("/webhooks/apple")
async def apple_webhook(request: Request):
body = await request.json()
signed_payload = body.get("signedPayload")
client = AppStoreServerNotificationsClient()
try:
notification = client.decode_notification(signed_payload)
except Exception:
raise HTTPException(400)
notification_type = notification.notificationType
subtype = notification.subtype
transaction_info = notification.data.signedTransactionInfo
match notification_type:
case "DID_RENEW":
# Подписка продлена
extend_ios_subscription(transaction_info)
case "EXPIRED":
# Подписка истекла (subtype: VOLUNTARY / BILLING_RETRY / PRICE_INCREASE)
deactivate_ios_subscription(transaction_info, reason=subtype)
case "REFUND":
# Возврат через Apple
handle_ios_refund(transaction_info)
case "GRACE_PERIOD_EXPIRED":
# Истёк grace period — окончательно блокируем
hard_deactivate_ios_subscription(transaction_info) הודעות מפתח בזמן אמת של Google Play (Android)
Google Play שולח הודעות דרך Cloud Pub/Sub, לא webhook HTTP. עליך ליצור נושא Pub/Sub, מינוי, ולבחור בין משיכה או מינוי push:
from google.cloud import pubsub_v1
import base64
def process_pubsub_message(message: pubsub_v1.types.ReceivedMessage):
data = json.loads(base64.b64decode(message.message.data))
if 'subscriptionNotification' in data:
notification = data['subscriptionNotification']
notification_type = notification['notificationType']
purchase_token = notification['purchaseToken']
# Верифицируем через Google Play Developer API
purchase = google_play_client.purchases().subscriptions().get(
packageName=PACKAGE_NAME,
subscriptionId=notification['subscriptionId'],
token=purchase_token
).execute()
match notification_type:
case 4: # SUBSCRIPTION_PURCHASED
activate_android_subscription(purchase_token, purchase)
case 2: # SUBSCRIPTION_RENEWED
extend_android_subscription(purchase_token, purchase)
case 3: # SUBSCRIPTION_CANCELED
mark_android_subscription_cancelled(purchase_token)
case 13: # SUBSCRIPTION_EXPIRED
deactivate_android_subscription(purchase_token)
השוואת מטפלי Webhook בין החנויות
| פרמטר | Stripe | App Store | Google Play |
|---|---|---|---|
| פרוטוקול | HTTP POST | HTTP POST (JWT) | Pub/Sub (push/pull) |
| Idempotency | event.id | signedPayload JTI | messageId Pub/Sub |
| זמן תגובה | 30 שניות | 5 שניות | 10 שניות |
| ניסיונות חוזרים | כן, עד 3 ימים | כן, עד 24 שעות | כן, עד 7 ימים |
| אימות | stripe-signature + סוד | מפתח JWT של Apple | אימות API של Google |
| אירועים אופייניים | payment_succeeded, refund | DID_RENEW, EXPIRED, REFUND | SUBSCRIPTION_RENEWED, CANCELED |
טעויות נפוצות ביישום Webhook
| טעות | תוצאה | פתרון |
|---|---|---|
| חוסר idempotency | חידוש מנוי כפול | שמור event.id ב-Redis |
| ללא אימות חתימה | גישה חופשית לתוקף | אמת חתימה לכל אירוע |
| שליחת 200 לפני סיום העיבוד | אובדן אירוע במקרה של כשל | עיבוד אסינכרוני ברקע |
| חסימה על EXPIRED ללא תקופת חסד | שלילת גישה שגויה | השתמש בתקופת חסד |
פרטי יישום Idempotency
שמור מזהה מעובד ב-Redis עם TTL של 30 יום. למקרים קריטיים במיוחד, הוסף נעילה ברמת מסד הנתונים: לפני העיבוד, הכנס event_id לטבלת processed_events עם אילוץ ייחודי. במקרה של הכנסה כפולה, תפוס את החריגה ודלג.
מה כלול בפיתוח Webhook סוהר
- עיצוב סכמת אירועים ויצירת נקודות קצה לכל חנות.
- יישום idempotency (Redis/Postgres) ואימות חתימה.
- טיפול בחידוש, ביטול, החזר כספי, שדרוג/הורדה.
- טיפול במקרי קצה: הפעלה מחדש, תקופת חסד, תשלומים חוזרים.
- רישום כל האירועים, ניטור שגיאות, התראות על כשלים.
- בדיקות אינטגרציה עם סביבת הבדיקה של כל חנות (TestFlight, מסלול בדיקה פנימי).
- תיעוד API של webhook והוראות פריסה.
- תמיכה לאחר פריסה (חודש אחד).
סדר אירועים: לפעמים ביטול מגיע לפני חידוש
מוזרות של App Store: async def process_stripe_event(event: dict): event_type = event['type'] data = event['data']['object'] match event_type: case 'invoice.payment_succeeded': # Подписка продлена — обновляем период доступа subscription_id = data['subscription'] period_end = data['lines']['data'][0]['period']['end'] db.extend_subscription( subscription_id=subscription_id, access_until=datetime.fromtimestamp(period_end) ) analytics.track('subscription_renewed', {'subscription_id': subscription_id}) case 'invoice.payment_failed': # Обрабатывается retry-логикой handle_payment_failure(data['subscription'], data.get('last_payment_error')) case 'customer.subscription.deleted': # Отмена: пользователь отменил или исчерпаны retry reason = data.get('cancellation_details', {}).get('reason') db.deactivate_subscription(data['id'], reason=reason) if reason == 'payment_failed': notify_subscription_expired_payment(data['customer']) else: notify_subscription_cancelled(data['customer']) case 'customer.subscription.updated': # Смена плана, изменение периода, reactivation if data['status'] == 'active' and data.get('pause_collection') is None: db.reactivate_subscription_if_paused(data['id']) case 'charge.refunded': # Возврат средств charge_id = data['id'] amount_refunded = data['amount_refunded'] reason = data.get('refund_reason') db.record_refund(charge_id, amount_refunded, reason) revoke_access_if_full_refund(data) יכול להגיע שניות לפני .well-known/apple-app-site-association כאשר חידוש מצליח ברגע האחרון. אם חסמת גישה עקב AppleJWT, עליך לשחזר אותה מיד עם from appstoreconnect import AppStoreServerNotificationsClient @app.post("/webhooks/apple") async def apple_webhook(request: Request): body = await request.json() signed_payload = body.get("signedPayload") client = AppStoreServerNotificationsClient() try: notification = client.decode_notification(signed_payload) except Exception: raise HTTPException(400) notification_type = notification.notificationType subtype = notification.subtype transaction_info = notification.data.signedTransactionInfo match notification_type: case "DID_RENEW": # Подписка продлена extend_ios_subscription(transaction_info) case "EXPIRED": # Подписка истекла (subtype: VOLUNTARY / BILLING_RETRY / PRICE_INCREASE) deactivate_ios_subscription(transaction_info, reason=subtype) case "REFUND": # Возврат через Apple handle_ios_refund(transaction_info) case "GRACE_PERIOD_EXPIRED": # Истёк grace period — окончательно блокируем hard_deactivate_ios_subscription(transaction_info) . מצב המנוי במסד הנתונים צריך להיקבע לא רק על ידי אירועי webhook אלא גם על ידי אימות קבלה/רכישה בשרתי Apple/Google. יש לנו ניסיון של 5+ שנים באינטגרציות תשלום — אנו מתחשבים בכל הניואנסים. החיסכון בזמן על ניפוי מקרי קצה כאלה יכול להגיע ל-50%.
ציר זמן
3–5 ימים. Webhook של Stripe עם idempotency ומערך אירועים מלא — 1.5 ימים. הודעות שרת של App Store v2 — יום אחד. Google Play Pub/Sub — יום אחד. בדיקות אינטגרציה של כל התרחישים — 0.5–1 יום. העלות מחושבת באופן אישי. צור קשר — נעריך את הפרויקט שלך ונציע פתרון אופטימלי. פנה אלינו ליישום מטפל webhook אמין.
להבנה מעמיקה יותר של פעולת webhook, עיין בתיעוד הרשמי של Stripe.







