Server-Side Webhook for Subscriptions: Renewal, Cancel, Refund

Subscription businesses lose revenue when renewal, cancel, and refund events are handled unreliably: they get duplicated, lost, or arrive in chaotic order. We implement server-side subscription webhooks turnkey—from Stripe, App Store, and Google Play to your database—with protection against duplicates and failures. Our team ensures stable payment processing and ongoing support so you don't lose money on integration errors.

Development and support of all types of mobile applications:

Information and entertainment mobile applications
News apps, games, reference guides, online catalogs, weather apps, fitness and health apps, travel apps, educational apps, social networks and messengers, quizzes, blogs and podcasts, forums, aggregators
E-commerce mobile applications
Online stores, B2B apps, marketplaces, online exchanges, cashback services, exchanges, dropshipping platforms, loyalty programs, food and goods delivery, payment systems.
Business process management mobile applications
CRM systems, ERP systems, project management, sales team tools, financial management, production management, logistics and delivery management, HR management, data monitoring systems
Electronic services mobile applications
Classified ads platforms, online schools, online cinemas, electronic service platforms, cashback platforms, video hosting, thematic portals, online booking and scheduling platforms, online trading platforms

These are just some of the types of mobile applications we work with, and each of them may have its own specific features and functionality, tailored to the specific needs and goals of the client.

Our competencies:

Frequently Asked Questions

Latest works

  • Development of a mobile application for FEEDME
    Development of a mobile application for FEEDME
    971
  • Development of a mobile application for XOOMER
    Development of a mobile application for XOOMER
    869
  • Development of a mobile application for RHL
    Development of a mobile application for RHL
    1289
  • Development of a mobile application for ZIPPY
    Development of a mobile application for ZIPPY
    1143
  • Development of a mobile application for Affhome
    Development of a mobile application for Affhome
    1076
  • Development of a mobile application for the FLAVORS company
    Development of a mobile application for the FLAVORS company
    677

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.