Day 16: Integrating Stripe - payments in a day

Day 16 of 30. Today we make money possible.

#payments #billing

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

  1. Create products and prices in Stripe Dashboard
  2. Implement Checkout Sessions for new subscriptions
  3. Handle webhooks for subscription events
  4. Sync subscription status to our database
  5. Add customer portal for self-service

Let’s go through each.

Interactive payment flow

See how the entire payment flow works:

payment-flow - Stripe payment flow
[U]
User
Clicks "Subscribe"
[A]
Our API
Create Checkout Session
[$]
Stripe
Hosted Checkout Page
[W]
Webhook
checkout.session.completed
[D]
Database
Subscription Activated
[*]
Done!
User is now subscribed
Event log:
Waiting to start...

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.


Day 16 stats

Hours
███████░░░░░░░░
46h
</> Code
██████████░░░░░
3,200
$ Revenue
░░░░░░░░░░░░░░░
$0
Customers
░░░░░░░░░░░░░░░
0
Hosting
████░░░░░░░░░░░
$5.5/mo
Achievements:
[✓] Stripe integration complete [✓] Webhooks handling [✓] Customer portal added
╔════════════════════════════════════════════════════════════╗
E

Erik

Building Allscreenshots. Writes code, takes screenshots, goes diving.

Try allscreenshots

Screenshot API for the modern web. Capture any URL with a simple API call.

Get started