Day 16: Integrating Stripe - payments in a day
Day 16 of 30. Today we make money possible.
Day 16 of 30. Today we make money possible.
We have pricing. We have a product. But clicking “Subscribe” does nothing. Today we wire up Stripe and make the buttons work.
Why Stripe?
Short answer: it’s the default for a reason.
Longer answer:
- Developer experience. The docs are excellent. The API makes sense.
- Stripe Checkout. Hosted payment pages. We don’t touch card numbers.
- Subscription management. Billing, invoices, dunning - all handled.
- Global payments. Works in most countries, most currencies.
- Trustworthy. Users recognize Stripe’s checkout. Builds confidence.
Alternatives like Paddle or Lemon Squeezy handle more (taxes, merchant of record), but Stripe is simpler to start with. We can switch later if needed.
The integration plan
- Create products and prices in Stripe Dashboard
- Implement Checkout Sessions for new subscriptions
- Handle webhooks for subscription events
- Sync subscription status to our database
- Add customer portal for self-service
Let’s go through each.
Interactive payment flow
See how the entire payment flow works:
Setting up Stripe products
In the Stripe Dashboard, we created:
Product: Screenshot API Pro
- Price: $29/month, recurring
Product: Screenshot API Scale
- Price: $99/month, recurring
We stored the price IDs in our config:
stripe:
api-key: ${STRIPE_SECRET_KEY}
webhook-secret: ${STRIPE_WEBHOOK_SECRET}
prices:
pro: price_1234567890abcdef
scale: price_0987654321fedcba
Checkout Sessions
When a user clicks “Subscribe to Pro,” we create a Checkout Session:
@RestController
@RequestMapping("/api/billing")
class BillingController(
private val stripeService: StripeService,
private val userRepository: UserRepository
) {
@PostMapping("/checkout")
fun createCheckoutSession(
@AuthUser user: User,
@RequestBody request: CheckoutRequest
): CheckoutResponse {
val priceId = when (request.plan) {
"pro" -> stripeConfig.prices.pro
"scale" -> stripeConfig.prices.scale
else -> throw BadRequestException("Invalid plan")
}
val session = stripeService.createCheckoutSession(
userId = user.id,
email = user.email,
priceId = priceId,
successUrl = "${appConfig.baseUrl}/dashboard?upgraded=true",
cancelUrl = "${appConfig.baseUrl}/pricing"
)
return CheckoutResponse(checkoutUrl = session.url)
}
}
The Stripe service:
@Service
class StripeService(
@Value("\${stripe.api-key}") apiKey: String,
private val stripeConfig: StripeConfig
) {
init {
Stripe.apiKey = apiKey
}
fun createCheckoutSession(
userId: UUID,
email: String,
priceId: String,
successUrl: String,
cancelUrl: String
): Session {
val params = SessionCreateParams.builder()
.setMode(SessionCreateParams.Mode.SUBSCRIPTION)
.setCustomerEmail(email)
.setSuccessUrl(successUrl)
.setCancelUrl(cancelUrl)
.addLineItem(
SessionCreateParams.LineItem.builder()
.setPrice(priceId)
.setQuantity(1)
.build()
)
.putMetadata("user_id", userId.toString())
.build()
return Session.create(params)
}
}
On the frontend, clicking “Subscribe” redirects to Stripe:
const handleSubscribe = async (plan) => {
const response = await fetch('/api/billing/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan })
});
const { checkoutUrl } = await response.json();
window.location.href = checkoutUrl;
};
Webhooks: the critical part
Stripe Checkout redirects users back to our site on success. But we can’t trust the redirect - users might close the browser, payment might be delayed, etc. We need webhooks.
@RestController
@RequestMapping("/api/webhooks")
class StripeWebhookController(
private val stripeService: StripeService,
private val subscriptionService: SubscriptionService,
@Value("\${stripe.webhook-secret}") private val webhookSecret: String
) {
private val logger = LoggerFactory.getLogger(javaClass)
@PostMapping("/stripe")
fun handleStripeWebhook(
@RequestBody payload: String,
@RequestHeader("Stripe-Signature") signature: String
): ResponseEntity<String> {
val event = try {
Webhook.constructEvent(payload, signature, webhookSecret)
} catch (e: SignatureVerificationException) {
logger.warn("Invalid Stripe signature")
return ResponseEntity.badRequest().body("Invalid signature")
}
when (event.type) {
"checkout.session.completed" -> {
val session = event.dataObjectDeserializer
.`object`.get() as Session
handleCheckoutComplete(session)
}
"customer.subscription.updated" -> {
val subscription = event.dataObjectDeserializer
.`object`.get() as Subscription
handleSubscriptionUpdate(subscription)
}
"customer.subscription.deleted" -> {
val subscription = event.dataObjectDeserializer
.`object`.get() as Subscription
handleSubscriptionCanceled(subscription)
}
"invoice.payment_failed" -> {
val invoice = event.dataObjectDeserializer
.`object`.get() as Invoice
handlePaymentFailed(invoice)
}
}
return ResponseEntity.ok("OK")
}
private fun handleCheckoutComplete(session: Session) {
val userId = UUID.fromString(session.metadata["user_id"])
val subscriptionId = session.subscription
val customerId = session.customer
subscriptionService.activateSubscription(
userId = userId,
stripeCustomerId = customerId,
stripeSubscriptionId = subscriptionId
)
logger.info("Activated subscription for user $userId")
}
private fun handleSubscriptionUpdate(subscription: Subscription) {
val plan = when (subscription.items.data[0].price.id) {
stripeConfig.prices.pro -> Plan.PRO
stripeConfig.prices.scale -> Plan.SCALE
else -> Plan.FREE
}
subscriptionService.updatePlan(
stripeSubscriptionId = subscription.id,
newPlan = plan,
status = subscription.status
)
}
private fun handleSubscriptionCanceled(subscription: Subscription) {
subscriptionService.cancelSubscription(subscription.id)
}
private fun handlePaymentFailed(invoice: Invoice) {
// Send email, maybe downgrade after grace period
logger.warn("Payment failed for customer ${invoice.customer}")
}
}
Subscription data model
We track subscriptions in our database:
@Entity
@Table(name = "subscriptions")
data class Subscription(
@Id
val id: UUID = UUID.randomUUID(),
val userId: UUID,
val stripeCustomerId: String,
val stripeSubscriptionId: String,
@Enumerated(EnumType.STRING)
val plan: Plan,
@Enumerated(EnumType.STRING)
val status: SubscriptionStatus, // ACTIVE, PAST_DUE, CANCELED
val currentPeriodStart: Instant,
val currentPeriodEnd: Instant,
val createdAt: Instant = Instant.now(),
val canceledAt: Instant? = null
)
When checking user plan:
fun getUserPlan(userId: UUID): Plan {
val subscription = subscriptionRepository
.findActiveByUserId(userId)
return subscription?.plan ?: Plan.FREE
}
Customer Portal
Users need to update payment methods, view invoices, and cancel. Stripe’s Customer Portal handles all of this:
@PostMapping("/billing/portal")
fun createPortalSession(@AuthUser user: User): PortalResponse {
val subscription = subscriptionRepository.findByUserId(user.id)
?: throw NotFoundException("No subscription found")
val params = com.stripe.param.billingportal.SessionCreateParams.builder()
.setCustomer(subscription.stripeCustomerId)
.setReturnUrl("${appConfig.baseUrl}/dashboard")
.build()
val session = com.stripe.model.billingportal.Session.create(params)
return PortalResponse(portalUrl = session.url)
}
In the dashboard:
<button onClick={openBillingPortal}>
Manage Subscription
</button>
One click, users can update cards, see invoices, cancel. We don’t build any of that UI.
Testing
Stripe’s test mode is excellent. We:
- Used test API keys
- Created test subscriptions with card
4242 4242 4242 4242 - Triggered test webhooks via Stripe CLI:
stripe trigger checkout.session.completed - Verified database updates correctly
Everything worked on the first try. (Okay, third try. The webhook signature validation tripped us up.)
What we built today
- Stripe product and price configuration
- Checkout Session creation
- Webhook handling for subscription lifecycle
- Customer portal integration
- Database sync for subscription status
Users can now pay us money. The business is real.
Tomorrow: documentation
Day 17 we write docs that don’t suck. API reference, quick start guide, examples. The stuff developers actually read.
Book of the day
The Mom Test by Rob Fitzpatrick
We’ve built payments, but do people actually want this? This book is about how to talk to potential customers without getting false validation.
The “Mom Test” is: never ask if your idea is good. Instead, ask about their problems, their current solutions, their past behavior. “Would you use a screenshot API?” is a bad question. “How do you currently generate preview images for your app?” is a good one.
We should have read this before starting. But it’s not too late to have real conversations with potential customers.
Short, practical, essential reading for anyone building products.