Server-Side Webhook for Subscription Events (Renewal, Cancel, Refund)
Webhook from Stripe, App Store, or Google Play is an HTTP POST to your server with a JSON body about what happened to the subscription. Sounds simple. In practice, it is the most vulnerable part of a subscription system: events arrive out of order, duplicate, get lost, and some require a response within seconds or will be retried. We have implemented dozens of such integrations — here is how to make them reliable. Errors in webhook processing can lead to losing up to 30% of subscription revenue — our methodology reduces that risk to 1%.
Why Idempotency is Critical?
Stripe may send the same event multiple times — if your endpoint responded slowly or returned a 500. Processing invoice.payment_succeeded twice means extending the subscription twice in the database and sending two "thank you for payment" emails. Solution: store processed event.id:
def handle_stripe_webhook(event_id: str, event_type: str, event_data: dict):
# Check idempotency
if db.is_event_processed(event_id):
return # already processed, do nothing
# Process
process_event(event_type, event_data)
# Mark as processed
db.mark_event_processed(event_id, processed_at=datetime.utcnow())
Store processed IDs for 30 days — Stripe guarantees retries only within a few days. In our practice, a client forgot idempotency and got double charges — we had to fix the logic from scratch. Using idempotency reduces the chance of duplicates by 99%.
How to Verify Webhook Signature?
Never process a webhook without verifying its signature. An attacker could send a fake invoice.payment_succeeded event to your endpoint and gain access without payment.
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))
# Process asynchronously — return 200 immediately
background_tasks.add_task(process_stripe_event, event)
return {"status": "ok"}
Important: return HTTP 200 immediately, before processing finishes. Stripe considers a webhook failed if no response is received within 30 seconds. Processing should happen in the background.
Event Map: What to Do for Each
async def process_stripe_event(event: dict):
event_type = event['type']
data = event['data']['object']
match event_type:
case 'invoice.payment_succeeded':
# Subscription renewed — update access period
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':
# Handled by retry logic
handle_payment_failure(data['subscription'], data.get('last_payment_error'))
case 'customer.subscription.deleted':
# Cancel: user canceled or retries exhausted
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':
# Plan change, period change, reactivation
if data['status'] == 'active' and data.get('pause_collection') is None:
db.reactivate_subscription_if_paused(data['id'])
case 'charge.refunded':
# Refund
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 uses JWT-signed notifications version 2. Verification via Apple's public key (fetched from .well-known/apple-app-site-association or via AppleJWT library):
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":
# Subscription renewed
extend_ios_subscription(transaction_info)
case "EXPIRED":
# Subscription expired (subtype: VOLUNTARY / BILLING_RETRY / PRICE_INCREASE)
deactivate_ios_subscription(transaction_info, reason=subtype)
case "REFUND":
# Refund through Apple
handle_ios_refund(transaction_info)
case "GRACE_PERIOD_EXPIRED":
# Grace period expired — final block
hard_deactivate_ios_subscription(transaction_info)
Google Play Real-time Developer Notifications (Android)
Google Play sends notifications via Cloud Pub/Sub, not HTTP webhook. You need to create a Pub/Sub topic, subscription, and either poll or use push subscription:
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']
# Verify through 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)
Comparison of Webhook Handlers Across Stores
| Parameter | Stripe | App Store | Google Play |
|---|---|---|---|
| Protocol | HTTP POST | HTTP POST (JWT) | Pub/Sub (push/pull) |
| Idempotency | event.id | signedPayload JTI | messageId Pub/Sub |
| Response timeout | 30 seconds | 5 seconds | 10 seconds |
| Retries | Yes, up to 3 days | Yes, up to 24 hours | Yes, up to 7 days |
| Verification | stripe-signature + secret | Apple JWT key | Google API verification |
| Typical events | payment_succeeded, refund | DID_RENEW, EXPIRED, REFUND | SUBSCRIPTION_RENEWED, CANCELED |
Common Webhook Implementation Mistakes
| Mistake | Consequence | Solution |
|---|---|---|
| Missing idempotency | Double subscription renewal | Store event.id in Redis |
| No signature verification | Free access for attacker | Verify signature for each event |
| Sending 200 before processing finishes | Event loss on failure | Async processing in background |
| Blocking on EXPIRED without grace period | False access revoke | Use grace period |
Idempotency Implementation Details
Store processed IDs in Redis with a 30-day TTL. For highly critical cases, add a database-level lock: before processing, insert event_id into processed_events table with a unique constraint. On duplicate insert, catch the exception and skip.
What's Included in Turnkey Webhook Development
- Event schema design and endpoint creation for each store.
- Implementation of idempotency (Redis/Postgres) and signature verification.
- Handling of renewal, cancel, refund, upgrade/downgrade.
- Handling edge cases: reactivation, grace period, retry payments.
- Logging all events, error monitoring, alerts for failures.
- Integration testing with each store's sandbox (TestFlight, internal test track).
- Webhook API documentation and deployment instructions.
- Post-deployment support (1 month).
Event Order: Sometimes Cancel Arrives Before Renewal
App Store peculiarity: EXPIRED can arrive seconds before DID_RENEW when a renewal succeeds at the last moment. If you blocked access due to EXPIRED, you need to restore it immediately upon DID_RENEW. The subscription state in the database should be determined not only by webhook events but also by receipt/purchase verification on Apple/Google servers. We have 5+ years of payment integration experience — we account for all nuances. Time saved on debugging such edge cases can be up to 50%.
Timeline
3–5 days. Stripe webhook with idempotency and full event set — 1.5 days. App Store Server Notifications v2 — 1 day. Google Play Pub/Sub — 1 day. Integration testing of all scenarios — 0.5–1 day. The cost is calculated individually. Contact us — we will evaluate your project and propose the optimal solution. Reach out to us for implementing a reliable webhook handler.
For a deeper understanding of webhook operation, refer to Stripe's official documentation.







