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
- User clicks "Subscribe Monthly" → redirected to Stripe Checkout
- User enters payment →
checkout.session.completed→ subscription created (ACTIVE) - 30 days later → Stripe auto-charges →
invoice.payment_succeeded→ period updated - This repeats every month
Cancellation
- User opens Customer Portal → clicks "Cancel Subscription"
customer.subscription.updated→cancelAtPeriodEnd: true- User keeps access until the current period ends
- Period ends →
customer.subscription.deleted→ status: EXPIRED
Failed Payment
- Payment fails →
invoice.payment_failed→ status: PAST_DUE - Stripe retries 3 times over ~2 weeks (configurable in Stripe Dashboard)
- If payment succeeds →
invoice.payment_succeeded→ status: ACTIVE - 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_SECRET2. 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_failed3. 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 Number | Scenario |
|---|---|
4242 4242 4242 4242 | Successful payment |
4000 0000 0000 0341 | Card declined |
4000 0000 0000 3220 | Requires 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 OKEnvironment 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 URLCommon 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.