Back to blog

Build a Video Platform: Stripe Subscription Integration

javaspring-bootreactnextjsvideo-streaming
Build a Video Platform: Stripe Subscription Integration

The platform plays videos, tracks progress, and serves streams securely. But it's free. Time to fix that. In this post, we'll integrate Stripe to handle subscription payments — users pay monthly or yearly, and only active subscribers can watch premium content.

We're using Stripe Checkout (a hosted payment page) instead of building our own payment form. Why? Because handling credit card numbers means PCI compliance headaches, fraud detection, and payment UI edge cases that Stripe has already solved. Stripe Checkout gives us a battle-tested payment experience with zero frontend payment code.

Time commitment: 3–4 hours
Prerequisites: Phase 8: Video Player & Progress Tracking

What we'll build in this post:
✅ Stripe Checkout session creation for monthly/yearly plans
✅ Webhook handler for subscription lifecycle events
✅ Subscription entity and database schema
✅ Customer Portal for self-service management
✅ Access gating — only subscribers can stream
✅ Pricing page with plan selection
✅ Grace period handling for failed payments


How Stripe Checkout Works

The key insight: never trust the redirect. When Stripe redirects the user to your success page, that just means they completed the form. The actual subscription confirmation comes through the webhook — a server-to-server callback from Stripe. Always create or update subscriptions in the webhook handler, not in the redirect handler.


Stripe Setup

1. Create Products and Prices

In the Stripe Dashboard, create a product with two prices:

  • Monthly: $9.99/month
  • Yearly: $99.99/year (save ~17%)

Note the Price IDs (e.g., price_monthly123, price_yearly456) — you'll need them in your configuration.

2. Add Stripe Dependency

<!-- api/pom.xml -->
<dependency>
    <groupId>com.stripe</groupId>
    <artifactId>stripe-java</artifactId>
    <version>26.1.0</version>
</dependency>

3. Configuration

# src/main/resources/application.yml
stripe:
  secret-key: ${STRIPE_SECRET_KEY}
  webhook-secret: ${STRIPE_WEBHOOK_SECRET}
  prices:
    monthly: ${STRIPE_PRICE_MONTHLY:price_monthly123}
    yearly: ${STRIPE_PRICE_YEARLY:price_yearly456}
  success-url: ${APP_URL:http://localhost:3000}/subscription/success?session_id={CHECKOUT_SESSION_ID}
  cancel-url: ${APP_URL:http://localhost:3000}/pricing
// src/main/java/com/videoplatform/api/config/StripeConfig.java
package com.videoplatform.api.config;
 
import com.stripe.Stripe;
import jakarta.annotation.PostConstruct;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
 
@Configuration
@ConfigurationProperties(prefix = "stripe")
public class StripeConfig {
 
    private String secretKey;
    private String webhookSecret;
    private Prices prices = new Prices();
    private String successUrl;
    private String cancelUrl;
 
    @PostConstruct
    public void init() {
        Stripe.apiKey = this.secretKey;
    }
 
    // Getters and setters
    public String getSecretKey() { return secretKey; }
    public void setSecretKey(String secretKey) { this.secretKey = secretKey; }
 
    public String getWebhookSecret() { return webhookSecret; }
    public void setWebhookSecret(String webhookSecret) { this.webhookSecret = webhookSecret; }
 
    public Prices getPrices() { return prices; }
    public void setPrices(Prices prices) { this.prices = prices; }
 
    public String getSuccessUrl() { return successUrl; }
    public void setSuccessUrl(String successUrl) { this.successUrl = successUrl; }
 
    public String getCancelUrl() { return cancelUrl; }
    public void setCancelUrl(String cancelUrl) { this.cancelUrl = cancelUrl; }
 
    public static class Prices {
        private String monthly;
        private String yearly;
 
        public String getMonthly() { return monthly; }
        public void setMonthly(String monthly) { this.monthly = monthly; }
        public String getYearly() { return yearly; }
        public void setYearly(String yearly) { this.yearly = yearly; }
    }
}

Database Schema

Flyway Migration

-- src/main/resources/db/migration/V5__create_subscriptions.sql
 
CREATE TABLE subscriptions (
    id                      BIGSERIAL PRIMARY KEY,
    user_id                 BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    stripe_subscription_id  VARCHAR(255) UNIQUE NOT NULL,
    stripe_customer_id      VARCHAR(255) NOT NULL,
    plan_type               VARCHAR(20) NOT NULL,  -- MONTHLY, YEARLY
    status                  VARCHAR(20) NOT NULL,  -- ACTIVE, CANCELED, PAST_DUE, EXPIRED
    current_period_start    TIMESTAMP NOT NULL,
    current_period_end      TIMESTAMP NOT NULL,
    cancel_at_period_end    BOOLEAN NOT NULL DEFAULT FALSE,
    created_at              TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at              TIMESTAMP NOT NULL DEFAULT NOW()
);
 
CREATE TABLE payment_history (
    id                      BIGSERIAL PRIMARY KEY,
    user_id                 BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    stripe_payment_intent   VARCHAR(255) UNIQUE NOT NULL,
    amount                  INTEGER NOT NULL,  -- in cents
    currency                VARCHAR(3) NOT NULL DEFAULT 'usd',
    status                  VARCHAR(20) NOT NULL,
    created_at              TIMESTAMP NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_subscriptions_user ON subscriptions(user_id);
CREATE INDEX idx_subscriptions_stripe_id ON subscriptions(stripe_subscription_id);
CREATE INDEX idx_subscriptions_customer ON subscriptions(stripe_customer_id);
CREATE INDEX idx_payment_history_user ON payment_history(user_id);

Entity

// src/main/java/com/videoplatform/api/entity/Subscription.java
package com.videoplatform.api.entity;
 
import jakarta.persistence.*;
import java.time.LocalDateTime;
 
@Entity
@Table(name = "subscriptions")
public class Subscription {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;
 
    @Column(name = "stripe_subscription_id", unique = true, nullable = false)
    private String stripeSubscriptionId;
 
    @Column(name = "stripe_customer_id", nullable = false)
    private String stripeCustomerId;
 
    @Enumerated(EnumType.STRING)
    @Column(name = "plan_type", nullable = false)
    private PlanType planType;
 
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private SubscriptionStatus status;
 
    @Column(name = "current_period_start", nullable = false)
    private LocalDateTime currentPeriodStart;
 
    @Column(name = "current_period_end", nullable = false)
    private LocalDateTime currentPeriodEnd;
 
    @Column(name = "cancel_at_period_end", nullable = false)
    private boolean cancelAtPeriodEnd = false;
 
    @Column(name = "created_at", nullable = false)
    private LocalDateTime createdAt = LocalDateTime.now();
 
    @Column(name = "updated_at", nullable = false)
    private LocalDateTime updatedAt = LocalDateTime.now();
 
    // Getters and setters omitted for brevity
    // Include all standard getters/setters for each field
 
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
 
    public User getUser() { return user; }
    public void setUser(User user) { this.user = user; }
 
    public String getStripeSubscriptionId() { return stripeSubscriptionId; }
    public void setStripeSubscriptionId(String stripeSubscriptionId) {
        this.stripeSubscriptionId = stripeSubscriptionId;
    }
 
    public String getStripeCustomerId() { return stripeCustomerId; }
    public void setStripeCustomerId(String stripeCustomerId) {
        this.stripeCustomerId = stripeCustomerId;
    }
 
    public PlanType getPlanType() { return planType; }
    public void setPlanType(PlanType planType) { this.planType = planType; }
 
    public SubscriptionStatus getStatus() { return status; }
    public void setStatus(SubscriptionStatus status) { this.status = status; }
 
    public LocalDateTime getCurrentPeriodStart() { return currentPeriodStart; }
    public void setCurrentPeriodStart(LocalDateTime currentPeriodStart) {
        this.currentPeriodStart = currentPeriodStart;
    }
 
    public LocalDateTime getCurrentPeriodEnd() { return currentPeriodEnd; }
    public void setCurrentPeriodEnd(LocalDateTime currentPeriodEnd) {
        this.currentPeriodEnd = currentPeriodEnd;
    }
 
    public boolean isCancelAtPeriodEnd() { return cancelAtPeriodEnd; }
    public void setCancelAtPeriodEnd(boolean cancelAtPeriodEnd) {
        this.cancelAtPeriodEnd = cancelAtPeriodEnd;
    }
 
    public LocalDateTime getCreatedAt() { return createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }
    public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}
// src/main/java/com/videoplatform/api/entity/PlanType.java
package com.videoplatform.api.entity;
 
public enum PlanType {
    MONTHLY,
    YEARLY
}
// src/main/java/com/videoplatform/api/entity/SubscriptionStatus.java
package com.videoplatform.api.entity;
 
public enum SubscriptionStatus {
    ACTIVE,
    CANCELED,
    PAST_DUE,
    EXPIRED
}

Checkout Session Creation

Service

// src/main/java/com/videoplatform/api/service/StripeService.java
package com.videoplatform.api.service;
 
import com.stripe.exception.StripeException;
import com.stripe.model.Customer;
import com.stripe.model.checkout.Session;
import com.stripe.param.CustomerCreateParams;
import com.stripe.param.CustomerListParams;
import com.stripe.param.checkout.SessionCreateParams;
import com.stripe.param.billingportal.SessionCreateParams as PortalSessionCreateParams;
import com.videoplatform.api.config.StripeConfig;
import com.videoplatform.api.entity.PlanType;
import com.videoplatform.api.entity.User;
import org.springframework.stereotype.Service;
 
@Service
public class StripeService {
 
    private final StripeConfig config;
 
    public StripeService(StripeConfig config) {
        this.config = config;
    }
 
    public String createCheckoutSession(User user, PlanType planType) throws StripeException {
        // Get or create Stripe customer
        String customerId = getOrCreateCustomer(user);
 
        // Select price based on plan type
        String priceId = planType == PlanType.MONTHLY
                ? config.getPrices().getMonthly()
                : config.getPrices().getYearly();
 
        SessionCreateParams params = SessionCreateParams.builder()
                .setMode(SessionCreateParams.Mode.SUBSCRIPTION)
                .setCustomer(customerId)
                .setSuccessUrl(config.getSuccessUrl())
                .setCancelUrl(config.getCancelUrl())
                .addLineItem(
                    SessionCreateParams.LineItem.builder()
                        .setPrice(priceId)
                        .setQuantity(1L)
                        .build()
                )
                .setSubscriptionData(
                    SessionCreateParams.SubscriptionData.builder()
                        .putMetadata("user_id", user.getId().toString())
                        .putMetadata("plan_type", planType.name())
                        .build()
                )
                // Allow promotion codes
                .setAllowPromotionCodes(true)
                .build();
 
        Session session = Session.create(params);
        return session.getUrl();
    }
 
    public String createPortalSession(String stripeCustomerId) throws StripeException {
        var params = com.stripe.param.billingportal.SessionCreateParams.builder()
                .setCustomer(stripeCustomerId)
                .setReturnUrl(config.getCancelUrl()) // Return to pricing page
                .build();
 
        var session = com.stripe.model.billingportal.Session.create(params);
        return session.getUrl();
    }
 
    private String getOrCreateCustomer(User user) throws StripeException {
        // Check if customer already exists
        CustomerListParams listParams = CustomerListParams.builder()
                .setEmail(user.getEmail())
                .setLimit(1L)
                .build();
 
        var customers = Customer.list(listParams);
        if (!customers.getData().isEmpty()) {
            return customers.getData().get(0).getId();
        }
 
        // Create new customer
        CustomerCreateParams createParams = CustomerCreateParams.builder()
                .setEmail(user.getEmail())
                .setName(user.getName())
                .putMetadata("user_id", user.getId().toString())
                .build();
 
        Customer customer = Customer.create(createParams);
        return customer.getId();
    }
}

Controller

// src/main/java/com/videoplatform/api/controller/SubscriptionController.java
package com.videoplatform.api.controller;
 
import com.videoplatform.api.dto.request.CheckoutRequest;
import com.videoplatform.api.dto.response.ApiResponse;
import com.videoplatform.api.dto.response.SubscriptionResponse;
import com.videoplatform.api.entity.*;
import com.videoplatform.api.exception.ResourceNotFoundException;
import com.videoplatform.api.repository.SubscriptionRepository;
import com.videoplatform.api.repository.UserRepository;
import com.videoplatform.api.service.StripeService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
 
import java.util.Map;
 
@RestController
@RequestMapping("/api/subscriptions")
public class SubscriptionController {
 
    private final StripeService stripeService;
    private final SubscriptionRepository subscriptionRepository;
    private final UserRepository userRepository;
 
    public SubscriptionController(
            StripeService stripeService,
            SubscriptionRepository subscriptionRepository,
            UserRepository userRepository) {
        this.stripeService = stripeService;
        this.subscriptionRepository = subscriptionRepository;
        this.userRepository = userRepository;
    }
 
    @PostMapping("/checkout")
    public ResponseEntity<ApiResponse<Map<String, String>>> createCheckout(
            @RequestBody CheckoutRequest request,
            @AuthenticationPrincipal UserDetails userDetails) throws Exception {
 
        User user = findUser(userDetails);
 
        // Check if user already has an active subscription
        subscriptionRepository.findByUserAndStatus(user, SubscriptionStatus.ACTIVE)
                .ifPresent(sub -> {
                    throw new IllegalStateException("You already have an active subscription");
                });
 
        String checkoutUrl = stripeService.createCheckoutSession(user, request.planType());
 
        return ResponseEntity.ok(
            ApiResponse.success(Map.of("url", checkoutUrl))
        );
    }
 
    @GetMapping("/current")
    public ResponseEntity<ApiResponse<SubscriptionResponse>> getCurrentSubscription(
            @AuthenticationPrincipal UserDetails userDetails) {
 
        User user = findUser(userDetails);
 
        SubscriptionResponse response = subscriptionRepository
                .findByUserAndStatusIn(user,
                    java.util.List.of(SubscriptionStatus.ACTIVE, SubscriptionStatus.PAST_DUE))
                .map(sub -> new SubscriptionResponse(
                        sub.getPlanType().name(),
                        sub.getStatus().name(),
                        sub.getCurrentPeriodEnd(),
                        sub.isCancelAtPeriodEnd()
                ))
                .orElse(null);
 
        return ResponseEntity.ok(ApiResponse.success(response));
    }
 
    @PostMapping("/portal")
    public ResponseEntity<ApiResponse<Map<String, String>>> createPortalSession(
            @AuthenticationPrincipal UserDetails userDetails) throws Exception {
 
        User user = findUser(userDetails);
 
        Subscription subscription = subscriptionRepository
                .findByUserAndStatusIn(user,
                    java.util.List.of(SubscriptionStatus.ACTIVE, SubscriptionStatus.PAST_DUE))
                .orElseThrow(() -> new ResourceNotFoundException("No active subscription"));
 
        String portalUrl = stripeService.createPortalSession(
                subscription.getStripeCustomerId()
        );
 
        return ResponseEntity.ok(
            ApiResponse.success(Map.of("url", portalUrl))
        );
    }
 
    private User findUser(UserDetails userDetails) {
        return userRepository.findByEmail(userDetails.getUsername())
                .orElseThrow(() -> new ResourceNotFoundException("User not found"));
    }
}

DTOs

// src/main/java/com/videoplatform/api/dto/request/CheckoutRequest.java
package com.videoplatform.api.dto.request;
 
import com.videoplatform.api.entity.PlanType;
 
public record CheckoutRequest(
    PlanType planType
) {}
// src/main/java/com/videoplatform/api/dto/response/SubscriptionResponse.java
package com.videoplatform.api.dto.response;
 
import java.time.LocalDateTime;
 
public record SubscriptionResponse(
    String planType,
    String status,
    LocalDateTime currentPeriodEnd,
    boolean cancelAtPeriodEnd
) {}

Webhook Handler

Webhooks are the backbone of Stripe integration. They tell you when payments succeed, subscriptions renew, cards fail, and users cancel. You must handle these events — your database won't stay in sync otherwise.

Webhook Controller

// src/main/java/com/videoplatform/api/controller/StripeWebhookController.java
package com.videoplatform.api.controller;
 
import com.stripe.exception.SignatureVerificationException;
import com.stripe.model.*;
import com.stripe.net.Webhook;
import com.videoplatform.api.config.StripeConfig;
import com.videoplatform.api.service.SubscriptionWebhookService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
 
@RestController
@RequestMapping("/api/webhooks")
public class StripeWebhookController {
 
    private static final Logger log = LoggerFactory.getLogger(StripeWebhookController.class);
 
    private final StripeConfig config;
    private final SubscriptionWebhookService webhookService;
 
    public StripeWebhookController(StripeConfig config, SubscriptionWebhookService webhookService) {
        this.config = config;
        this.webhookService = webhookService;
    }
 
    @PostMapping("/stripe")
    public ResponseEntity<String> handleWebhook(
            @RequestBody String payload,
            @RequestHeader("Stripe-Signature") String sigHeader) {
 
        Event event;
 
        // 1. Verify webhook signature
        try {
            event = Webhook.constructEvent(payload, sigHeader, config.getWebhookSecret());
        } catch (SignatureVerificationException e) {
            log.error("Webhook signature verification failed: {}", e.getMessage());
            return ResponseEntity.badRequest().body("Invalid signature");
        }
 
        // 2. Handle the event
        String eventType = event.getType();
        log.info("Received Stripe event: {} ({})", eventType, event.getId());
 
        try {
            switch (eventType) {
                case "checkout.session.completed" -> {
                    var session = (com.stripe.model.checkout.Session)
                            event.getDataObjectDeserializer().getObject().orElseThrow();
                    webhookService.handleCheckoutCompleted(session);
                }
                case "customer.subscription.updated" -> {
                    var subscription = (com.stripe.model.Subscription)
                            event.getDataObjectDeserializer().getObject().orElseThrow();
                    webhookService.handleSubscriptionUpdated(subscription);
                }
                case "customer.subscription.deleted" -> {
                    var subscription = (com.stripe.model.Subscription)
                            event.getDataObjectDeserializer().getObject().orElseThrow();
                    webhookService.handleSubscriptionDeleted(subscription);
                }
                case "invoice.payment_failed" -> {
                    var invoice = (Invoice)
                            event.getDataObjectDeserializer().getObject().orElseThrow();
                    webhookService.handlePaymentFailed(invoice);
                }
                case "invoice.payment_succeeded" -> {
                    var invoice = (Invoice)
                            event.getDataObjectDeserializer().getObject().orElseThrow();
                    webhookService.handlePaymentSucceeded(invoice);
                }
                default -> log.info("Unhandled event type: {}", eventType);
            }
        } catch (Exception e) {
            log.error("Error processing webhook event {}: {}", eventType, e.getMessage(), e);
            // Return 200 anyway — Stripe will retry on 4xx/5xx
            // We don't want Stripe to keep retrying if our handler has a bug
        }
 
        // Always return 200 to acknowledge receipt
        return ResponseEntity.ok("OK");
    }
}

Webhook Service

// src/main/java/com/videoplatform/api/service/SubscriptionWebhookService.java
package com.videoplatform.api.service;
 
import com.stripe.model.Invoice;
import com.stripe.model.checkout.Session;
import com.videoplatform.api.entity.*;
import com.videoplatform.api.repository.PaymentHistoryRepository;
import com.videoplatform.api.repository.SubscriptionRepository;
import com.videoplatform.api.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Map;
 
@Service
public class SubscriptionWebhookService {
 
    private static final Logger log = LoggerFactory.getLogger(SubscriptionWebhookService.class);
 
    private final SubscriptionRepository subscriptionRepository;
    private final UserRepository userRepository;
    private final PaymentHistoryRepository paymentHistoryRepository;
 
    public SubscriptionWebhookService(
            SubscriptionRepository subscriptionRepository,
            UserRepository userRepository,
            PaymentHistoryRepository paymentHistoryRepository) {
        this.subscriptionRepository = subscriptionRepository;
        this.userRepository = userRepository;
        this.paymentHistoryRepository = paymentHistoryRepository;
    }
 
    @Transactional
    public void handleCheckoutCompleted(Session session) {
        // Only handle subscription checkouts
        if (!"subscription".equals(session.getMode())) return;
 
        String stripeSubscriptionId = session.getSubscription();
        String stripeCustomerId = session.getCustomer();
 
        // Extract metadata
        // Note: metadata is on the subscription, not the session
        try {
            var stripeSub = com.stripe.model.Subscription.retrieve(stripeSubscriptionId);
            Map<String, String> metadata = stripeSub.getMetadata();
 
            Long userId = Long.parseLong(metadata.get("user_id"));
            PlanType planType = PlanType.valueOf(metadata.get("plan_type"));
 
            User user = userRepository.findById(userId)
                    .orElseThrow(() -> new RuntimeException("User not found: " + userId));
 
            // Check if subscription already exists (idempotency)
            if (subscriptionRepository.findByStripeSubscriptionId(stripeSubscriptionId).isPresent()) {
                log.info("Subscription already exists: {}", stripeSubscriptionId);
                return;
            }
 
            Subscription subscription = new Subscription();
            subscription.setUser(user);
            subscription.setStripeSubscriptionId(stripeSubscriptionId);
            subscription.setStripeCustomerId(stripeCustomerId);
            subscription.setPlanType(planType);
            subscription.setStatus(SubscriptionStatus.ACTIVE);
            subscription.setCurrentPeriodStart(
                    toLocalDateTime(stripeSub.getCurrentPeriodStart()));
            subscription.setCurrentPeriodEnd(
                    toLocalDateTime(stripeSub.getCurrentPeriodEnd()));
 
            subscriptionRepository.save(subscription);
 
            log.info("Created subscription for user {} ({})", userId, planType);
        } catch (Exception e) {
            log.error("Failed to handle checkout completed: {}", e.getMessage(), e);
            throw new RuntimeException(e);
        }
    }
 
    @Transactional
    public void handleSubscriptionUpdated(com.stripe.model.Subscription stripeSub) {
        subscriptionRepository.findByStripeSubscriptionId(stripeSub.getId())
                .ifPresent(subscription -> {
                    // Update period dates
                    subscription.setCurrentPeriodStart(
                            toLocalDateTime(stripeSub.getCurrentPeriodStart()));
                    subscription.setCurrentPeriodEnd(
                            toLocalDateTime(stripeSub.getCurrentPeriodEnd()));
 
                    // Update status
                    subscription.setStatus(mapStripeStatus(stripeSub.getStatus()));
 
                    // Track cancellation
                    subscription.setCancelAtPeriodEnd(
                            Boolean.TRUE.equals(stripeSub.getCancelAtPeriodEnd()));
 
                    subscription.setUpdatedAt(LocalDateTime.now());
                    subscriptionRepository.save(subscription);
 
                    log.info("Updated subscription {}: status={}, cancelAtPeriodEnd={}",
                            stripeSub.getId(), stripeSub.getStatus(),
                            stripeSub.getCancelAtPeriodEnd());
                });
    }
 
    @Transactional
    public void handleSubscriptionDeleted(com.stripe.model.Subscription stripeSub) {
        subscriptionRepository.findByStripeSubscriptionId(stripeSub.getId())
                .ifPresent(subscription -> {
                    subscription.setStatus(SubscriptionStatus.EXPIRED);
                    subscription.setUpdatedAt(LocalDateTime.now());
                    subscriptionRepository.save(subscription);
 
                    log.info("Subscription expired: {}", stripeSub.getId());
                });
    }
 
    @Transactional
    public void handlePaymentFailed(Invoice invoice) {
        String subscriptionId = invoice.getSubscription();
        if (subscriptionId == null) return;
 
        subscriptionRepository.findByStripeSubscriptionId(subscriptionId)
                .ifPresent(subscription -> {
                    subscription.setStatus(SubscriptionStatus.PAST_DUE);
                    subscription.setUpdatedAt(LocalDateTime.now());
                    subscriptionRepository.save(subscription);
 
                    log.warn("Payment failed for subscription {}", subscriptionId);
                    // TODO: Send email notification to user
                });
    }
 
    @Transactional
    public void handlePaymentSucceeded(Invoice invoice) {
        // Record payment in history
        if (invoice.getPaymentIntent() != null) {
            if (paymentHistoryRepository.findByStripePaymentIntent(
                    invoice.getPaymentIntent()).isEmpty()) {
 
                PaymentHistory payment = new PaymentHistory();
 
                // Find user from subscription
                if (invoice.getSubscription() != null) {
                    subscriptionRepository.findByStripeSubscriptionId(invoice.getSubscription())
                            .ifPresent(sub -> payment.setUser(sub.getUser()));
                }
 
                payment.setStripePaymentIntent(invoice.getPaymentIntent());
                payment.setAmount(invoice.getAmountPaid().intValue());
                payment.setCurrency(invoice.getCurrency());
                payment.setStatus("succeeded");
 
                paymentHistoryRepository.save(payment);
            }
        }
 
        // Reactivate subscription if it was past_due
        if (invoice.getSubscription() != null) {
            subscriptionRepository.findByStripeSubscriptionId(invoice.getSubscription())
                    .filter(sub -> sub.getStatus() == SubscriptionStatus.PAST_DUE)
                    .ifPresent(subscription -> {
                        subscription.setStatus(SubscriptionStatus.ACTIVE);
                        subscription.setUpdatedAt(LocalDateTime.now());
                        subscriptionRepository.save(subscription);
 
                        log.info("Reactivated subscription after successful payment: {}",
                                invoice.getSubscription());
                    });
        }
    }
 
    private SubscriptionStatus mapStripeStatus(String stripeStatus) {
        return switch (stripeStatus) {
            case "active", "trialing" -> SubscriptionStatus.ACTIVE;
            case "past_due" -> SubscriptionStatus.PAST_DUE;
            case "canceled", "unpaid" -> SubscriptionStatus.CANCELED;
            default -> SubscriptionStatus.EXPIRED;
        };
    }
 
    private LocalDateTime toLocalDateTime(Long epochSeconds) {
        return LocalDateTime.ofInstant(
                Instant.ofEpochSecond(epochSeconds), ZoneOffset.UTC);
    }
}

Webhook Event Flow

Here's what happens for each subscription lifecycle event:


Subscription Lifecycle

The Happy Path

  1. User clicks "Subscribe Monthly" → redirected to Stripe Checkout
  2. User enters payment → checkout.session.completed → subscription created (ACTIVE)
  3. 30 days later → Stripe auto-charges → invoice.payment_succeeded → period updated
  4. This repeats every month

Cancellation

  1. User opens Customer Portal → clicks "Cancel Subscription"
  2. customer.subscription.updatedcancelAtPeriodEnd: true
  3. User keeps access until the current period ends
  4. Period ends → customer.subscription.deleted → status: EXPIRED

Failed Payment

  1. Payment fails → invoice.payment_failed → status: PAST_DUE
  2. Stripe retries 3 times over ~2 weeks (configurable in Stripe Dashboard)
  3. If payment succeeds → invoice.payment_succeeded → status: ACTIVE
  4. If all retries fail → customer.subscription.deleted → status: EXPIRED

Access Gating

Update SubscriptionService

We already created a basic SubscriptionService in VID-8. Let's expand it:

// src/main/java/com/videoplatform/api/service/SubscriptionService.java
package com.videoplatform.api.service;
 
import com.videoplatform.api.entity.Subscription;
import com.videoplatform.api.entity.SubscriptionStatus;
import com.videoplatform.api.entity.User;
import com.videoplatform.api.repository.SubscriptionRepository;
import com.videoplatform.api.repository.UserRepository;
import org.springframework.stereotype.Service;
 
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
 
@Service
public class SubscriptionService {
 
    private final SubscriptionRepository subscriptionRepository;
    private final UserRepository userRepository;
 
    public SubscriptionService(
            SubscriptionRepository subscriptionRepository,
            UserRepository userRepository) {
        this.subscriptionRepository = subscriptionRepository;
        this.userRepository = userRepository;
    }
 
    public boolean hasActiveSubscription(String email) {
        User user = userRepository.findByEmail(email).orElse(null);
        if (user == null) return false;
 
        return subscriptionRepository
                .findByUserAndStatusIn(user,
                    List.of(SubscriptionStatus.ACTIVE, SubscriptionStatus.PAST_DUE))
                .filter(sub -> sub.getCurrentPeriodEnd().isAfter(LocalDateTime.now()))
                .isPresent();
    }
 
    public Optional<Subscription> getActiveSubscription(String email) {
        User user = userRepository.findByEmail(email).orElse(null);
        if (user == null) return Optional.empty();
 
        return subscriptionRepository
                .findByUserAndStatusIn(user,
                    List.of(SubscriptionStatus.ACTIVE, SubscriptionStatus.PAST_DUE))
                .filter(sub -> sub.getCurrentPeriodEnd().isAfter(LocalDateTime.now()));
    }
}

Notice we allow PAST_DUE status to still access content. Why? Because Stripe is still retrying the payment — the user might fix their card info. Cutting them off immediately is a bad user experience. They get a grace period until Stripe gives up (usually ~2 weeks).

Access Check in StreamController

The StreamController from VID-8 already calls subscriptionService.hasActiveSubscription(). No changes needed — the gating is already in place. If the user doesn't have an active subscription (or past_due within the period), they get a 403 on the stream endpoint.


Frontend: Pricing Page

Pricing Component

// web/src/app/(public)/pricing/page.tsx
"use client";
 
import { useState } from "react";
import { Check } from "lucide-react";
 
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
 
const plans = [
  {
    name: "Monthly",
    price: "$9.99",
    period: "/month",
    planType: "MONTHLY",
    features: [
      "Access all courses",
      "HD video streaming (720p)",
      "Track your progress",
      "New content every week",
      "Cancel anytime",
    ],
  },
  {
    name: "Yearly",
    price: "$99.99",
    period: "/year",
    planType: "YEARLY",
    badge: "Save 17%",
    features: [
      "Everything in Monthly",
      "2 months free",
      "Priority support",
      "Early access to new courses",
      "Cancel anytime",
    ],
  },
];
 
export default function PricingPage() {
  const [loading, setLoading] = useState<string | null>(null);
 
  const handleSubscribe = async (planType: string) => {
    setLoading(planType);
 
    try {
      const token = localStorage.getItem("accessToken");
 
      if (!token) {
        // Redirect to login
        window.location.href = "/login?redirect=/pricing";
        return;
      }
 
      const res = await fetch(`${API_BASE}/api/subscriptions/checkout`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify({ planType }),
      });
 
      if (!res.ok) {
        const data = await res.json();
        throw new Error(data.message || "Failed to create checkout session");
      }
 
      const data = await res.json();
      // Redirect to Stripe Checkout
      window.location.href = data.data.url;
    } catch (error) {
      console.error("Checkout error:", error);
      alert(error instanceof Error ? error.message : "Something went wrong");
    } finally {
      setLoading(null);
    }
  };
 
  return (
    <div className="max-w-4xl mx-auto py-16 px-4">
      <div className="text-center mb-12">
        <h1 className="text-4xl font-bold mb-4">Simple, Transparent Pricing</h1>
        <p className="text-xl text-muted-foreground">
          Get unlimited access to all courses. Cancel anytime.
        </p>
      </div>
 
      <div className="grid md:grid-cols-2 gap-8">
        {plans.map((plan) => (
          <div
            key={plan.planType}
            className={`relative rounded-2xl border p-8 ${
              plan.badge
                ? "border-primary shadow-lg ring-1 ring-primary"
                : "border-border"
            }`}
          >
            {plan.badge && (
              <span className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary
                text-primary-foreground text-sm font-medium px-3 py-1 rounded-full">
                {plan.badge}
              </span>
            )}
 
            <div className="mb-6">
              <h2 className="text-2xl font-bold">{plan.name}</h2>
              <div className="mt-2">
                <span className="text-4xl font-bold">{plan.price}</span>
                <span className="text-muted-foreground">{plan.period}</span>
              </div>
            </div>
 
            <ul className="space-y-3 mb-8">
              {plan.features.map((feature) => (
                <li key={feature} className="flex items-center gap-2">
                  <Check className="h-5 w-5 text-primary shrink-0" />
                  <span>{feature}</span>
                </li>
              ))}
            </ul>
 
            <button
              onClick={() => handleSubscribe(plan.planType)}
              disabled={loading !== null}
              className={`w-full py-3 px-6 rounded-lg font-medium transition-colors ${
                plan.badge
                  ? "bg-primary text-primary-foreground hover:bg-primary/90"
                  : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
              } disabled:opacity-50 disabled:cursor-not-allowed`}
            >
              {loading === plan.planType ? "Loading..." : "Get Started"}
            </button>
          </div>
        ))}
      </div>
    </div>
  );
}

Subscription Management

// web/src/components/SubscriptionStatus.tsx
"use client";
 
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
 
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
 
interface SubscriptionInfo {
  planType: string;
  status: string;
  currentPeriodEnd: string;
  cancelAtPeriodEnd: boolean;
}
 
export function SubscriptionStatus() {
  const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null);
  const [loading, setLoading] = useState(true);
 
  useEffect(() => {
    async function loadSubscription() {
      try {
        const token = localStorage.getItem("accessToken");
        const res = await fetch(`${API_BASE}/api/subscriptions/current`, {
          headers: { Authorization: `Bearer ${token}` },
        });
        if (res.ok) {
          const data = await res.json();
          setSubscription(data.data);
        }
      } catch {
        // No subscription
      } finally {
        setLoading(false);
      }
    }
    loadSubscription();
  }, []);
 
  if (loading || !subscription) return null;
 
  const endDate = new Date(subscription.currentPeriodEnd).toLocaleDateString();
 
  const handleManage = async () => {
    try {
      const token = localStorage.getItem("accessToken");
      const res = await fetch(`${API_BASE}/api/subscriptions/portal`, {
        method: "POST",
        headers: { Authorization: `Bearer ${token}` },
      });
      const data = await res.json();
      window.location.href = data.data.url;
    } catch (error) {
      console.error("Portal error:", error);
    }
  };
 
  return (
    <div className="rounded-lg border p-4">
      <div className="flex items-center justify-between">
        <div>
          <p className="font-medium">
            {subscription.planType} Plan
            <Badge variant="outline" className="ml-2">
              {subscription.status}
            </Badge>
          </p>
          {subscription.cancelAtPeriodEnd && (
            <p className="text-sm text-muted-foreground mt-1">
              Access until {endDate}
            </p>
          )}
          {!subscription.cancelAtPeriodEnd && (
            <p className="text-sm text-muted-foreground mt-1">
              Renews on {endDate}
            </p>
          )}
        </div>
        <button
          onClick={handleManage}
          className="text-sm text-primary hover:underline"
        >
          Manage subscription
        </button>
      </div>
    </div>
  );
}

Security Considerations

1. Webhook Signature Verification

Always verify the webhook signature. Without it, anyone could send fake events to your webhook endpoint:

// This is critical — never skip signature verification
try {
    event = Webhook.constructEvent(payload, sigHeader, config.getWebhookSecret());
} catch (SignatureVerificationException e) {
    return ResponseEntity.badRequest().body("Invalid signature");
}

2. Exclude Webhook from CSRF/Auth

The webhook endpoint needs to accept POST requests from Stripe (no auth token):

// In SecurityConfig.java — add webhook to public endpoints
.requestMatchers("/api/webhooks/**").permitAll()

3. Idempotency

Stripe may send the same webhook event multiple times (due to retries). Always check if you've already processed an event before taking action:

// Check if subscription already exists
if (subscriptionRepository.findByStripeSubscriptionId(stripeSubscriptionId).isPresent()) {
    log.info("Subscription already exists — skipping");
    return;
}

4. Raw Request Body for Signature Verification

Spring Boot may parse the request body as JSON before your controller gets it. For webhook signature verification, you need the raw body. Use @RequestBody String payload — not a deserialized object:

@PostMapping("/stripe")
public ResponseEntity<String> handleWebhook(
        @RequestBody String payload,  // Raw body, not parsed
        @RequestHeader("Stripe-Signature") String sigHeader) {

Testing

1. Local Webhook Testing with Stripe CLI

# Install Stripe CLI
brew install stripe/stripe-cli/stripe
 
# Login
stripe login
 
# Forward webhooks to your local server
stripe listen --forward-to localhost:8080/api/webhooks/stripe
# Note the webhook signing secret it outputs — use it as STRIPE_WEBHOOK_SECRET

2. Trigger Test Events

# Trigger a checkout completed event
stripe trigger checkout.session.completed
 
# Trigger a subscription update
stripe trigger customer.subscription.updated
 
# Trigger a payment failure
stripe trigger invoice.payment_failed

3. Test Checkout Flow

# Create a checkout session
curl -X POST http://localhost:8080/api/subscriptions/checkout \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"planType": "MONTHLY"}'
 
# Response:
{ "data": { "url": "https://checkout.stripe.com/c/pay/cs_test_..." } }

Open the URL in a browser. Use Stripe's test card numbers:

Card NumberScenario
4242 4242 4242 4242Successful payment
4000 0000 0000 0341Card declined
4000 0000 0000 3220Requires 3D Secure

4. Verify Subscription Created

# Check current subscription
curl -s http://localhost:8080/api/subscriptions/current \
  -H "Authorization: Bearer ${TOKEN}" | jq
 
# Response:
{
  "data": {
    "planType": "MONTHLY",
    "status": "ACTIVE",
    "currentPeriodEnd": "2026-04-23T00:00:00",
    "cancelAtPeriodEnd": false
  }
}

5. Test Access Gating

# Without subscription — should get 403
curl -v http://localhost:8080/api/lessons/1/stream \
  -H "Authorization: Bearer ${UNSUBSCRIBED_TOKEN}"
# < HTTP/1.1 403 Forbidden
 
# With subscription — should get stream URL
curl -v http://localhost:8080/api/lessons/1/stream \
  -H "Authorization: Bearer ${SUBSCRIBED_TOKEN}"
# < HTTP/1.1 200 OK

Environment Variables

# .env
STRIPE_SECRET_KEY=sk_test_...        # From Stripe Dashboard
STRIPE_WEBHOOK_SECRET=whsec_...      # From Stripe CLI or Dashboard
STRIPE_PRICE_MONTHLY=price_...       # Monthly price ID
STRIPE_PRICE_YEARLY=price_...        # Yearly price ID
APP_URL=http://localhost:3000        # Your frontend URL

Common Mistakes

1. Creating Subscriptions on Redirect Instead of Webhook

// WRONG — create subscription when user returns from Stripe
@GetMapping("/success")
public void handleSuccess(@RequestParam String session_id) {
    // User reached success page — create subscription
    createSubscription(session_id);
    // Problem: user could close the tab before reaching this page!
}
 
// RIGHT — create subscription in webhook handler
@PostMapping("/webhooks/stripe")
public void handleWebhook(String payload, String signature) {
    // Stripe sends this server-to-server — guaranteed delivery
    handleCheckoutCompleted(session);
}

2. Not Handling Duplicate Webhook Events

Stripe may send the same event 2-3 times. Without idempotency checks, you'll create duplicate subscriptions:

// Always check before creating
if (subscriptionRepository.findByStripeSubscriptionId(id).isPresent()) {
    return; // Already processed
}

3. Cutting Off Access Immediately on Past Due

// WRONG — immediately block access
if (status == SubscriptionStatus.PAST_DUE) {
    throw new AccessDeniedException("Payment failed");
}
 
// RIGHT — allow access during grace period
if (status == SubscriptionStatus.ACTIVE || status == SubscriptionStatus.PAST_DUE) {
    if (subscription.getCurrentPeriodEnd().isAfter(LocalDateTime.now())) {
        // Still within the paid period — allow access
        return true;
    }
}

4. Skipping Webhook Signature Verification

// WRONG — anyone can fake webhook events
@PostMapping("/webhooks/stripe")
public void handle(@RequestBody String payload) {
    Event event = Event.GSON.fromJson(payload, Event.class);
    // An attacker could grant themselves a free subscription!
}
 
// RIGHT — verify the signature
Event event = Webhook.constructEvent(payload, sigHeader, webhookSecret);

What's Next?

Subscriptions are working — users can pay, and only subscribers can stream. In Post #11, we'll build the public-facing course catalog:

  • Server-side rendered course pages with generateMetadata()
  • JSON-LD schema for SEO
  • Free preview lessons for non-subscribers
  • Enrollment flow (browse → preview → subscribe → watch)
  • Responsive catalog grid with course cards

Time to make this platform look like a real product.

Series: Build a Video Streaming Platform
Previous: Phase 8: Video Player & Progress Tracking
Next: Phase 10: Public Course Catalog & SEO

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.