Реализация серверного Webhook для событий подписки (renewal, cancel, refund)

TRUETECH занимается разработкой, поддержкой и обслуживанием мобильных приложений iOS, Android, PWA. Имеем большой опыт и экспертизу для публикации мобильных приложений в популярные маркеты Google Play, App Store, Amazon, AppGallery и другие.

Разработка и поддержка любых видов мобильных приложений:

Информационные и развлекательные мобильные приложения
Новостные приложения, игры, справочники, онлайн-каталоги, погодные, фитнес и здоровье, туристические, образовательные, социальные сети и мессенджеры, квиз, блоги и подкасты, форумы, агрегаторы
Мобильные приложения электронной коммерции
Интернет-магазины, B2B-приложения, маркетплейсы, онлайн-обменники, кэшбэк-сервисы, биржи, дропшиппинг-платформы, программы лояльности, доставка еды и товаров, платежные системы
Мобильные приложения для управления бизнес-процессами
CRM-системы, ERP-системы, управление проектами, инструменты для команды продаж, учет финансов, управление производством, логистика и доставка, управление персоналом, системы мониторинга данных
Мобильные приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, платформы предоставления электронных услуг, платформы кешбека, видеохостинги, тематические порталы, платформы онлайн-бронирования и записи, платформы онлайн-торговли

Это лишь некоторые из типы мобильных приложений, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента.

Услуги, которые мы предлагаем
Показано 1 из 1Все 1735 услуг
Реализация серверного Webhook для событий подписки (renewal, cancel, refund)
Средний
~3-5 дней
Часто задаваемые вопросы

Наши компетенции:

Этапы разработки

Последние работы

  • image_mobile-applications_feedme_467_0.webp
    Разработка мобильного приложения для компании FEEDME
    792
  • image_mobile-applications_xoomer_471_0.webp
    Разработка мобильного приложения для компании XOOMER
    671
  • image_mobile-applications_rhl_428_0.webp
    Разработка мобильного приложения для компании RHL
    1097
  • image_mobile-applications_zippy_411_0.webp
    Разработка мобильного приложения для компании ZIPPY
    969
  • image_mobile-applications_affhome_429_0.webp
    Разработка мобильного приложения для компании Affhome
    914
  • image_mobile-applications_flavors_409_0.webp
    Разработка мобильного приложения для компании FLAVORS
    495

Реализация серверного 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 день. Стоимость рассчитывается индивидуально.