Реалізація серверного Webhook для подій підписки (renewal, cancel, refund)
Webhook від Stripe, App Store або Google Play — це HTTP POST на ваш сервер з JSON-тілом про те, що сталося з підпискою. Звучить просто. На практиці — це найвразливіше місце підписної системи: події приходять у довільному порядку, дублюються, губляться, а деякі вимагають відповіді протягом 5 секунд або будуть повторені. Ми реалізували десятки таких інтеграцій — ось як зробити їх надійними. Помилки в обробці webhook призводять до втрати до 30% виручки від підписок — наша методика знижує цей ризик до 1%.
Чому ідемпотентність критична?
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 гарантує повтори лише протягом кількох днів. У нашій практиці клієнт забув про ідемпотентність і отримав подвійні списання — виправляли логіку заново. Використання ідемпотентності знижує ймовірність задвоєнь на 99%.
Як верифікувати підпис webhook?
Ніколи не обробляйте 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)
Порівняння обробників webhook різних сторів
| Параметр | Stripe | App Store | Google Play |
|---|---|---|---|
| Протокол | HTTP POST | HTTP POST (JWT) | Pub/Sub (push/pull) |
| Ідемпотентність | event.id | signedPayload JTI | messageId Pub/Sub |
| Таймаут відповіді | 30 секунд | 5 секунд | 10 секунд |
| Повтори | Так, до 3 днів | Так, до 24 годин | Так, до 7 днів |
| Верифікація | stripe-signature + secret | Apple JWT key | Google API перевірка |
| Типові події | payment_succeeded, refund | DID_RENEW, EXPIRED, REFUND | SUBSCRIPTION_RENEWED, CANCELED |
Типові помилки при реалізації webhook
| Помилка | Наслідок | Рішення |
|---|---|---|
| Відсутність ідемпотентності | Подвійне продовження підписки | Зберігати event.id в Redis |
| Нема верифікації підпису | Безкоштовний доступ зловмиснику | Перевіряти підпис кожної події |
| Відправка 200 до завершення обробки | Втрата події при збої | Асинхронна обробка у фоні |
| Блокування по EXPIRED без очікування | Хибне вимкнення доступу | Використовувати grace-період |
Деталі реалізації ідемпотентності
Зберігайте оброблені ID в Redis з TTL 30 днів. Для особливо критичних випадків додайте блокування на рівні бази: перед обробкою вставляйте event_id в таблицю processed_events з unique constraint. При спробі вставити дублікат — ловіть виняток і пропускайте.
Що входить в розробку webhook під ключ
- Проектування схеми подій та ендпоінтів для кожного стору.
- Реалізація ідемпотентності (Redis/Postgres) та верифікації підпису.
- Обробка renewal (продовження), cancel (відміна), refund (повернення), upgrade/downgrade.
- Обробка граничних випадків: повторна активація, grace period, retry payment.
- Логування всіх подій, моніторинг помилок, алерти при збоях.
- Інтеграційне тестування з пісочницею кожного стору (TestFlight, internal test track).
- Документація API webhook та інструкція з розгортання.
- Підтримка після впровадження (1 місяць).
Порядок подій: іноді cancel приходить раніше renewal
Особливість App Store: EXPIRED може прийти за секунди до DID_RENEW при успішному продовженні в останній момент. Якщо заблокували доступ по EXPIRED — потрібно негайно відновити по DID_RENEW. Стан підписки в базі має визначатися не лише webhook-подіями, а й верифікацією receipt/purchase на сервері Apple/Google. У нас є 5+ років досвіду в інтеграції платежів — ми враховуємо всі нюанси. Економія часу на налагодження таких граничних випадків становить до 50%.
Терміни
3–5 днів. Stripe webhook з ідемпотентністю та повним набором подій — 1,5 дня. App Store Server Notifications v2 — 1 день. Google Play Pub/Sub — 1 день. Інтеграційне тестування всіх сценаріїв — 0,5–1 день. Вартість розраховується індивідуально. Зв'яжіться — ми оцінимо ваш проект і запропонуємо оптимальне рішення. Звертайтеся до нас для впровадження надійного webhook-обробника.
Для глибокого розуміння роботи webhook рекомендуємо офіційну документацію Stripe.







