Реализация серверного Webhook для событий подписки (renewal, cancel, refund)
Webhook от Stripe, App Store или Google Play — это HTTP POST на ваш сервер с JSON-телом о том, что произошло с подпиской. Звучит просто. На практике — это самое уязвимое место подписочной системы: события приходят в произвольном порядке, дублируются, теряются, а некоторые требуют ответа в течение 5 секунд или будут повторены.
Идемпотентность: первое, что нужно решить
Stripe может отправить одно событие несколько раз — если ваш endpoint ответил с задержкой или вернул 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())
Хранить обработанные ID достаточно 30 дней — Stripe гарантирует повторы только в течение нескольких дней.
Верификация подписи: обязательно
Никогда не обрабатывайте webhook без проверки подписи. Злоумышленник может отправить фиктивное событие invoice.payment_succeeded на ваш endpoint и получить доступ без оплаты.
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 Server Notifications (iOS)
Apple использует JWT-подписанные уведомления версии 2. Верификация — через публичный ключ Apple (загружается из .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)
Google Play Real-time Developer Notifications (Android)
Google Play отправляет уведомления через Cloud Pub/Sub, не через HTTP webhook. Нужно создать тему 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)
Порядок событий: иногда cancel приходит раньше renewal
Особенность App Store: EXPIRED может прийти за секунды до DID_RENEW при успешном продлении в последний момент. Если заблокировали доступ по EXPIRED — нужно немедленно восстановить по DID_RENEW. Состояние подписки в базе должно определяться не только webhook-событиями, но и верификацией receipt/purchase на сервере Apple/Google.
Сроки
3–5 дней. Stripe webhook с идемпотентностью и полным набором событий — 1,5 дня. App Store Server Notifications v2 — 1 день. Google Play Pub/Sub — 1 день. Интеграционное тестирование всех сценариев — 0,5–1 день. Стоимость рассчитывается индивидуально.







